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

字符设备驱动,是Linux驱动里最基础、也最常用的一类。说白了,就是那些像串口、按键、LED一样,数据按字节流读写,不支持随机访问的设备。我刚开始学驱动时,就是从字符设备入手的。今天咱们就把它的核心骨架拆开看看。

3.1 驱动与应用程序的桥梁:file_operations

应用程序调用 open()、read()、write() 这些系统调用时,内核怎么知道该执行哪段驱动代码?答案就在 file_operations 结构体里。它就像一个函数跳转表,把系统调用和驱动里的具体实现一一对应起来。

核心结构体定义(Linux 5.x内核):

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 *);
    __poll_t (*poll) (struct file *, struct poll_table_struct *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    /* ... 还有不少,但上面这些最常用 */
};

每个成员都是一个函数指针。你想想看,驱动开发的大部分工作,其实就是实现这些函数。我个人习惯,写驱动时至少会实现 open、release、read、write 这四个,其他的按需添加。

避坑指南: 我曾经在 read 函数里忘了检查用户空间指针的合法性,结果内核直接 oops。记住,从用户空间拷贝数据一定要用 copy_to_user() / copy_from_user(),千万别直接 memcpy!

3.2 设备的身份证:主次设备号

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

概念 作用 示例
主设备号 标识设备对应的驱动程序 89 对应 I2C 设备
次设备号 标识同类型设备的不同实例 0 表示第一个 I2C 适配器

分配设备号有两种方式:

  1. 静态分配:手动指定一个主设备号。适合你知道这个号没被占用的场景,比如实验或内部驱动。
  2. 动态分配:调用 alloc_chrdev_region() 让内核自动分配。我强烈推荐这种方式,省心,不会冲突。
dev_t dev_num;  // 设备号,32位,高12位是主设备号,低20位是次设备号

// 动态分配:申请 3 个设备号,起始次设备号为 0
if (alloc_chrdev_region(&dev_num, 0, 3, "my_driver") < 0) {
    printk(KERN_ERR "Failed to allocate device numbers\n");
    return -EIO;
}

// 提取主设备号和次设备号
int major = MAJOR(dev_num);
int minor = MINOR(dev_num);
printk("Allocated major=%d, minor=%d\n", major, minor);

注意: 卸载驱动时,一定要记得用 unregister_chrdev_region() 释放设备号。我见过有人忘了这步,结果下次 insmod 时设备号分配失败,折腾了半天才发现是资源泄漏。

3.3 核心注册流程:cdev 的完整生命周期

有了 file_operations 和设备号,接下来就是把它们绑定到一起,注册到内核里。这个工作由 cdev 结构体完成。

注册流程其实就四步,我习惯把它写成模板,每次写新驱动时直接套用:

  1. 分配 cdev 对象:cdev_alloc() 或静态定义
  2. 初始化 cdev:cdev_init(),绑定 file_operations
  3. 添加 cdev 到内核:cdev_add(),指定设备号范围
  4. 创建设备节点:手动 mknod 或通过 udev 自动创建
#include <linux/cdev.h>
#include <linux/fs.h>

static struct cdev my_cdev;
static dev_t dev_num;

static int __init my_driver_init(void)
{
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) return ret;

    // 2. 初始化 cdev,绑定 file_operations
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 注册到内核
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk("My driver loaded, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit my_driver_exit(void)
{
    // 4. 卸载时反向操作
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk("My driver unloaded\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

经验之谈: cdev_add() 之后,内核就认为这个设备已经就绪了。所以 cdev_add() 之前一定要确保所有初始化都完成,否则用户空间一打开设备,可能访问到未初始化的资源。我习惯把 cdev_add() 放在初始化函数的最后一步。

3.4 整体流程可视化

为了让你更直观地理解整个注册流程,我画了一张图。它展示了从驱动入口到设备节点创建的全链路:

字符设备驱动注册完整流程 module_init() alloc_chrdev_region() cdev_init() + fops绑定 cdev_add() mknod / udev自动创建 open/read/write 关键说明 • 每一步失败都要回滚 • cdev_add 是最后一步 • 卸载顺序与加载相反 数据结构关系 cdev 包含 fops 指针 设备号标识 cdev inode 关联 cdev

3.5 卸载时的注意事项

有加载就有卸载。卸载驱动的顺序,刚好和加载相反:

  • 先调用 cdev_del() 从内核中移除 cdev
  • 再调用 unregister_chrdev_region() 释放设备号

这里有个坑:如果 cdev_del() 执行时,还有进程在访问这个设备,内核会等待引用计数归零后才真正删除。但如果你在 release 函数里访问了已经释放的资源,那就麻烦了。所以,我建议在 cdev_del() 之前,先设置一个标志位,让 open 函数返回 -ENODEV,阻止新的访问。

总结一下: 字符设备驱动的核心就三样东西——file_operations 定义操作、设备号做标识、cdev 负责注册。把这套框架练熟了,后面学平台驱动、设备树都会轻松很多。