上篇我们把字符设备驱动的基础框架捋了一遍:模块怎么加载、cdev 怎么注册、file_operations 里的 open/release 钩子怎么挂,最后 insmod 出来一个能创建节点的"玩具"驱动。说句实在话,那套代码放在真实项目里基本不能直接交货,因为它没考虑多进程同时访问、数据拷贝失败、设备节点手工创建这些烂事。这篇的下半部分,我打算直接按实战标准来做:设计一个带缓冲区的字符设备,支持读写、ioctl、阻塞/非阻塞切换,再把并发控制和调试手段完整走一遍。目标就一个——你照着敲完,能写出一台在真实环境里扛得住压力的设备驱动雏形。
适合谁看?已经知道 file_operations 里那七八个回调函数分别是干嘛的,但被并发、阻塞、动态调试折腾得头大的开发者。如果你连 insmod / rmmod 都还没玩明白,建议先翻翻基础篇,再回来读这篇,不然会有点跳跃。
1. 实战目标与环境准备
1.1 从玩具代码到可交付驱动的差距
很多人学字符设备驱动时,写的第一个 demo 大概长这样:register_chrdev 申请一个主设备号,注册 file_operations,然后 insmod 成功就觉得完事了。但真实设备驱动面对的场面要复杂得多。你做的板子可能同时被主控线程、监控线程、甚至多个用户进程访问;上层 app 可能没按你的套路传参数,一个野指针直接敲进内核;系统里可能同时插了两个同型号的设备,寄存器地址还不一样。如果代码里没有自己的数据结构,没有锁,没有错误处理,这种驱动跑起来就是一颗定时炸弹。
上篇讲的 register_chrdev 思路其实已经过时了,它在 2.6 内核之后基本被 cdev 架构取代。register_chrdev 会一次性占用 0~255 个次设备号,太浪费,而且不方便管理多个设备。实战项目里我们通常用 cdev_add + alloc_chrdev_region,让内核动态分配主设备号,再用 class_create + device_create 自动生成 /dev 节点。这套组合拳才算现代字符设备驱动的标准姿势。
1.2 开发环境与目标效果
先明确一下我这边的实验环境:Linux 5.10 内核,x86_64 虚拟机,带标准内核头文件。你换成 ARM 开发板也完全没问题,只要内核配置了模块加载支持,代码逻辑是一样的。我用一个假设的虚拟设备,名字叫 demo_dev,它内部维护一块 4KB 的缓冲区,用户程序可以对它读写,也可以通过 ioctl 设置缓冲区长度或读取统计信息。
最终效果预期:
- 模块加载后自动出现 /dev/demo_dev 节点,权限自动设好,不需要手敲 mknod;
- 用户程序可以 open、read、write、close,多进程同时读写不会把缓冲区数据搞乱;
- read 默认是阻塞的,缓冲区没数据时线程挂起;打开时设置 O_NONBLOCK 则立即返回 -EAGAIN;
- write 超过缓冲区剩余空间时能正确处理,不越界;
- 通过 ioctl 能获取设备状态、设置行为参数。
Makefile 还是老一套,具体编译命令我就不反复解释了:
obj-m := demo_dev.o KERNELDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean2. 设备模型设计与注册骨架
2.1 用私有结构体把设备封装起来
字符设备驱动里最核心的一件事,就是设计"设备私有结构体"。它承载了硬件资源描述、缓冲区、同步原语、设备号、类指针等一切信息。说白了这就是驱动里的"对象",所有回调函数都通过它来访问设备资源。
我习惯这样定义:
struct demo_dev { char __iomem *base; // 映射后的寄存器基地址 unsigned long size; // 缓冲区大小 char *buffer; // 内核缓冲区指针 struct mutex lock; // 互斥锁,保护缓冲区访问 wait_queue_head_t read_wait; // 等待队列,用于阻塞读 int data_ready; // 是否有新数据可读 dev_t devno; // 设备号 struct cdev cdev; // 字符设备结构 struct class *cls; // 设备类 struct device *dev; // 设备 };buffer 用 kzalloc 申请,初始全零。mutex 用来保护 buffer 的并发访问。这里的__iomem是内核里为访问内存映射 IO 区域准备的修饰符,如果你的设备是纯虚拟设备,可以去掉,改成普通指针。
有人会问:为什么非要把这么多东西打包成一个结构体?因为 file_operations 回调函数里只能拿到 file 指针或 inode 指针,我们得想办法从里面找回自己的设备对象。最常见的做法是在 open 时把私有结构体地址存储到file->private_data,后续的 read/write/ioctl 再通过file->private_data取回来。如果不封装,你就得用全局变量存指针,一个设备实例还好,两个设备就乱套了。
2.2 设备号、类和节点的自动创建
老的 register_chrdev 会自动给你分配一个主设备号,但那东西太笨重。实战中我们用两步走:
alloc_chrdev_region(&devno, 0, 1, "demo_dev"); cdev_init(&demo_dev->cdev, &demo_dev_fops); demo_dev->cdev.owner = THIS_MODULE; cdev_add(&demo_dev->cdev, devno, 1);alloc_chrdev_region 让内核从空闲区自动挑一个主设备号,次设备号从 0 开始,数量 1。这样既不会跟别的设备冲突,也方便后面扩展成多个次设备。
接下来自动创建设备节点,否则用户还得手动 mknod /dev/demo_dev c 主号 0:
demo_dev->cls = class_create(THIS_MODULE, "demo_class"); demo_dev->dev = device_create(demo_dev->cls, NULL, devno, NULL, "demo_dev");class_create 会在 /sys/class/demo_class 下面注册一个类,device_create 则利用内核的 devtmpfs 机制,在 /dev 下自动生成节点。这一步做完,用户空间马上就能看到 /dev/demo_dev 了。
2.3 卸载时反向操作与错误回滚
驱动的加载函数里,每一句成功操作后面都可能失败。如果第三行失败,你得把前面申请的设备号、创建的 cdev 全部滚回去,否则模块卸载时系统里残留一堆脏资源。我用goto err_xxx的方式处理,内核代码里非常常见:
err_device_create: device_destroy(demo_dev->cls, demo_dev->devno); err_class_create: class_destroy(demo_dev->cls); err_cdev_add: cdev_del(&demo_dev->cdev); err_alloc_chrdev: unregister_chrdev_region(demo_dev->devno, 1); err_alloc_buffer: kfree(demo_dev->buffer); kfree(demo_dev); return PTR_ERR(ret);卸载函数则完全反过来:
device_destroy(dev->cls, dev->devno); class_destroy(dev->cls); cdev_del(&dev->cdev); unregister_chrdev_region(dev->devno, 1); kfree(dev->buffer); kfree(dev);注意 cdev_del 要在 unregister_chrdev_region 之前,因为 /dev 节点被删掉之后,用户程序就无法再 open 到这个设备了,这时候删除字符设备才安全。
3. 读写接口的完整实现
3.1 read/write 的数据搬运到底怎么搬
很多新手第一次写 read 回调时会直接用 memcpy 把内核 buffer 的数据拷给用户空间指针,结果一跑就崩溃。原因是用户空间指针在内核态不能直接解引用,轻则返回 EFAULT,重则触发 oops。正确姿势是 copy_to_user / copy_from_user。
static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { struct demo_dev *dev = filp->private_data; size_t bytes_to_read; if (mutex_lock_interruptible(&dev->lock)) return -ERESTARTSYS; // 等待数据,这个逻辑后面细讲 while (!dev->data_ready) { mutex_unlock(&dev->lock); if (filp->f_flags & O_NONBLOCK) return -EAGAIN; if (wait_event_interruptible(dev->read_wait, dev->data_ready)) return -ERESTARTSYS; if (mutex_lock_interruptible(&dev->lock)) return -ERESTARTSYS; } bytes_to_read = min(count, dev->size); if (copy_to_user(buf, dev->buffer, bytes_to_read)) { mutex_unlock(&dev->lock); return -EFAULT; } dev->data_ready = 0; mutex_unlock(&dev->lock); return bytes_to_read; }这里最关键的是copy_to_user的返回值:它返回的是未拷贝成功的字节数,不是成功数。所以判断条件应该是if (ret != 0),而不是if (ret < 0)。很多人栽在这里,查了半天 bug 结果是返回值用反了。
3.2 用互斥锁解决多进程竞争
多进程同时 read 或 write 同一个设备时,如果不加锁,A 进程读到一半,B 进程把 buffer 改了,读出来的数据就是拼凑出来的残废数据。缓冲区越界更是家常便饭。所以每个设备至少需要一个互斥锁。
为什么用 mutex 而不是自旋锁?因为 read/write 里我们可能还要访问等待队列、调用 copy_to_user,这些操作都可能睡眠。在持有自旋锁的时候睡眠是内核编程的大忌,会导致死锁。mutex 允许睡眠,适合这种场景。如果是在中断处理函数或原子上下文,才需要用 spinlock。
写操作同理,拿锁改缓冲区:
static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { struct demo_dev *dev = filp->private_data; size_t bytes_to_write; if (mutex_lock_interruptible(&dev->lock)) return -ERESTARTSYS; bytes_to_write = min(count, dev->size); if (copy_from_user(dev->buffer, buf, bytes_to_write)) { mutex_unlock(&dev->lock); return -EFAULT; } dev->data_ready = 1; mutex_unlock(&dev->lock); wake_up_interruptible(&dev->read_wait); return bytes_to_write; }注意 write 完成后要wake_up_interruptible唤醒正在阻塞读的进程。否则你往设备里写一坨数据,读进程永远不知道数据来了。
3.3 阻塞读与非阻塞读的切换
上面代码里我用了 wait_event_interruptible,这是内核里最标准的阻塞等待方式。它会把当前进程放进等待队列,然后调度出去,直到条件dev->data_ready变为真。中断唤醒有两种:一种是被 wake_up 正常唤醒,另一种是被信号打断。被打断时要返回-ERESTARTSYS,让用户态库有机会重试。
如果是非阻塞打开(O_NONBLOCK),就不应该睡进去,直接返回-EAGAIN。这个逻辑我在 read 函数里也写了,先检查锁,再判断标志位,再决定等不等。
有人会问:poll/epoll 怎么办?真实项目里用户程序很少直接读一个阻塞 fd,一般都用 poll 监听可读事件。所以我们的驱动应该实现 poll 回调:
static __poll_t demo_poll(struct file *filp, poll_table *wait) { struct demo_dev *dev = filp->private_data; __poll_t mask = 0; poll_wait(filp, &dev->read_wait, wait); if (dev->data_ready) mask |= EPOLLIN | EPOLLRDNORM; return mask; }poll_wait 把当前进程加到等待队列,如果 data_ready 为真,说明有数据可读,返回 EPOLLIN。这样用户态用 select/epoll 也能正常配合。
4. ioctl 和调试手段
4.1 ioctl 命令码不是随便写个数字
很多教学 demo 里写 ioctl 命令直接就是个 1、2、3,这在实际项目里会出问题:不同设备驱动之间命令码可能撞车,而且内核无法检查参数长度,误传一个超长指针就崩了。内核提供了标准宏来编码命令码:
#define DEMO_IOCTL_MAGIC 'D' #define DEMO_IOCTL_SET_LEN _IOW(DEMO_IOCTL_MAGIC, 0, int) #define DEMO_IOCTL_GET_STAT _IOR(DEMO_IOCTL_MAGIC, 1, struct demo_stat)其中_IOW表示这个命令要往内核传一个 int 类型参数,_IOR表示内核要往用户空间传一个 demo_stat 结构体。命令码的四个字段分别是 type(魔术字符)、nr(序号)、size(参数大小)、dir(方向)。这样内核在 copy_to_user 之前就知道要拷贝多少字节,天然防止越界。
4.2 双向数据传递与用户态验证
ioctl 回调里怎么取参数?最稳妥的方式是用 copy_from_user / copy_to_user,先开辟内核缓冲区,再拷贝。下面是一个设置缓冲区长度的示例:
static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct demo_dev *dev = filp->private_data; int len; int ret = 0; switch (cmd) { case DEMO_IOCTL_SET_LEN: if (copy_from_user(&len, (int __user *)arg, sizeof(len))) return -EFAULT; if (len < 0 || len > 4096) return -EINVAL; dev->size = len; break; case DEMO_IOCTL_GET_STAT: // 构造返回结构体 break; default: return -ENOTTY; } return ret; }注意 ioctl 的第三个参数是 unsigned long,如果用户传的是指针,要先转成对应类型的用户空间指针,再交给 copy 函数。不要直接用int *user_ptr = (int *)arg;后去解引用,那又是一个崩溃点。
用户态验证代码很简朴,打开设备,调用 ioctl,再读写,看一下返回值。
4.3 用 printk 之外的方式定位问题
printk 好用,但在真实设备上我们不想每次加调试都重新编译模块。内核的动态调试机制是首选:把驱动里的 dev_dbg / pr_debug 编译进去,运行时通过 debugfs 动态开关。
echo 'file demo_dev.c +p' > /sys/kernel/debug/dynamic_debug/control开启后 dmesg 就能看到 dev_dbg 输出。我的习惯是:正常路径用 dev_info,关键状态切换用 dev_dbg,错误路径用 dev_err。这样平时 dmesg 不会刷屏,出问题再动态打开详细日志。
如果驱动导致内核 oops,不要慌,把 oops 里的 PC 指针和调用栈贴出来,对照 System.map 或 objdump 定位函数。我排查最多的两类 oops,一类是 copy_to_user 用错返回值的逻辑越界,一类是 open 里没初始化 private_data,导致 read 里取到野指针。
5. 常见问题速查与避坑心得
5.1 问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| insmod 报 Device or resource busy | 设备号冲突,之前驱动没卸载干净 | 查看 /proc/devices,或者卸载残留模块 |
| open /dev/demo_dev 失败 No such device | 设备节点没创建,或 device_create 失败 | 检查 dmesg,ls /dev 确认节点 |
| insmod 成功但 open 一直返回 -1 | 权限不够 | chmod 666 /dev/demo_dev,或者使用 udev 规则 |
| 读写时用户程序段错误 | copy_to_user 访问了非法用户指针 | 检查用户传的 buf 是否可写;驱动计数返回值是否用反 |
| 卸载时系统崩溃 | 还有进程在持有 fd,或 buffer 释放顺序错误 | 先关闭所有 fd 再 rmmod;检查 kfree 指针 |
| 进程 read 永远阻塞 | 写数据后忘了 wake_up | 确认 write 回调里调用了 wake_up_interruptible |
| ioctl 返回 -ENOTTY | 命令码定义不匹配,或未处理该命令 | 检查命令码宏定义,加 default 分支打印 cmd |
5.2 几条百试百灵的实战经验
第一,open 回调里一定要记住把 private_data 设置成你的设备结构体,而且要在 open 开头就设置,别放到 if 分支后面。我见过排查半天读不到数据,最后发现是 open 没写这行。
第二,write 回调返回的字节数必须是实际拷贝成功的字节数,不能是请求的 count。如果你只拷了一半,却返回整个 count,用户程序会以为数据全写进去了,接着读回一堆垃圾。
第三,mutex_lock 和 mutex_unlock 之间的代码要短。千万别在锁里做 msleep、甚至 copy_to_user 大量数据。如果确实需要长时间拷贝,可以先拷到临时内核缓冲区,再统一上锁更新设备状态,能最大限度降低阻塞时间。
第四,设备卸载前,先确保没有用户进程持有 fd。最粗暴的做法是内部维护一个打开计数,rmmod 时检查计数不为 0 就直接返回 -EBUSY,不让卸载。
第五,类名、设备名别乱起。后面 udev 规则要根据这些名字匹配,名字一改,用户空间权限和符号链接全乱套。我习惯在驱动里定义宏,统一管理。
5.3 一点额外的调试心得
如果你遇到的问题是"时有时无、很难稳定复现",我强烈建议在驱动里加入访问计数器,每次 open/read/write/ioctl 都给统计字段加 1。用户空间配合 perf 或 strace,能很清楚地看到函数调用次数和耗时。我调试一个偶发的数据错乱问题,就是从 strace 里发现 read 和 write 交叉得太密集,才想到加锁范围不够。
另外,不要把 printk 全部注释掉再重新编译,效率太低。用上面说的 dynamic debug 开关,一套配置做好,运行期随时切换,省太多事了。
最后谈一点个人体会。写字符设备驱动跟写普通用户程序是两个思路,内核环境里没有那么多容错机制,指针一步走错就是整个系统陪你重启。但这恰恰是它最有意思的地方:当你把一个又一个"凭什么这么做"的问题搞明白,再把驱动挂到真实设备上跑通,那种掌控硬件的感觉是纯软件开发很难体会到的。我每次调完一个 bug,都会把 oops 日志截个图存档,过两个月回头看,还能想起当时的排查路径。也建议你建一个自己的问题笔记,把命令码设计、锁的使用范围这些细节沉淀下来,下次做同类驱动能省一半时间。