字符设备驱动:设备号、file_operations结构体、cdev注册与注销
好,咱们今天聊聊字符设备驱动。这是Linux驱动开发里最基础、也最核心的一块。说白了,你写的驱动最终要跟用户态的程序打交道,而字符设备就是最常见的接口。
我记得刚接触Linux驱动时,第一件事就是搞懂设备号。没有设备号,内核根本不知道你在操作哪个设备。嗯,这里咱们一步步来。
设备号:设备的身份证
每个字符设备在内核里都有一个唯一的标识,就是设备号。它分两部分:主设备号和次设备号。
- 主设备号:标识设备对应的驱动程序。比如硬盘驱动用一个号,串口驱动用另一个号。
- 次设备号:标识同一个驱动管理的不同设备实例。比如两个串口,主设备号相同,次设备号不同。
在内核里,设备号用 dev_t 类型表示。这是一个32位的数,高12位是主设备号,低20位是次设备号。
关键点:主设备号范围是0~4095,次设备号范围是0~1048575。别搞混了。
我早期做项目时,有一次随手写了个主设备号,结果跟系统里已有的驱动冲突了。系统直接报错,设备节点创建失败。从那以后,我养成了一个习惯:要么用动态分配,要么查清楚哪些号是空闲的。
设备号的分配方式
分配设备号有两种方式:静态分配和动态分配。
静态分配
你自己指定一个主设备号。用 register_chrdev_region() 函数。
#include <linux/fs.h>
dev_t dev_num;
int major = 240; // 自己选一个号
int minor = 0;
int count = 1;
dev_num = MKDEV(major, minor);
ret = register_chrdev_region(dev_num, count, "my_device");
if (ret < 0) {
printk("设备号注册失败\n");
return ret;
}
静态分配的好处是设备号固定,用户态程序不用改。但风险也大——万一这个号被别的驱动用了呢?
注意:静态分配前,最好查一下 /proc/devices,看看哪些主设备号已经被占用了。我吃过这个亏,你千万别踩坑。
动态分配
让内核帮你分配一个空闲的主设备号。用 alloc_chrdev_region() 函数。
dev_t dev_num;
int minor = 0;
int count = 1;
const char *name = "my_device";
ret = alloc_chrdev_region(&dev_num, minor, count, name);
if (ret < 0) {
printk("动态分配设备号失败\n");
return ret;
}
printk("分配的主设备号: %d\n", MAJOR(dev_num));
动态分配是推荐的做法。内核会帮你找一个没被占用的号,省心多了。你想想看,要是每个驱动都硬编码一个号,那系统得多乱?
我个人习惯用动态分配。除非有特殊需求,比如你要跟已有的用户态工具兼容,那才考虑静态分配。
file_operations结构体:驱动与用户态的桥梁
设备号只是让内核能找到你的驱动。但用户态程序怎么操作设备?比如打开、读写、关闭?这就靠 file_operations 结构体了。
说白了,这个结构体就是一堆函数指针。你把驱动里实现的函数填进去,内核就知道该调用哪个了。
struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.llseek = my_llseek,
};
常用的成员有这些:
| 成员 | 说明 | 用户态对应操作 |
|---|---|---|
| open | 打开设备 | open() |
| release | 关闭设备 | close() |
| read | 从设备读取数据 | read() |
| write | 向设备写入数据 | write() |
| llseek | 改变文件读写位置 | lseek() |
| unlocked_ioctl | 设备控制命令 | ioctl() |
每个函数的原型都是固定的。比如 open 函数长这样:
static int my_open(struct inode *inode, struct file *filp) {
printk("设备已打开\n");
return 0;
}
这里 inode 指向设备节点,filp 指向打开的文件实例。你可以把设备的私有数据存在 filp->private_data 里,方便其他函数使用。
小技巧:我习惯在 open 里用 container_of 宏从 inode 拿到自定义的设备结构体,然后挂到 filp->private_data 上。这样 read、write 里就能直接用了,不用到处找。
cdev结构体:内核里的设备对象
设备号有了,操作函数也有了。怎么把它们告诉内核?答案是 cdev 结构体。
cdev 是内核里表示字符设备的对象。你需要初始化它,然后注册到内核里。
初始化cdev
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_init() 把 file_operations 关联到 cdev 上。设置 owner 是为了防止模块被卸载时还有人在用设备。
注册cdev
ret = cdev_add(&my_cdev, dev_num, count);
if (ret < 0) {
printk("cdev注册失败\n");
// 别忘了释放之前分配的设备号
unregister_chrdev_region(dev_num, count);
return ret;
}
cdev_add() 告诉内核:这个设备号归我管了,操作函数就是 my_fops。第三个参数 count 表示管理几个次设备号。
注意顺序:一定要先分配设备号,再注册cdev。我曾经调换过顺序,结果内核直接崩溃。嗯,血的教训。
cdev的注销
驱动卸载时,必须把cdev从内核里移除,释放设备号。顺序跟注册时相反。
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, count);
cdev_del() 从内核里移除cdev。之后内核就不会再调用你的操作函数了。unregister_chrdev_region() 释放设备号,供其他驱动使用。
我见过有人只注销cdev,忘了释放设备号。结果模块卸载后,设备号还占着,下次加载时就分配失败了。这种小细节,往往最坑人。
完整的驱动框架
把上面这些串起来,就是一个完整的字符设备驱动骨架:
#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("my_open\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk("my_release\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
printk("my_read\n");
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off) {
printk("my_write\n");
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_init(void) {
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk("alloc_chrdev_region failed\n");
return ret;
}
printk("主设备号: %d\n", MAJOR(dev_num));
// 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) {
printk("cdev_add failed\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk("驱动加载成功\n");
return 0;
}
static void __exit my_exit(void) {
// 1. 注销cdev
cdev_del(&my_cdev);
// 2. 释放设备号
unregister_chrdev_region(dev_num, 1);
printk("驱动卸载成功\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");
这个框架虽然简单,但五脏俱全。你可以在 my_read 和 my_write 里加上真正的硬件操作逻辑。
建议:刚开始写驱动时,先用这个框架跑通。确认设备号分配、cdev注册、用户态访问都没问题,再往里填业务逻辑。这样调试起来轻松很多。
好了,字符设备驱动的核心就这些。设备号是身份证,file_operations是操作手册,cdev是内核里的登记表。三者缺一不可。下一章咱们聊聊如何创建设备节点,让用户态真正能用上你的驱动。