4、Linux字符设备驱动框架回顾:file_operations与核心数据结构
做嵌入式驱动开发,字符设备驱动是最基础、也是最常打交道的一类。我刚开始接触Linux驱动时,面对那堆结构体和回调函数,说实话有点懵。但后来发现,只要抓住几个核心数据结构,整个框架就清晰了。
今天咱们就一起回顾一下字符设备驱动的骨架。嗯,这部分内容虽然基础,但很多坑都藏在这些细节里。
4.1 字符设备驱动的核心三要素
一个完整的字符设备驱动,说白了就三样东西:
- 设备号:系统用来识别设备的ID,分主设备号和次设备号
- file_operations:操作函数集,告诉内核“这个设备能干啥”
- 设备私有数据:驱动自己维护的状态信息
这三者缺一不可。我在项目中遇到过新手把设备号搞错,结果驱动加载成功但应用层死活打不开设备——其实就是主设备号冲突了。
核心要点:字符设备驱动的本质就是将设备操作映射到文件操作。应用层读写文件,内核层调用你的驱动函数。
4.2 设备号管理:从注册到注销
设备号是驱动的“身份证”。Linux提供了两种分配方式:
| 分配方式 | 函数 | 适用场景 |
|---|---|---|
| 静态分配 | register_chrdev_region() |
已知设备号,避免冲突时使用 |
| 动态分配 | alloc_chrdev_region() |
推荐方式,由内核自动分配 |
我个人习惯用动态分配。为什么?省心。你想想看,静态分配你得去查Documentation/devices.txt,还得祈祷别的驱动没占你的号。动态分配就简单了,内核给你一个可用的号,省去一堆麻烦。
// 动态分配设备号示例
dev_t dev_num;
int major, minor;
// 申请一个主设备号,次设备号从0开始,数量为1
alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
major = MAJOR(dev_num);
minor = MINOR(dev_num);
// 使用完毕后记得释放
unregister_chrdev_region(dev_num, 1);
注意:设备号是系统资源,申请了就必须释放。我曾经见过驱动卸载时忘了调用unregister_chrdev_region,结果设备号一直被占用,其他驱动加载失败。这种问题排查起来很头疼。
4.3 file_operations:驱动的操作接口
这是字符设备驱动的灵魂。每个成员都是一个函数指针,对应应用层的系统调用。
常用的几个操作:
open:打开设备,做初始化工作release:关闭设备,清理资源read:从设备读取数据write:向设备写入数据unlocked_ioctl:设备控制命令(注意,现在都用unlocked版本了)
// 典型的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一定要赋值为THIS_MODULE。为什么?它用来管理模块的引用计数。如果不设置,驱动被卸载时如果有进程还在使用设备,系统会崩溃。嗯,这个坑我踩过。
4.4 核心数据结构关系图
光说理论不够直观,我画了一张图,把字符设备驱动的核心数据结构串起来:
这张图展示了数据流的完整路径。应用层调用open(),VFS找到对应的struct file,通过f_op找到file_operations,最终调用你的驱动函数。反过来,struct inode里保存了cdev的指针,用于设备号的匹配。
4.5 cdev结构体与设备注册
struct cdev是内核用来管理字符设备的核心对象。注册流程如下:
- 分配cdev对象:
cdev_alloc()或静态定义 - 初始化cdev:
cdev_init(),绑定file_operations - 添加cdev到系统:
cdev_add(),关联设备号
// 完整的字符设备注册流程
static struct cdev my_cdev;
static int __init my_driver_init(void)
{
dev_t dev_num;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
if (ret < 0) {
pr_err("Failed to alloc chrdev region\n");
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) {
pr_err("Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
pr_info("My driver loaded, major=%d\n", MAJOR(dev_num));
return 0;
}
static void __exit my_driver_exit(void)
{
dev_t dev_num = my_cdev.dev;
// 先删除cdev,再释放设备号
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("My driver unloaded\n");
}
经验之谈:注册和注销的顺序一定要对称。先cdev_add再device_create,卸载时先device_destroy再cdev_del。顺序错了,系统可能在设备文件还存在时访问已经释放的cdev,导致Oops。
4.6 私有数据与容器宏
驱动里经常需要维护自己的数据结构,比如硬件寄存器基址、中断号、缓冲区等。怎么把这些数据和cdev关联起来?
常用的做法是定义一个包含cdev的结构体:
// 自定义设备结构体
struct my_device {
struct cdev cdev; // 必须放在第一个成员?不一定
void __iomem *base; // 寄存器基址
int irq; // 中断号
struct mutex lock; // 互斥锁
wait_queue_head_t wq; // 等待队列
};
// 在open函数中获取私有数据
static int my_open(struct inode *inode, struct file *filp)
{
struct my_device *mydev;
// 从inode中获取cdev,再通过container_of获取my_device
mydev = container_of(inode->i_cdev, struct my_device, cdev);
filp->private_data = mydev; // 保存到file的私有数据区
return 0;
}
// 在read/write中直接使用
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_device *mydev = filp->private_data;
// 使用mydev->base, mydev->lock等
...
}
container_of这个宏很关键。它通过结构体成员的地址,反推出结构体的首地址。我在项目中经常用它来获取驱动私有数据,比全局变量优雅多了。
4.7 常见避坑指南
回顾这些年调试字符设备驱动的经历,有几个坑特别容易踩:
- 设备号冲突:静态分配时没检查,导致驱动加载失败。建议用动态分配。
- cdev_add后忘了检查返回值:注册失败还继续使用,系统直接挂掉。
- open/release不配对:引用计数混乱,模块卸载时还在使用中。
- ioctl命令号重复:不同驱动用了相同的命令号,行为异常。
- 没有处理并发:多个进程同时访问,数据错乱。记得加锁。
我曾经踩过的坑:有一次写SPI字符设备驱动,cdev_add成功了,但device_create时传错了设备号,结果/dev下生成的设备文件主设备号是0。应用层打开设备时返回ENODEV,排查了半天才发现是设备号不匹配。所以,注册完成后一定要检查/proc/devices和/dev下的设备号是否一致。
4.8 小结
字符设备驱动的框架其实不复杂。记住三个核心:设备号、file_operations、cdev。再配合container_of管理私有数据,一个完整的驱动骨架就搭起来了。
嗯,这部分内容虽然基础,但它是后续所有驱动开发的基础。接口隔离、中间件集成,都是在这些数据结构上做文章。把基础打牢,后面才能走得更远。