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);
这个函数做了两件事:
- 把
cdev->ops指向你传进来的fops。 - 把
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);
这个函数会做两件事:
- 从内核的设备链表中删除这个 cdev。
- 如果此时还有进程在使用这个设备,它会等待所有引用释放后再真正删除。
对应的卸载函数:
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_init:初始化 cdev,绑定文件操作。
- cdev_add:注册到内核,指定设备号。
- 设备生效,用户空间可以访问。
- 卸载时先 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