3. 字符设备驱动框架
字符设备驱动是 Linux 驱动开发中最基础、也最能体现“内核与用户空间协作”的一类驱动。一个字符设备驱动从代码到用户可访问,需要经过四个关键环节:申请设备号、实现 file_operations、注册 cdev、创建设备节点。理解这条链路,就理解了字符设备驱动的骨架。
一、主次设备号:内核识别设备的“身份证”
在 Linux 中,每个字符设备都由一个 dev_t 类型标识,它本质上是一个 32 位整数,由主设备号和次设备号组成。主设备号用于标识“哪一类驱动”,次设备号用于标识“该类驱动下的哪一个具体设备实例”。内核通过主设备号找到对应的驱动,再通过次设备号区分同一驱动管理的多个设备。
内核提供了三个宏来操作设备号:
MAJOR(dev_t dev) // 提取主设备号
MINOR(dev_t dev) // 提取次设备号
MKDEV(int major, int minor) // 合成设备号
设备号有两种分配方式:静态指定和动态申请。静态指定使用 register_chrdev_region(),需要开发者自己选定一个未被占用的主设备号,容易冲突;动态申请使用 alloc_chrdev_region(),由内核自动分配可用主设备号,是推荐做法。释放时统一使用 unregister_chrdev_region()。
cat /proc/devices 查看实际分配结果,最后在量产时改为静态指定。二、file_operations:用户空间系统调用的“函数跳转表”
用户空间调用 open()、read()、write()、ioctl() 等系统调用时,内核并不会直接执行用户代码,而是通过 file_operations 结构体找到驱动中对应的函数。这个结构体是字符设备驱动的核心接口表,相当于一张“函数跳转表”。
常用成员及其对应关系如下表所示:
| file_operations 成员 | 用户空间系统调用 | 作用说明 |
|---|---|---|
.open |
open() |
打开设备时初始化硬件、申请资源 |
.release |
close() |
关闭设备时释放资源 |
.read |
read() |
从设备读取数据到用户缓冲区 |
.write |
write() |
将用户数据写入设备 |
.unlocked_ioctl |
ioctl() |
执行设备特有的控制命令 |
.llseek |
lseek() |
调整文件读写位置 |
其中 .open 和 .release 是最基本的两个回调,即使不做任何事也建议实现,便于调试和资源管理。.read 和 .write 的返回值必须遵循内核约定:成功返回实际读写的字节数,失败返回负的错误码。
三、cdev 注册:把驱动挂入内核字符设备体系
仅有 file_operations 还不够,内核需要知道“这个设备号对应这个操作集合”。struct cdev 就是连接设备号与 file_operations 的桥梁。注册流程通常分为四步:
- 定义并初始化
struct cdev,使用cdev_init()绑定 file_operations; - 设置
cdev.owner = THIS_MODULE,防止模块在使用中被卸载; - 调用
cdev_add()将 cdev 加入内核,同时指定设备号与设备数量; - 模块卸载时调用
cdev_del()注销,再释放设备号。
static struct cdev my_cdev;
static dev_t my_devno;
static int __init my_init(void)
{
alloc_chrdev_region(&my_devno, 0, 1, "my_char");
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, my_devno, 1);
return 0;
}
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(my_devno, 1);
}
cdev_add() 再 cdev_init(),会导致 file_operations 未绑定,用户打开设备时内核找不到回调函数,出现 oops。顺序必须是先 init、再 add。四、设备节点创建:让用户空间“看得见”设备
cdev 注册成功后,设备只存在于内核中,用户空间还看不到 /dev 下的节点。设备节点是用户空间访问驱动的入口,创建方式有两种:
- 手动创建:使用
mknod /dev/my_char c 主设备号 次设备号,适合调试阶段; - 自动创建:使用
class_create()创建类,再用device_create()创建设备,udev/mdev 会自动在/dev下生成节点,适合产品化。
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, my_devno, NULL, "my_char");
// 卸载时反向操作
device_destroy(my_class, my_devno);
class_destroy(my_class);
自动创建节点依赖内核的 sysfs 与用户空间 udev/mdev 机制。在嵌入式根文件系统中,通常使用 busybox 的 mdev,需要在启动脚本中挂载 sysfs 并启动 mdev,否则 /dev 下不会自动出现节点。
五、完整链路与常见误区复盘
把上述环节串起来,字符设备驱动从加载到用户访问的完整链路如下:
结合项目实践,初学者最容易踩的坑集中在以下几点:
- 设备号泄漏:模块卸载时忘记
unregister_chrdev_region(),反复加载后主设备号被耗尽; - cdev 与设备号不匹配:
cdev_add()传入的设备号与申请的不一致,导致用户打开错误设备; - 忘记设置 owner:模块在使用中被
rmmod,内核直接崩溃; - 节点创建失败:根文件系统未挂载 sysfs 或未启动 mdev,
/dev下始终没有节点; - 返回值错误:read/write 返回正数错误码,用户空间误判为成功读取。
pr_info 打印主次设备号,配合 dmesg 与 ls -l /dev/my_char 交叉验证,能快速定位设备号与节点是否一致。掌握主次设备号、file_operations、cdev 注册与设备节点创建这四步,就具备了编写任意字符设备驱动的基础能力。后续的 GPIO、I2C、SPI 字符驱动,本质上都是在这套框架上填充具体的硬件操作函数。