字符设备驱动框架:file_operations结构体、主次设备号、设备节点创建、cdev注册与注销
字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种模型。我刚开始接触嵌入式Linux驱动时,第一个练手的项目就是字符设备驱动。那时候踩了不少坑,现在回想起来,其实核心就四个东西:file_operations结构体、设备号、设备节点、cdev对象。把这四个搞明白,字符设备驱动的基本框架你就拿下了。
1. file_operations结构体:驱动与应用的接口
你想想看,应用程序调用open、read、write这些系统调用时,内核怎么知道该执行哪个驱动的代码?答案就在file_operations结构体里。它就像一个函数指针表,把系统调用和驱动里的具体实现一一对应起来。
核心理解:file_operations是驱动暴露给用户空间的“操作菜单”。用户调open,驱动就执行你的open函数;用户调read,驱动就执行你的read函数。
// 一个典型的file_operations定义
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,
};
我在项目中遇到过一个问题:忘记设置.owner字段。结果模块卸载时,内核还在引用这个模块,直接导致系统崩溃。嗯,这里要注意,.owner = THIS_MODULE是必须的,它告诉内核这个结构体属于哪个模块,方便引用计数管理。
常用的操作函数大概有这些:
| 系统调用 | file_operations成员 | 典型用途 |
|---|---|---|
| open | .open | 初始化设备、分配资源 |
| release | .release | 释放资源、关闭设备 |
| read | .read | 从设备读取数据 |
| write | .write | 向设备写入数据 |
| ioctl | .unlocked_ioctl | 设备控制命令 |
| mmap | .mmap | 内存映射 |
个人习惯:我一般会把所有用不到的成员设为NULL。内核会帮你做默认处理,比如read为NULL时,用户调用read会返回-EINVAL。这样既安全又简洁。
2. 主次设备号:设备的身份证
每个字符设备在内核里都有一个唯一的编号,这就是设备号。它由两部分组成:主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一个驱动下的不同设备。
举个例子:我做过一个项目,用同一个驱动控制4个LED灯。主设备号都是250,次设备号分别是0、1、2、3。这样内核就能通过设备号精确找到对应的设备和驱动。
// 设备号相关操作
dev_t devno; // 设备号类型,32位,高12位主设备号,低20位次设备号
int major = 250; // 主设备号
int minor = 0; // 次设备号
// 组合设备号
devno = MKDEV(major, minor);
// 静态申请设备号
register_chrdev_region(devno, 1, "my_device");
// 动态申请设备号(推荐)
alloc_chrdev_region(&devno, 0, 1, "my_device");
major = MAJOR(devno);
minor = MINOR(devno);
我曾经踩过的坑:静态申请设备号时,用了已经被占用的主设备号。结果insmod成功,但mknod后一访问设备就报错。后来我改用动态申请,让内核自动分配空闲的主设备号,再也没出过这个问题。所以我建议你,除非有特殊要求,否则都用alloc_chrdev_region。
3. 设备节点创建:让用户空间找到你
设备号是内核里的标识,但用户空间怎么访问设备?答案是设备节点。设备节点是一个特殊的文件,通常放在/dev目录下。它记录了设备的主次设备号,用户程序通过操作这个文件来与驱动交互。
创建设备节点有两种方式:
- 手动创建:使用mknod命令。比如
mknod /dev/mydev c 250 0。这种方式适合调试,但产品中不实用。 - 自动创建:使用udev或mdev。驱动注册时,内核会发送uevent,用户空间的设备管理器自动在/dev下创建节点。
在驱动代码里,我们通常这样配合自动创建:
// 在模块初始化函数中
struct class *my_class;
struct device *my_device;
// 创建类
my_class = class_create(THIS_MODULE, "my_class");
// 创建设备节点
my_device = device_create(my_class, NULL, devno, NULL, "my_device");
// 在模块退出函数中
device_destroy(my_class, devno);
class_destroy(my_class);
我的经验:class_create的名字最好和驱动名一致,方便调试时一眼认出。另外,device_create的最后一个参数是设备节点名,用户空间看到的就是/dev/这个名字。
4. cdev注册与注销:驱动核心的注册
cdev是内核里表示字符设备的核心结构体。你把file_operations和设备号准备好后,最后一步就是注册cdev。只有注册了cdev,内核才知道这个设备是“活的”,才能响应系统调用。
// cdev注册的完整流程
struct cdev my_cdev;
// 1. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 2. 注册cdev到内核
cdev_add(&my_cdev, devno, 1);
// 3. 模块退出时注销
cdev_del(&my_cdev);
这里有个顺序问题:先申请设备号,再注册cdev。反过来不行,因为cdev_add需要设备号。我见过有人把顺序搞反,结果cdev_add返回-EINVAL,查了半天才发现是设备号还没申请。
核心流程总结:
- 定义并填充file_operations结构体
- 申请设备号(推荐动态申请)
- 初始化cdev并关联file_operations
- 调用cdev_add注册到内核
- 创建类和设备节点(自动创建)
- 模块退出时,按相反顺序注销:device_destroy → class_destroy → cdev_del → unregister_chrdev_region
知识体系结构图
下面这张图展示了字符设备驱动框架的核心逻辑。我习惯用这种图来理清思路,你看一眼就能明白各个组件之间的关系。
注意事项:cdev_add之后,设备就“上线”了,用户空间随时可能访问。所以一定要确保所有资源都初始化完成后再调用cdev_add。我习惯把cdev_add放在初始化函数的最后一步,这样最安全。
字符设备驱动框架,说白了就是这四个东西的配合。你只要记住:file_operations定义操作、设备号标识身份、设备节点提供入口、cdev完成注册。把这套流程练熟了,后面学平台驱动、设备树都会轻松很多。
我的建议:初学者可以先写一个最简单的字符设备驱动,只实现open和release,不涉及硬件操作。先跑通整个注册、创建设备节点、应用层访问的流程。这个“空壳”驱动跑通了,再往里填业务逻辑。我当年就是这么过来的,效果很好。
可可精品资料库