第四节:字符设备驱动核心概念
说实话,字符设备驱动是Linux驱动开发里最基础、也最经典的一类。我刚开始学驱动时,就是从字符设备入手的。它不像块设备那么复杂,也不像网络设备那么抽象。说白了,字符设备就是像串口、键盘、鼠标这类设备——数据是按字节流一个个传的,没有缓冲区,没有随机访问。
这一节,我们来啃四个核心概念:设备号、设备文件、file_operations结构体,以及驱动与应用程序的交互流程。搞懂这些,你就能手写第一个字符设备驱动了。
4.1 设备号:主设备号与次设备号
每个字符设备在内核里都有一个唯一的身份标识——设备号。它是个32位的无符号整数,高12位是主设备号,低20位是次设备号。
| 概念 | 位数 | 作用 |
|---|---|---|
| 主设备号 | 12位(0~4095) | 标识设备对应的驱动程序 |
| 次设备号 | 20位(0~1048575) | 标识同一个驱动下的不同设备实例 |
我举个例子你就明白了。你电脑上可能插了两个USB转串口设备,它们用的是同一个驱动(主设备号相同),但一个是ttyUSB0,一个是ttyUSB1(次设备号不同)。内核通过次设备号来区分它们。
核心要点:主设备号找驱动,次设备号找设备。
设备号的分配有两种方式:
- 静态分配:在驱动代码里写死一个主设备号。比如很多经典驱动用主设备号240。但问题是,万一别的驱动也用了这个号,就冲突了。
- 动态分配:调用
alloc_chrdev_region()让内核自动分配。我个人习惯用动态分配,省心,也避免冲突。
嗯,这里要注意:alloc_chrdev_region()分配的是设备号范围,不是单个设备号。比如你要支持4个设备,就分配4个连续的次设备号。
// 动态分配设备号
dev_t dev_num;
int major, minor;
// 分配一个设备号范围(次设备号从0开始,共1个)
alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");
major = MAJOR(dev_num); // 提取主设备号
minor = MINOR(dev_num); // 提取次设备号
printk("Major: %d, Minor: %d\n", major, minor);
我在项目中遇到过一个问题:用静态分配时,主设备号被另一个内核模块占用了,结果insmod失败。从那以后,我基本只用动态分配。
4.2 设备文件:/dev 目录下的桥梁
设备文件是用户空间和内核空间之间的桥梁。你想想看,应用程序不能直接调用内核函数,它只能通过文件操作来访问设备。设备文件就充当了这个中间人。
设备文件在/dev目录下,用ls -l查看时,你会看到:
crw-rw-rw- 1 root root 240, 0 Jan 15 10:30 my_device
开头的c表示字符设备。后面的240, 0就是主设备号和次设备号。
创建设备文件有两种方式:
- 手动创建:用
mknod /dev/my_device c 240 0。调试时方便,但产品里不推荐。 - 自动创建:用
udev或devtmpfs。驱动加载时自动生成设备文件。我建议你用这种方式,省事又规范。
小技巧:在驱动里用class_create()和device_create(),udev会自动在/dev下创建设备文件。你只需要在驱动里注册好设备号,剩下的交给系统。
4.3 file_operations结构体:驱动的函数表
这是字符设备驱动的灵魂。说白了,file_operations就是一个函数指针表,告诉内核:当应用程序对这个设备文件执行open、read、write、close等操作时,该调用驱动里的哪个函数。
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() | 修改文件读写位置 |
我曾经犯过一个低级错误:忘了给.owner赋值THIS_MODULE。结果驱动卸载时,内核还在用这个结构体,直接导致内核崩溃。嗯,这个坑我踩过,你别再踩了。
4.4 驱动与应用程序的交互流程
理解了上面三个概念,我们来看看整个交互流程。我画了一张图,帮你理清思路。
整个流程是这样的:
- 应用程序调用:比如
fd = open("/dev/my_device", O_RDWR)。这是用户空间的第一步。 - 系统调用:应用程序陷入内核,触发系统调用。内核根据文件路径找到
/dev/my_device这个设备文件。 - VFS层:虚拟文件系统从设备文件的inode里提取主设备号和次设备号。然后根据主设备号,找到对应的驱动。
- 驱动响应:内核调用驱动注册的
file_operations里的open函数。你的my_open()就被执行了。 - 硬件操作:驱动里的函数通过
ioremap、readl/writel等接口,直接操作硬件寄存器。 - 返回结果:数据从内核空间拷贝到用户空间(比如
copy_to_user),然后返回给应用程序。
注意:驱动里的read和write函数不能直接访问用户空间的指针。必须用copy_to_user()和copy_from_user()来安全地传输数据。我见过有人直接解引用用户指针,结果内核直接oops了。
4.5 避坑指南
最后,分享几个我踩过的坑:
- 设备号冲突:用
cat /proc/devices查看当前已用的主设备号。动态分配能避免这个问题。 - 设备文件权限:默认创建的设备文件可能只有root能访问。记得在
device_create()里设置合适的权限位。 - file_operations没注册:调用
cdev_add()之前,一定要确保file_operations结构体已经正确初始化。否则内核调用时会崩溃。 - 忘记释放资源:驱动卸载时,要调用
cdev_del()、unregister_chrdev_region(),以及销毁类和设备。否则下次加载会出问题。
好了,这一节的内容就到这里。设备号、设备文件、file_operations、交互流程——这四个概念是字符设备驱动的基石。搞懂了它们,下一节我们就可以动手写第一个驱动了。