3、字符设备驱动框架:file_operations结构体、主次设备号、cdev注册与注销

字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动模型。你想想看,键盘、鼠标、串口、LED灯……这些设备本质上都是字符设备。它们的特点是数据按字节流顺序访问,不像块设备那样可以随机读写。

我个人习惯把字符设备驱动拆成三个核心要素来理解:操作函数集设备编号内核对象。这三样东西搞明白了,字符设备驱动你就掌握了七成。

3.1 file_operations:驱动与用户态的桥梁

用户程序调用open()read()write()这些系统调用时,内核怎么知道该执行哪段代码?答案就在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 *);
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    // ... 还有不少其他成员
};

这里有个细节我特别想提醒你:owner字段通常设为THIS_MODULE,它用来防止模块正在被使用时被卸载。我曾经见过一个新手写的驱动,忘了设这个字段,结果模块卸载时系统直接崩溃——嗯,教训深刻。

核心要点:你不需要实现file_operations里的所有函数。用不到的接口设为NULL即可,内核会帮你处理默认行为。比如不实现llseek,用户程序调用lseek时会返回错误。

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

每个字符设备在内核里都有一个唯一的编号,就像人的身份证号。这个编号由两部分组成:主设备号次设备号

  • 主设备号:标识设备对应的驱动程序。比如串口驱动的主设备号通常是4,LED驱动可能自己申请一个。
  • 次设备号:标识同一个驱动管理的不同设备实例。比如两个串口,主设备号相同,次设备号分别是0和1。

在内核里,设备号用dev_t类型表示,它是一个32位的无符号整数。高12位是主设备号,低20位是次设备号。你可以用宏来操作它:

#define MAJOR(dev)    ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev)    ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma, mi) (((ma) << MINORBITS) | (mi))

申请设备号有两种方式:

  1. 静态申请:用register_chrdev_region(),需要你知道一个可用的主设备号。适合你自己测试用。
  2. 动态分配:用alloc_chrdev_region(),让内核帮你挑一个空闲的主设备号。我建议你优先用动态分配,省得跟别的驱动撞车。

我的经验:动态分配设备号时,可以用cat /proc/devices查看当前系统里已经占用的主设备号。我曾经在项目里手动指定了一个主设备号,结果跟系统自带的驱动冲突,折腾了半天才找到原因。从那以后,我都是老老实实用动态分配。

3.3 cdev:内核里的设备对象

file_operations定义了操作,设备号定义了身份,那怎么把它们绑定到一起?这就需要cdev结构体出场了。它是内核里表示字符设备的对象。

struct cdev {
    struct kobject kobj;
    struct module *owner;
    const struct file_operations *ops;
    struct list_head list;
    dev_t dev;
    unsigned int count;
};

使用cdev的流程其实就三步:

  1. 分配并初始化:用cdev_alloc()或静态定义后调用cdev_init()
  2. 添加到内核:调用cdev_add(),把设备号和cdev关联起来。
  3. 卸载时移除:调用cdev_del()

来看一个完整的示例:

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

static dev_t dev_num;
static struct cdev my_cdev;

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

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

static int __init my_init(void) {
    // 1. 动态分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    printk("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));

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

    // 3. 注册到内核
    cdev_add(&my_cdev, dev_num, 1);
    return 0;
}

static void __exit my_exit(void) {
    // 4. 卸载时清理
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
}

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

注意:cdev_add()一旦成功,你的设备就暴露给用户空间了。所以一定要在cdev_add()之前完成所有初始化工作。我曾经犯过一个错误:先调了cdev_add(),然后才去初始化硬件,结果用户程序一打开设备,驱动就访问了未初始化的寄存器——系统直接挂掉。

3.4 注册与注销的完整流程

把上面几个知识点串起来,字符设备驱动的生命周期是这样的:

阶段 操作 说明
模块加载 alloc_chrdev_region() 申请设备号
cdev_init() 初始化cdev,绑定file_operations
cdev_add() 将设备注册到内核
设备操作 open/read/write/ioctl 用户态通过系统调用触发
模块卸载 cdev_del() 从内核移除设备
unregister_chrdev_region() 释放设备号

这里有个顺序问题:注销时一定要先cdev_del()unregister_chrdev_region()。反过来会导致什么?设备号已经释放了,但内核里还挂着这个cdev,用户程序如果正好在访问,就会访问到野指针。

一句话总结:字符设备驱动框架就是「file_operations定义行为,设备号定义身份,cdev把它们注册到内核」。这三样东西配合好,一个基本的字符设备驱动就成型了。

嗯,说到这我想起一个实际项目里的坑。当时我写一个GPIO控制的LED驱动,open函数里需要配置引脚方向。我图省事,在cdev_add()之前就把引脚初始化了。结果驱动加载后,LED莫名其妙闪了一下——因为cdev_add()还没完成,用户空间的udev规则就触发了open调用。从那以后,我都是把硬件初始化放到open函数里,或者用引用计数来控制。

你想想看,驱动开发就是这样,细节决定成败。框架本身不复杂,但每个环节的时序、资源管理、错误处理,都需要你心里有数。下一节我们会深入openread的具体实现,到时候再聊。