3. 操作系统驱动模型

字符设备驱动,说白了就是 Linux 下最基础、最常用的驱动模型。你写的每一个 GPIO 控制、每一个 I2C 传感器、每一个 UART 通信,底层几乎都是字符设备。今天我就带你把这个框架彻底捋一遍。

我个人习惯把字符设备驱动拆成四个步骤:设备号注册与注销、file_operations 结构体实现、cdev 的初始化与添加、设备节点的自动创建。这四个步骤串起来,就是一个完整的字符设备驱动骨架。

核心要点:字符设备驱动的本质是让用户空间的 open/read/write/close 等系统调用,能够通过 VFS 层最终调用到你写的驱动函数。设备号是桥梁,cdev 是载体,file_operations 是具体实现。

1. 设备号:驱动的身份证

每个字符设备在系统中都有一个唯一的编号,这就是设备号。它由两部分组成:主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。

我记得刚入行时,有个老工程师跟我说:“设备号就像你家门牌号,主设备号是小区号,次设备号是单元号。” 这个比喻我一直记到现在。

设备号的数据结构

Linux 内核用 dev_t 类型来表示设备号,它是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。

typedef __u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;

// 主设备号:12 位,范围 0~4095
// 次设备号:20 位,范围 0~1048575

// 常用宏操作
MAJOR(dev_t dev)    // 从 dev_t 中提取主设备号
MINOR(dev_t dev)    // 从 dev_t 中提取次设备号
MKDEV(int major, int minor)  // 将主次设备号组合成 dev_t

设备号注册的两种方式

方式 函数 适用场景 注意事项
静态分配 register_chrdev_region() 已知主设备号(如调试阶段) 可能与其他驱动冲突
动态分配 alloc_chrdev_region() 生产环境、模块化开发 需要从 /proc/devices 查看分配结果

我个人强烈建议用动态分配。为什么?因为静态分配的主设备号需要去内核维护的 Documentation/admin-guide/devices.txt 里查,万一冲突了,驱动加载就会失败。我曾经在一个项目里吃过这个亏——两个模块用了同一个主设备号,结果系统直接 panic 了。

// 静态分配示例
#define MY_MAJOR 240
#define MY_MINOR 0
#define MY_DEVICE_COUNT 2

dev_t dev = MKDEV(MY_MAJOR, MY_MINOR);
int ret = register_chrdev_region(dev, MY_DEVICE_COUNT, "my_device");
if (ret < 0) {
    printk(KERN_ERR "Failed to register device number\n");
    return ret;
}

// 动态分配示例(推荐)
dev_t dev;
int ret = alloc_chrdev_region(&dev, 0, 2, "my_device");
if (ret < 0) {
    printk(KERN_ERR "Failed to allocate device number\n");
    return ret;
}
printk(KERN_INFO "Allocated major=%d, minor=%d\n", MAJOR(dev), MINOR(dev));

小技巧:动态分配后,可以通过 cat /proc/devices 查看系统已分配的设备号。我习惯在驱动加载成功后,主动打印主设备号,方便后续 mknod 或 udev 规则配置。

设备号注销

驱动卸载时,必须释放设备号。这个操作很简单,但很多人会忘。

void cleanup_module(void)
{
    unregister_chrdev_region(dev, MY_DEVICE_COUNT);
    // 注意:必须先注销 cdev,再注销设备号
}

警告:设备号注销的顺序很重要。必须先调用 cdev_del() 删除 cdev,再调用 unregister_chrdev_region() 释放设备号。顺序反了,系统可能还在引用已释放的设备号,导致内核崩溃。

2. file_operations:驱动的灵魂

设备号只是让系统知道“有这个设备”,但设备能做什么,完全取决于 file_operations 结构体。它定义了用户空间通过系统调用操作设备时,内核该调用哪些驱动函数。

说白了,这就是一张函数跳转表。用户调用 open(),内核查这张表,找到你的 my_open() 函数去执行。

核心成员

成员 函数原型 说明
open int (*open)(struct inode *, struct file *) 打开设备,做初始化工作
release int (*release)(struct inode *, struct file *) 关闭设备,释放资源
read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令(新内核用这个)
llseek loff_t (*llseek)(struct file *, loff_t, int) 文件定位操作

嗯,这里要注意:read 和 write 函数的缓冲区参数带有 __user 标记。这意味着你不能直接访问这个指针,必须用 copy_to_user() 和 copy_from_user() 来安全地传输数据。我曾经见过新手直接 memcpy 操作用户空间指针,结果内核直接 oops。

// 一个简单的 file_operations 实现
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 count, loff_t *f_pos)
{
    char kernel_buf[] = "Hello from kernel!";
    size_t len = strlen(kernel_buf);
    
    if (count < len)
        return -EINVAL;
    
    if (copy_to_user(buf, kernel_buf, len))
        return -EFAULT;
    
    return len;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    char kernel_buf[128];
    
    if (count > sizeof(kernel_buf))
        return -EINVAL;
    
    if (copy_from_user(kernel_buf, buf, count))
        return -EFAULT;
    
    printk(KERN_INFO "Received: %s\n", kernel_buf);
    return count;
}

// 定义 file_operations 结构体
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

经验之谈:我习惯在 open 函数里用 filp->private_data 保存设备结构体指针。这样在 read/write 里可以直接取出来用,避免全局变量满天飞。你想想看,如果驱动管理多个设备,全局变量根本没法区分是哪个设备在操作。

3. cdev:内核中的设备对象

设备号有了,操作函数有了,怎么把它们绑定在一起?答案就是 cdev 结构体。它是内核中表示字符设备的对象,负责将设备号和 file_operations 关联起来。

cdev 的初始化与添加

操作步骤很简单,就三步:分配、初始化、添加。

static struct cdev my_cdev;

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

// 2. 将 cdev 添加到内核
int ret = cdev_add(&my_cdev, dev, 1);  // 第三个参数是次设备号数量
if (ret < 0) {
    printk(KERN_ERR "Failed to add cdev\n");
    unregister_chrdev_region(dev, 1);
    return ret;
}

这里有个细节:cdev_add() 一旦成功,设备就立即可用了。所以在调用 cdev_add() 之前,必须确保所有初始化工作都已完成。我曾经在 cdev_add() 之后才去申请中断,结果用户空间一打开设备就触发了中断,而中断处理函数还没准备好——系统直接挂了。

cdev 的删除

void cleanup_module(void)
{
    cdev_del(&my_cdev);          // 先删除 cdev
    unregister_chrdev_region(dev, 1);  // 再释放设备号
}

重要:删除顺序绝对不能错。先 cdev_del() 确保不会有新的操作进来,再释放设备号。如果先释放设备号,此时 cdev 还在内核里,其他进程可能还在使用这个设备,就会访问到已经释放的内存。

4. 设备节点的自动创建:告别 mknod

早期写驱动,每次加载模块后都要手动 mknod /dev/mydevice c 240 0。不仅麻烦,而且主设备号如果是动态分配的,你还得先 cat /proc/devices 查看。这体验太差了。

现代 Linux 内核提供了两种自动创建设备节点的机制:udev(用户空间)和 mdev(嵌入式常用)。内核只需要在驱动中创建类(class)和设备(device),udev/mdev 会自动在 /dev 下生成节点。

使用内核自动创建设备节点

static struct class *my_class;
static struct device *my_device;

static int __init my_init(void)
{
    dev_t dev;
    int ret;
    
    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
    if (ret < 0)
        return ret;
    
    // 2. 初始化并添加 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev, 1);
        return ret;
    }
    
    // 3. 创建类
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev, 1);
        return PTR_ERR(my_class);
    }
    
    // 4. 创建设备节点(udev/mdev 会自动在 /dev 下生成)
    my_device = device_create(my_class, NULL, dev, NULL, "my_device");
    if (IS_ERR(my_device)) {
        class_destroy(my_class);
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev, 1);
        return PTR_ERR(my_device);
    }
    
    printk(KERN_INFO "My device initialized, major=%d\n", MAJOR(dev));
    return 0;
}

static void __exit my_exit(void)
{
    device_destroy(my_class, dev);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev, 1);
    printk(KERN_INFO "My device removed\n");
}

你看,驱动加载后,/dev/my_device 就自动出现了。不需要手动 mknod,也不需要关心主设备号是多少。这就是现代驱动开发的正确姿势。

嵌入式环境注意:如果你的系统没有 udev(比如 busybox 环境),可以用 mdev。只需要在驱动加载后执行 echo /sbin/mdev > /proc/sys/kernel/hotplug,然后 mdev -s 扫描即可。我做的几个 ARM 项目都是这么干的。

完整框架图:字符设备驱动核心流程

下面这张图总结了整个字符设备驱动的框架流程,从模块加载到设备节点创建,再到用户空间操作。我建议你把它存下来,写驱动时对照着看。

字符设备驱动框架流程图 字符设备驱动核心框架流程图 module_init(my_init) ① 分配设备号 alloc_chrdev_region() ② 初始化 cdev cdev_init() + cdev_add() ③ 创建类 class_create() ④ 创建设备节点 device_create() → /dev/my_device 用户空间操作 open/read/write/close → file_operations 核心数据结构 • dev_t:设备号 • cdev:字符设备对象 • file_operations:操作表 卸载顺序(反向) 1. device_destroy() 2. class_destroy() 3. cdev_del() 4. unregister_chrdev_region()

避坑指南:我踩过的那些坑

  • 设备号冲突:我曾经在一个项目里,两个驱动模块用了同一个静态主设备号。加载第二个模块时,系统直接报错 register_chrdev_region: major 240 already in use。从那以后,我全部改用动态分配。
  • cdev_add 顺序:有一次我在 cdev_add() 之后才初始化自旋锁,结果用户空间一打开设备就触发并发访问,锁还没初始化,直接死锁。记住:cdev_add() 之前,所有资源必须就绪。
  • copy_to_user 失败:新手最容易犯的错——直接解引用用户空间指针。内核空间不能直接访问用户空间内存,必须用 copy_to_user/copy_from_user。而且这两个函数会返回剩余未拷贝的字节数,返回 0 才表示成功。
  • 设备节点权限:自动创建的设备节点默认权限是 0600,只有 root 能访问。如果想让普通用户也能操作,可以在 device_create() 之后用 dev_set_uevent_suppress() 或者修改 udev 规则。我一般会在 udev 规则里加一行:SUBSYSTEM=="my_device", MODE="0666"。

总结一下:字符设备驱动的框架其实不复杂。设备号是身份标识,file_operations 是功能实现,cdev 是绑定桥梁,自动创建设备节点是用户体验。把这四块搞明白,你就能写出一个完整的字符设备驱动。下一章我们会在这个框架基础上,加入实际硬件操作,让驱动真正动起来。