1. 字符设备驱动基础回顾:file_operations、主次设备号、cdev_init 与 cdev_add

好,咱们正式开始。这一章,我带你回顾字符设备驱动的几个核心概念。说白了,字符设备驱动是 Linux 驱动里最基础、最经典的一类。你想想看,从按键、LED 到串口、I2C 设备,底层都是字符设备那一套。我个人习惯把这一套东西叫做「驱动三板斧」——file_operations 结构体、设备号、cdev 注册。这三样搞明白了,后面迁移到杂项设备就顺理成章了。

1.1 file_operations 结构体:驱动与用户态的桥梁

先说说 file_operations。这个结构体,说白了就是驱动暴露给用户态的一组函数指针。用户程序调用 open、read、write、ioctl 时,内核会通过这个结构体找到你写的对应函数。

我在项目中遇到过不少新手,一上来就把所有函数都实现了,其实没必要。你只需要实现你的硬件真正需要的操作就行。比如一个简单的 LED 驱动,可能只需要 open 和 ioctl,read 和 write 根本用不上。

// 一个典型的 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,
};

嗯,这里要注意几个细节:

  • .owner 一定要赋值为 THIS_MODULE,防止模块被卸载时还有进程在使用它。
  • .open.release 通常成对出现,一个做初始化,一个做清理。
  • .unlocked_ioctl 是现在的主流,老版本用 .ioctl,但已经 deprecated 了。

我的经验: 写 file_operations 时,我习惯把不用的函数指针设为 NULL。内核会帮你做默认处理,比如 read 为 NULL 时,用户态 read 会返回 -EINVAL。这样代码更干净,也更容易排查问题。

1.2 主次设备号:驱动的身份证

设备号是什么?你可以把它想象成驱动的身份证。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。比如你的系统里有两个串口,主设备号都是 4,次设备号分别是 0 和 1。

我记得刚做驱动那会儿,最头疼的就是设备号冲突。你分配了一个主设备号,结果另一个驱动也用了同一个号,加载时就报错。后来我学乖了,要么用内核动态分配,要么去查 Documentation/devices.txt 确认号没被占用。

// 静态分配设备号
#define MY_MAJOR 240
#define MY_MINOR 0

// 动态分配设备号(推荐)
dev_t dev_num;
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
int major = MAJOR(dev_num);
int minor = MINOR(dev_num);

避坑指南: 我曾经在一个项目里用了静态主设备号 240,结果客户的内核里有个第三方驱动也用了 240。加载时直接报 "register_chrdev_region: device or resource busy"。从那以后,我所有新项目都改用 alloc_chrdev_region 动态分配,省心多了。

主次设备号在内核里用 dev_t 类型表示,32 位:高 12 位是主设备号,低 20 位是次设备号。你可以用 MAJOR() 和 MINOR() 宏来提取,也可以用 MKDEV() 来合成。

1.3 cdev_init 与 cdev_add:把驱动注册进内核

有了 file_operations 和设备号,下一步就是把它们绑定起来,告诉内核:「嘿,这个设备号归我管,用户操作它时调用我的函数。」这就是 cdev_init 和 cdev_add 干的事。

cdev_init 负责初始化一个 cdev 结构体,把 file_operations 挂上去。cdev_add 则把这个 cdev 注册到内核的设备表中,关联上设备号。

// 定义一个 cdev 结构体
static struct cdev my_cdev;

// 在模块初始化函数中
static int __init my_init(void)
{
    dev_t dev;
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
    if (ret < 0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }

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

    // 3. 添加 cdev 到内核
    ret = cdev_add(&my_cdev, dev, 1);
    if (ret < 0) {
        printk("cdev_add failed\n");
        unregister_chrdev_region(dev, 1);
        return ret;
    }

    printk("my_device registered, major=%d minor=%d\n",
           MAJOR(dev), MINOR(dev));
    return 0;
}

// 在模块退出函数中
static void __exit my_exit(void)
{
    dev_t dev = my_cdev.dev;
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev, 1);
    printk("my_device unregistered\n");
}

你看,整个流程其实就三步:分配设备号 → 初始化 cdev → 添加 cdev。退出时反过来:删除 cdev → 释放设备号。

我的习惯: 我通常会在 cdev_add 之后检查返回值,如果失败就回滚之前的所有操作。很多新手只检查 alloc_chrdev_region 的返回值,忽略了 cdev_add 也可能失败。嗯,内核编程里,每一步都要考虑「如果失败了怎么办」。

1.4 知识体系总览

为了让你更直观地理解这三者的关系,我画了一张图。你看一眼就明白了:

字符设备驱动核心结构 用户态应用程序 (open/read/write/ioctl) 内核 VFS 层 (虚拟文件系统) file_operations 结构体 .open = my_open .read = my_read .write = my_write cdev 结构体 .ops = &my_fops .dev = dev_t .owner = THIS_MODULE cdev_init 设备号 (dev_t): 主设备号 + 次设备号 cdev_add

这张图把整个流程串起来了。用户态调用系统接口,经过 VFS 层,找到对应的 cdev,再通过 file_operations 调用到你写的驱动函数。设备号就是中间的「门牌号」,cdev 就是「门」,file_operations 就是「门后的服务清单」。

1.5 小结

好了,这一章的内容就这些。总结一下:

  • file_operations:定义驱动能干什么,只实现需要的函数即可。
  • 设备号:驱动的身份证,推荐动态分配避免冲突。
  • cdev_init + cdev_add:把驱动注册进内核,三步走:分配号 → 初始化 → 添加。

这些基础打牢了,下一章我们就能轻松理解杂项设备是怎么「偷懒」的——它把很多重复的注册工作封装好了,让你少写不少代码。嗯,到时候你就知道为什么我推荐新项目优先用杂项设备了。

一句话记住: 字符设备驱动 = file_operations(功能) + 设备号(身份) + cdev(注册)。这三样缺一不可。