3、字符设备驱动框架:file_operations结构体、主次设备号、cdev注册与注销
字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动模型。你想想看,键盘、鼠标、串口、LED灯……这些设备本质上都是字符设备。它们的特点是数据按字节流顺序访问,不像块设备那样可以随机读写。
我个人习惯把字符设备驱动拆成三个核心要素来理解:操作函数集、设备编号、内核对象。这三样东西搞明白了,字符设备驱动你就掌握了七成。
3.1 file_operations:驱动与用户态的桥梁
用户程序调用open()、read()、write()这些系统调用时,内核怎么知道该执行哪段代码?答案就在file_operations结构体里。它就像一个函数指针的集合,把用户态的操作映射到驱动层的具体实现。
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ... 还有不少其他成员
};
这里有个细节我特别想提醒你:owner字段通常设为THIS_MODULE,它用来防止模块正在被使用时被卸载。我曾经见过一个新手写的驱动,忘了设这个字段,结果模块卸载时系统直接崩溃——嗯,教训深刻。
核心要点:你不需要实现file_operations里的所有函数。用不到的接口设为NULL即可,内核会帮你处理默认行为。比如不实现llseek,用户程序调用lseek时会返回错误。
3.2 主次设备号:设备的身份证
每个字符设备在内核里都有一个唯一的编号,就像人的身份证号。这个编号由两部分组成:主设备号和次设备号。
- 主设备号:标识设备对应的驱动程序。比如串口驱动的主设备号通常是4,LED驱动可能自己申请一个。
- 次设备号:标识同一个驱动管理的不同设备实例。比如两个串口,主设备号相同,次设备号分别是0和1。
在内核里,设备号用dev_t类型表示,它是一个32位的无符号整数。高12位是主设备号,低20位是次设备号。你可以用宏来操作它:
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma, mi) (((ma) << MINORBITS) | (mi))
申请设备号有两种方式:
- 静态申请:用
register_chrdev_region(),需要你知道一个可用的主设备号。适合你自己测试用。 - 动态分配:用
alloc_chrdev_region(),让内核帮你挑一个空闲的主设备号。我建议你优先用动态分配,省得跟别的驱动撞车。
我的经验:动态分配设备号时,可以用cat /proc/devices查看当前系统里已经占用的主设备号。我曾经在项目里手动指定了一个主设备号,结果跟系统自带的驱动冲突,折腾了半天才找到原因。从那以后,我都是老老实实用动态分配。
3.3 cdev:内核里的设备对象
file_operations定义了操作,设备号定义了身份,那怎么把它们绑定到一起?这就需要cdev结构体出场了。它是内核里表示字符设备的对象。
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
使用cdev的流程其实就三步:
- 分配并初始化:用
cdev_alloc()或静态定义后调用cdev_init()。 - 添加到内核:调用
cdev_add(),把设备号和cdev关联起来。 - 卸载时移除:调用
cdev_del()。
来看一个完整的示例:
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/module.h>
static dev_t dev_num;
static struct cdev my_cdev;
static int my_open(struct inode *inode, struct file *file) {
printk(KERN_INFO "my_device opened\n");
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
};
static int __init my_init(void) {
// 1. 动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
printk("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));
// 2. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册到内核
cdev_add(&my_cdev, dev_num, 1);
return 0;
}
static void __exit my_exit(void) {
// 4. 卸载时清理
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
注意:cdev_add()一旦成功,你的设备就暴露给用户空间了。所以一定要在cdev_add()之前完成所有初始化工作。我曾经犯过一个错误:先调了cdev_add(),然后才去初始化硬件,结果用户程序一打开设备,驱动就访问了未初始化的寄存器——系统直接挂掉。
3.4 注册与注销的完整流程
把上面几个知识点串起来,字符设备驱动的生命周期是这样的:
| 阶段 | 操作 | 说明 |
|---|---|---|
| 模块加载 | alloc_chrdev_region() |
申请设备号 |
cdev_init() |
初始化cdev,绑定file_operations | |
cdev_add() |
将设备注册到内核 | |
| 设备操作 | open/read/write/ioctl |
用户态通过系统调用触发 |
| 模块卸载 | cdev_del() |
从内核移除设备 |
unregister_chrdev_region() |
释放设备号 |
这里有个顺序问题:注销时一定要先cdev_del()再unregister_chrdev_region()。反过来会导致什么?设备号已经释放了,但内核里还挂着这个cdev,用户程序如果正好在访问,就会访问到野指针。
一句话总结:字符设备驱动框架就是「file_operations定义行为,设备号定义身份,cdev把它们注册到内核」。这三样东西配合好,一个基本的字符设备驱动就成型了。
嗯,说到这我想起一个实际项目里的坑。当时我写一个GPIO控制的LED驱动,open函数里需要配置引脚方向。我图省事,在cdev_add()之前就把引脚初始化了。结果驱动加载后,LED莫名其妙闪了一下——因为cdev_add()还没完成,用户空间的udev规则就触发了open调用。从那以后,我都是把硬件初始化放到open函数里,或者用引用计数来控制。
你想想看,驱动开发就是这样,细节决定成败。框架本身不复杂,但每个环节的时序、资源管理、错误处理,都需要你心里有数。下一节我们会深入open和read的具体实现,到时候再聊。