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

字符设备驱动,说白了就是Linux世界里最基础的一类驱动。你写的第一行驱动代码,十有八九就是它。我刚开始学驱动时,总觉得这东西玄乎——不就是操作几个结构体吗?后来踩了坑才明白,这几个结构体背后藏着整个VFS(虚拟文件系统)的设计哲学。

今天咱们就把字符设备驱动的骨架拆开来看。你掌握了这个框架,后面写任何字符设备驱动,都只是往这个骨架里填肉而已。

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 *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    /* ... 还有几十个回调,但常用的就这几个 */
};

我个人习惯,写驱动时至少实现open、release、read、write这四个。其他的按需添加。你想想看,用户程序打不开设备,或者读不到数据,这驱动还有什么意义?

避坑指南:owner字段一定要初始化为THIS_MODULE。我曾经漏掉这个,结果模块卸载时内核直接Oops。这个字段防止模块在被使用时被卸载,相当于一个引用计数锁。

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

Linux用设备号来标识一个设备。主设备号(major)表示设备类型,次设备号(minor)表示同类设备中的第几个。就像小区楼号和门牌号的关系。

概念 含义 示例
主设备号 标识设备驱动类型 89对应I2C,254对应GPIO
次设备号 标识同一驱动下的不同设备 /dev/tty0的minor=0,/dev/tty1的minor=1
设备编号 主次合并后的32位值 MKDEV(major, minor)宏生成

设备号分配有两种方式:

  • 静态分配:调用register_chrdev_region(),需要你知道一个没被占用的主设备号。我建议新手别用这个,万一冲突了,驱动加载会失败。
  • 动态分配:调用alloc_chrdev_region(),内核自动给你分配一个空闲的主设备号。我个人强烈推荐这种方式,省心。
dev_t dev_num;
int major, minor;

// 动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk("Allocated major=%d, minor=%d\n", major, minor);

注意:动态分配的主设备号每次可能不同。你需要在模块加载后通过cat /proc/devices查看,然后手动创建节点。或者用后面讲的自动创建方式。

3.3 cdev注册与注销:驱动核心的注册

cdev是内核里代表字符设备的核心结构体。你得先初始化它,然后注册到内核的设备表中。流程其实就三步:

  1. 分配cdev:cdev_alloc()或静态定义
  2. 初始化:cdev_init()绑定file_operations
  3. 注册:cdev_add()告诉内核这个设备存在了
struct cdev my_cdev;
dev_t dev_num;

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

// 2. 注册到内核
cdev_add(&my_cdev, dev_num, 1);  // 第三个参数是设备数量

// 注销时
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);

嗯,这里要注意顺序。注册时先分配设备号,再添加cdev。注销时反过来,先删除cdev,再释放设备号。我在项目中遇到过有人搞反了顺序,结果设备号已经被别人占用了,cdev却还没删干净,内核报了一堆警告。

3.4 设备节点的创建:让用户态看得见

设备节点就是/dev/目录下的那个文件。没有它,用户程序没法通过open()访问你的设备。创建节点有两种方式:

  • 手动创建:mknod /dev/mydev c 240 0(主设备号240,次设备号0)。调试时可以用,产品里别这么干。
  • 自动创建:使用udev或mdev,配合内核的device_create()接口。这是生产环境的标准做法。

自动创建的代码也很简单:

static struct class *my_class;

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

// 在模块退出函数中
device_destroy(my_class, dev_num);
class_destroy(my_class);

这样,模块加载后/dev/my_device就自动出现了。用户程序直接open("/dev/my_device", O_RDWR)就能用。

小技巧:用device_create()时,最后一个参数是设备节点名。你可以用格式化字符串,比如"mydev%d", minor,这样每个次设备号对应一个独立节点。

3.5 完整框架流程图

下面这张图把整个流程串起来了。从模块加载到设备节点出现,每一步都离不开。

字符设备驱动注册与节点创建流程 module_init() alloc_chrdev_region() cdev_init() + cdev_add() class_create() device_create() /dev/my_device 就绪 关键点 • 主设备号动态分配 • cdev_add后设备才存在 • device_create自动生成节点 注销顺序(反向) 1. device_destroy() 2. class_destroy() 3. cdev_del() + unregister

3.6 完整示例代码

把上面所有知识点揉在一起,就是一个能用的字符设备驱动骨架。你拿去改改就能用:

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

static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;

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

static int my_release(struct inode *inode, struct file *filp) {
    printk("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) {
    // 1. 分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "my_driver");

    // 2. 初始化并注册cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    cdev_add(&my_cdev, dev_num, 1);

    // 3. 自动创建设备节点
    my_class = class_create(THIS_MODULE, "my_class");
    device_create(my_class, NULL, dev_num, NULL, "my_device");

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

static void __exit my_exit(void) {
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk("Module unloaded\n");
}

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

重要提醒:上面的代码只实现了open和release,没有read/write。如果你真的去cat /dev/my_device,会返回什么?嗯,什么都不会返回,因为read函数没实现。实际项目中,至少要把read和write补上,不然用户程序没法交换数据。

字符设备驱动框架,说白了就是这三板斧:设备号、cdev、设备节点。你把这个流程刻在脑子里,后面学什么LED驱动、按键驱动、I2C驱动,都只是在这个框架上做加法。我当年第一次写完这个骨架,insmod之后看到/dev/my_device出现,那种成就感——嗯,你试了就知道了。