4. 字符设备驱动框架:从零搭建你的第一个驱动骨架

字符设备驱动,说白了就是 Linux 驱动世界里最基础、最经典的一种模型。你想想看,键盘、鼠标、串口、LED 灯——这些设备本质上都是“按字节流读写”的,它们都属于字符设备。我刚开始学驱动时,第一个上手的也是字符设备。今天我们就把它拆开揉碎了讲清楚。

4.1 file_operations:驱动与内核的“接口契约”

每个字符设备驱动,核心就是一张函数跳转表——file_operations 结构体。内核不知道你的设备怎么读写,它只认这个结构体里的函数指针。

核心认知: file_operations 是驱动写给内核的“操作手册”。用户调用 open/read/write/ioctl 时,内核就翻这本手册,找到对应的驱动函数去执行。

我见过不少新手,上来就写 .read = my_read,结果忘了初始化其他成员。嗯,这里要注意:未赋值的成员必须显式设为 NULL,内核会帮你做默认处理。千万别用未初始化的局部变量去赋值,那是坑。

// 一个典型的 file_operations 实例
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,      // 模块引用计数,必须填
    .open    = my_open,          // 打开设备
    .release = my_release,       // 关闭设备
    .read    = my_read,          // 从设备读数据
    .write   = my_write,         // 向设备写数据
    .unlocked_ioctl = my_ioctl,  // 控制命令(新内核用这个)
    .llseek  = my_llseek,        // 文件定位
};

这里有个细节:.owner 为什么要设成 THIS_MODULE?我在项目中遇到过,有人忘了填这个字段,结果模块被 rmmod 卸载后,用户态程序还在调用驱动——直接内核 panic。这个字段就是防止模块在被使用时被卸载的。

成员 作用 避坑要点
.owner 模块引用计数 必须设为 THIS_MODULE,否则 rmmod 可能崩溃
.open 设备打开时的初始化 可做自增计数、分配私有数据
.release 设备关闭时的清理 别忘了释放 open 里申请的资源
.read/.write 数据读写入口 注意用户态指针要用 copy_to/from_user
.unlocked_ioctl 自定义控制命令 新内核已废弃 ioctl,改用 unlocked_ioctl

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

设备号分两部分:主设备号标识驱动类型,次设备号标识同类型下的不同设备。比如你插两个串口,主设备号都是 4,次设备号分别是 0 和 1。

分配方式有两种:

  • 静态分配:用 register_chrdev_region(),提前知道设备号。适合固定硬件、嵌入式板级支持包。
  • 动态分配:用 alloc_chrdev_region(),让内核自动分配。我建议你优先用动态分配——避免设备号冲突,也方便模块化。

我的习惯: 开发阶段用动态分配,通过 cat /proc/devices 查看分配结果。产品定型后,如果设备号需要固定,再改成静态分配。

dev_t dev_num;                     // 设备号(主+次)
int major, minor;

// 方式一:动态分配
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
    printk("alloc_chrdev_region failed\n");
    return ret;
}
major = MAJOR(dev_num);
minor = MINOR(dev_num);

// 方式二:静态分配(已知主设备号 240)
dev_num = MKDEV(240, 0);
ret = register_chrdev_region(dev_num, 1, "my_device");

我曾经犯过一个错:动态分配后直接用了 dev_num,没提取主次号就去注册 cdev。结果 cdev 注册成功,但 mknod 时设备号对不上——驱动死活不工作。记住:alloc_chrdev_region 返回的 dev_t 要拆开用,或者直接用 MKDEV 重新组装。

4.3 cdev 注册与注销:让内核认识你的设备

设备号有了,操作函数表有了,接下来就是把它们绑定到一起——用 cdev 结构体。这步做不好,内核根本不知道你的驱动在哪儿。

struct cdev my_cdev;

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

// 2. 注册 cdev 到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
    printk("cdev_add failed\n");
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

这里有个顺序问题:必须先分配设备号,再注册 cdev。反过来会怎样?cdev_add 需要一个有效的 dev_t,你还没分配就传个 0 进去——内核不会报错,但用户态永远找不到你的设备。

我曾经踩过的坑: 在模块卸载函数里,先调了 unregister_chrdev_region(),再调 cdev_del()。结果设备号先被回收,cdev_del 时内核尝试访问已释放的设备号——Oops 报错。正确顺序:先 cdev_del,再 unregister_chrdev_region。

注销代码的标准写法:

static void __exit my_exit(void)
{
    cdev_del(&my_cdev);                    // 先删除 cdev
    unregister_chrdev_region(dev_num, 1);  // 再释放设备号
}

4.4 设备节点创建:用户态怎么找到你?

驱动注册完了,内核知道你了,但用户态怎么访问?答案是设备节点——就是 /dev/ 目录下的那个文件。有两种方式:

  • 手动 mknod:适合调试阶段。命令:mknod /dev/mydev c 240 0(c 表示字符设备,240 是主设备号,0 是次设备号)。
  • 自动创建:用 class_create + device_create,内核自动在 /dev/ 下生成节点。我建议产品代码都用这种方式。
static struct class *my_class;
static struct device *my_device;

// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
    // 错误处理
}

my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
    class_destroy(my_class);
    // 错误处理
}

自动创建的好处很明显:设备号变了,节点自动更新;模块卸载时,节点自动删除。你想想看,如果用手动 mknod,每次 insmod 后还得去查设备号、敲命令——多麻烦。

调试小技巧: 注册成功后,用 ls -l /dev/my_device 查看节点信息。如果显示的是 c 240 0 就对了。如果显示问号或设备号不对,说明 cdev_add 或 device_create 有问题。

4.5 完整框架图:一张图看懂字符设备驱动

下面这张图把整个流程串起来了。我建议你把它打印出来贴在工位上——每次写驱动前看一眼,顺序就不会乱。

字符设备驱动注册与注销流程 字符设备驱动:注册与注销完整流程 注册流程 1. alloc_chrdev_region() 分配设备号(主+次) 2. cdev_init() 初始化 cdev + 绑定 file_operations 3. cdev_add() 将 cdev 注册到内核 4. class_create + device_create 自动创建设备节点 /dev/xxx 注销流程 A. device_destroy + class_destroy 删除设备节点和类 B. cdev_del() 从内核移除 cdev C. unregister_chrdev_region() 释放设备号 ⚠️ 关键顺序:注册时先分配设备号 → 再注册 cdev → 最后创建节点 注销时反过来:先删节点 → 再删 cdev → 最后释放设备号

4.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 struct device *my_device;

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)
{
    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;
    }

    // 4. 自动创建设备节点
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }

    my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
    if (IS_ERR(my_device)) {
        class_destroy(my_class);
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_device);
    }

    printk("my_device registered, major=%d, minor=%d\n",
           MAJOR(dev_num), MINOR(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("my_device unregistered\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A minimal char device driver");

重要提醒: 上面的代码只实现了 open/release,没有 read/write。如果你加载后去 cat /dev/my_device,会返回什么?嗯,什么都不会返回——因为 read 函数是 NULL,内核直接返回 -EINVAL。下一章我们会补上真正的读写逻辑。

好了,字符设备驱动的骨架你已经有了。记住这个四步走:分配设备号 → 初始化 cdev → 注册 cdev → 创建节点。注销时反向操作。把这个流程刻在脑子里,以后写任何字符设备驱动都不会迷路。