4、cdev结构体:cdev_init、cdev_add、cdev_del,将设备与文件操作绑定

好,咱们继续往下走。上一章我们聊了文件操作接口,也就是那个 file_operations 结构体。但光有操作函数还不够,你得告诉内核:「嘿,我这个设备驱动在这儿,它支持这些操作。」

怎么告诉内核?答案就是 cdev 结构体。说白了,cdev 就是内核里用来描述一个字符设备的核心数据结构。你写的驱动,最终都要通过它来注册到内核里。

4.1 cdev 结构体长什么样?

先看一眼它的定义,在 <linux/cdev.h> 里:

struct cdev {
    struct kobject kobj;          // 内核对象,用于设备模型
    struct module *owner;         // 所属模块,通常是 THIS_MODULE
    const struct file_operations *ops;  // 文件操作接口,就是上一章我们填的那个
    struct list_head list;        // 内核内部链表,不用你管
    dev_t dev;                    // 设备号(主+次)
    unsigned int count;           // 次设备号数量
};

嗯,这里我重点说三个字段:

  • owner:填 THIS_MODULE 就行,告诉内核这个设备属于哪个模块。卸载模块时,内核会检查这个字段,防止你还在用设备就把模块卸了。
  • ops:指向你定义的 file_operations 结构体。这就是「绑定」的关键。
  • dev:设备号,主设备号和次设备号打包在一起。

核心理解:cdev 就是一座桥,一头连着设备号(dev),另一头连着文件操作(ops)。用户空间通过设备号找到 cdev,再通过 cdev 找到你的 open、read、write 函数。

4.2 cdev_init:初始化 cdev

拿到一个 cdev 结构体后,第一步是初始化。内核提供了 cdev_init 函数:

void cdev_init(struct cdev *cdev, const struct file_operations *fops);

这个函数做了两件事:

  1. cdev->ops 指向你传进来的 fops
  2. cdev->kobj 初始化好,设置默认的释放函数等。

我个人习惯这样用:

struct cdev my_cdev;

// 先定义 file_operations
static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .write = my_write,
    .release = my_release,
};

// 然后初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;  // 别忘了这一行!

小提示cdev_init 不会帮你设置 owner 字段。我见过不少新手踩这个坑——初始化完了忘了设 owner,结果模块卸载时内核报错。所以每次初始化后,我都习惯手动补一句 my_cdev.owner = THIS_MODULE

4.3 cdev_add:把设备注册进内核

初始化完了,设备还没生效。你得调用 cdev_add 把它加到内核的设备链表里:

int cdev_add(struct cdev *p, dev_t dev, unsigned int count);

参数说明:

  • p:cdev 结构体指针。
  • dev:设备号,通常用 MKDEV(major, minor) 生成。
  • count:关联的次设备号数量。比如你申请了 4 个次设备号,这里就填 4。

返回值:成功返回 0,失败返回负的错误码。

我一般在模块初始化函数里这样写:

static int __init my_init(void)
{
    dev_t devno;
    int ret;

    // 1. 分配设备号(动态分配)
    ret = alloc_chrdev_region(&devno, 0, 1, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "alloc_chrdev_region failed\n");
        return ret;
    }

    // 2. 初始化 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 注册 cdev
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret < 0) {
        printk(KERN_ERR "cdev_add failed\n");
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    printk(KERN_INFO "my_device registered, major=%d minor=%d\n",
           MAJOR(devno), MINOR(devno));
    return 0;
}

注意cdev_add 一旦成功,你的设备就「活」了。用户空间马上就能打开它。所以 一定要在 cdev_add 之前把所有准备工作做完。我曾经在项目里犯过这个错——cdev_add 之后才初始化硬件,结果用户进程一打开设备,硬件还没准备好,直接崩溃。

4.4 cdev_del:卸载设备

有注册就有卸载。模块退出时,必须调用 cdev_del 把设备从内核里移除:

void cdev_del(struct cdev *p);

这个函数会做两件事:

  1. 从内核的设备链表中删除这个 cdev。
  2. 如果此时还有进程在使用这个设备,它会等待所有引用释放后再真正删除。

对应的卸载函数:

static void __exit my_exit(void)
{
    dev_t devno = my_cdev.dev;  // 保存设备号,用于释放

    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);

    printk(KERN_INFO "my_device unregistered\n");
}

重要顺序:先 cdev_del,再 unregister_chrdev_region。顺序反了会怎样?嗯,设备号先释放了,但 cdev 还在链表里,内核可能通过这个设备号访问到已经释放的 cdev,造成 use-after-free 错误。

4.5 核心流程总结

整个流程其实就三步,我画个图帮你理清思路:

cdev 设备注册核心流程 1. cdev_init 初始化 cdev 结构体 绑定 file_operations 2. cdev_add 注册到内核设备链表 指定设备号 + 次设备数量 3. 设备 对外可用 用户可 open 卸载流程(反向) cdev_del 从内核链表移除,等待引用释放 unregister_chrdev_region 释放设备号 注意:卸载顺序不能反,否则可能造成 use-after-free

你看,整个流程非常清晰:

  1. cdev_init:初始化 cdev,绑定文件操作。
  2. cdev_add:注册到内核,指定设备号。
  3. 设备生效,用户空间可以访问。
  4. 卸载时先 cdev_del,再释放设备号。

4.6 避坑指南

最后,我把自己踩过的坑总结一下,你写代码时多留个心眼:

我曾经犯过的错:

  • cdev_add 后忘了检查返回值:有一次我直接忽略了返回值,结果设备号冲突,cdev_add 返回 -EBUSY,但我的驱动继续运行,用户空间打开设备时直接 oops。
  • cdev_del 和 unregister_chrdev_region 顺序搞反:这个前面说过了,use-after-free 的经典案例。
  • 多个次设备共用一个 cdev:如果你申请了多个次设备号,记得在 cdev_add 的 count 参数里填对数量。否则内核只注册了第一个次设备,后面的打开会失败。

我的个人习惯:每次写完 cdev_add 之后,我都会在 /proc/devices 里看一眼,确认设备注册成功了。命令是 cat /proc/devices | grep my_device。如果没看到,说明注册有问题,赶紧查 dmesg。

好了,cdev 这块就讲到这里。你想想看,其实核心就三个函数:init、add、del。把这三个函数用对,字符设备驱动的骨架就搭起来了。下一章我们聊聊设备号的分配策略——静态分配和动态分配,各有各的适用场景。


公众号:蓝海资料掘金营,微信deep3321