字符设备驱动核心:file_operations、设备号与cdev

字符设备驱动,说白了就是让用户空间的程序能通过文件操作(open、read、write)来访问硬件。我刚开始学驱动时,总觉得这层抽象很玄乎——明明是个传感器,怎么就成了一个文件?后来写多了才明白,这正是Linux设计的精髓:一切皆文件。

这一章,咱们就把字符设备驱动的三个核心要素掰开揉碎:file_operations结构体、设备号的注册与注销、以及cdev接口。这三样东西,你写任何字符驱动都绕不开。

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

先看个最简单的例子。假设你有个温度传感器,用户程序想读温度数据,它调用read()。这个read()怎么就能跑到你的驱动代码里?答案就在file_operations结构体里。

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,用户态read()就会调用你的函数。你没实现.write,用户态write()就会返回-EINVAL。

关键点:owner字段必须设为THIS_MODULE,防止模块被卸载时还有人在用。我见过有人忘了设这个,结果模块卸载后系统直接Oops。

我个人习惯,写驱动时先列一个表格,把要实现的回调标出来:

回调典型用途必须实现?
open初始化硬件、递增使用计数通常需要
release释放资源、递减使用计数通常需要
read从硬件读取数据到用户空间视设备而定
write从用户空间写入数据到硬件视设备而定
llseek调整文件读写位置可选
unlocked_ioctl设备控制命令常用

注意看read和write的第二个参数——char __user *。这个__user标记告诉内核:这个指针来自用户空间,不能直接解引用。必须用copy_to_user()和copy_from_user()来安全传输数据。

我曾经在一个项目中直接解引用了用户空间指针,结果在ARM平台上跑得好好的,换到x86上就随机崩溃。查了两天才发现是没做copy_from_user。嗯,从那以后我再也不敢偷懒了。

2.2 设备号:驱动的身份证

每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:

  • 主设备号(major):标识设备对应的驱动程序。比如1是内存设备,4是串口。
  • 次设备号(minor):标识同一个驱动管理的不同设备实例。比如两个串口,主设备号都是4,次设备号分别是64和65。

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

#define MAJOR(dev)  ((unsigned int) ((dev) >> 20))
#define MINOR(dev)  ((unsigned int) ((dev) & 0xFFFFF))
#define MKDEV(ma, mi) (((ma) << 20) | (mi))

注册设备号有两种方式:

2.2.1 静态分配

你指定一个主设备号,内核尝试注册。如果被占用了就失败。

int major = 200;  // 自己选一个
if (register_chrdev_region(MKDEV(major, 0), 1, "my_device") < 0) {
    printk("Failed to register device number\n");
    return -EIO;
}

这种方式简单,但容易冲突。我早期做项目时喜欢用200,结果有一次跟另一个驱动撞车了,调试了半天才发现是设备号冲突。

2.2.2 动态分配

让内核帮你选一个空闲的主设备号。这是推荐做法。

dev_t dev_num;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
    printk("Failed to allocate device number\n");
    return ret;
}
printk("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));

动态分配的好处是永远不会冲突。内核会从234到511之间找一个空闲号给你。你可以在/proc/devices里看到所有已注册的设备号。

小技巧:我习惯在模块初始化时用动态分配,然后把主设备号打印出来。用户态程序可以通过cat /proc/devices获取这个号,再用mknod创建设备节点。

2.3 cdev接口:现代字符驱动的标准方式

老版本的Linux用register_chrdev()注册字符设备,但这种方式一次注册256个次设备,太浪费了。现在都用cdev接口,更灵活。

cdev是一个内核结构体,代表一个字符设备。使用步骤很简单:

  1. 分配并初始化cdev
  2. 设置file_operations
  3. 将cdev添加到内核

看代码:

struct cdev my_cdev;
dev_t dev_num;

// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");

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

// 3. 添加到内核
cdev_add(&my_cdev, dev_num, 1);

这里有个坑:cdev_add之后,内核随时可能调用你的file_operations。所以必须在cdev_add之前完成所有初始化工作。我见过有人把硬件初始化放在cdev_add之后,结果用户态程序一打开设备,驱动还没准备好,直接崩溃。

卸载时反过来:

cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);

顺序不能错。先删除cdev,确保不会再有人调用你的回调,再释放设备号。

2.4 完整示例:一个虚拟字符设备

把上面三样东西串起来,写一个完整的虚拟字符设备。它不操作真实硬件,只是在内核空间维护一个缓冲区。

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

#define BUFFER_SIZE 1024

static char device_buffer[BUFFER_SIZE];
static struct cdev my_cdev;
static dev_t dev_num;

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 ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos) {
    size_t available = BUFFER_SIZE - *f_pos;
    size_t to_read = count < available ? count : available;

    if (to_read == 0) return 0;

    if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
        return -EFAULT;
    }

    *f_pos += to_read;
    return to_read;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos) {
    size_t available = BUFFER_SIZE - *f_pos;
    size_t to_write = count < available ? count : available;

    if (to_write == 0) return -ENOSPC;

    if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
        return -EFAULT;
    }

    *f_pos += to_write;
    return to_write;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

static int __init my_init(void) {
    int ret;

    ret = alloc_chrdev_region(&dev_num, 0, 1, "vdev");
    if (ret < 0) return ret;

    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

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

static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk("Virtual device unloaded\n");
}

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

这个驱动虽然简单,但包含了字符设备驱动的完整骨架。你可以在用户态用echo "hello" > /dev/vdev0写数据,再用cat /dev/vdev0读出来。

2.5 核心流程一览

下面这张图把字符设备驱动的注册和卸载流程画清楚了:

字符设备驱动注册与卸载流程 1. alloc_chrdev_region() 2. cdev_init() + 设置fops 3. cdev_add() 4. 设备可用,用户可访问 1. cdev_del() 2. unregister_chrdev_region() 3. 设备号释放,驱动卸载 ⚠️ 关键注意事项 • cdev_add() 之后内核随时可能调用你的回调,初始化必须在此之前完成 • 卸载时先 cdev_del() 再 unregister_chrdev_region(),顺序不能反 • 动态分配设备号是推荐做法,避免主设备号冲突

2.6 避坑指南

写字符驱动时,有几个坑我反复踩过,分享给你:

  • copy_to_user/copy_from_user必须检查返回值:非零表示拷贝失败,要返回-EFAULT。别忽略这个检查,否则用户态可能读到垃圾数据。
  • cdev_add失败后要清理:如果cdev_add返回错误,别忘了调用unregister_chrdev_region释放设备号。否则设备号就泄漏了。
  • 并发访问:多个进程可能同时打开你的设备。我早期写的驱动没考虑并发,结果两个进程同时写缓冲区,数据全乱了。后面会讲锁机制,但你现在就要有这个意识。
  • module引用计数:file_operations的owner字段设为THIS_MODULE后,内核会自动管理模块引用计数。但如果你自己实现了open回调,别忘了调用try_module_get和module_put。

我个人习惯:在open里递增一个原子变量记录打开次数,release里递减。这样能清楚知道设备是否在被使用。调试时特别有用。