资讯动态

Linux设备驱动中的阻塞IO:等待队列、唤醒机制与工程实践

发布时间:2026/10/19 1:36:12 来源:尧图企业网站定制
1. 为什么设备驱动离不开阻塞IO做Linux驱动这行早晚都要跟阻塞IO打交道。很多刚入行的朋友写个最简单的字符设备demoread函数里直接copy_to_user返回数据觉得“挺好啊什么都能读”。但真到了实际项目里比如某个传感器数据没就绪、某个硬件FIFO为空、某个外设正在忙你总不能让它返回一个错误或者空数据就算完事吧应用层更没法接受CPU跟着空转去反复试探。这时候阻塞IO就是最自然的解法。阻塞IO说白了就是当应用调用read()时如果内核里数据还没准备好就让这个进程睡过去不占CPU等数据来了内核再把它唤醒然后继续把数据交出去。从进程的视角看read()就是“卡住”了但从系统的视角看这个卡住是有意义的——它在等一个事件而这个事件终归会到来。我在实际项目中见过太多把阻塞IO用歪的情况比如在read里用for循环忙等甚至用mdelay做延时轮询。这么写短时间能work一旦上多进程、上中断、上性能测试问题就全暴露了。所以这篇我打算从底层机制到完整demo把阻塞IO在设备驱动里的实现路径讲透最后梳理我这些年踩过的坑和排查思路希望对正在写驱动的朋友有点用。2. 理解阻塞的底层机制从进程状态到等待队列2.1 进程为什么能“睡着”阻塞的本质是改变进程的状态。Linux里每个进程都有一个task_struct结构里面有一个state字段表示当前进程处于什么状态。可运行状态是TASK_RUNNING这时候调度器才会把CPU分配给这个进程。如果进程调用了阻塞操作比如socket的recv、磁盘IO的等待内核会把它的状态改成TASK_INTERRUPTIBLE可中断睡眠或TASK_UNINTERRUPTIBLE不可中断睡眠然后调用schedule()主动交出CPU。调度器看到这个进程不可运行就不会再给它分配CPU时间片这就实现了一个最基本的“不占CPU的等待”。TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE的区别在于前者能被信号唤醒。也就是说如果进程处于TASK_INTERRUPTIBLE状态你给它发个kill命令它会醒来先处理信号然后再决定是继续睡还是直接返回系统调用。这也是为什么内核API里有一大堆名字带interruptible的函数因为它们都要考虑信号打断的问题。而TASK_UNINTERRUPTIBLE睡眠基本只有几种特殊情况才会用比如等待磁盘IO完成因为这种等待被打断会造成数据不一致。驱动开发里我建议默认使用interruptible版本用户体验要好得多——至少用户CtrlC不会完全没反应。2.2 等待队列阻塞睡在哪只要涉及多任务就要有“排队”的意识。等待队列wait queue就是Linux内核为阻塞IO提供的基础设施。它本质上是一个双向链表链表里挂的都是当前正在等待某个事件发生的进程。每个进程在链表里都有一个节点节点里记录了task_struct指针、等待的标志位、以及一个回调函数。当进程要睡下去时它做的事情可以在概念上分成几步先初始化一个等待队列项把自己的task_struct填进去然后把等待队列项挂到某个等待队列头wait_queue_head_t上再检查条件是否已经满足如果满足那就没必要睡了否则把进程状态设为睡眠最后调用schedule()交CPU。将来某个时刻事件发生了内核会遍历这个等待队列把里面的每个进程唤醒——具体是改成TASK_RUNNING再调用一下schedule()让它们有机会跑回来。这个数据结构在驱动里的使用率极高几乎每个阻塞型驱动都会有一个或多个等待队列头。你甚至可以把等待队列理解成一个“闹钟集合”闹钟挂在哪由驱动的设计决定什么时候响由硬件事件或者中断决定。只要事件发生的地方和挂起等待的地方有同一个等待队列头整个唤醒链条就串起来了。2.3 内核是怎么保证“不丢唤醒”的阻塞IO最经典的一个坑叫“唤醒丢失”。设想这样一个场景进程A检查条件发现数据没准备好正想睡觉此时中断来了数据准备好了中断处理程序把进程A唤醒——可A还没正式挂到等待队列上呢完全叫不醒。然后A执行挂接、睡眠结果再也没人管它了。解决思路其实特别朴素把“检查条件”和“真正睡下去”做成一个原子操作。内核的wait_event系列宏干的事情就是在一个循环里反复做两件事先把当前进程挂上等待队列然后检查条件如果条件为真就说明没必要睡直接退出如果条件不成立就把自己睡眠等被唤醒后重新回到循环开头再检查一次条件。外层再包上锁保证整个过程不会被人打断。所以内核源码里wait_event宏写起来只有几行但背后的考虑并不简单。这也解释了为什么在驱动代码里官方推荐用wait_event_interruptible而不是自己手动用add_wait_queue加schedule——手动写很容易在中间插入的时间窗里丢掉唤醒尤其是信号量、自旋锁混在一起用的时候排查起来非常痛苦。3. 实操前的准备一个可用的字符设备骨架3.1 从零搭一个最小设备驱动写阻塞IO之前先要有一个能加载、能卸载、能open/read/write的字符设备框架。我这里用经典的方式创建一个misc设备的示例因为miscdevice对驱动新人来说是最友好的注册方式不用手动分配主设备号也不用care device_create那一堆繁琐步骤。#include linux/module.h #include linux/miscdevice.h #include linux/fs.h #include linux/wait.h #include linux/sched.h #include linux/uaccess.h #include linux/slab.h #define DEV_NAME blockdemo struct block_dev { wait_queue_head_t wq; int data_ready; /* 数据是否就绪的标志 */ char buffer[256]; int buf_len; }; static struct block_dev *g_dev; static int demo_open(struct inode *inode, struct file *filp) { filp-private_data g_dev; return 0; } static int demo_release(struct inode *inode, struct file *filp) { return 0; }上面只是搭了骨架重点在read和write的实现下面会详细展开。这里要强调的是private_data的使用它可以让驱动支持多实例时每个打开的文件保存独立的上下文。在这个demo里因为设备只有一个直接用全局指针也行但养成private_data的习惯对后面写复杂驱动有帮助。3.2 关键变量的初始化在probe函数或者设备初始化阶段等待队列头和标志位都要被正确初始化。等待队列头如果忘记初始化后面add_wait_queue的操作可能会直接导致内核崩溃这种问题一般会在insmod完第一次open后就炸。static int __init demo_init(void) { g_dev kzalloc(sizeof(*g_dev), GFP_KERNEL); if (!g_dev) return -ENOMEM; init_waitqueue_head(g_dev-wq); g_dev-data_ready 0; g_dev-buf_len 0; return 0; }init_waitqueue_head是必须的它会把等待队列头的自旋锁初始化为未锁定状态同时把链表头指向自己。如果你用静态方式定义等待队列头还可以用DECLARE_WAIT_QUEUE_HEAD宏在编译期就初始化好。这个宏和init_waitqueue_head是等价的我一般在模块级别定义时用它在结构体内部字段就用init_waitqueue_head。3.3 注册misc设备misc设备框架会把设备自动放到/dev下面名字就是你给的设备名。对驱动开发者来说这意味着不需要自己写class_create和device_create内核的misc核心代码已经处理好了。static struct miscdevice demo_miscdev { .minor MISC_DYNAMIC_MINOR, .name DEV_NAME, .fops demo_fops, }; static int __init demo_init(void) { int ret; g_dev kzalloc(sizeof(*g_dev), GFP_KERNEL); if (!g_dev) return -ENOMEM; init_waitqueue_head(g_dev-wq); g_dev-data_ready 0; g_dev-buf_len 0; ret misc_register(demo_miscdev); if (ret) { kfree(g_dev); g_dev NULL; return ret; } return 0; } static void __exit demo_exit(void) { misc_deregister(demo_miscdev); kfree(g_dev); g_dev NULL; } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这里有个细节misc_deregister返回之后设备节点可能还在被应用层引用。一般来说字符设备在close完成后才能真正释放私有数据所以更稳妥的做法是在release里做清理。但demo里用的全局指针比较简单只要保证卸载前应用都关闭了文件就行。真正常见的做法是维护一个引用计数或者在release里做最后的释放不过那属于设备生命周期管理的话题了这篇文章不展开。4. 阻塞IO的完整实现read/write与中断联动4.1 read函数的阻塞式写法最核心的是read函数。阻塞IO语义下read要做的逻辑是如果数据没准备好就睡如果数据准备好了就把数据复制到用户空间然后返回长度。调用wait_event_interruptible可以一步到位地把“挂等待队列”和“条件检查”封装起来。这个宏的展开逻辑非常巧妙它首先判断条件如果条件成立直接跳过整个睡眠过程如果条件不成立它把当前进程加进等待队列设置state为TASK_INTERRUPTIBLE然后schedule()。被唤醒后会重新检查条件条件成立就退出循环条件不成立就继续睡。static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { struct block_dev *dev filp-private_data; int ret; ret wait_event_interruptible(dev-wq, dev-data_ready); if (ret) return ret; /* 被信号打断 */ if (count dev-buf_len) count dev-buf_len; ret copy_to_user(buf, dev-buffer, count); if (ret) return -EFAULT; dev-data_ready 0; /* 数据已读出复位标志位 */ dev-buf_len 0; return count; }这段代码里几个地方要特别说明。首先wait_event_interruptible的返回值要判错。如果返回非零说明这个睡眠被信号打断了这时候按照规范应该返回-ERESTARTSYS让应用层的read调用返回-1并且errno等于EINTR。直接返回ret这个负数也可以但返回值语义上不如-ERESTARTSYS精确。内核在系统调用返回路径上会对ERESTARTSYS做特殊处理有可能自动重启动系统调用。如果没有明确的偏好驱动里一般直接返回-ERESTARTSYS这样用户态看到的行为更符合预期。其次条件变量data_ready的复位时机要设计清楚。我这个Demo是“一次read读走所有的数据”所以读完直接清零。如果你的设备是边沿触发、数据持续有效那不要随便清零。要结合自己的硬件语义来定义data_ready的含义。count的处理也值得注意如果用户要读取的长度比内核缓冲长度大就要截断到内核实际数据长度。反过来如果用户长度小于缓冲长度只拷贝用户要求的那么多剩下的数据理论上应该留给下一次read。但很多demo都是直接丢弃了这取决于你的设备是否允许数据丢失。如果是一个串口设备显然不能丢必须维护一个环形缓冲区或者FIFO。这是另一个话题这里不展开。4.2 write函数如何唤醒read进程有读就有写。对许多简单设备来说write触发的动作就是“产生数据”然后唤醒正在等待的读进程。以我做的模拟设备为例write把数据拷进内核buffer置位data_ready然后唤醒等待队列上所有的reader。唤醒接口的选择也值得聊一下。wake_up(dev-wq)会唤醒队列上所有非独占等待的进程。wake_up_interruptible(dev-wq)则只唤醒睡眠状态为TASK_INTERRUPTIBLE或TASK_EXCLUSIVE的进程。因为read用的是wait_event_interruptible这里要匹配用wake_up_interruptible确保那些可中断睡眠的进程能被打醒。static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { struct block_dev *dev filp-private_data; size_t copy_len count; if (copy_len sizeof(dev-buffer)) copy_len sizeof(dev-buffer); if (copy_from_user(dev-buffer, buf, copy_len)) { pr_err(copy_from_user failed\n); return -EFAULT; } dev-buf_len copy_len; smp_wmb(); /* 保证buffer写入完成后才置位flag */ dev-data_ready 1; wake_up_interruptible(dev-wq); return copy_len; }smp_wmb那行是内存屏障很多人写驱动会忽略。它存在的意义是确保多核处理器上buffer里的数据写入在置位data_ready之前对所有CPU可见。如果你的数据是int类型、只写一次这种极端简单场景下丢失风险很小但一旦数据量大了编译器重排指令或者CPU乱序执行就可能让读端看到flag已经置位但数据还是旧值。所以我写驱动时只要涉及flag加数据这种组合就习惯性地加屏障或者用WRITE_ONCE/READ_ONCE。4.3 由中断触发的唤醒模式比write唤醒更常见的场景是硬件中断唤醒。真实的设备驱动程序里read阻塞等待的一般不是另一个进程来write而是硬件产生数据后触发中断中断下半部或直接处理程序去唤醒read进程。以一颗典型的SPI或I2C传感器为例应用层read一个温度值驱动向传感器发出读请求然后进程进入睡眠传感器数据准备好后通过GPIO中断通知SoC中断处理函数里除了读取硬件寄存器取数据还要把data_ready置位并调用wake_up_interruptible。这就是一个完整的“硬件事件驱动阻塞IO”模式。写中断处理函数要留意上下文。中断处理函数运行在原子上下文中不能调用可能睡眠的函数比如kmalloc带GFP_KERNEL、copy_to_user这类操作。唤醒操作本身不会睡眠在中断里调用wake_up_interruptible是安全的。如果你用的是tasklet或workqueue处理下半部在下半部里唤醒也没问题。模拟真实中断场景我经常用一个高精度内核定时器hrtimer来模拟硬件事件定时器到期就执行回调把数据放到buffer里置位flag并唤醒。这样整个驱动的阻塞链路是完整的又不需要真实的外设。static enum hrtimer_restart demo_timer_handler(struct hrtimer *timer) { struct block_dev *dev container_of(timer, struct block_dev, hr_timer); unsigned long flags; spin_lock_irqsave(dev-lock, flags); strcpy(dev-buffer, sim-data); dev-buf_len 8; dev-data_ready 1; spin_unlock_irqrestore(dev-lock, flags); wake_up_interruptible(dev-wq); return HRTIMER_NORESTART; }我在demo里会用spin_lock保护共享数据因为中断上下文和进程上下文可能同时访问buffer。而4.2的write版本没加锁是因为简化场景只有一个writer或者你可以用mutex保护进程上下文里的写操作。一旦涉及中断自旋锁是不能少的否则数据竞争会导致诡异的问题。5. 用户态怎么配合从open到select/poll5.1 阻塞与非阻塞的行为差异内核驱动里的阻塞行为最终还是要反映到用户态的系统调用上。Linux系统对文件描述符有两种打开模式阻塞和非阻塞。默认情况下open一个设备不指定O_NONBLOCK这个fd就是阻塞模式的。在阻塞模式下调用read时如果内核没有数据进程会被挂起直到有数据或信号打断。非阻塞模式则在数据没就绪时立即返回-1errno设为EAGAIN。内核驱动里要实现这个区分核心在于检查file-f_flags中的O_NONBLOCK位。也就是说驱动代码里不能只写阻塞逻辑还必须主动判一下非阻塞场景否则用户以O_NONBLOCK方式打开设备read还是会阻塞。这在应用层看来就是“程序卡死”而且拿kill都没法轻易杀掉因为进程处于不可中断睡眠如果是你用错了唤醒函数。正确的写法是在read开头加一段判断if (filp-f_flags O_NONBLOCK) { if (!dev-data_ready) return -EAGAIN; }这样非阻塞模式下数据没准备好就立即返回-EAGAIN用户态拿到这个errno就知道“设备暂时没有数据下次再试”。很多应用层框架比如epoll、select之所以不会卡死线程正是基于这种非阻塞模式下的EAGAIN行为。5.2 poll/epoll与驱动的关系select和epoll在用户态用得非常普遍但很多写驱动的新人不太清楚它们在内核侧对应什么。其实poll、select、epoll最终都会调用驱动里的poll函数。poll函数的作用是向内核报告当前设备是否有数据可读、是否有空间可写、是否发生异常。驱动如果不实现poll那select和epoll对该设备就无效会永远认为设备既可读又可写。这不一定是问题但如果你希望用户态能用select等待设备变得可读就一定要实现poll函数。poll的标准写法如下static unsigned int demo_poll(struct file *filp, poll_table *wait) { struct block_dev *dev filp-private_data; unsigned int mask 0; poll_wait(filp, dev-wq, wait); if (dev-data_ready) mask | POLLIN | POLLRDNORM; if (mask 0) mask | POLLOUT | POLLWRNORM; return mask; }poll_wait的含义非常微妙它不是阻塞而是把当前进程挂入驱动内部的等待队列同时允许内核在后续执行select/epoll的睡眠逻辑时一旦驱动调用wake_up就能唤起select所在进程。这相当于把驱动的等待队列和select自身的等待机制桥接起来。实现的要点是先在poll_wait挂上等待队列然后立刻检查设备状态并返回mask。如果设备已有数据mask包含POLLINselect就会认为可读直接返回。否则mask是0进程会在select的内部等待队列里睡眠直到drvier的wake_up把它叫起来。5.3 应用层测试脚本示例写驱动不能只在内核里自嗨用户态的验证脚本同样重要。这里给一个最简单的C测试程序先演示阻塞读再演示select等待#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include errno.h #include string.h #include sys/select.h int main(void) { int fd; char buf[64]; int ret; fd open(/dev/blockdemo, O_RDWR); if (fd 0) { perror(open); return 1; } /* 阻塞读取会一直等 */ printf(blocking read...\n); ret read(fd, buf, sizeof(buf) - 1); if (ret 0) { buf[ret] 0; printf(read %d bytes: %s\n, ret, buf); } else { printf(read error: %s\n, strerror(errno)); } close(fd); /* 非阻塞读取演示 */ fd open(/dev/blockdemo, O_RDWR | O_NONBLOCK); if (fd 0) { perror(open); return 1; } ret read(fd, buf, sizeof(buf) - 1); if (ret 0 errno EAGAIN) printf(nonblock read returned EAGAIN, no data ready\n); close(fd); return 0; }实际操作你会发现阻塞read确实卡住不动直到write进程写入数据才会返回。如果没人写可以用CtrlC终止程序因为read使用了TASK_INTERRUPTIBLE睡眠可以响应SIGINT。这个体验非常直观便于刚入手的同学建立对阻塞语义的感性认识。5.4 多进程同时read的调度体验有时候你会有多个进程同时阻塞在同一个设备的read上。比如一个数据采集服务主进程负责读取另一个看门狗进程也开着设备做监控。唤醒时wake_up_interruptible会唤醒等待队列上的所有进程它们都会从read返回竞争同一份数据。这会导致多个进程读到相同的数据或者有的进程醒来发现数据已经被别人读走了然后继续睡。针对这种情况内核提供了独占等待WQ_EXCLUSIVE机制。使用wait_event_interruptible_exclusive宏可以让当前进程以独占模式等待。唤醒时内核对于独占等待的进程只会唤醒第一个。这在处理“多个进程共享一个数据源”的问题时非常有用。不过exclusive模式需要小心如果你想唤醒所有进程就不能只用wake_up而应该用wake_up_all。实际项目中是否要使用独占模式取决于你的数据分发策略没有统一的答案。6. 高级变体killable、超时与互斥保护6.1 不可中断睡眠带来的风险虽然前文建议默认使用interruptible版本但有一个反例必须提在某些情况下驱动必须保证读操作不会被信号打断否则会破坏硬件状态机。比如触摸屏的坐标读取、某些寄存器的连续读取如果中途被打断硬件可能停留在半途状态。此时可以改用wait_event_killable这个版本只响应致命信号普通的SIGUSR1之类不会打断睡眠但SIGKILL还是能杀。它比TASK_UNINTERRUPTIBLE温和又能避免大部分意外打断。我一般在确实需要“不被打断的短等待”时会优先考虑killable而不是uninterruptible因为TASK_UNINTERRUPTIBLE的进程不仅杀不掉还会拖累系统关机流程你是见过用户骂“怎么kill -9都没用”的。6.2 wait_event_timeout防止永久等待有些设备可能出现故障导致中断永远不来应用层调用read就永久阻塞了。这时候驱动可以提供一个等待超时机制到时间没有数据就把进程唤醒返回0或-EIO。内核同样提供了wait_event_interruptible_timeout宏多传一个超时时间参数。我看过很多驱动代码用timeout的方式做轮询降级正常走阻塞等待中断正常就高效唤醒如果中断挂了靠timeout兜底主程序定期检查硬件状态。这个思路在工业现场设备里很实用。比如一些老旧的传感器在干扰下经常丢中断,GPI0信号不可靠驱动里给等待加个1秒超时超时后再主动读一下硬件状态能大幅提高系统的鲁棒性。6.3 与互斥锁共舞的坑阻塞等待里如果涉及共享资源的修改一定会用到锁。比较常见的组合是mutex保护共享数据的完整性wait_event负责睡眠。先看一个常见错误在持有mutex的时候调用wait_event_interruptible。这会导致一个问题——睡眠期间mutex一直被持有其他进程想访问同一个共享资源时也会被阻塞相当于所有功能都被冻结了。正确做法一般是先把数据读出来或者把状态准备好释放锁再等待事件等被唤醒后再重新拿锁检查数据。另一种常见场景是在read函数里读环形缓冲区先拿自旋锁查数据如果数据不足则解锁睡等唤醒后再拿锁查一次。看起来没问题但如果两个reader同时醒来它们都去抢数据一个读完另一个读到的可能就少了。所以必须在wait_event的条件里写清楚“数据是否足够我这次read所需长度”而不仅仅是“是否有数据”。这个细节在实际项目中经常被忽视。7. 踩坑实录阻塞IO常见问题与排查手段7.1 永远睡眠read一直卡住这是驱动开发里最典型的故障但不是最好排查的。出现这种情况时第一个动作是用ps查看进程状态。如果进程state是D说明它在不可中断睡眠中如果是S说明在可中断睡眠。然后看它卡在哪个系统调用——可以用/proc/PID/stack查看内核栈通常能看到具体卡在哪个内核函数上。常见原因有三个一是唤醒函数与睡眠函数不匹配比如用了wait_event_interruptible却调用了wake_up而不是wake_up_interruptible二是唤醒虽然调用了但条件标志没有置位进程醒来后又继续睡。第二种情况极其隐蔽你会在栈上看到进程确实被唤醒了但一检查条件发现还是假又睡回去了。排查手段是在唤醒函数附近加printk打印flag值。还有一个原因是等待队列头搞错了。比如驱动里有多个等待队列头read等的是queue A中断唤醒时却调用了wake_up_interruptible(queue B)那当然永远叫不醒。这种错误在复制粘贴代码时特别容易犯我踩过两次都是靠逐行核对才发现的。7.2 唤醒丢失数据来了但没人读到和永远睡眠症状相反的是数据已经到了但阻塞的进程没有被唤醒。这通常是“检查条件”和“睡眠”之间存在竞态的变体。如果代码是自己手动写的add_wait_queue加schedule而不是用wait_event系列宏特别容易出现。比如进程先检查data_ready为0正准备睡眠此时中断把data_ready置为1并调用wake_up但进程尚未进入睡眠所以这个唤醒丢失了然后进程真的睡过去。以后数据再也不会来就变成了永远睡眠。官方宏wait_event会自动在一个循环里重复检查条件并设置状态从而最大限度避免这种竞态。但这也不是万能的如果你在外部直接修改了data_ready而没有锁保护编译器优化后仍可能有一小段窗口期。所以要保证对data_ready这类标志的读写要么用锁要么加READ_ONCE/WRITE_ONCE。7.3 假唤醒条件没变但进程醒了Linux的等待队列机制允许所谓的“虚假唤醒”。意思是有些时机下进程会被唤醒但实际条件不成立需要回到循环重新判断。wait_event系列宏天然支持这个所以由宏保护的阻塞操作一般没事。但如果你写了裸的add_wait_queue加schedule又没有在外面套循环一个wake_up过来进程就会误以为数据准备好了结果copy_to_user拷了个空缓冲应用层就很困惑。防止假唤醒的办法就是永远使用wait_event系列API不要手写裸循环。很多初学者喜欢看内核里那些“老式写法”的驱动但那些代码多半是历史遗留新代码完全没有必要去模仿那种风格。7.4 rmmod卸载挂死写驱动时最尴尬的操作就是一个read阻塞在那里然后你执行rmmod结果模块卸载函数卡死。这个现象一般是因为模块的exit函数在做清理但还有进程睡眠在等待队列上或者还在使用模块提供的函数。如果卸载时不对这些阻塞进程做处理内核可能直接崩溃或者卸载流程被卡住。稳妥的做法是在exit函数里把设备的状态直接打断把data_ready置为1、把所有阻塞中的进程唤醒让read返回-EIO或者更复杂一点用引用计数保证所有fd都close后再释放。我一般会维护一个atomic_t记录打开的次数exit时如果打开次数不为0就不真正释放内存等release里减到0再释放。这会让代码量上去但稳定性的提升非常明显。7.5 多核条件下看到的诡异数据如果一个设备有多个线程同时访问flag和数据在多个CPU上运行时会出现“flag说OK但数据读出来不对”的奇怪现象。这跟4.2节提到的内存屏障有关。多核架构下CPU的写操作对别的CPU并不是严格有序的即使A核先写buffer再写flagB核也可能先看到flag更新而buffer还没刷过去。解决方案是在置位flag前加wmb()或者在写入flag和buffer时都用WRITE_ONCE读取则用READ_ONCE加内存屏障。现在内核提供的atomic操作也自带屏障语义比如atomic_set和atomic_read配合可以替代一部分手动屏障。我在实践中更偏向用READ_ONCE/WRITE_ONCE加上必要的smp_rmb/smp_wmb组合代码读起来更明确。8. 测试策略与工具组合8.1 手工测试的基本流程驱动编译安装后先做手工功能验证。我会准备两个终端一个跑阻塞read程序另一个用echo写入数据。第一步先只读不写确认读程序确实卡住第二步写入数据观察读程序是否立刻返回第三步连续快速写入多次确认不会丢唤醒第四步在read阻塞时CtrlC确认能正常被打断返回。手工测试虽简单但能暴露八成的基础问题。特别是展开第一和第三步时如果读程序有时候卡住有醒来那就得怀疑有唤醒丢失。手工测只能做定性不能定量确定是否每次都唤醒所以还得上自动化工具。8.2 用strace观察系统调用行为当驱动和应用层配合出问题时先别急着怀疑驱动可以用strace看应用层到底经历了什么。比如一个read一直卡住strace能告诉你read调用是否真的进入内核、返回了什么、errno是什么。如果驱动返回了-EAGAIN但应用没处理那问题往往出在应用层。用strace配合ps查看进程状态是排查阻塞IO的一个非常高效的组合。很多次我定位到问题不在驱动而在应用层比如应用层open时没设置O_NONBLOCK但我判断成了非阻塞场景靠的就是strace。8.3 压力测试与多个进程并发功能测试通过后我会用脚本做一轮压力测试多个进程同时打开设备同时读主进程随机写入数据观察是否有进程饿死、是否有数据重复、是否有进程异常退出。这类测试很容易暴露出共享数据和锁保护的问题。我会在驱动里临时加上一对性能计数器统计read总次数、阻塞次数、平均睡眠时间用调试接口输出到proc或者sysfs能直观看到每路读进程的阻塞行为是否健康。测试结束后一定要卸载这些临时统计代码因为proc文件操作也会引入额外的锁交互可能干扰最终的性能结论。9. 一些进一步扩展的方向阻塞IO写明白之后基本上也就摸到了字符设备驱动的大门口。后面你可以沿着几个方向继续深入。如果想提高读的效率可以研究内核里的零拷贝机制比如直接用mmap让用户态和设备共享一段内核内存减少copy_to_user的开销。如果想处理更密集的数据流可以尝试用内核里的kfifo或者自己实现环形缓冲区替代我demo里的固定buf。如果想支持大量并发fd的监测可以研究epoll在内核侧的实现以及epoll回调时驱动poll函数的调用时机。线程化的唤醒也很值得研究。不同的唤醒语义——wake_up、wake_up_interruptible、wake_up_all、wake_up_sync——它们分别适合什么样的场景。比如wake_up_sync在驱动持有锁时调用可以避免唤醒后的进程立刻抢占锁造成不必要的调度开销。这些细节在性能敏感的驱动里都是有价值的优化点。另外把阻塞IO和中断下半部、tasklet、workqueue结合起来的复杂度会比本文的demo更高。中断上下文不能睡眠但你可以在下半部里做更多逻辑然后在合适时机唤醒等在那里的进程。这是每个真实设备驱动都会遇到的结构性问题。10. 最后的经验总结写了这么多最重要的还是那几句话能用官方宏的地方绝不手写检查条件、睡眠、唤醒要成对考虑标志位和数据的读写要用锁或原子操作保证顺序用户态的O_NONBLOCK一定要照顾到做异步事件源中断、定时器时不要忘记把流程图画出来再动手。我自己做驱动开发这几年吃过的亏大多不是不懂函数而是没有把进程状态、竞态、锁、内存序这几件事联合起来考虑。阻塞IO看着只是一个read函数背后牵涉的却是整个内核的调度与同步机制。把这个机制吃透了后面那些复杂的设备树、DMA、中断控制器学起来都会顺畅很多。如果你刚起步建议先拿这个demo反复改着玩加一个超时版本、再加一个非阻塞分支、改成多点唤醒、模拟两个reader同时读的场景。每改一次都亲手去验证很快就能建立扎实的感觉。阻塞IO这件事真正理解了后面那些复杂机制其实就是在这基础上加更多的等待队列和更精细的唤醒策略而已。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑