字符设备驱动核心:file_operations、设备号与cdev
字符设备驱动,说白了就是让用户空间的程序能通过文件操作(open、read、write)来访问硬件。我刚开始学驱动时,总觉得这层抽象很玄乎——明明是个传感器,怎么就成了一个文件?后来写多了才明白,这正是Linux设计的精髓:一切皆文件。
这一章,咱们就把字符设备驱动的三个核心要素掰开揉碎:file_operations结构体、设备号的注册与注销、以及cdev接口。这三样东西,你写任何字符驱动都绕不开。
2.1 file_operations:驱动与用户态的桥梁
先看个最简单的例子。假设你有个温度传感器,用户程序想读温度数据,它调用read()。这个read()怎么就能跑到你的驱动代码里?答案就在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 *);
/* ... 还有几十个回调,但常用的就这几个 */
};
这个结构体就是个函数指针表。每个成员对应一个系统调用。你实现了.read,用户态read()就会调用你的函数。你没实现.write,用户态write()就会返回-EINVAL。
关键点:owner字段必须设为THIS_MODULE,防止模块被卸载时还有人在用。我见过有人忘了设这个,结果模块卸载后系统直接Oops。
我个人习惯,写驱动时先列一个表格,把要实现的回调标出来:
| 回调 | 典型用途 | 必须实现? |
|---|---|---|
| open | 初始化硬件、递增使用计数 | 通常需要 |
| release | 释放资源、递减使用计数 | 通常需要 |
| read | 从硬件读取数据到用户空间 | 视设备而定 |
| write | 从用户空间写入数据到硬件 | 视设备而定 |
| llseek | 调整文件读写位置 | 可选 |
| unlocked_ioctl | 设备控制命令 | 常用 |
注意看read和write的第二个参数——char __user *。这个__user标记告诉内核:这个指针来自用户空间,不能直接解引用。必须用copy_to_user()和copy_from_user()来安全传输数据。
我曾经在一个项目中直接解引用了用户空间指针,结果在ARM平台上跑得好好的,换到x86上就随机崩溃。查了两天才发现是没做copy_from_user。嗯,从那以后我再也不敢偷懒了。
2.2 设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:
- 主设备号(major):标识设备对应的驱动程序。比如
1是内存设备,4是串口。 - 次设备号(minor):标识同一个驱动管理的不同设备实例。比如两个串口,主设备号都是
4,次设备号分别是64和65。
设备号用dev_t类型表示,它是一个32位无符号整数。高12位是主设备号,低20位是次设备号。内核提供了宏来操作它:
#define MAJOR(dev) ((unsigned int) ((dev) >> 20))
#define MINOR(dev) ((unsigned int) ((dev) & 0xFFFFF))
#define MKDEV(ma, mi) (((ma) << 20) | (mi))
注册设备号有两种方式:
2.2.1 静态分配
你指定一个主设备号,内核尝试注册。如果被占用了就失败。
int major = 200; // 自己选一个
if (register_chrdev_region(MKDEV(major, 0), 1, "my_device") < 0) {
printk("Failed to register device number\n");
return -EIO;
}
这种方式简单,但容易冲突。我早期做项目时喜欢用200,结果有一次跟另一个驱动撞车了,调试了半天才发现是设备号冲突。
2.2.2 动态分配
让内核帮你选一个空闲的主设备号。这是推荐做法。
dev_t dev_num;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk("Failed to allocate device number\n");
return ret;
}
printk("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));
动态分配的好处是永远不会冲突。内核会从234到511之间找一个空闲号给你。你可以在/proc/devices里看到所有已注册的设备号。
小技巧:我习惯在模块初始化时用动态分配,然后把主设备号打印出来。用户态程序可以通过cat /proc/devices获取这个号,再用mknod创建设备节点。
2.3 cdev接口:现代字符驱动的标准方式
老版本的Linux用register_chrdev()注册字符设备,但这种方式一次注册256个次设备,太浪费了。现在都用cdev接口,更灵活。
cdev是一个内核结构体,代表一个字符设备。使用步骤很简单:
- 分配并初始化cdev
- 设置file_operations
- 将cdev添加到内核
看代码:
struct cdev my_cdev;
dev_t dev_num;
// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
// 2. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加到内核
cdev_add(&my_cdev, dev_num, 1);
这里有个坑:cdev_add之后,内核随时可能调用你的file_operations。所以必须在cdev_add之前完成所有初始化工作。我见过有人把硬件初始化放在cdev_add之后,结果用户态程序一打开设备,驱动还没准备好,直接崩溃。
卸载时反过来:
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
顺序不能错。先删除cdev,确保不会再有人调用你的回调,再释放设备号。
2.4 完整示例:一个虚拟字符设备
把上面三样东西串起来,写一个完整的虚拟字符设备。它不操作真实硬件,只是在内核空间维护一个缓冲区。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define BUFFER_SIZE 1024
static char device_buffer[BUFFER_SIZE];
static struct cdev my_cdev;
static dev_t dev_num;
static int my_open(struct inode *inode, struct file *filp) {
printk("Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk("Device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
size_t available = BUFFER_SIZE - *f_pos;
size_t to_read = count < available ? count : available;
if (to_read == 0) return 0;
if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
return -EFAULT;
}
*f_pos += to_read;
return to_read;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos) {
size_t available = BUFFER_SIZE - *f_pos;
size_t to_write = count < available ? count : available;
if (to_write == 0) return -ENOSPC;
if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
return -EFAULT;
}
*f_pos += to_write;
return to_write;
}
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;
ret = alloc_chrdev_region(&dev_num, 0, 1, "vdev");
if (ret < 0) return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk("Virtual device 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("Virtual device unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
这个驱动虽然简单,但包含了字符设备驱动的完整骨架。你可以在用户态用echo "hello" > /dev/vdev0写数据,再用cat /dev/vdev0读出来。
2.5 核心流程一览
下面这张图把字符设备驱动的注册和卸载流程画清楚了:
2.6 避坑指南
写字符驱动时,有几个坑我反复踩过,分享给你:
- copy_to_user/copy_from_user必须检查返回值:非零表示拷贝失败,要返回
-EFAULT。别忽略这个检查,否则用户态可能读到垃圾数据。 - cdev_add失败后要清理:如果
cdev_add返回错误,别忘了调用unregister_chrdev_region释放设备号。否则设备号就泄漏了。 - 并发访问:多个进程可能同时打开你的设备。我早期写的驱动没考虑并发,结果两个进程同时写缓冲区,数据全乱了。后面会讲锁机制,但你现在就要有这个意识。
- module引用计数:
file_operations的owner字段设为THIS_MODULE后,内核会自动管理模块引用计数。但如果你自己实现了open回调,别忘了调用try_module_get和module_put。
我个人习惯:在open里递增一个原子变量记录打开次数,release里递减。这样能清楚知道设备是否在被使用。调试时特别有用。