3. Linux内核与驱动模型

字符设备驱动,说白了就是 Linux 驱动世界里最基础、最经典的一种驱动模型。你想想看,键盘、鼠标、串口、LED 灯——这些按字节流读写的外设,几乎都用字符设备驱动来管理。我刚开始做驱动时,第一个练手的项目就是字符设备驱动,那时候踩的坑,现在回想起来还挺有意思的。

这一章,我们就来把字符设备驱动的几个核心环节彻底讲透:设备号怎么申请、file_operations 怎么填、cdev 接口怎么用、设备节点怎么自动创建。这些东西你搞明白了,后面写任何字符驱动都会顺手很多。

设备号:驱动的身份证

每个字符设备在 Linux 内核里都有一个唯一的标识——设备号。它由两部分组成:主设备号和次设备号。主设备号用来区分驱动类型,次设备号用来区分同类型下的不同设备。

概念 说明 示例
主设备号 标识设备对应的驱动程序 89(I2C 设备)
次设备号 标识同驱动下的不同设备实例 0、1、2…
设备号类型 dev_t,32 位无符号整数 高 12 位主设备号,低 20 位次设备号

设备号的申请有两种方式:静态申请和动态分配。

静态申请:指定主设备号

这种方式适合你知道某个主设备号没有被占用的情况。比如一些经典的驱动,像 LED、按键,主设备号是固定的。我早期做项目时,喜欢用静态申请,因为调试起来方便,设备号固定,不用每次去查。

#include <linux/fs.h>

#define MY_MAJOR 200
#define MY_MINOR 0
#define DEVICE_NAME "my_device"

int major;
dev_t dev_num;

// 静态申请设备号
dev_num = MKDEV(MY_MAJOR, MY_MINOR);
ret = register_chrdev_region(dev_num, 1, DEVICE_NAME);
if (ret < 0) {
    printk(KERN_ERR "Failed to register device number\n");
    return ret;
}
注意:静态申请时,如果指定的主设备号已经被其他驱动占用,register_chrdev_region 会返回 -EBUSY。我曾经在一个项目里,因为没检查返回值,结果驱动加载后设备节点死活打不开,排查了半天才发现是主设备号冲突了。

动态分配:让内核帮你选

动态分配更灵活,内核会从空闲的设备号里挑一个给你。我现在的习惯是,除非有特殊要求,否则都用动态分配。省心,也避免冲突。

// 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
    printk(KERN_ERR "Failed to alloc device number\n");
    return ret;
}
major = MAJOR(dev_num);
printk(KERN_INFO "Allocated major number: %d\n", major);

动态分配后,你需要通过 MAJOR() 和 MINOR() 宏来提取主次设备号。嗯,这里要注意,设备号申请成功后,记得在模块卸载时释放:

unregister_chrdev_region(dev_num, 1);

file_operations:驱动与用户态的桥梁

用户程序通过 open、read、write、ioctl 等系统调用操作设备文件,这些调用最终会落到驱动里的 file_operations 结构体上。说白了,file_operations 就是驱动暴露给用户态的函数指针表。

我见过不少新手,一上来就把所有函数指针都实现了,其实没必要。你只需要实现你的设备真正需要的操作就行。比如一个简单的 LED 驱动,可能只需要 open、release 和 ioctl。

函数指针 说明 常用场景
.open 打开设备 初始化硬件、增加引用计数
.release 关闭设备 释放资源、减少引用计数
.read 从设备读取数据 读取传感器数据、接收缓冲区
.write 向设备写入数据 发送命令、写入配置
.unlocked_ioctl 设备控制命令 设置参数、启动停止、获取状态
.llseek 修改文件读写位置 支持随机访问的设备

来看一个典型的实现:

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 len, loff_t *off) {
    // 从内核空间拷贝数据到用户空间
    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 len, loff_t *off) {
    // 从用户空间拷贝数据到内核空间
    if (copy_from_user(kernel_buf, buf, len)) {
        return -EFAULT;
    }
    return len;
}

static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
    switch (cmd) {
        case MY_IOCTL_SET:
            // 处理设置命令
            break;
        case MY_IOCTL_GET:
            // 处理获取命令
            break;
        default:
            return -EINVAL;
    }
    return 0;
}

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,
};
避坑指南:copy_to_user 和 copy_from_user 一定要检查返回值。我曾经因为没检查,导致用户态传了个非法指针,内核直接 oops。另外,unlocked_ioctl 的 cmd 参数建议用 _IO/_IOR/_IOW/_IOWR 宏来生成,保证命令号的唯一性。

cdev 接口:内核里的设备对象

设备号有了,file_operations 也填好了,接下来怎么把它们告诉内核?答案就是 cdev 结构体。cdev 是内核用来管理字符设备的核心数据结构,每个字符设备在内核里都对应一个 cdev 实例。

cdev 的使用分三步走:

  1. 分配 cdev 对象:可以用静态定义,也可以用 cdev_alloc 动态分配。
  2. 初始化 cdev:调用 cdev_init,把 file_operations 挂上去。
  3. 注册 cdev:调用 cdev_add,把 cdev 和设备号关联起来。
#include <linux/cdev.h>

struct cdev my_cdev;
dev_t dev_num;

// 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(KERN_ERR "Failed to add cdev\n");
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

这里有个细节:cdev_add 之后,设备就立即可用了。所以,我建议在 cdev_add 之前,先把硬件初始化、资源申请这些事情都做完。否则,用户态程序一打开设备,驱动还没准备好,就容易出问题。

卸载时,顺序要反过来:先 cdev_del,再 unregister_chrdev_region。

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

设备节点自动创建:udev 与 class 机制

驱动注册好了,设备号也有了,但用户态怎么访问?传统做法是手动 mknod 创建设备节点。但这样做太麻烦了,而且设备号一变,mknod 的参数就得改。

现代 Linux 系统里,设备节点由 udev(用户空间设备管理器)自动创建。驱动只需要在注册时,创建一个 class,然后在 class 下创建 device,udev 就会自动在 /dev 下生成对应的设备节点。

来看代码:

#include <linux/device.h>

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

// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
    ret = PTR_ERR(my_class);
    goto err_class;
}

my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
    ret = PTR_ERR(my_device);
    goto err_device;
}

这样,当驱动加载后,udev 会在 /dev 下创建名为 my_device 的设备节点,权限默认是 660,属主是 root。你可以通过 udev 规则来调整权限和属组。

卸载时,记得销毁 device 和 class:

device_destroy(my_class, dev_num);
class_destroy(my_class);
核心要点:设备节点自动创建的关键是 class 和 device 的配对使用。class 是设备的逻辑分组,device 是具体的设备实例。udev 监听内核的 uevent,当 device_create 被调用时,内核会发送 uevent,udev 收到后根据规则创建设备节点。

完整流程:从申请到注册

把上面所有环节串起来,一个完整的字符设备驱动初始化流程是这样的:

static int __init my_driver_init(void) {
    dev_t dev_num;
    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) goto err_cdev;

    // 4. 创建 class 和 device
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        ret = PTR_ERR(my_class);
        goto err_class;
    }

    my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
    if (IS_ERR(my_device)) {
        ret = PTR_ERR(my_device);
        goto err_device;
    }

    printk(KERN_INFO "My driver loaded, major=%d\n", MAJOR(dev_num));
    return 0;

err_device:
    class_destroy(my_class);
err_class:
    cdev_del(&my_cdev);
err_cdev:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

static void __exit my_driver_exit(void) {
    dev_t dev_num = MKDEV(MAJOR(my_cdev.dev), 0);
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "My driver unloaded\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

这个流程,我建议你背下来。后面写任何字符设备驱动,基本就是这个模板。你想想看,设备号、cdev、file_operations、class/device,这四个东西搞清楚了,字符设备驱动你就掌握了八成。

个人经验:调试阶段,我习惯在 init 和 exit 函数里多打几条 printk,方便跟踪加载和卸载的流程。另外,建议先用动态分配设备号,等驱动稳定了,再考虑要不要改成静态的。
字符设备驱动注册完整流程 申请设备号 初始化 cdev 注册 cdev 创建 class 创建 device 关键说明 • alloc_chrdev_region 或 register_chrdev_region • cdev_init 绑定 file_operations • cdev_add 关联设备号 • 注册后设备立即可用 • class_create 创建 设备逻辑分组 • device_create 触发 udev 自动创建节点 • 卸载顺序相反: device→class→cdev→设备号 卸载流程 device_destroy ↓ class_destroy ↓ cdev_del ↓ unregister_chrdev_region

这张图把整个流程串起来了。你写驱动的时候,就照着这个顺序来,准没错。记住,注册顺序和卸载顺序完全相反,这是内核编程里的一条铁律。