3、字符设备驱动框架:设备号分配、file_operations结构体、cdev注册与注销
字符设备是Linux驱动开发中最基础、最典型的设备类型,其核心特征是以字节流形式进行顺序读写,例如串口、GPIO、I2C控制器、温度传感器等。理解字符设备驱动框架,是掌握内核驱动开发的第一步。本章将围绕设备号分配、file_operations结构体实现、cdev注册与注销三个核心环节,构建完整的字符设备驱动骨架,并深入剖析内核对象之间的关联关系与生命周期管理。
上图展示了字符设备驱动的核心对象关系:用户空间通过设备文件(/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()释放。
| 对比维度 | 静态分配 | 动态分配 |
|---|---|---|
| API | register_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,阴极接地,高电平点亮。
关键实现步骤:
- 在
my_init中调用gpio_request申请GPIO,gpio_direction_output设置为输出模式。 - 在
my_write中解析用户传入的字符('0'或'1'),调用gpio_set_value控制电平。 - 使用
class_create和device_create自动创建设备节点/dev/led。 - 在
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中释放,否则每次打开都会泄漏资源。
| 检查项 | 检查内容 | 常见错误 |
|---|---|---|
| 设备号 | 分配与释放是否配对 | 只分配不释放 |
| cdev | init/add/del顺序 | add前未init |
| fops | owner字段是否设置 | owner为NULL导致模块计数异常 |
| 用户拷贝 | copy_to/from_user返回值 | 忽略返回值导致数据错误 |
| 并发 | 共享资源是否有锁 | 无锁导致竞态 |