3、字符设备驱动框架:设备号分配、file_operations结构体、cdev注册与注销

字符设备是Linux驱动开发中最基础、最典型的设备类型,其核心特征是以字节流形式进行顺序读写,例如串口、GPIO、I2C控制器、温度传感器等。理解字符设备驱动框架,是掌握内核驱动开发的第一步。本章将围绕设备号分配、file_operations结构体实现、cdev注册与注销三个核心环节,构建完整的字符设备驱动骨架,并深入剖析内核对象之间的关联关系与生命周期管理。

字符设备驱动框架核心对象关系图 用户空间 open/read/write/close /dev/mydev VFS 虚拟文件系统 inode / dentry file 结构体 系统调用 struct cdev owner = THIS_MODULE ops = &fops dev = MKDEV(major,minor) file_operations .open = my_open .read = my_read .write = my_write ops指针 查找cdev 设备号分配 静态:register_chrdev_region 已知主设备号,手动指定 动态:alloc_chrdev_region 内核自动分配,避免冲突 dev_t cdev 生命周期 cdev_init() → 初始化 cdev_add() → 注册到内核 cdev_del() → 从内核移除 设备节点创建 mknod /dev/mydev c major minor 或 udev 自动创建 设备文件关联 设备号是桥梁,cdev是载体,file_operations是行为定义

上图展示了字符设备驱动的核心对象关系:用户空间通过设备文件(/dev/mydev)发起系统调用,VFS根据设备号定位到对应的cdev对象,进而调用file_operations中注册的函数指针。整个链路中,设备号是桥梁,cdev是载体,file_operations是行为定义。下面逐一展开每个环节的实现细节。

3.1 设备号分配:静态与动态策略

核心原理

Linux内核用dev_t类型(32位无符号整数)表示设备号,其中高12位为主设备号(major),低20位为次设备号(minor)。主设备号标识设备对应的驱动程序,次设备号标识由该驱动管理的具体设备实例。例如major=240, minor=0表示第一个设备,minor=1表示第二个设备。内核提供MAJOR(dev)和MINOR(dev)宏用于提取主次设备号,MKDEV(major, minor)用于合成dev_t。

设备号分配有两种策略:静态分配和动态分配。静态分配使用register_chrdev_region(),需要开发者自行指定起始设备号;动态分配使用alloc_chrdev_region(),由内核自动分配未使用的主设备号。两种方式最终都通过unregister_chrdev_region()释放。

对比维度静态分配动态分配
APIregister_chrdev_region()alloc_chrdev_region()
主设备号来源开发者手动指定内核自动分配
优点设备号固定,便于预创建节点避免冲突,适合广泛分发
缺点可能与其他驱动冲突设备号不固定,需动态创建节点
适用场景内核树内驱动、嵌入式定制开源驱动、模块化加载

操作流程

// 静态分配示例
dev_t dev_num = MKDEV(240, 0);
int ret = register_chrdev_region(dev_num, 1, "my_device");
if (ret < 0) {
    pr_err("Failed to register region\n");
    return ret;
}

// 动态分配示例
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
    pr_err("Failed to alloc region\n");
    return ret;
}
major = MAJOR(dev_num);
minor = MINOR(dev_num);
经验提醒:动态分配是推荐做法。在模块加载时通过dmesg打印实际分配的主设备号,用户空间配合udev规则自动创建设备节点,避免手动mknod的繁琐和错误。

3.2 file_operations结构体:驱动行为定义

核心原理

file_operations是字符设备驱动的“行为字典”,它定义了用户空间对设备文件进行操作时,内核应调用的驱动函数。该结构体包含数十个函数指针,但驱动只需实现需要的接口,未实现的置为NULL。最常用的成员包括:open(打开设备)、release(关闭设备)、read(读取数据)、write(写入数据)、unlocked_ioctl(控制命令)、llseek(文件偏移定位)等。

关键点在于:read和write函数的buf参数指向用户空间地址,内核空间不能直接访问,必须使用copy_to_user()和copy_from_user()进行安全的数据拷贝。这是内核与用户空间交互的基本规则,违反会导致内核崩溃或安全漏洞。

实现要点

static int my_open(struct inode *inode, struct file *filp) {
    pr_info("Device opened\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) {
    char kernel_buf[64] = "Hello from kernel!";
    size_t len = strlen(kernel_buf) + 1;
    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 *pos) {
    char kernel_buf[128];
    if (count > sizeof(kernel_buf)) return -EINVAL;
    if (copy_from_user(kernel_buf, buf, count)) return -EFAULT;
    pr_info("Received: %s\n", kernel_buf);
    return count;
}

static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .write = my_write,
};
风险提示:切勿直接使用memcpy或sprintf操作__user指针。必须使用copy_to_user/copy_from_user,否则在ARM、RISC-V等架构上会因地址映射差异导致内核Oops。同时要检查copy_*_user的返回值,非零表示拷贝失败,应返回-EFAULT。

3.3 cdev注册与注销:设备对象管理

核心原理

struct cdev是内核中表示字符设备的核心对象,它关联了设备号与file_operations。注册流程为:cdev_init()初始化对象,cdev_add()将设备加入内核,cdev_del()从内核移除。在设备号分配之后、cdev_add之前,设备号已经占用但设备尚未就绪,此时访问设备会失败,因此cdev_add应尽早执行。

生命周期管理的关键在于:cdev_add成功后,设备立即可被用户空间访问,因此驱动必须确保所有资源(内存、锁、硬件寄存器)在cdev_add之前初始化完毕。注销时则相反,先cdev_del断开访问,再释放资源。

完整注册与注销流程

static struct cdev my_cdev;
static dev_t dev_num;
static int major, minor;

static int __init my_init(void) {
    int ret;
    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) return ret;
    major = MAJOR(dev_num);
    minor = MINOR(dev_num);
    
    // 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) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    pr_info("Device registered: major=%d minor=%d\n", major, minor);
    return 0;
}

static void __exit my_exit(void) {
    // 1. 从内核移除cdev
    cdev_del(&my_cdev);
    // 2. 释放设备号
    unregister_chrdev_region(dev_num, 1);
    pr_info("Device unregistered\n");
}

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

案例拆解:一个完整的LED字符设备驱动

假设硬件平台为ARM开发板,GPIO1_12连接一个LED。驱动目标:用户空间通过echo 1 > /dev/led点亮LED,echo 0 > /dev/led熄灭LED。

初始现象与约束:开发板内核版本5.10,GPIO子系统已就绪。驱动需要支持动态设备号分配,并自动创建设备节点。硬件上LED阳极接GPIO1_12,阴极接地,高电平点亮。

关键实现步骤:

  1. 在my_init中调用gpio_request申请GPIO,gpio_direction_output设置为输出模式。
  2. 在my_write中解析用户传入的字符('0'或'1'),调用gpio_set_value控制电平。
  3. 使用class_create和device_create自动创建设备节点/dev/led。
  4. 在my_exit中依次销毁设备、类、释放GPIO、注销cdev和设备号。

测试结果:加载模块后ls /dev/led可见节点;echo 1 > /dev/led后LED点亮,echo 0 > /dev/led后熄灭。dmesg输出显示设备注册成功。

风险与代价:GPIO申请失败时需回滚已分配的设备号;device_create失败时需清理class;注意在my_write中校验用户输入长度,防止缓冲区溢出。

复盘经验:字符设备驱动的错误处理必须“逐级回滚”。例如cdev_add失败时,要释放设备号;device_create失败时,要销毁class并释放cdev。建议使用goto语句统一跳转到错误处理标签,避免资源泄漏。

3.4 常见误区与总结复盘

误区一:设备号分配后忘记释放

设备号是内核全局资源,模块卸载时若未调用unregister_chrdev_region,会导致设备号泄漏,后续模块无法使用该主设备号。务必在module_exit中释放。

误区二:cdev_add与设备号分配顺序颠倒

必须先分配设备号,再cdev_add。如果先cdev_add再分配设备号,内核会在cdev_add时使用未初始化的dev_t,导致设备无法访问。

误区三:忽略并发访问控制

字符设备可能被多个进程同时打开和读写。若驱动中有共享资源(如全局缓冲区、硬件寄存器),必须使用信号量或互斥锁保护。file_operations中的函数可能被并发调用,不加锁会导致数据竞争。

误区四:open/release配对不完整

每次open成功,最终必须有对应的release调用。若在open中申请了资源(如内存、中断),必须在release中释放,否则每次打开都会泄漏资源。

检查项检查内容常见错误
设备号分配与释放是否配对只分配不释放
cdevinit/add/del顺序add前未init
fopsowner字段是否设置owner为NULL导致模块计数异常
用户拷贝copy_to/from_user返回值忽略返回值导致数据错误
并发共享资源是否有锁无锁导致竞态