4、字符设备驱动框架:file_operations结构体、设备号注册与注销、cdev_init与cdev_add
好,咱们今天来聊聊字符设备驱动的核心骨架。说实话,这部分内容我当年刚入门时也绕了不少弯路。你想想看,一个驱动要跟用户态程序打交道,总得有个“接口”吧?这个接口就是字符设备驱动框架。说白了,它就是一套约定,告诉内核:我这个驱动能干啥,你怎么找到我。
4.1 file_operations:驱动与用户态的“翻译官”
先说说 file_operations 结构体。这个家伙,是驱动世界里最核心的数据结构之一。我习惯叫它“操作函数表”。为什么?因为它里面装满了函数指针,每个指针都对应一个系统调用。
举个例子,你在用户态调用 open("/dev/mydev", O_RDWR),内核就会找到这个设备对应的 file_operations,然后调用里面的 .open 函数。同样的,read、write、ioctl 也都是这么映射的。
核心要点:file_operations 是驱动与 VFS(虚拟文件系统)之间的桥梁。没有它,用户态程序根本碰不到你的硬件。
来看一个典型的定义:
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,模块卸载时可能会引发内核崩溃。我在项目中遇到过这种问题,调试了一下午才发现是忘了赋值。
常用的操作函数大概有这些:
| 函数指针 | 对应系统调用 | 典型用途 |
|---|---|---|
| .open | open() | 初始化设备、递增引用计数 |
| .release | close() | 释放资源、递减引用计数 |
| .read | read() | 从设备读取数据 |
| .write | write() | 向设备写入数据 |
| .unlocked_ioctl | ioctl() | 设备控制命令(无大内核锁) |
| .llseek | lseek() | 修改文件读写位置 |
个人习惯:我一般会把所有用不到的函数指针设为 NULL。内核会帮你做默认处理。比如 .llseek 为 NULL 时,默认不允许定位。
4.2 设备号:驱动的“身份证”
每个字符设备在内核里都有一个唯一的编号,叫设备号。它由两部分组成:主设备号和次设备号。主设备号用来标识驱动类型,次设备号用来区分同一驱动下的不同设备。
你可以把主设备号想象成“小区门牌号”,次设备号就是“单元号”。同一个驱动(比如串口驱动)可以管理多个串口设备,每个设备用不同的次设备号区分。
设备号用 dev_t 类型表示,这是一个32位的整数。高12位是主设备号,低20位是次设备号。内核提供了宏来操作它:
#include <linux/kdev_t.h>
dev_t devno = MKDEV(major, minor); // 合成设备号
int major = MAJOR(devno); // 提取主设备号
int minor = MINOR(devno); // 提取次设备号
注册设备号有两种方式:
- 静态分配:你自己指定一个主设备号,调用
register_chrdev_region()。适合你知道哪些号没被占用的情况。 - 动态分配:让内核帮你分配,调用
alloc_chrdev_region()。我强烈推荐这种方式,省心又安全。
来看代码:
dev_t devno;
int major, ret;
// 动态分配设备号
ret = alloc_chrdev_region(&devno, 0, 1, "my_audio_dev");
if (ret < 0) {
printk(KERN_ERR "Failed to alloc chrdev region\n");
return ret;
}
major = MAJOR(devno);
printk(KERN_INFO "Allocated major number: %d\n", major);
避坑指南:我曾经在项目里用了静态分配,选了一个“看起来没人用”的主设备号。结果跟另一个模块冲突了,系统启动时直接 panic。从那以后,我再也不敢偷懒了,一律用动态分配。
注销设备号同样重要,在模块卸载时一定要记得释放:
unregister_chrdev_region(devno, 1); // 1 表示只注销一个设备
4.3 cdev_init 与 cdev_add:让设备“活”起来
有了操作函数表,有了设备号,接下来就要把它们绑定到一起。这就是 cdev 结构体的工作。cdev 是内核里代表字符设备的对象。
流程很简单,就两步:
- cdev_init:初始化 cdev 结构体,绑定 file_operations。
- cdev_add:把 cdev 注册到内核,告诉内核“这个设备号归我管”。
看代码:
#include <linux/cdev.h>
struct cdev my_cdev;
dev_t devno;
// 第一步:初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 第二步:添加到内核
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(devno, 1);
return ret;
}
这里有个关键点——cdev_add 一旦成功,你的设备就“活”了。用户态程序马上就能通过 /dev 下的设备文件来访问它。所以,我建议在 cdev_add 之前,先把硬件初始化好。否则用户一打开设备,你的驱动还没准备好,那就尴尬了。
我的经验:我习惯把 cdev 结构体放在一个自定义的设备结构体里,而不是用全局变量。这样方便管理多个设备。比如:
struct my_audio_dev {
struct cdev cdev;
void __iomem *regs;
int irq;
// ... 其他字段
};
注销时顺序要反过来:
cdev_del(&my_cdev); // 先删除 cdev
unregister_chrdev_region(devno, 1); // 再释放设备号
为什么要先删 cdev?因为 cdev_del 会确保没有正在进行的操作,然后才把设备从内核中移除。如果顺序反了,可能会出现设备号已经被释放,但还有进程在访问的情况。
4.4 完整的驱动骨架
把上面这些串起来,就是一个最简的字符设备驱动框架:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static dev_t devno;
static struct cdev my_cdev;
static int my_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "Device closed\n");
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
};
static int __init my_init(void)
{
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&devno, 0, 1, "mydev");
if (ret) return ret;
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加 cdev
ret = cdev_add(&my_cdev, devno, 1);
if (ret) {
unregister_chrdev_region(devno, 1);
return ret;
}
printk(KERN_INFO "Mydev loaded, major=%d\n", MAJOR(devno));
return 0;
}
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(devno, 1);
printk(KERN_INFO "Mydev unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
这个骨架虽然简单,但五脏俱全。你往 my_open 和 my_release 里加硬件操作,就是一个真正的驱动了。
总结一下:字符设备驱动的核心就三件事——定义操作函数、分配设备号、注册 cdev。顺序不能乱,注销时反着来。记住这个套路,后面写音频驱动时,你只需要往这个框架里填充 ALSA 相关的代码就行了。
嗯,今天就先聊到这儿。下一节咱们会讲如何创建设备节点,让用户态能真正访问到你的驱动。到时候你会看到,udev 和 class_create 是怎么自动帮你搞定 /dev 下那些设备文件的。