3. 字符设备驱动模型
字符设备驱动是 Linux 驱动开发中最基础、也最能体现内核对象模型的一类驱动。要真正理解字符设备驱动,不能只记住 register_chrdev 或 alloc_chrdev_region 的调用顺序,而要把五个核心概念串成一条完整的注册链路:主次设备号负责标识设备身份,dev_t 是设备号的内核数据类型,cdev 把字符设备挂入内核的字符设备映射表,file_operations 提供用户空间系统调用到驱动函数的跳转入口,设备注册流程则把上述对象按正确顺序装配起来。本节围绕这条链路展开,帮助读者建立“设备号—内核对象—操作函数—用户接口”的整体认知。
一、主次设备号与 dev_t:设备身份的双层编码
在 Linux 中,每个字符设备都由一个设备号唯一标识。设备号并不是一个简单的整数,而是由主设备号(major)和次设备号(minor)共同组成的复合编码。主设备号用于标识“使用哪一个驱动”,内核通过主设备号找到对应的 file_operations;次设备号用于标识“该驱动管理的哪一个具体设备实例”,例如同一驱动下的第 0 路串口、第 1 路串口。内核用 dev_t 类型保存设备号,在 32 位系统中通常为 32 位无符号整数,其中高 12 位为主设备号,低 20 位为次设备号;在 64 位系统中编码方式有所扩展,但对外接口保持一致。
内核提供了一组宏来拆分和组合设备号,驱动代码中不应手工进行位运算,而应使用标准宏:
#include <linux/kdev_t.h>
dev_t dev = MKDEV(major, minor); /* 组合主次设备号 */
unsigned int major = MAJOR(dev); /* 提取主设备号 */
unsigned int minor = MINOR(dev); /* 提取次设备号 */
设备号有两种分配方式:静态指定和动态分配。静态指定使用 register_chrdev_region(),需要开发者事先确定一个未被占用的主设备号,适合有固定设备号规范的产品;动态分配使用 alloc_chrdev_region(),由内核自动选择空闲主设备号,适合大多数新驱动,可避免设备号冲突。无论哪种方式,释放时都应调用 unregister_chrdev_region(),否则会造成设备号泄漏。
mknod /dev/mydev c 240 0 时,其中的 240 和 0 就是主、次设备号,内核据此把打开操作路由到对应驱动。二、cdev 结构体:字符设备的内核代表
struct cdev 是内核对字符设备的抽象表示,它把设备号与 file_operations 绑定在一起,并挂入内核的字符设备映射表。一个典型的字符设备驱动会为每个设备实例分配一个 cdev,或者为同一类设备共享一个 cdev 并通过次设备号区分实例。其核心字段包括 kobj(内嵌的 kobject,用于 sysfs 和引用计数)、owner(通常为 THIS_MODULE,防止模块在使用中被卸载)、ops(指向 file_operations)以及 dev(设备号)。
cdev 的生命周期管理有一套标准接口:
cdev_init():初始化 cdev,并绑定 file_operations。cdev_add():把 cdev 加入内核字符设备表,使设备号正式生效。cdev_del():从内核表中移除 cdev,通常在模块卸载时调用。cdev_alloc()/kfree():动态分配和释放 cdev 内存。
需要特别注意的是,cdev_add() 成功返回后,用户空间就可能立刻打开设备并调用驱动函数。因此,所有在 open 路径中会用到的资源,必须在 cdev_add() 之前完成初始化,否则会出现竞态。
三、file_operations:系统调用到驱动的跳转表
struct file_operations 是字符设备驱动与用户空间之间的契约。用户调用 open()、read()、write()、ioctl()、release() 等系统调用时,VFS 会根据设备号找到 cdev,再通过 cdev 中的 ops 指针找到对应的函数。驱动开发者不需要实现全部成员,只需实现业务需要的回调,未实现的成员保持为 NULL,内核会返回合适的错误码。
常见成员及其职责如下表所示:
| 成员 | 对应系统调用 | 典型职责 |
|---|---|---|
open | open() | 初始化设备、申请资源、设置 private_data |
release | close() | 释放资源、关闭硬件、清理状态 |
read | read() | 从设备读取数据到用户缓冲区 |
write | write() | 把用户数据写入设备 |
unlocked_ioctl | ioctl() | 执行设备特有的控制命令 |
llseek | lseek() | 调整文件读写位置 |
poll | poll()/select() | 支持多路复用与阻塞等待 |
在实现 read 和 write 时,必须使用 copy_to_user() 和 copy_from_user() 完成内核空间与用户空间的数据交换,不能直接解引用用户指针。返回值方面,成功时返回实际传输的字节数,失败时返回负的错误码,如 -EFAULT、-EINVAL。
四、设备注册流程:从模块加载到设备可用
一个完整的字符设备注册流程通常包含以下步骤:申请设备号、初始化 cdev、添加 cdev、创建设备节点、实现 file_operations。下面给出一个最小可用的注册骨架:
static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;
static int __init my_init(void)
{
int ret;
ret = alloc_chrdev_region(&devno, 0, 1, "mydev");
if (ret < 0)
return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
unregister_chrdev_region(devno, 1);
return ret;
}
my_class = class_create(THIS_MODULE, "mydev");
device_create(my_class, NULL, devno, NULL, "mydev");
return 0;
}
static void __exit my_exit(void)
{
device_destroy(my_class, devno);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(devno, 1);
}
上述流程中,class_create() 与 device_create() 负责在 /sys/class 和 /dev 下自动创建设备节点,配合 udev 或 mdev 可实现热插拔式的节点管理。若使用传统方式,也可以手动执行 mknod 创建节点。加载模块后,可通过以下命令验证:
insmod mydev.ko
cat /proc/devices | grep mydev
ls -l /dev/mydev
dmesg | tail
下图展示了字符设备驱动从模块加载到用户访问的完整对象关系与数据流:
五、常见误区与复盘要点
误区一:先 cdev_add 再初始化资源。cdev_add 成功后设备即可被打开,若此时 open 中访问的缓冲区、锁或硬件尚未初始化,会直接触发内核崩溃或竞态。正确顺序是先完成所有资源准备,再 cdev_add。
误区二:忘记释放设备号。只调用 cdev_del 而不调用 unregister_chrdev_region,会导致设备号泄漏,多次加载卸载后可能无法再申请到设备号。
误区三:直接访问用户指针。read/write 中必须使用 copy_to_user/copy_from_user,否则在开启 SMAP 或用户地址不可访问时会出错。
误区四:owner 未设置为 THIS_MODULE。这会使模块在设备被打开时仍可能被卸载,导致 use-after-free。
复盘时可以用一条主线检查驱动是否完整:设备号是否申请并正确释放、cdev 是否初始化并添加、file_operations 是否覆盖了业务所需的系统调用、设备节点是否自动或手动创建、错误路径是否逐级回滚。把这五点做成清单,在每次编写新字符设备驱动时逐项核对,可以显著减少低级错误。掌握这条注册链路后,再学习并发控制、阻塞 I/O、ioctl 命令编码等进阶主题,就会有一个稳固的落脚点。