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 并放入调度器的就绪队列。
等待队列数据结构关系图 wait_queue_head_t lock (自旋锁) head (链表头) wait_queue_entry_t private → task_struct func → wake_function wait_queue_entry_t private → task_struct func → wake_function 进程A (睡眠) state=TASK_INTERRUPTIBLE 进程B (睡眠) state=TASK_INTERRUPTIBLE 说明: 等待队列头通过双向链表串联多个等待项;每个等待项持有指向进程描述符的指针和唤醒回调函数。

3.2 进程状态切换与阻塞流程

进程在阻塞 I/O 过程中经历三个关键状态:运行态(TASK_RUNNING) → 睡眠态(TASK_INTERRUPTIBLE / TASK_UNINTERRUPTIBLE) → 运行态(TASK_RUNNING)。驱动中典型的阻塞读操作流程如下:

  1. 检查条件: 驱动 read() 函数检查设备是否有数据可读。
  2. 条件不满足: 调用 wait_event_interruptible() 或 wait_event() 宏。
  3. 进程状态切换: 内核将当前进程的 state 从 TASK_RUNNING 改为 TASK_INTERRUPTIBLE。
  4. 加入等待队列: 将当前进程的等待项插入到驱动维护的等待队列中。
  5. 调度器切换: 调用 schedule() 主动让出 CPU,调度器选择其他就绪进程运行。
  6. 唤醒条件满足: 中断处理函数或其他内核路径调用 wake_up() 系列函数。
  7. 进程状态恢复: 唤醒函数将等待队列中所有进程的 state 置为 TASK_RUNNING。
  8. 重新调度: 调度器在下次时钟中断或主动调度时,将唤醒的进程放回 CPU 运行。
阻塞 I/O 进程状态切换流程 TASK_RUNNING 数据未就绪 TASK_INTERRUPTIBLE 等待队列 (wait_queue_head_t) 进程A | 进程B | 进程C ... 加入等待队列 调度器 (schedule()) wake_up() 唤醒 重新获得CPU TASK_RUNNING
编程提示: 驱动中推荐使用 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。

调度器与等待队列协作关系 等待队列 wait_queue_head_t 进程A(睡眠) 进程B(睡眠) 进程C(睡眠) wake_up() 运行队列 (runqueue) 进程D(运行中) 进程E(就绪) 进程F(就绪) 调度器 (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 的基础。