字符设备驱动基础(一):设备号、file_operations、cdev 结构体
字符设备驱动,说白了就是 Linux 世界里最基础、最常用的一类驱动。你想想看,串口、GPIO、I2C、SPI……这些外设本质上都是字符设备。今天这一章,我们就来把字符设备驱动的几个核心概念彻底讲透。
我个人习惯,学驱动一定要先搞懂三个东西:设备号、file_operations 结构体、cdev 结构体。这三者就像驱动程序的身份证、操作手册和注册表,缺一不可。
设备号:驱动与内核的“身份证”
每个字符设备在内核里都有一个唯一的编号,这就是设备号。它由两部分组成:主设备号和次设备号。
- 主设备号:标识设备对应的驱动程序。比如所有串口设备可能共用同一个主设备号。
- 次设备号:标识同一个驱动管理的不同设备实例。比如串口1、串口2。
在内核源码里,设备号用 dev_t 类型表示,它是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。
关键宏操作:
MAJOR(dev_t dev):从设备号中提取主设备号MINOR(dev_t dev):从设备号中提取次设备号MKDEV(int major, int minor):将主次设备号合成一个 dev_t
我在项目中遇到过一个问题:有同事直接拿 MKDEV 生成的设备号去注册,结果发现次设备号范围算错了,导致驱动只能管理 16 个设备。嗯,这里要注意——次设备号是 20 位,范围是 0~1048575,别想当然。
手动分配 vs 自动分配设备号
分配设备号有两种方式,我分别说说它们的用法和坑。
手动分配:register_chrdev_region
这种方式适合你知道要用的主设备号是多少。比如某些经典设备号是固定的(像 1 号给内存设备,4 号给 tty 设备)。
#include <linux/fs.h>
dev_t dev_num;
int major = 200; // 假设我们用 200 号
int minor = 0;
int count = 1;
dev_num = MKDEV(major, minor);
ret = register_chrdev_region(dev_num, count, "my_device");
if (ret < 0) {
printk("设备号注册失败\n");
return ret;
}
手动分配的好处是设备号固定,方便用户空间 mknod。但坏处也很明显——万一你选的号被别的驱动用了,就冲突了。我曾经在调试一块开发板时,发现某个驱动死活加载不上,查了半天才发现是主设备号跟内核里一个内置驱动撞车了。
自动分配:alloc_chrdev_region
现在的主流做法是让内核动态分配。你只需要告诉内核你要几个设备,内核会帮你挑一个没被占用的主设备号。
dev_t dev_num;
int minor = 0;
int count = 1;
const char *name = "my_device";
ret = alloc_chrdev_region(&dev_num, minor, count, name);
if (ret < 0) {
printk("动态分配设备号失败\n");
return ret;
}
printk("分配的主设备号: %d\n", MAJOR(dev_num));
我个人更推荐用自动分配。原因很简单:省心。你不需要去查哪些号被占用了,内核帮你搞定。用户空间可以通过 /proc/devices 查看分配到的设备号,然后用 mknod 手动创建设备节点。
| 分配方式 | 函数 | 适用场景 | 风险 |
|---|---|---|---|
| 手动分配 | register_chrdev_region | 设备号固定、已知 | 可能冲突 |
| 自动分配 | alloc_chrdev_region | 通用、推荐 | 设备号不固定 |
file_operations 结构体:驱动与应用的“操作手册”
应用层调用 open、read、write、ioctl 这些系统调用时,内核怎么知道该执行哪段驱动代码?答案就在 file_operations 结构体里。
这个结构体定义在 <linux/fs.h> 中,里面全是函数指针。你只需要把你实现的函数地址填进去,内核就会在对应操作发生时调用它们。
struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
};
这里有个细节:.owner 一定要赋值为 THIS_MODULE。为什么?因为内核需要知道这个结构体属于哪个模块,防止模块被卸载后还有人调用这些函数。我见过一个新手写的驱动,忘了设 owner,结果模块卸载后系统直接 oops 了。
我的经验:刚开始写驱动时,别贪多。先实现 open 和 release,能正常打开关闭设备,再慢慢加 read/write。这样调试起来问题容易定位。
cdev 结构体:驱动的“注册表”
有了设备号,有了操作函数集,怎么把它们告诉内核?这就需要 cdev 结构体了。它把设备号和 file_operations 绑定在一起,然后注册到内核的设备管理体系中。
使用 cdev 的步骤很固定,我一般按这个流程来:
- 定义一个
struct cdev变量(或动态分配) - 调用
cdev_init初始化,绑定 file_operations - 调用
cdev_add将设备添加到内核 - 模块卸载时调用
cdev_del删除设备
struct cdev my_cdev;
dev_t dev_num;
// 1. 分配设备号(假设用自动分配)
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk("cdev_add 失败\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
这里有个容易踩的坑:cdev_add 之后,你的设备就“活”了。内核随时可能调用你的 open/read 函数。所以一定要确保在 cdev_add 之前,所有必要的初始化都完成了。我曾经在 cdev_add 之后才去申请中断,结果中断处理函数里访问了一个空指针……嗯,血的教训。
完整流程:从设备号到设备节点
把上面几个概念串起来,一个字符设备驱动的注册流程就是:
- 分配设备号(手动或自动)
- 初始化 cdev 结构体,绑定 file_operations
- 调用 cdev_add 注册设备
- 在 /proc/devices 中查看设备号
- 用户空间用 mknod 创建设备节点
- 应用程序通过设备节点访问驱动
卸载时反过来:先 cdev_del,再 unregister_chrdev_region。
注意:cdev_del 和 unregister_chrdev_region 的顺序不能反。先注销设备号的话,cdev 还在内核里,可能造成野指针访问。
知识结构图
下面这张图把字符设备驱动的核心关系画清楚了,我建议你多看几遍:
从这张图可以看得很清楚:应用程序通过设备节点找到设备号,设备号关联到 cdev,cdev 又指向 file_operations,最终调用到你写的驱动函数。整个链路清晰明了。
避坑指南
最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路:
- 设备号冲突:手动分配时一定要确认主设备号没被占用。查看
/proc/devices是个好习惯。 - cdev_add 时机:所有资源申请完毕后再调用 cdev_add。别像我一样,先注册再申请中断。
- owner 字段:file_operations 和 cdev 的 owner 都要设成 THIS_MODULE,缺一不可。
- 卸载顺序:先 cdev_del,再 unregister_chrdev_region。顺序反了会出问题。
- 设备节点权限:mknod 创建的设备节点默认权限是 0600,如果应用层没有 root 权限可能打不开。记得用 chmod 调整。
字符设备驱动的基础就这些。说白了,就是设备号、file_operations、cdev 这三个东西的组合。搞懂了它们,后面的实战章节你就不会觉得吃力了。