字符设备驱动基础:file_operations、设备号与cdev

字符设备驱动,说白了就是 Linux 驱动里最基础、最常用的一类。你写个 LED 灯、按键、串口、I2C 传感器,本质上都是字符设备。我刚开始学驱动时,觉得这玩意儿挺玄乎,后来发现核心就三样东西:file_operations 结构体、设备号、cdev 对象。搞懂这三样,字符驱动你就入门了。

1. file_operations:驱动与应用的“接口契约”

应用层调用 open、read、write、ioctl,内核怎么知道该执行哪段代码?答案就在 file_operations 结构体里。它是一张函数指针表,每个成员对应一个系统调用。

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    // ... 还有不少,但常用的就这几个
};

你想想看,应用层调用 read(fd, buf, len),内核 VFS 层会找到这个设备对应的 file_operations,然后调用你注册的 .read 函数。这就是驱动的入口。

我的习惯:每次写驱动,我都会先把 file_operations 实例化,哪怕有些函数暂时为空。这样结构清晰,后面加功能也方便。

这里有个坑——.owner 一定要赋值为 THIS_MODULE。我曾经漏掉这一行,结果模块卸载时系统直接崩溃。为什么?因为内核需要知道这个结构体属于哪个模块,防止你在模块卸载后还有人调用你的函数。

2. 主次设备号:设备的“身份证”

设备号分两部分:主设备号(major)和次设备号(minor)。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。

概念 作用 示例
主设备号 告诉内核用哪个驱动 89 对应 I2C 总线
次设备号 区分同驱动下的不同设备 0 号 I2C 控制器、1 号 I2C 控制器

分配设备号有两种方式:

  • 静态分配:自己指定主设备号,调用 register_chrdev_region()。适合你知道这个号没被占用的场景。
  • 动态分配:调用 alloc_chrdev_region(),让内核给你分配一个。我强烈推荐这种方式,省心。
dev_t dev_num;
int major, minor;

// 动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk("Major: %d, Minor: %d\n", major, minor);
注意:设备号是系统全局资源,用完一定要释放。我曾经在测试时忘了调用 unregister_chrdev_region(),结果第二次加载模块时设备号冲突,驱动死活注册不上。

3. cdev 注册与注销:驱动的“户口登记”

cdev 是内核里代表字符设备的核心结构体。你得先初始化它,然后注册到内核,内核才知道“哦,这个设备号对应这个驱动”。

标准流程分三步:

  1. 初始化 cdev:cdev_init(&cdev, &fops),把 file_operations 绑定上去。
  2. 添加 cdev:cdev_add(&cdev, dev_num, count),告诉内核这个设备号归你管。
  3. 注销 cdev:cdev_del(&cdev),模块卸载时调用。
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. 注册到内核
cdev_add(&my_cdev, dev_num, 1);

// 模块卸载时
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
关键顺序:先分配设备号,再 cdev_add。反过来会出问题——cdev_add 成功了,但设备号还没分配,内核会一脸懵。

4. 设备节点创建:让应用层“看见”你的设备

设备节点就是 /dev/my_device 这样的文件。应用层通过它来操作设备。创建节点有两种方式:

  • 手动创建:mknod /dev/my_device c 240 0(c 表示字符设备,240 是主设备号,0 是次设备号)。调试时用用还行,产品里别这么干。
  • 自动创建:使用 udev 或 mdev。驱动里注册设备类,内核会自动通知用户空间创建设备节点。

我推荐用自动创建的方式。在驱动里加上这几行:

static struct class *my_class;

// 在模块初始化时
my_class = class_create(THIS_MODULE, "my_device_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");

// 在模块卸载时
device_destroy(my_class, dev_num);
class_destroy(my_class);

这样,驱动加载后 /dev/my_device 就自动出现了。省心又规范。

避坑指南:我曾经在 device_create 时传错了次设备号,结果节点指向了错误的设备。读写数据全乱套。排查了半天才发现是次设备号写成了 1 而不是 0。细节决定成败啊。

5. 完整流程与知识结构

把上面几块串起来,就是字符设备驱动的完整生命周期。我画了张图,帮你理清脉络:

字符设备驱动注册与注销完整流程 模块初始化入口 ① alloc_chrdev_region() 分配设备号 ② cdev_init() 初始化 cdev 对象 ③ cdev_add() 注册到内核 ④ class_create + device_create 创建设备节点 设备运行,应用可访问 模块卸载入口 ⑤ device_destroy + class_destroy ⑥ cdev_del() 注销 cdev ⑦ unregister_chrdev_region() 注意:注册和注销的顺序必须严格对称,否则会导致资源泄漏或内核崩溃

这张图把注册流程(左侧绿色)和注销流程(右侧红色)对照着画出来了。你写驱动时,就照着这个顺序来,基本不会出错。

6. 常见问题与排查思路

我整理了几个新手最容易踩的坑,你写代码时多留个心眼:

问题现象 可能原因 排查方法
open 返回 -ENODEV cdev_add 没成功,或设备号不匹配 检查 dmesg,确认设备号是否正确注册
write 没反应 file_operations 里 .write 没实现或返回错误 加 printk 调试,确认函数是否被调用
模块卸载时死机 cdev_del 顺序不对,或 .owner 没设 检查卸载函数中的资源释放顺序
/dev 下没有节点 device_create 没调用,或 udev 规则不对 手动 mknod 测试,确认驱动本身没问题
重要提醒:cdev_add 之后,你的驱动就暴露给内核了。如果此时你的初始化还没完成(比如中断还没注册、硬件还没复位),其他进程可能已经来访问了。我建议把 cdev_add 放在初始化的最后一步。

嗯,字符设备驱动的基础就这些。说白了就是:分配设备号 → 初始化 cdev → 注册到内核 → 创建节点。这四个步骤串起来,就是一个完整的字符设备驱动骨架。后面的章节我们会在这个骨架上填肉,加上硬件操作、中断处理、锁机制等等。