Linux 字符设备驱动:cdev、设备号与设备模型

Linux 字符设备驱动:cdev、设备号与设备模型

仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!
仓库地址:https://github.com/Awesome-Embedded-Learning-Studio/imx-forge
静态网页:https://awesome-embedded-learning-studio.github.io/imx-forge/

前言:走进字符设备的内心世界

前一章我们从整体上讲了新 API 的设计理念,那个"三步走"的流程:领号、填表、进门。现在我们要深入细节,把每一步拆开来仔细看看。

这一章我们重点讲几个核心概念:struct cdev 结构体、设备号管理、以及 class 和 device 模型。cdev 是字符设备驱动的核心数据结构,内核通过它来管理你的设备。设备号则是设备的唯一标识,用户空间通过设备号来找到对应的设备驱动。class 和 device 模型则负责自动创建设备节点,让驱动程序无缝集成到 Linux 系统中。

理解这些概念,你就理解了字符设备驱动的核心。


cdev 结构体长什么样

struct cdev 是内核中字符设备的核心结构体,定义在 include/linux/cdev.h 中。我们先看一下它的完整定义:

c 复制代码
struct cdev {
    struct kobject kobj;           /* 内核对象基础 */
    struct module *owner;          /* 模块所属 */
    const struct file_operations *ops;  /* 操作函数集 */
    struct list_head list;         /* 字符设备链表节点 */
    dev_t dev;                     /* 设备号 */
    unsigned int count;            /* 次设备数量 */
} __randomize_layout;

乍一看好像挺复杂的,但其实每个字段都有它的用武之地。我们来一个一个看。

kobj:内核对象系统的门面

kobj 是内核对象系统(kobject)的基础结构。说实话,kobject 本身是一个非常宏大的话题,足够写一章专门讲它。但对于我们写驱动的人来说,只需要知道它是干什么的就够了。

kobject 在这里主要扮演三个角色。第一是管理对象的生命周期,通过引用计数来跟踪对象被使用了多少次。当引用计数归零时,对象会被自动清理。第二是提供 sysfs 表示,在 /sys 下创建目录。第三是支持对象层次结构,通过 parent 和 kset 来建立对象之间的关系。

在我们的驱动中,cdev.kobjcdev_add() 自动初始化,我们通常不需要直接操作它。当然,如果你想做一些高级操作,比如在 sysfs 里创建自定义属性,那就要和 kobject 打交道了。不过那是进阶话题,我们先不聊。

owner:防止模块被意外卸载

owner 字段指向拥有此 cdev 的内核模块。你需要在初始化时这样设置:

c 复制代码
cdev.owner = THIS_MODULE;

这一行代码非常重要,但很多人容易忘。如果忘了设置,会发生什么?

当用户空间打开设备文件时,内核会增加 owner 指向模块的引用计数。这样,在设备文件被使用期间,rmmod 会失败,防止模块被意外卸载导致系统崩溃。如果你没设置 owner,内核就不知道这个设备属于哪个模块,可能会在设备还在使用时就允许卸载模块,然后你的系统就崩了。

说实话,这个坑我们踩过。一开始没太注意 owner 这个字段,结果调试的时候模块突然没了,系统直接 panic。后来才知道,设置 owner 是必须的,不是可选项。

ops:操作函数集

ops 指向 file_operations 结构体,定义了设备的所有操作。这是你真正实现设备功能的地方。

c 复制代码
const struct file_operations *ops;

一个典型的例子:

c 复制代码
static struct file_operations aes_fops = {
    .owner = THIS_MODULE,
    .open = aes_chardev_open,
    .read = aes_chardev_read,
    .write = aes_chardev_write,
    .release = aes_chardev_release,
};

cdev_init(&cdev, &aes_fops);  // 初始化时关联 ops

cdev_init() 会把 aes_fops 的地址赋给 cdev.ops,这样内核就知道当用户调用 openreadwrite 时该跳转到哪个函数了。

dev 和 count:设备号相关

dev 存储分配给此设备的设备号,count 是此设备支持的次设备号数量。我们后面会详细讲设备号,这里先简单说一下。

设备号包含主设备号和次设备号,主设备号标识驱动程序,次设备号标识具体设备。如果你的驱动只有一个设备,count 就是 1。如果你想用一个驱动管理多个设备(比如多个 LED),count 就可以大于 1。


cdev 操作函数:初始化、添加、删除

有了 cdev 结构体,接下来就是怎么操作它了。主要有三个函数:cdev_init()cdev_add()cdev_del()

cdev_init():初始化结构体

cdev_init() 的函数原型是:

c 复制代码
void cdev_init(struct cdev *cdev, const struct file_operations *fops);

它的作用是初始化 struct cdev 结构体并关联 file_operations 操作函数集。我们可以看一下它的源码(简化版):

c 复制代码
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
{
    memset(cdev, 0, sizeof *cdev);     // 清零
    INIT_LIST_HEAD(&cdev->list);       // 初始化链表节点
    kobject_init(&cdev->kobj, &ktype_cdev_dynamic);  // 初始化 kobj
    cdev->ops = fops;                   // 关联操作函数
}

基本就是把结构体清零,初始化各个字段,然后把 fops 关联进来。

使用示例:

c 复制代码
struct cdev cdev;
cdev_init(&cdev, &fops);
cdev.owner = THIS_MODULE;  // 注意:需要手动设置 owner

这里有个细节一定要记住:cdev_init() 不会设置 owner 字段,你必须手动设置。我们前面讲过为什么这个很重要,这里就不重复了。

cdev_add():添加到系统

cdev_add() 的函数原型是:

c 复制代码
int cdev_add(struct cdev *p, dev_t dev, unsigned count);

参数包括:指向 cdev 的指针、设备号、次设备数量。返回值是 0 表示成功,负数表示失败。

它的作用是将字符设备注册到内核,使设备号与 cdev 关联,用户空间可以通过设备号访问设备。

使用示例:

c 复制代码
int ret;
ret = cdev_add(&cdev, devid, 1);
if (ret < 0) {
    printk("cdev_add failed: %d\n", ret);
    return ret;
}

注意错误处理,cdev_add() 是可能失败的。失败的原因可能是设备号冲突、内存不足等。如果不检查返回值,驱动加载时可能看起来成功了,但实际上设备并没有注册成功,用户空间访问时就会出错。

cdev_del():从系统删除

cdev_del() 的函数原型是:

c 复制代码
void cdev_del(struct cdev *p);

它的作用是从内核注销字符设备,释放相关资源。

使用示例:

c 复制代码
cdev_del(&cdev);

调用 cdev_del() 后,不应再访问 cdev 结构体。这是很多人的一个误区,以为删除后还能继续用,其实这时候结构体已经被内核回收了,再访问就是未定义行为,可能导致系统崩溃。


设备号的那些事

设备号是一个 32 位无符号整数,用于标识设备。它包含两部分:主设备号和次设备号。

dev_t 的结构

dev_t 本质上就是一个 u32

c 复制代码
typedef u32 dev_t;

但它的位布局有特殊含义:

复制代码
31            20 19                   0
┌───────────────┬───────────────────────┐
│  Major (12位)  │  Minor (20位)          │
│  主设备号       │   次设备号             │
└───────────────┴───────────────────────┘

主设备号占高 12 位,次设备号占低 20 位。这意味着主设备号范围是 0 到 4095,次设备号范围是 0 到 1048575。

设备号宏:MKDEV、MAJOR、MINOR

内核提供了三个宏来操作设备号:

MKDEV() 用于构建设备号:

c 复制代码
#define MKDEV(ma, mi)    (((ma) << MINORBITS) | (mi))

示例:

c 复制代码
dev_t devid = MKDEV(200, 0);  // 主设备号 200,次设备号 0

MAJOR() 用于提取主设备号:

c 复制代码
#define MAJOR(dev)    ((unsigned int) ((dev) >> MINORBITS))

示例:

c 复制代码
dev_t devid = MKDEV(200, 0);
unsigned int major = MAJOR(devid);  // major = 200

MINOR() 用于提取次设备号:

c 复制代码
#define MINOR(dev)    ((unsigned int) ((dev) & MINORMASK))

示例:

c 复制代码
dev_t devid = MKDEV(200, 5);
unsigned int minor = MINOR(devid);  // minor = 5

这三个宏在驱动开发中非常常用,基本上只要涉及到设备号就会用到它们。

动态分配 vs 静态注册

分配设备号有两种方式:动态分配和静态注册。动态分配是让内核帮你找一个空闲的设备号,静态注册是你指定一个设备号。

动态分配的函数是 alloc_chrdev_region()

c 复制代码
int alloc_chrdev_region(dev_t *dev, unsigned baseminor,
                         unsigned count, const char *name);

参数包括:传出参数返回分配到的设备号、次设备号的起始值(通常为 0)、申请的设备数量、设备名称(显示在 /proc/devices)。

示例:

c 复制代码
dev_t devid;
int ret;

ret = alloc_chrdev_region(&devid, 0, 1, "aes_led");
if (ret < 0) {
    printk("alloc_chrdev_region failed: %d\n", ret);
    return ret;
}

printk("allocated: major=%d, minor=%d\n",
       MAJOR(devid), MINOR(devid));

输出示例:

复制代码
allocated: major=241, minor=0

静态注册的函数是 register_chrdev_region()

c 复制代码
int register_chrdev_region(dev_t from, unsigned count, const char *name);

参数包括:指定的起始设备号、设备数量、设备名称。

示例:

c 复制代码
dev_t devid = MKDEV(200, 0);
int ret;

ret = register_chrdev_region(devid, 1, "aes_led");
if (ret < 0) {
    printk("register_chrdev_region failed: %d\n", ret);
    return ret;
}

静态注册可能因设备号冲突而失败,这是它的主要问题。如果你指定了一个已经被占用的设备号,注册就会失败。因此,除非你有特殊需求必须用特定的设备号,否则建议使用动态分配。

注销设备号

无论使用哪种方式分配,卸载时都必须注销设备号:

c 复制代码
void unregister_chrdev_region(dev_t from, unsigned count);

示例:

c 复制代码
unregister_chrdev_region(devid, 1);

如果忘记注销,设备号就会被一直占用,即使你的驱动已经卸载了。下次再想用这个设备号就会失败。所以记住,申请了就要释放,这是编程的基本素养。

查看系统中的设备号分配

系统中的设备号分配情况可以从 /proc/devices 查看:

bash 复制代码
$ cat /proc/devices
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  7 vcs
 10 misc
 13 input
  ...
 241 aes_led          # 我们的驱动

这是我们驱动加载后的实际输出。你可以看到,aes_led 驱动被分配了主设备号 241。(哦对,每一个系统上可能被分配的不一样,真的看Linux的心情的)


class 和 device 模型:自动创建设备节点

写驱动最烦的事情是什么?不是写代码,而是每次加载驱动后都要手动创建设备节点。你肯定经历过这样的流程:加载驱动,然后敲 mknod /dev/xxx c 主设备号 次设备号,要是主设备号还是动态分配的,还得先去 /proc/devices 里查一遍。说实话,这个过程重复几次就会让人崩溃。

现代 Linux 系统有一个叫 udev/mdev 的机制,它可以监听内核事件然后自动创建设备节点。但这个机制是怎么工作的?驱动程序需要做什么才能触发它?这就是我们要讨论的 class 和 device 模型。

从 sysfs 说起:内核的设备表示层

在深入代码之前,我们需要先理解 sysfs。你可以把它想象成一个内核向用户空间展示的"设备地图"。当你挂载 /sys 目录时,实际上是在查看内核内部维护的设备层次结构。

bash 复制代码
$ ls /sys/
block/  bus/  class/  dev/  devices/  firmware/  kernel/  module/  power/

我们关注的是 /sys/class/ 这个目录。这里按类别组织了系统中的所有设备。当你创建一个字符设备驱动时,如果正确使用了 class 和 device API,这里就会出现对应的条目。

这个目录结构不是摆设,它是 udev/mdev 工作的基础。当驱动程序在 sysfs 中注册设备时,内核会发送一个 uevent 事件,用户空间的设备管理器监听这些事件,然后自动在 /dev 下创建对应的设备节点。

所以整个流程是这样的:驱动调用 API → 内核创建 sysfs 条目 → 内核发送 uevent → udev/mdev 创建设备节点。我们的任务就是搞定前两步,后面的由系统自动完成。

class_create():创建设备类别

我们首先要创建一个"类"(class)。这个概念听起来很抽象,你可以把它理解为一个设备分类。比如所有的 LED 设备可以归到 led 类,所有的 TTY 设备归到 tty 类。

在老内核(Linux 4.x)时代,class_create() 的签名是这样的:

c 复制代码
struct class *class_create(struct module *owner, const char *name);

你需要在调用时传入 THIS_MODULE 作为 owner 参数。但说实话,内核开发者分析大量驱动代码后发现,这个参数几乎总是被设置为 THIS_MODULE。既然如此,为什么不自动推断呢?于是新内核(Linux 5.x+)把这个参数去掉了:

c 复制代码
struct class *class_create(const char *name);

现在我们只需要传入类名称就可以了。这个名称会出现在 /sys/class/<name>/ 路径中。比如我们传 "aes_led",就会创建 /sys/class/aes_led/ 目录。

调用代码非常简单:

c 复制代码
struct class *led_class;

led_class = class_create("aes_led");
if (IS_ERR(led_class)) {
    pr_warn("Failed to create class: %ld\n", PTR_ERR(led_class));
    return PTR_ERR(led_class);
}

pr_info("class create success!\n");

这里有个细节值得注意。class_create() 的返回值需要用 IS_ERR() 来检查,而不是直接判断 == NULL。这是因为内核使用了一种叫做"错误指针"的机制来传递详细的错误码。如果函数失败,它会返回一个特殊的指针值,这个指针指向的地址包含错误码信息。IS_ERR() 判断是否为错误指针,PTR_ERR() 提取其中的错误码。

为什么要这样设计?因为如果函数只返回 NULL,调用者就无法知道具体是什么错误(内存不足?参数无效?权限问题?)。通过错误指针机制,内核可以在返回指针的同时传递详细的错误信息。

device_create():真正创建设备

有了类之后,我们就可以创建具体的设备了。device_create() 的参数多一些,我们逐个来看:

c 复制代码
struct device *device_create(
    struct class *class,      /* 设备所属的类 */
    struct device *parent,    /* 父设备,通常填 NULL */
    dev_t devt,               /* 设备号 */
    void *drvdata,            /* 驱动私有数据,通常填 NULL */
    const char *fmt, ...      /* 设备名称,支持 printf 格式化 */
);

第一个参数就是我们刚才创建的 led_class。设备必须属于某个类,这样才能触发 uevent 机制。

第二个参数是父设备,用于建立设备层次结构。对于简单的字符设备,我们通常填 NULL 表示没有父设备。

第三个参数是设备号,这应该是在之前调用 alloc_chrdev_region() 时分配的。

第四个参数是驱动私有数据,你可以传入任意指针,之后通过 dev_get_drvdata() 获取。如果不需要就填 NULL

第五个参数最有意思,它支持 printf 风格的格式化字符串。这意味着你可以这样批量创建设备:

c 复制代码
for (i = 0; i < 3; i++) {
    dev_t dev = MKDEV(major, i);
    device_create(cls, NULL, dev, NULL, "led%d", i);
}

// 创建的结果:
// /dev/led0, /dev/led1, /dev/led2

完整的调用代码如下:

c 复制代码
struct device *led_device;

led_device = device_create(led_class, NULL, devid, NULL, "AES_LED");
if (IS_ERR(led_device)) {
    pr_warn("Failed to create device: %ld\n", PTR_ERR(led_device));
    class_destroy(led_class);  // 清理已创建的 class
    return PTR_ERR(led_device);
}

pr_info("device create success!\n");

当这段代码执行后,系统会发生什么?首先,内核会在 /sys/class/aes_led/AES_LED/ 创建一个目录,里面包含 devueventsubsystem 等文件。然后内核发送 uevent 事件,udev/mdev 监听到这个事件后,会读取 dev 文件获取设备号,然后在 /dev/ 下创建设备节点。

你可以通过 ls 命令验证设备节点是否创建成功:

bash 复制代码
$ ls -l /dev/AES_LED
crw-------    1 root     root      241,   0 ... /dev/AES_LED

这里的 241, 0 就是主设备号和次设备号,c 表示这是一个字符设备。

udev/mdev 的工作原理

虽然这一部分主要在用户空间,但了解一下对我们理解整个流程很有帮助。udev 是桌面系统和服务器上使用的完整设备管理器,而 mdev 是 BusyBox 提供的简化版本,专门用于嵌入式系统。

mdev 的工作流程大致是这样的:它打开一个 netlink socket 监听内核的 uevent 事件,然后在一个循环中不断接收事件。当接收到设备添加事件时,它会解析事件内容,提取设备信息,然后调用 mknod() 创建设备节点。

c 复制代码
// mdev 的简化逻辑(伪代码)
void mdev_main(void)
{
    sock = socket(PF_NETLINK, SOCK_DGRAM, NETLINK_KOBJECT_UEVENT);

    while (1) {
        recvmsg(sock, &msg, ...);  // 接收 uevent

        if (strstr(buf, "add@/class/aes_led/AES_LED")) {
            // 从环境变量或事件数据中提取设备号和权限
            mknod("/dev/AES_LED", mode, dev);
        }
    }
}

在嵌入式系统中,通常在启动脚本中配置 mdev:

bash 复制代码
# /etc/init.d/rcS
echo /sbin/mdev > /proc/sys/kernel/hotplug

这样 mdev 就会接管内核的所有热插拔事件。


一个完整的例子

我们把前面讲的东西串起来,看一个完整的设备号分配、cdev 注册和设备节点自动创建的例子:

c 复制代码
static int init_led_handle(struct IMXAesLED *led_handle)
{
    int ret;

    /* 第一步:申请设备号 */
    ret = alloc_chrdev_region(&led_handle->devid, 0, LED_CNT, CHARDEV_NAME);
    if (ret < 0) {
        pr_warn("Failed to alloc chrdev region: %d\n", ret);
        return ret;
    }

    /* 打印分配结果 */
    {
        const int major = MAJOR(led_handle->devid);
        const int minor = MINOR(led_handle->devid);
        pr_info("LED handle get the device number: major: %d, minor: %d\n",
                major, minor);
    }

    /* 第二步:初始化并添加 cdev */
    led_handle->char_device_handle.owner = THIS_MODULE;
    cdev_init(&led_handle->char_device_handle, &fops);

    ret = cdev_add(&led_handle->char_device_handle,
                   led_handle->devid, LED_CNT);
    if (ret < 0) {
        pr_warn("Error when trying to make a cdev in kernel: %d\n", ret);
        unregister_chrdev_region(led_handle->devid, LED_CNT);
        return ret;
    }

    /* 第三步:创建 class 和 device */
    led_handle->char_device_class = class_create(CHARDEV_NAME);
    if (IS_ERR(led_handle->char_device_class)) {
        pr_warn("Failed to create class: %ld\n",
                PTR_ERR(led_handle->char_device_class));
        cdev_del(&led_handle->char_device_handle);
        unregister_chrdev_region(led_handle->devid, LED_CNT);
        return PTR_ERR(led_handle->char_device_class);
    }

    led_handle->char_device = device_create(
        led_handle->char_device_class,
        NULL,
        led_handle->devid,
        NULL,
        CHARDEV_NAME
    );

    if (IS_ERR(led_handle->char_device)) {
        pr_warn("Failed to create device: %ld\n",
                PTR_ERR(led_handle->char_device));
        class_destroy(led_handle->char_device_class);
        cdev_del(&led_handle->char_device_handle);
        unregister_chrdev_region(led_handle->devid, LED_CNT);
        return PTR_ERR(led_handle->char_device);
    }

    pr_info("cdev, class and device series api called success!\n");
    return 0;
}

注意这里的错误处理:如果任何一步失败,我们会按照逆序清理之前分配的资源。这种资源清理的顺序很重要,分配和释放要相反的顺序进行。

对应的清理代码:

c 复制代码
static void release_led_handle(struct IMXAesLED *led_handle)
{
    device_destroy(led_handle->char_device_class, led_handle->devid);
    class_destroy(led_handle->char_device_class);
    cdev_del(&led_handle->char_device_handle);
    unregister_chrdev_region(led_handle->devid, LED_CNT);
}

注意清理的顺序和初始化的顺序是相反的。这是一种常见的编程模式,叫做栈式分配释放。


cdev_device_add():更现代的 API

内核还提供了一个更高级的 API:cdev_device_add()。它把 cdev_add()device_create() 合并成一个操作。

c 复制代码
int cdev_device_add(struct cdev *cdev, struct device *dev);
void cdev_device_del(struct cdev *cdev, struct device *dev);

使用示例:

c 复制代码
struct cdev cdev;
struct device *dev;

// 初始化 cdev
cdev_init(&cdev, &fops);
cdev.owner = THIS_MODULE;

// 初始化 device(需要先设置 devt)
dev->devt = devid;

// 统一注册
ret = cdev_device_add(&cdev, dev);
if (ret) {
    // 自动清理,无需手动处理
    return ret;
}

这个 API 的优势是一次性完成注册,自动设置父子关系,失败时自动清理,代码更简洁。但它的使用场景相对有限,需要你已经有了一个 device 结构体。对于简单的字符设备驱动,传统的 cdev_add() + device_create() 组合可能更直观。


资源清理:逆序原则的重要性

我们前面花了很大篇幅讲如何创建资源,现在要讲如何清理。说实话,这一步做不好,系统真的会炸。

资源清理有一个必须遵守的原则:逆序清理。也就是说,最后创建的资源要最先销毁。为什么要这样?因为后创建的资源可能依赖先创建的资源。如果你先销毁了被依赖的资源,依赖它的资源就会处于"悬空"状态,可能导致内核崩溃或者资源泄漏。

在我们的驱动中,创建顺序是这样的:

复制代码
alloc_chrdev_region() → cdev_add() → class_create() → device_create()

所以清理顺序必须是:

复制代码
device_destroy() → class_destroy() → cdev_del() → unregister_chrdev_region()

device_destroy() 的原型很简单:

c 复制代码
void device_destroy(struct class *class, dev_t devt);

调用它会删除设备,触发 uevent 事件(这次是删除事件),udev/mdev 会自动删除 /dev 节点。

class_destroy() 也很简单:

c 复制代码
void class_destroy(struct class *cls);

它会删除设备类,释放相关资源,/sys/class/aes_led/ 目录会被删除。


常见错误和解决方法

我们总结了一些常见的错误,希望能帮你节省调试时间。

第一个错误是忘记设置 owner。这个我们已经讲过了,但还是要强调一下:

c 复制代码
/* 错误 */
cdev_init(&cdev, &fops);
cdev_add(&cdev, devid, 1);  // 忘记设置 owner

/* 正确 */
cdev.owner = THIS_MODULE;
cdev_init(&cdev, &fops);
cdev_add(&cdev, devid, 1);

第二个错误是设备号冲突。你会看到这样的错误:

复制代码
register_chrdev_region failed: -16 (-EBUSY)

解决方法是使用动态分配 alloc_chrdev_region(),让内核帮你找一个空闲的设备号。

第三个错误是忘记注销设备号:

c 复制代码
/* 错误 */
cdev_del(&cdev);
// 忘记 unregister_chrdev_region

/* 正确 */
cdev_del(&cdev);
unregister_chrdev_region(devid, 1);

忘记注销会导致设备号泄漏,虽然不会立即引起问题,但多次加载卸载后可能会耗尽设备号。

第四个错误是清理顺序错误。如果你先调用 class_destroy() 再调用 device_destroy(),内核会直接崩溃。因为 device 依赖 class,class 销毁了,device 就成了"孤儿"。记住逆序清理这个原则,能省很多麻烦。

第五个错误是忘记检查返回值。class_create()device_create() 都可能失败,如果不检查就直接使用返回的指针,空指针解引用会立刻触发内核崩溃。这一点真的不能偷懒。


struct class 的更多细节

虽然我们使用 class_create() 时不需要传入 class 结构体,但了解一下它的内部结构有助于理解设备模型的工作原理。

c 复制代码
struct class {
    const char *name;                           /* 类名称 */

    const struct attribute_group **class_groups;     /* 类本身的属性 */
    const struct attribute_group **dev_groups;       /* 设备的默认属性 */

    int (*dev_uevent)(const struct device *dev,
                      struct kobj_uevent_env *env);  /* 热插拔事件处理 */
    char *(*devnode)(const struct device *dev,
                     umode_t *mode);                    /* 设备节点权限 */

    void (*class_release)(struct class *class);        /* 类释放回调 */
    void (*dev_release)(struct device *dev);           /* 设备释放回调 */

    const struct dev_pm_ops *pm;                       /* 电源管理操作 */
};

大部分字段我们用不到,但 dev_groups 值得关注。它允许我们为设备创建 sysfs 属性文件。用户空间可以通过读写这些文件与设备交互,这是一种比设备节点更灵活的交互方式。

不过对于简单的字符设备驱动,我们通常不需要关心这些细节。class_create() 会用默认值填充结构体,足够应付大部分场景。


本章小结

这一章我们深入讲解了 Linux 字符设备驱动的核心概念。回顾一下,我们学了什么:

第一,cdev 结构体的各个字段都有它的用途。kobj 是内核对象系统的门面,owner 防止模块被意外卸载,ops 是操作函数集,devcount 是设备号相关。

第二,cdev_init()cdev_add()cdev_del() 是操作 cdev 的三个主要函数。初始化、添加到系统、从系统删除,这是生命周期管理的基本流程。

第三,设备号是一个 32 位数,包含主设备号和次设备号。MKDEV()MAJOR()MINOR() 是操作设备号的三个常用宏。

第四,动态分配和静态注册是分配设备号的两种方式。动态分配避免冲突,是推荐的做法。静态注册适用于有特殊需求必须用特定设备号的场景。

第五,class 和 device API 不仅仅是自动创建设备节点的工具,它们是驱动程序与系统设备管理框架交互的接口。通过正确使用 class_create()device_create(),我们的驱动可以无缝集成到 Linux 系统中。

第六,资源清理的逆序原则是本章的重要知识点,这不是某种约定俗成的风格,而是内核对象依赖关系的必然要求。违反这个原则会导致严重的系统问题。

理解了这些概念,你就掌握了 Linux 字符设备驱动的核心知识。下一篇我们会学习驱动中的错误处理模式,包括如何优雅地处理资源分配失败的情况,以及 goto 标签的正确使用方式。


相关阅读

  1. 嵌入式Linux学习指南之设备树------Linux内核设备树编译机制深度解析 - 相似度 100%
  2. 嵌入式Linux驱动开发指南02------内核空间基础与硬件访问 - 相似度 100%
  3. 入门 · 环境搭建 · 00 · Qt6 安装踩坑指南 - 相似度 80%
相关推荐
无人生还别怕44 分钟前
如何制作一个linux标准iso镜像
linux·iso·opencloudos·openanolisos
Scott9999HH8 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
Yana.nice9 小时前
Linux 只保留 30 天内日志(find命令删除日志文件)
linux·运维·chrome
码智社9 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海9 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖10 小时前
JDK 26 新特性详解
java·开发语言
马优晨10 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
DFT计算杂谈12 小时前
无 Root 权限在 Tesla K80 零门槛部署 DeepSeek 大模型
linux·服务器·网络·数据库·机器学习
人邮异步社区12 小时前
怎么把C语言学到精通?
c语言·开发语言
心平气和量大福大13 小时前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf