字符设备驱动框架:file_operations、主次设备号与cdev注册
字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动模型。你想想看,键盘、鼠标、串口、LED灯——这些按字节流读写的外设,几乎都是用字符驱动来管理的。今天我就带你把这个框架彻底吃透。
1. 驱动与应用程序的“契约”:file_operations
应用程序怎么跟你的驱动打交道?答案就在 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 *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
/* 还有ioctl、mmap等,先掌握上面这些 */
};
我个人习惯,写驱动时至少实现 open、release、read、write 这四个。为什么?因为用户态最常用的就是打开设备、读写数据、关闭设备。你想想看,如果连 read 都不实现,那应用程序调用 read() 就会返回 -EINVAL,用户肯定骂娘。
.owner = THIS_MODULE 一定要写。我曾经见过新手漏掉这行,结果模块在被使用时被 rmmod 强制卸载,直接 kernel panic。这个字段告诉内核:“有人在用我,别卸我。”
2. 设备的“身份证”:主次设备号
每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:主设备号 和 次设备号。
| 概念 | 作用 | 示例 |
|---|---|---|
| 主设备号 | 标识设备对应的驱动程序 | 89 对应 i2c 总线 |
| 次设备号 | 区分同类型设备的不同实例 | 0 表示第一个设备 |
分配设备号有两种方式:
- 静态分配:自己指定一个数字,比如
MKDEV(200, 0)。但容易冲突,不推荐。 - 动态分配:调用
alloc_chrdev_region(),让内核帮你选。我强烈建议用这种方式。
dev_t dev_num;
int major, minor;
/* 动态分配:第一个参数是输出,第二个是次设备号起始,第三个是数量,第四个是名字 */
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num); /* 提取主设备号 */
minor = MINOR(dev_num); /* 提取次设备号 */
unregister_chrdev_region() 释放。我曾经在项目里漏了这步,结果设备号被占用,下次 insmod 直接失败,排查了半天。
3. 核心数据结构:cdev
cdev 是内核里代表字符设备的对象。你得先初始化它,然后告诉内核:“这个设备归我管。”
struct cdev my_cdev;
/* 方式一:静态初始化 */
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
/* 方式二:动态分配(推荐,更灵活) */
struct cdev *my_cdev = cdev_alloc();
my_cdev->owner = THIS_MODULE;
my_cdev->ops = &my_fops;
初始化完 cdev,最后一步是注册:
int ret = cdev_add(my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "cdev_add failed\n");
/* 别忘了清理前面分配的资源 */
}
这里第三个参数是次设备号的数量。如果你要管理多个同类设备,比如 4 个串口,就传 4。
4. 完整框架:从入口到出口
把上面三块拼起来,就是一个完整的字符设备驱动骨架。我画了一张图帮你理清流程:
嗯,这里要注意:注册顺序和注销顺序必须相反。先加的cdev,后删;先分配的设备号,后释放。这是内核编程的基本素养。
5. 避坑指南:我踩过的三个坑
- 坑一:cdev_add 后立即访问设备。我曾经在
cdev_add之后、初始化完成之前,用户态就打开了设备。解决方案:在cdev_add之前完成所有硬件初始化。 - 坑二:次设备号数量传错。如果你要管理 4 个设备,但
cdev_add的第三个参数传了 1,那么只有第一个设备能用,后面三个打开就报错。 - 坑三:忘记设置 owner。前面提过,不设 owner 会导致模块被强制卸载时崩溃。这个 bug 特别隐蔽,因为大多数时候 rmmod 不会触发,但一旦触发就是死机。
6. 实战:一个最小化的字符驱动
光说不练假把式。我给你一个可以直接编译的骨架,你拿去改改就能用:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
static dev_t dev_num;
static struct cdev my_cdev;
static int my_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
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) {
alloc_chrdev_region(&dev_num, 0, 1, "my_demo");
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, dev_num, 1);
printk(KERN_INFO "my_demo loaded, major=%d\n", MAJOR(dev_num));
return 0;
}
static void __exit my_exit(void) {
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "my_demo unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
这个驱动虽然不读写数据,但框架是完整的。你加载后用 cat /proc/devices 就能看到它。然后 mknod /dev/my_demo c 主设备号 0 创建设备节点,再用 echo "hello" > /dev/my_demo 测试——虽然 write 没实现会报错,但 open 和 release 的打印信息会出现在 dmesg 里。
字符设备驱动框架,说白了就是这三板斧。你把这个骨架练熟了,后面加 read/write、加 ioctl、加硬件操作,都是在这个基础上添砖加瓦。好,这一节就到这里,你自己动手试试看。