3. 字符设备驱动框架:file_operations、主次设备号与cdev
字符设备驱动,说白了就是让用户空间的程序能通过文件操作(open、read、write、close)来访问硬件。Linux 里有一句老话——"一切皆文件"。你的硬件设备在用户眼里,就是一个文件节点,比如 /dev/led0。用户程序对着它读写,内核就会调用你写的驱动函数。
我刚开始学驱动时,总觉得这层抽象很绕。后来自己写了一个 GPIO 按键驱动,才真正理解:字符设备驱动的核心就三件事——告诉内核你的设备号、注册你的操作函数、以及清理时别留尾巴。
3.1 file_operations:驱动与用户态的桥梁
file_operations 是一个巨大的结构体,里面全是函数指针。你实现了哪些操作,用户程序就能对设备文件做哪些操作。没实现的,系统会返回 -ENOTTY 或者直接忽略。
来看一个最精简的例子:
static int my_open(struct inode *inode, struct file *filp) {
pr_info("设备已打开\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
char kbuf[64] = "hello from kernel";
if (copy_to_user(buf, kbuf, strlen(kbuf)))
return -EFAULT;
return strlen(kbuf);
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos) {
char kbuf[64];
if (count > 63) return -EINVAL;
if (copy_from_user(kbuf, buf, count))
return -EFAULT;
kbuf[count] = '\0';
pr_info("收到用户数据: %s\n", kbuf);
return count;
}
static int my_release(struct inode *inode, struct file *filp) {
pr_info("设备已关闭\n");
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
file_operations 时,我都会把 .owner = THIS_MODULE 放在第一行。这能防止模块被卸载时还有进程在操作设备。我曾经漏掉这一行,结果模块卸载后系统直接 oops,教训深刻。
这里有个关键点——copy_to_user 和 copy_from_user。内核空间不能直接访问用户空间指针,必须用这两个函数。为什么?因为用户空间的页可能被换出,或者指针是无效的。这两个函数会帮你做检查和拷贝。
3.2 主次设备号:设备的身份证
每个字符设备在内核里都有一个唯一的编号——主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。
| 概念 | 作用 | 示例 |
|---|---|---|
| 主设备号 | 标识驱动,内核通过它找到对应的 cdev |
1 表示内存设备,4 表示 tty 设备 |
| 次设备号 | 标识具体设备实例 | /dev/tty0 次设备号 0,/dev/tty1 次设备号 1 |
分配设备号有两种方式:
- 静态分配:你自己指定一个主设备号。适合你知道这个号没被占用的场景。
- 动态分配:让内核帮你分配。我强烈推荐这种方式,省心。
dev_t dev_num;
int major;
// 方式一:静态分配
major = 250; // 自己选一个
dev_num = MKDEV(major, 0);
register_chrdev_region(dev_num, 1, "my_device");
// 方式二:动态分配(推荐)
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
pr_info("分配的主设备号: %d\n", major);
alloc_chrdev_region。动态分配虽然每次设备号不一样,但你可以通过 cat /proc/devices 查看,然后用 mknod 手动创建设备节点。
3.3 cdev:内核里的设备对象
cdev 是内核用来管理字符设备的核心结构体。你把 file_operations 和设备号绑定到 cdev 上,然后告诉内核:"嘿,这个设备归我管了。"
注册流程分三步:
- 分配并初始化
cdev - 将
cdev添加到内核 - 在
/dev下创建设备节点(或者用mknod手动创建)
#include <linux/cdev.h>
static struct cdev my_cdev;
static int __init my_init(void) {
dev_t dev_num;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
pr_err("设备号分配失败\n");
return ret;
}
// 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) {
pr_err("cdev 注册失败\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
pr_info("驱动加载成功,主设备号: %d\n", MAJOR(dev_num));
return 0;
}
static void __exit my_exit(void) {
dev_t dev_num = my_cdev.dev;
// 1. 删除 cdev
cdev_del(&my_cdev);
// 2. 释放设备号
unregister_chrdev_region(dev_num, 1);
pr_info("驱动卸载完成\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
cdev_del,再 unregister_chrdev_region。顺序反了的话,内核里还挂着这个 cdev,但设备号已经释放了,别的驱动可能抢走这个号,导致混乱。
3.4 完整框架图
下面这张图展示了字符设备驱动的完整骨架。从用户态的系统调用,到内核的 VFS 层,再到你的驱动函数,最后到硬件——整个链路一目了然。
3.5 避坑指南与经验总结
写字符设备驱动时,有几个地方特别容易出问题。我把自己踩过的坑列出来,你遇到了可以少走弯路。
- cdev_add 失败后要释放设备号:很多人只检查了
cdev_add的返回值,但忘了调用unregister_chrdev_region。结果设备号被占用,下次加载时动态分配会跳过这个号,但静态分配就直接冲突了。 - copy_to_user 的返回值检查:这个函数返回的是未拷贝成功的字节数,不是 0 表示成功。我见过有人写成
if (copy_to_user(...)),逻辑反了。 - 并发访问:多个进程同时 open 同一个设备时,你的驱动能扛住吗?我早期写的驱动没加锁,结果两个进程同时读写同一个寄存器,数据全乱了。后面会讲互斥锁和原子操作,但你现在就要有这个意识。
- 设备节点权限:用
mknod创建的设备节点默认权限是 0600,普通用户打不开。记得用chmod 666 /dev/my_device或者写 udev 规则。
cat /proc/devices 查看设备号是否注册成功。然后用 ls -l /dev/my_device 确认设备节点的主次设备号是否匹配。这两个对了,驱动基本就通了一半。
嗯,字符设备驱动的框架就这些。说白了就是三个东西:file_operations 定义操作、设备号做标识、cdev 做注册。你把这个三角关系理清了,后面学平台驱动、设备树、中断处理都会轻松很多。