3. 阻塞IO原理:等待队列与进程状态切换
阻塞 I/O 是 Linux 驱动中最基础的 I/O 模型。当应用程序发起 read() / write() 系统调用时,如果设备数据未就绪,内核会将当前进程置为睡眠状态(阻塞),直到设备可读/可写时才唤醒。这一机制的核心数据结构是 等待队列(wait_queue),其背后涉及进程状态切换与调度器的协同工作。
3.1 等待队列数据结构
等待队列是内核用于管理阻塞进程的双向链表。每个等待队列头(wait_queue_head_t)关联一组等待项(wait_queue_entry_t),每个等待项对应一个因等待某事件而睡眠的进程。
| 数据结构 | 定义位置 | 核心字段 | 作用 |
|---|---|---|---|
wait_queue_head_t |
<linux/wait.h> |
spinlock_t lock + struct list_head head |
等待队列的链表头,保护链表操作的自旋锁 |
wait_queue_entry_t |
<linux/wait.h> |
unsigned int flags + void *private + wait_queue_func_t func + struct list_head entry |
每个等待项,包含唤醒函数指针和关联的进程描述符 |
task_struct |
<linux/sched.h> |
state + on_rq + prio |
进程描述符,state 字段记录进程当前状态 |
关键点: 等待队列中的每个等待项通过
private 指针指向 task_struct,唤醒时内核遍历链表,调用每个等待项的 func 回调函数(通常是 default_wake_function),将对应进程状态置为 TASK_RUNNING 并放入调度器的就绪队列。
3.2 进程状态切换与阻塞流程
进程在阻塞 I/O 过程中经历三个关键状态:运行态(TASK_RUNNING) → 睡眠态(TASK_INTERRUPTIBLE / TASK_UNINTERRUPTIBLE) → 运行态(TASK_RUNNING)。驱动中典型的阻塞读操作流程如下:
- 检查条件: 驱动
read()函数检查设备是否有数据可读。 - 条件不满足: 调用
wait_event_interruptible()或wait_event()宏。 - 进程状态切换: 内核将当前进程的
state从TASK_RUNNING改为TASK_INTERRUPTIBLE。 - 加入等待队列: 将当前进程的等待项插入到驱动维护的等待队列中。
- 调度器切换: 调用
schedule()主动让出 CPU,调度器选择其他就绪进程运行。 - 唤醒条件满足: 中断处理函数或其他内核路径调用
wake_up()系列函数。 - 进程状态恢复: 唤醒函数将等待队列中所有进程的
state置为TASK_RUNNING。 - 重新调度: 调度器在下次时钟中断或主动调度时,将唤醒的进程放回 CPU 运行。
编程提示: 驱动中推荐使用
wait_event_interruptible() 而非 wait_event(),因为前者允许进程接收信号(如 Ctrl+C),避免进程永久阻塞。对应的唤醒函数应使用 wake_up_interruptible()。
3.3 调度器与阻塞唤醒的关系
调度器(Scheduler)是进程状态切换的执行者。阻塞 I/O 中调度器与等待队列的协作关系体现在以下三个层面:
| 操作 | 调用者 | 调度器行为 | 等待队列行为 |
|---|---|---|---|
| 进程阻塞 | 驱动 read() / write() |
将当前进程从运行队列移除,选择新进程运行 | 将当前进程的等待项插入链表 |
| 进程唤醒 | 中断处理 / 其他内核路径 | 将进程状态置为 TASK_RUNNING,加入运行队列 |
遍历链表,调用每个等待项的 func |
| 进程抢占 | 时钟中断 / 高优先级任务 | 检查是否需要抢占当前进程 | 不直接操作等待队列 |
调度器维护一个全局的 运行队列(runqueue),每个 CPU 核心对应一个运行队列。当进程被唤醒时,调度器将其放入对应 CPU 的运行队列中,等待下次调度时机(如时间片耗尽、主动 schedule() 调用、中断返回等)获得 CPU。
3.4 驱动中等待队列的典型用法
以下代码片段展示字符驱动中使用等待队列实现阻塞读的标准模式:
// 定义等待队列头
static wait_queue_head_t my_wq;
static int data_ready = 0;
// 初始化(通常在 probe 或 init 中)
init_waitqueue_head(&my_wq);
// 阻塞读实现
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
// 等待数据就绪,允许被信号打断
if (wait_event_interruptible(my_wq, data_ready != 0)) {
return -ERESTARTSYS; // 被信号打断
}
// 数据已就绪,执行拷贝操作
data_ready = 0;
// ... 拷贝数据到用户空间 ...
return bytes_read;
}
// 中断处理函数或数据到达回调
static void my_data_arrive(void)
{
data_ready = 1;
wake_up_interruptible(&my_wq); // 唤醒所有等待进程
}
注意事项:
- 等待队列的
lock自旋锁保护链表操作,在 SMP 系统下必须使用。 wait_event_interruptible()返回非零值表示被信号打断,驱动应返回-ERESTARTSYS让 VFS 层重新启动系统调用。- 唤醒操作必须在条件变为真 之后 调用,否则可能发生“唤醒丢失”导致进程永久睡眠。
- 使用
wake_up_all()唤醒所有等待进程时,注意“惊群效应”——多个进程同时被唤醒但只有一个能获得资源。
3.5 阻塞与非阻塞的对比
| 对比维度 | 阻塞 I/O | 非阻塞 I/O |
|---|---|---|
| 进程状态 | 睡眠态(TASK_INTERRUPTIBLE) | 运行态(TASK_RUNNING) |
| CPU 占用 | 不占用 CPU,让出处理器 | 占用 CPU 轮询或立即返回 |
| 数据就绪时延 | 低(被立即唤醒) | 取决于轮询间隔 |
| 驱动实现复杂度 | 需要等待队列和唤醒机制 | 只需检查条件并返回 |
| 适用场景 | 数据到达时间不确定,如串口、按键 | 数据可立即获取,或需要非阻塞轮询 |
阻塞 I/O 通过等待队列机制实现了“零 CPU 开销等待”,是嵌入式 Linux 驱动中最常用的 I/O 模型。理解等待队列的数据结构、进程状态切换流程以及调度器的协作关系,是掌握后续 poll() 机制和异步 I/O 的基础。