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

字符设备驱动,说白了就是让用户空间的程序能通过文件操作(open、read、write、close)来访问硬件。Linux 里有一句老话——"一切皆文件"。你的硬件设备在用户眼里,就是一个文件节点,比如 /dev/led0。用户程序对着它读写,内核就会调用你写的驱动函数。

我刚开始学驱动时,总觉得这层抽象很绕。后来自己写了一个 GPIO 按键驱动,才真正理解:字符设备驱动的核心就三件事——告诉内核你的设备号、注册你的操作函数、以及清理时别留尾巴。

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

file_operations 是一个巨大的结构体,里面全是函数指针。你实现了哪些操作,用户程序就能对设备文件做哪些操作。没实现的,系统会返回 -ENOTTY 或者直接忽略。

来看一个最精简的例子:

static int my_open(struct inode *inode, struct file *filp) {
    pr_info("设备已打开\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos) {
    char kbuf[64] = "hello from kernel";
    if (copy_to_user(buf, kbuf, strlen(kbuf)))
        return -EFAULT;
    return strlen(kbuf);
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos) {
    char kbuf[64];
    if (count > 63) return -EINVAL;
    if (copy_from_user(kbuf, buf, count))
        return -EFAULT;
    kbuf[count] = '\0';
    pr_info("收到用户数据: %s\n", kbuf);
    return count;
}

static int my_release(struct inode *inode, struct file *filp) {
    pr_info("设备已关闭\n");
    return 0;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .read    = my_read,
    .write   = my_write,
    .release = my_release,
};
我的习惯:每次写 file_operations 时,我都会把 .owner = THIS_MODULE 放在第一行。这能防止模块被卸载时还有进程在操作设备。我曾经漏掉这一行,结果模块卸载后系统直接 oops,教训深刻。

这里有个关键点——copy_to_user 和 copy_from_user。内核空间不能直接访问用户空间指针,必须用这两个函数。为什么?因为用户空间的页可能被换出,或者指针是无效的。这两个函数会帮你做检查和拷贝。

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

每个字符设备在内核里都有一个唯一的编号——主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。

概念 作用 示例
主设备号 标识驱动,内核通过它找到对应的 cdev 1 表示内存设备,4 表示 tty 设备
次设备号 标识具体设备实例 /dev/tty0 次设备号 0,/dev/tty1 次设备号 1

分配设备号有两种方式:

  1. 静态分配:你自己指定一个主设备号。适合你知道这个号没被占用的场景。
  2. 动态分配:让内核帮你分配。我强烈推荐这种方式,省心。
dev_t dev_num;
int major;

// 方式一:静态分配
major = 250;  // 自己选一个
dev_num = MKDEV(major, 0);
register_chrdev_region(dev_num, 1, "my_device");

// 方式二:动态分配(推荐)
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
pr_info("分配的主设备号: %d\n", major);
我曾经踩过的坑:静态分配时选了 250,结果另一个模块也用了 250,两个驱动都加载失败。从那以后,我全部改用 alloc_chrdev_region。动态分配虽然每次设备号不一样,但你可以通过 cat /proc/devices 查看,然后用 mknod 手动创建设备节点。

3.3 cdev:内核里的设备对象

cdev 是内核用来管理字符设备的核心结构体。你把 file_operations 和设备号绑定到 cdev 上,然后告诉内核:"嘿,这个设备归我管了。"

注册流程分三步:

  1. 分配并初始化 cdev
  2. 将 cdev 添加到内核
  3. 在 /dev 下创建设备节点(或者用 mknod 手动创建)
#include <linux/cdev.h>

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) {
        pr_err("设备号分配失败\n");
        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) {
        pr_err("cdev 注册失败\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("驱动加载成功,主设备号: %d\n", MAJOR(dev_num));
    return 0;
}

static void __exit my_exit(void) {
    dev_t dev_num = my_cdev.dev;

    // 1. 删除 cdev
    cdev_del(&my_cdev);

    // 2. 释放设备号
    unregister_chrdev_region(dev_num, 1);

    pr_info("驱动卸载完成\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
关键顺序:卸载时一定要先 cdev_del,再 unregister_chrdev_region。顺序反了的话,内核里还挂着这个 cdev,但设备号已经释放了,别的驱动可能抢走这个号,导致混乱。

3.4 完整框架图

下面这张图展示了字符设备驱动的完整骨架。从用户态的系统调用,到内核的 VFS 层,再到你的驱动函数,最后到硬件——整个链路一目了然。

字符设备驱动框架 用户态程序 (open/read/write/close) VFS (虚拟文件系统) file_operations 结构体 .open / .read / .write / .release / .unlocked_ioctl cdev (字符设备对象) 设备号 (主设备号 + 次设备号) 硬件设备 (GPIO/UART/ADC...) 注册流程 ① alloc_chrdev_region ② cdev_init ③ cdev_add 注销流程 ① cdev_del ② unregister_chrdev_region

3.5 避坑指南与经验总结

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

  • cdev_add 失败后要释放设备号:很多人只检查了 cdev_add 的返回值,但忘了调用 unregister_chrdev_region。结果设备号被占用,下次加载时动态分配会跳过这个号,但静态分配就直接冲突了。
  • copy_to_user 的返回值检查:这个函数返回的是未拷贝成功的字节数,不是 0 表示成功。我见过有人写成 if (copy_to_user(...)),逻辑反了。
  • 并发访问:多个进程同时 open 同一个设备时,你的驱动能扛住吗?我早期写的驱动没加锁,结果两个进程同时读写同一个寄存器,数据全乱了。后面会讲互斥锁和原子操作,但你现在就要有这个意识。
  • 设备节点权限:用 mknod 创建的设备节点默认权限是 0600,普通用户打不开。记得用 chmod 666 /dev/my_device 或者写 udev 规则。
调试小技巧:加载驱动后,用 cat /proc/devices 查看设备号是否注册成功。然后用 ls -l /dev/my_device 确认设备节点的主次设备号是否匹配。这两个对了,驱动基本就通了一半。

嗯,字符设备驱动的框架就这些。说白了就是三个东西:file_operations 定义操作、设备号做标识、cdev 做注册。你把这个三角关系理清了,后面学平台驱动、设备树、中断处理都会轻松很多。