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 的桥梁。注册流程通常分为四步:

  1. 定义并初始化 struct cdev,使用 cdev_init() 绑定 file_operations;
  2. 设置 cdev.owner = THIS_MODULE,防止模块在使用中被卸载;
  3. 调用 cdev_add() 将 cdev 加入内核,同时指定设备号与设备数量;
  4. 模块卸载时调用 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 下不会自动出现节点。

五、完整链路与常见误区复盘

把上述环节串起来,字符设备驱动从加载到用户访问的完整链路如下:

字符设备驱动注册与访问链路 内核空间 申请设备号 alloc_chrdev_region 绑定操作集 cdev_init + fops 注册 cdev cdev_add 创建类与设备 class_create + device_create 用户空间 /dev/my_char 设备节点 open / read / write / ioctl file_operations 回调 udev/mdev

结合项目实践,初学者最容易踩的坑集中在以下几点:

  • 设备号泄漏:模块卸载时忘记 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 字符驱动,本质上都是在这套框架上填充具体的硬件操作函数。