字符设备驱动框架:file_operations、主次设备号与cdev注册

字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动模型。你想想看,键盘、鼠标、串口、LED灯——这些按字节流读写的外设,几乎都是用字符驱动来管理的。今天我就带你把这个框架彻底吃透。

1. 驱动与应用程序的“契约”:file_operations

应用程序怎么跟你的驱动打交道?答案就在 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 *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    /* 还有ioctl、mmap等,先掌握上面这些 */
};

我个人习惯,写驱动时至少实现 open、release、read、write 这四个。为什么?因为用户态最常用的就是打开设备、读写数据、关闭设备。你想想看,如果连 read 都不实现,那应用程序调用 read() 就会返回 -EINVAL,用户肯定骂娘。

小技巧: .owner = THIS_MODULE 一定要写。我曾经见过新手漏掉这行,结果模块在被使用时被 rmmod 强制卸载,直接 kernel panic。这个字段告诉内核:“有人在用我,别卸我。”

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

每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:主设备号 和 次设备号。

概念 作用 示例
主设备号 标识设备对应的驱动程序 89 对应 i2c 总线
次设备号 区分同类型设备的不同实例 0 表示第一个设备

分配设备号有两种方式:

  1. 静态分配:自己指定一个数字,比如 MKDEV(200, 0)。但容易冲突,不推荐。
  2. 动态分配:调用 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);  /* 提取次设备号 */
注意: 动态分配后,记得在模块退出时调用 unregister_chrdev_region() 释放。我曾经在项目里漏了这步,结果设备号被占用,下次 insmod 直接失败,排查了半天。

3. 核心数据结构:cdev

cdev 是内核里代表字符设备的对象。你得先初始化它,然后告诉内核:“这个设备归我管。”

struct cdev my_cdev;

/* 方式一:静态初始化 */
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

/* 方式二:动态分配(推荐,更灵活) */
struct cdev *my_cdev = cdev_alloc();
my_cdev->owner = THIS_MODULE;
my_cdev->ops = &my_fops;

初始化完 cdev,最后一步是注册:

int ret = cdev_add(my_cdev, dev_num, 1);
if (ret < 0) {
    printk(KERN_ERR "cdev_add failed\n");
    /* 别忘了清理前面分配的资源 */
}

这里第三个参数是次设备号的数量。如果你要管理多个同类设备,比如 4 个串口,就传 4。

4. 完整框架:从入口到出口

把上面三块拼起来,就是一个完整的字符设备驱动骨架。我画了一张图帮你理清流程:

字符设备驱动注册流程 module_init(my_init) alloc_chrdev_region() cdev_init() / cdev_alloc() cdev_add() module_exit(my_exit) 出口函数中必须按相反顺序释放:cdev_del() → unregister_chrdev_region()

嗯,这里要注意:注册顺序和注销顺序必须相反。先加的cdev,后删;先分配的设备号,后释放。这是内核编程的基本素养。

5. 避坑指南:我踩过的三个坑

  • 坑一:cdev_add 后立即访问设备。我曾经在 cdev_add 之后、初始化完成之前,用户态就打开了设备。解决方案:在 cdev_add 之前完成所有硬件初始化。
  • 坑二:次设备号数量传错。如果你要管理 4 个设备,但 cdev_add 的第三个参数传了 1,那么只有第一个设备能用,后面三个打开就报错。
  • 坑三:忘记设置 owner。前面提过,不设 owner 会导致模块被强制卸载时崩溃。这个 bug 特别隐蔽,因为大多数时候 rmmod 不会触发,但一旦触发就是死机。
核心要点: 字符设备驱动的本质就是三件事——定义 file_operations、分配设备号、注册 cdev。把这三步刻在脑子里,写任何字符驱动都不会跑偏。

6. 实战:一个最小化的字符驱动

光说不练假把式。我给你一个可以直接编译的骨架,你拿去改改就能用:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>

static dev_t dev_num;
static struct cdev my_cdev;

static int my_open(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "device opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "device closed\n");
    return 0;
}

static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .release = my_release,
};

static int __init my_init(void) {
    alloc_chrdev_region(&dev_num, 0, 1, "my_demo");
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    cdev_add(&my_cdev, dev_num, 1);
    printk(KERN_INFO "my_demo loaded, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "my_demo unloaded\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

这个驱动虽然不读写数据,但框架是完整的。你加载后用 cat /proc/devices 就能看到它。然后 mknod /dev/my_demo c 主设备号 0 创建设备节点,再用 echo "hello" > /dev/my_demo 测试——虽然 write 没实现会报错,但 open 和 release 的打印信息会出现在 dmesg 里。

字符设备驱动框架,说白了就是这三板斧。你把这个骨架练熟了,后面加 read/write、加 ioctl、加硬件操作,都是在这个基础上添砖加瓦。好,这一节就到这里,你自己动手试试看。