4. 字符设备驱动基础:设备号注册与注销、file_operations 结构体、cdev_init 与 cdev_add

字符设备驱动,是 Linux 驱动开发里最基础、也最常用的一类。说白了,就是按字节流读写数据的设备——键盘、鼠标、串口,都属于这个范畴。我刚开始学驱动时,觉得字符设备挺简单,不就是 open、read、write 吗?真上手才发现,设备号怎么分配、cdev 怎么注册,这里头有不少门道。

这一章,我们就从最核心的三个点入手:设备号、file_operations、cdev 的注册与注销。把这几个搞明白,你就能写出一个能用的字符设备驱动了。

4.1 设备号:设备的“身份证”

每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:主设备号和次设备号。主设备号标识设备类型(比如硬盘是 8,串口是 4),次设备号标识同类设备中的不同实例。

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

#include <linux/types.h>

MAJOR(dev_t dev);  // 提取主设备号
MINOR(dev_t dev);  // 提取次设备号
MKDEV(int major, int minor);  // 合成设备号

嗯,这里要注意:主设备号的范围是 0~4095,次设备号是 0~1048575。别超了。

4.1.1 静态分配 vs 动态分配

分配设备号有两种方式:静态和动态。我个人习惯用动态分配,省心。

分配方式 函数 适用场景 风险
静态分配 register_chrdev_region() 已知主设备号(如 240~255 预留范围) 设备号冲突,需手动协调
动态分配 alloc_chrdev_region() 大多数场景,推荐使用 需读取 /proc/devices 确认分配结果

动态分配的代码示例:

#include <linux/fs.h>

dev_t dev_num;
int major, minor;
int ret;

ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
    printk(KERN_ERR "Failed to allocate chrdev region\n");
    return ret;
}

major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk(KERN_INFO "Allocated device number: major=%d, minor=%d\n", major, minor);

我曾经在一个项目里用了静态分配,结果跟另一个模块撞了主设备号,系统启动时直接崩溃。从那以后,我基本只用动态分配——除非有特殊要求。

4.1.2 注销设备号

模块卸载时,必须把设备号还回去。用 unregister_chrdev_region():

void unregister_chrdev_region(dev_t from, unsigned count);

参数 from 是起始设备号,count 是设备数量。记得跟分配时的参数保持一致。

⚠️ 注意: 设备号注销必须在所有设备文件关闭之后进行。否则,用户态程序还在操作设备,内核却把设备号回收了——后果就是 oops。

4.2 file_operations:驱动与用户态的“接口协议”

用户态程序通过系统调用(open、read、write 等)访问设备,内核怎么知道该调用驱动的哪个函数?答案就是 file_operations 结构体。它是一张函数指针表,把系统调用映射到驱动实现上。

结构体定义在 <linux/fs.h> 里,成员非常多,但常用的就这几个:

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 *);
    // ... 还有几十个,不一一列举
};

你想想看,用户态调用 read(fd, buf, 1024),内核最终会调用你注册的 read 函数。这个函数里,你要把设备的数据拷贝到用户空间的 buf 里。

一个最简单的实现示例:

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 ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off) {
    // 这里只是示例,实际要拷贝数据到用户空间
    printk(KERN_INFO "Read called, len=%zu\n", len);
    return 0;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
};
💡 提示: .owner = THIS_MODULE 这行不能省。它告诉内核,这个 file_operations 属于哪个模块。如果省了,模块卸载时内核可能还在调用你的函数——又是 oops。

4.3 cdev_init 与 cdev_add:把驱动“挂”进内核

设备号有了,file_operations 也准备好了,接下来就是把它们组合成一个 cdev 结构体,注册到内核里。cdev 是内核用来管理字符设备的核心数据结构。

4.3.1 cdev_init:初始化 cdev

cdev_init() 把 file_operations 绑定到 cdev 上:

void cdev_init(struct cdev *cdev, const struct file_operations *fops);

用法:

struct cdev my_cdev;

cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;  // 别忘了设置 owner

4.3.2 cdev_add:注册到内核

cdev_add() 把 cdev 和设备号关联起来,告诉内核:“这个设备号归我管了。”

int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);

参数 count 是设备数量,通常为 1。如果注册多个次设备,可以传大于 1 的值。

完整流程:

static struct cdev my_cdev;

static int __init my_init(void) {
    dev_t dev_num;
    int ret;

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

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

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

    printk(KERN_INFO "My device registered: major=%d, minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));
    return 0;
}

4.3.3 cdev_del:卸载时的清理

模块卸载时,调用 cdev_del() 把 cdev 从内核里移除:

void cdev_del(struct cdev *cdev);

完整的卸载函数:

static void __exit my_exit(void) {
    dev_t dev_num = MKDEV(major, 0);  // 假设你保存了 major

    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "My device unregistered\n");
}
📌 核心流程总结:
  1. alloc_chrdev_region() → 分配设备号
  2. cdev_init() → 绑定 file_operations
  3. cdev_add() → 注册到内核
  4. 模块卸载:cdev_del() → unregister_chrdev_region()

4.4 知识结构图:字符设备驱动注册全流程

下面这张图,把整个流程串起来了。我建议你把它记在脑子里——每次写字符设备驱动,都是这个套路。

字符设备驱动注册与注销流程 alloc_chrdev_region() 动态分配设备号 cdev_init() 绑定 file_operations cdev_add() 注册到内核 设备就绪 用户态可访问 cdev_del() 从内核移除 cdev unregister_chrdev_region() 释放设备号 模块卸载完成 资源全部释放 ⚠️ 关键注意事项 1. cdev_add 失败时,必须立即 unregister_chrdev_region 2. 模块卸载顺序:cdev_del → unregister_chrdev_region 3. 确保所有设备文件关闭后再卸载

4.5 避坑指南:我踩过的几个坑

写字符设备驱动,有几个地方特别容易出问题。我把自己踩过的坑列出来,你遇到了可以少走弯路。

  • cdev_add 失败后忘了释放设备号——我曾经犯过这个错,结果设备号被占用,模块再也加载不上了。解决办法:cdev_add 失败后立即调用 unregister_chrdev_region。
  • file_operations 的 owner 没设置——模块卸载后,内核还在调用你的函数,直接 oops。这个坑我见过不止一次。
  • 设备号注销顺序搞反——先注销设备号再删 cdev,会导致 cdev_del 访问非法内存。正确顺序:先 cdev_del,再 unregister_chrdev_region。
  • 用户态还在操作设备就卸载模块——这会导致内核 panic。解决方案:在模块卸载前,检查设备引用计数,或者用 try_module_get/module_put 机制。
💡 调试小技巧: 模块加载后,用 cat /proc/devices 查看设备号是否注册成功。用 mknod /dev/mydevice c major minor 创建设备文件,然后用 echo "test" > /dev/mydevice 测试读写。

好了,这一章的内容就到这里。设备号、file_operations、cdev 注册与注销,是字符设备驱动的三大基石。把这套流程练熟,后面的复杂驱动就水到渠成了。