1.1 忙等待带来的问题
在驱动开发过程中,经常会遇到这样一种场景:用户进程发起 read() 请求,但硬件数据尚未到达;或者应用程序等待某个 GPIO 状态变化、DMA 传输完成、CAN 数据接收完成等事件。如果驱动简单采用 while 循环不断检查状态位,那么 CPU 将持续运行,形成典型的 Busy Loop。虽然这种方式实现简单,但会造成大量 CPU 资源浪费,特别是在等待时间较长的情况下,一个进程可能长期占据 CPU 而什么工作都没有完成,例如串口驱动等待数据到达时,如果采用如下方式:
CPU 会不断执行循环指令。对于嵌入式系统而言,这不仅浪费处理器资源,还可能影响其他任务运行;对于服务器系统而言,大量忙等待线程甚至可能导致系统负载异常升高。因此 Linux 内核采用睡眠与唤醒机制替代忙等待,当条件不满足时主动放弃 CPU,等事件真正发生后再恢复执行。
1.2 等待队列解决什么问题
等待队列(Wait Queue)是 Linux 最基础的进程同步机制之一,其核心思想是“条件不满足则睡眠,条件满足后再唤醒”。当进程发现目标资源尚未准备好时,不再反复轮询,而是将自己挂入等待队列并进入睡眠状态。此时调度器会切换到其他可运行任务执行,CPU 不会被浪费。当硬件中断到来或者条件发生变化时,驱动通过 wake_up() 唤醒等待队列中的进程,进程重新进入运行队列继续执行。
从实现角度来看,等待队列实际上是 Linux 驱动实现阻塞 I/O 的基础设施。无论是字符设备驱动中的阻塞 read()、网络驱动中的数据接收等待,还是 poll()/select()/epoll() 事件通知机制,底层都离不开等待队列。可以说等待队列是连接驱动事件与用户进程的重要桥梁,也是 Linux 内核事件驱动模型的核心组成部分。
第二章 等待队列的数据结构
2.1 wait_queue_head_t
等待队列本质上是一个链表,用于保存所有等待某个条件的任务。Linux 使用 wait_queue_head_t 描述一个等待队列头:
typedef struct wait_queue_head { spinlock_t lock; struct list_head head;} wait_queue_head_t;
其中 head 是等待任务链表,而 lock 用于保护链表并发访问。每个需要支持阻塞等待的驱动通常都会定义一个等待队列头。例如串口驱动接收缓冲区为空时,可以让读进程挂到这个等待队列上;当中断接收到数据后,再统一唤醒等待任务,初始化方式如下:
wait_queue_head_t rx_wait;init_waitqueue_head(&rx_wait);
或者直接静态定义:
DECLARE_WAIT_QUEUE_HEAD(rx_wait);
初始化完成后,该等待队列即可用于后续阻塞和唤醒操作。
2.2 wait_queue_entry_t
除了等待队列头之外,每个等待进程还对应一个等待队列节点:
struct wait_queue_entry { unsigned int flags; void *private; wait_queue_func_t func; struct list_head entry;};
这里 private 通常指向 task_struct,即当前进程控制块。当进程进入等待状态时,内核会自动创建等待节点并挂接到等待队列链表中。此时等待队列头负责管理所有等待任务,而具体节点则记录每个等待者的信息,从结构关系来看,可以简单理解为:
wait_queue_head_t │ ▼+------------+| task A || task B || task C |+------------+
当驱动调用 wake_up() 时,内核会遍历等待链表,将符合条件的任务逐个唤醒并重新加入调度队列。
第三章 等待与唤醒流程
3.1 wait_event 工作原理
Linux 提供了一组 wait_event 宏用于实现阻塞等待:
wait_event(queue, condition);
其语义非常直观:如果 condition 为真,则立即返回;如果 condition 为假,则将当前进程挂入等待队列并进入睡眠状态。很多驱动中的阻塞读操作都采用这种模式,例如:
wait_event(rx_wait, rx_ready == 1);
这里表示如果没有接收到数据,则进程进入睡眠。当数据到达后,驱动设置 rx_ready=1 并调用 wake_up(),等待进程被重新调度执行。相比 Busy Loop,CPU 利用率大幅提高,因为等待期间进程完全不占用处理器资源。
3.2 wake_up 唤醒机制
等待队列与中断机制通常配合使用。硬件事件到来时,中断服务程序更新状态并唤醒等待任务:
rx_ready = 1;wake_up(&rx_wait);
调用链大致如:
Interrupt │ ▼wake_up() │ ▼try_to_wake_up() │ ▼TASK_RUNNING
被唤醒的任务状态从 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 变为 TASK_RUNNING,并重新进入运行队列。至于何时真正获得 CPU,则由调度器决定。这样驱动只负责事件通知,而不需要关心具体调度细节。
第四章 驱动中的典型应用
4.1 字符设备阻塞读
字符设备最典型的应用场景就是阻塞 read()。例如 UART 驱动接收缓冲区为空时,如果直接返回错误,用户程序需要不断重试;而使用等待队列后,可以让 read() 自动阻塞直到数据到达,典型代码:
ssize_t drv_read(...){ wait_event(rx_wait, rx_cnt > 0); copy_to_user(...); return len;}
这里 read() 会一直等待直到接收缓冲区出现数据。用户程序看到的是一个普通阻塞 read() 接口,而驱动内部则利用等待队列完成同步。这种模式广泛应用于串口、CAN、I2C、SPI 等设备驱动。
4.2 DMA 完成等待
DMA 驱动同样大量使用等待队列。应用程序启动 DMA 传输后,数据搬运过程完全由硬件完成,CPU 无需参与。如果采用轮询方式检查 DMA 完成标志,会造成大量资源浪费,更合理的实现:
dma_done = 0;start_dma();wait_event(dma_wait,dma_done);
DMA 中断到来时:
dma_done = 1;wake_up(&dma_wait);
这样 CPU 可以在 DMA 工作期间执行其他任务,直到 DMA 真正完成才恢复当前进程。这种模式在网络驱动、存储驱动以及高性能数据采集中十分常见。
第五章 等待队列与 Poll/Epoll
5.1 poll 为什么依赖等待队列
很多开发者知道 poll()、select()、epoll() 可以同时监听多个文件描述符,却不知道其底层正是依赖等待队列实现。用户调用 poll() 时,内核并不会持续轮询设备状态,而是将当前进程注册到对应驱动的等待队列中。
驱动通常需要实现 poll 回调:
unsigned intdrv_poll( struct file *file, poll_table *wait){ poll_wait(file, &rx_wait, wait); if(rx_ready) return POLLIN; return 0;}
这里 poll_wait() 实际上就是将当前任务挂入等待队列。当设备数据到达时,wake_up() 会通知 poll 子系统,从而唤醒用户进程。
5.2 epoll 为什么高效
epoll 的高性能本质上来自事件驱动,而不是轮询驱动。传统 select() 每次都需要遍历全部文件描述符,而 epoll 则依赖等待队列建立事件回调关系,流程:
epoll_wait() │ ▼wait queue │ ▼device event │ ▼wake_up() │ ▼epoll callback
因此 epoll 即使管理上万个连接,也不需要反复扫描全部描述符。真正发生事件时,驱动通过等待队列直接通知 epoll,从而实现 O(1) 级别事件响应能力。
第六章 等待队列源码分析与注意事项
6.1 TASK_INTERRUPTIBLE 与 TASK_UNINTERRUPTIBLE
等待队列睡眠时主要有两种状态:
TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE
前者允许被信号打断,例如 Ctrl+C 或 kill 命令;后者则无法被普通信号中断,必须等待条件满足。大多数驱动采用 TASK_INTERRUPTIBLE,因为这样可以避免进程永久挂死,典型实现:
wait_event_interruptible( rx_wait, rx_ready);
如果收到信号,该函数会提前返回错误码,用户程序可以自行决定是否继续等待。相比之下,TASK_UNINTERRUPTIBLE 更适合必须等待完成的内核内部操作。
6.2 使用等待队列时的常见错误
驱动开发中最常见的问题是“先唤醒后睡眠”。如果驱动在进程进入等待队列之前就调用了 wake_up(),那么唤醒事件会丢失,导致进程永久睡眠。因此正确写法一定是先检查条件,再进入等待队列,并在唤醒后重新检查条件,典型模式如:
wait_event(rx_wait, rx_ready == 1);不是:if(rx_ready == 0) sleep();
因为 wait_event 内部已经处理了竞态条件问题。现代 Linux 驱动几乎都采用 wait_event 系列宏,而不是手工管理 task_struct 状态。这样不仅代码更简洁,也能避免大量同步错误。
从本质上看,等待队列解决的是“事件尚未发生时如何高效等待”的问题。它将 CPU 从无意义轮询中解放出来,使 Linux 驱动能够以事件驱动方式运行。无论是字符设备阻塞读、DMA 完成通知,还是 poll、epoll 等高级 I/O 模型,其底层最终都会回到等待队列这一基础机制上。