第4讲:不要独占CPU——阻塞机制与空闲任务的诞生

上一讲我们学会了用关中断保护临界区,避免了数据错乱。但还有一个让人头疼的问题:
如果任务A只是想让LED闪烁,每隔1秒翻转一次,但它在“等待”期间却死死霸占着CPU,让其他任务无法运行。
即使没有其他任务需要执行,CPU也在空转、发热、耗电。

这一讲,我们将让任务学会“主动睡觉”——进入阻塞状态,把CPU让给别的任务;当无事可做时,CPU会跑一个特殊的“空闲任务”,甚至可以进入低功耗模式。


1. 忙等待:CPU 的“空转灾难”

回忆第一讲的例子,我们当时这样实现延时:

void delay_ms(int ms) {
    for (int i = 0; i < ms * 1000; i++) {
        // 空循环,浪费时间
        asm volatile("nop");
    }
}

这种“忙等待”(Busy Waiting)在裸机编程里很常见,但在多任务系统中是大忌:

  • CPU 100% 占用:即使所有任务都在延时,CPU 依然在空转,电量白白浪费。
  • 低优先级任务饿死:如果高优先级任务一直在忙等待,低优先级任务永远得不到运行。
  • 实时性差:无法精确处理多个不同周期的任务。

我们需要的是一种机制:任务自愿放弃CPU,直到某个条件满足(例如时间到,或者等待的信号量可用)。 这就是 阻塞(Blocking)


2. 阻塞状态:任务进入“休眠室”

我们在第2讲已经定义了任务的四种状态:RUNNINGREADYBLOCKEDSUSPENDED。其中阻塞态(BLOCKED) 就是专门用来处理“等待事件”的状态。

阻塞 vs 就绪的本质区别:

  • 就绪态:任务还在就绪队列里,调度器随时可能选中它。
  • 阻塞态:任务不在就绪队列中,调度器根本看不见它,直到等待的事件发生,它才会被重新放入就绪队列。

这就好比餐厅里的服务员:

  • 就绪态 = 站在取餐口等着接单。
  • 阻塞态 = 去后厨催菜了,但菜还没好,他就在后厨门口等着(不在前厅服务)。

3. 实现延时阻塞:os_sleep

我们来实现最常用的阻塞原语:让当前任务睡眠若干个系统时钟滴答(Tick)。

3.1 扩展 TCB

在 TCB 中增加一个字段 sleep_ticks,表示还需要阻塞多少个 Tick。

typedef struct tcb {
    // ... 前面已有的 sp, next, priority, state ...
    unsigned int sleep_ticks;   // 0 表示不阻塞,>0 表示还需阻塞的 tick 数
} tcb_t;

3.2 os_sleep 函数

void os_sleep(int ticks) {
    if (ticks <= 0) return;
    
    enter_critical();   // 关中断,保护 TCB 链表操作
    
    current_task->sleep_ticks = ticks;
    current_task->state = TASK_BLOCKED;
    
    // 从就绪队列中移除当前任务(需要知道它在哪个优先级链表里,这里简化)
    remove_from_ready_list(current_task);
    
    // 将当前任务加入阻塞队列(按唤醒时间排序,这里简单插入头部)
    current_task->next = blocked_list;
    blocked_list = current_task;
    
    exit_critical();
    
    // 主动触发调度,让出 CPU
    schedule();
}

注意schedule() 内部会寻找下一个可运行的任务。由于当前任务已被移出就绪队列,调度器不会选它,直到它被唤醒。

3.3 Tick 中断处理:唤醒阻塞任务

在每个时钟节拍中断中,除了进行时间片计数,还要遍历阻塞队列,将 sleep_ticks 减到 0 的任务唤醒。

void SysTick_Handler(void) {
    // 保存当前任务上下文(由硬件或汇编完成)
    
    // 处理阻塞队列
    tcb_t *prev = NULL;
    tcb_t *t = blocked_list;
    while (t) {
        if (t->sleep_ticks > 0) {
            t->sleep_ticks--;
        }
        if (t->sleep_ticks == 0 && t->state == TASK_BLOCKED) {
            // 唤醒任务:从阻塞队列中摘除
            if (prev) prev->next = t->next;
            else blocked_list = t->next;
            
            // 改变状态并插入就绪队列
            t->state = TASK_READY;
            insert_into_ready_list(t);   // 根据优先级插入
        } else {
            prev = t;
        }
        t = t->next;
    }
    
    // 最后调度(可能切换到更高优先级的就绪任务)
    schedule();
}

关键点

  • 每次 Tick 都扫描整个阻塞队列(效率 O(n)),对于教学足够;实际 RTOS 会用“延时列表 + 时间轮”优化。
  • 唤醒后任务重新加入就绪队列,调度器会在合适的时候让它恢复运行。

4. 空闲任务:当所有任务都睡了

如果系统中所有任务都进入了阻塞状态(比如都在等待外部事件或延时),就绪队列为空,此时 CPU 该做什么?

答案是:运行一个特殊的“空闲任务”(Idle Task)。

空闲任务的优先级通常最低(比如 255),它的唯一职责就是:什么都不做,或者让 CPU 进入低功耗模式。

void idle_task(void *arg) {
    while (1) {
        // 进入低功耗模式(ARM 的 WFI 指令)
        asm volatile("wfi");
        // 或者简单的一个空循环
        // asm volatile("nop");
    }
}

4.1 调度器如何处理空闲任务?

我们修改 get_next_task(),确保永远有一个“兜底”的任务:

tcb_t *get_next_task(void) {
    // 从高优先级到低优先级搜索就绪队列
    for (int prio = 0; prio < MAX_PRIORITY; prio++) {
        if (ready_lists[prio] != NULL) {
            return ready_lists[prio];
        }
    }
    // 如果所有就绪队列都为空,返回空闲任务
    return idle_task_tcb;
}

空闲任务是一个普通的 TCB,在系统初始化时创建,并且永远不会被阻塞。当它运行时,CPU 会执行 wfi 指令(等待中断),直到下一个 Tick 中断到来,它又被抢占,重新检查是否有任务就绪。

效果:当系统无事可做时,CPU 几乎不耗电(或处于极低功耗状态),这才是真正的“节能”。


5. 完整示例:三个任务轮流运行

假设我们有三个任务:

  • Task1:高优先级,每隔 100ms 执行一次传感器采集(阻塞 100ms)。
  • Task2:中优先级,每隔 200ms 执行一次数据处理(阻塞 200ms)。
  • Task3:低优先级,空闲时才运行的后台任务(主动 os_sleep(0) 或一直就绪)。

调度器的表现:

时间(ms) 运行任务 说明
0-1 task1 采集传感器,很快结束
1-100 idle task1 阻塞,task2 也阻塞,CPU 空闲
100 task1 定时器唤醒 task1,抢占执行
101-200 idle 再次空闲
200 task2 唤醒 task2(以及 task1),task2 运行
如此循环

可以看到,CPU 在绝大多数时间处于空闲状态,功耗大幅降低。


6. 阻塞的泛化:不只是延时

os_sleep 只是阻塞的一种特例。更通用的阻塞发生在等待信号量、消息队列、互斥锁时。它们的共同模式是:

  1. 检查资源是否可用。
  2. 如果不可用,将任务标记为 BLOCKED,并挂载到该资源的等待队列上。
  3. 调用 schedule() 让出 CPU。
  4. 当资源可用时,由另一个任务或中断将该任务重新加入就绪队列。

我们将在第5讲实现这些通用的同步原语。


7. 注意事项:阻塞与临界区的交互

在阻塞期间,中断是开启的(否则 Tick 中断无法唤醒任务)。因此,阻塞函数内部不能长时间关中断。

一个常见的错误是在临界区内调用 os_sleep,比如:

enter_critical();
if (buffer_full) {
    os_sleep(10);   // 危险!当前中断已关闭,Tick 中断进不来,永远无法唤醒
}
exit_critical();

正确做法:在阻塞前退出临界区(或使用更高级的同步原语)。许多 RTOS 会提供 xxx_take() 函数,它们在内部会短暂关中断、检查资源、若不可用则加入等待队列,然后在调度前开中断


8. 本讲小结 & 下集预告

今天我们添加了两个核心机制:

  • 阻塞延时(os_sleep:任务主动进入 BLOCKED 状态,释放 CPU,让其他任务运行。
  • 空闲任务:当所有任务都阻塞时,运行最低优先级的空闲任务,它通常执行低功耗指令(如 wfi),极大降低系统功耗。

有了阻塞机制,我们的操作系统才能真正做到“无任务时不空转”,为后续更复杂的同步和通信原语打下基础。

下一讲:任务间的鹊桥——队列、信号量与互斥量
我们将学习如何在任务之间传递数据、同步动作,并解决经典的“优先级反转”问题,让任务间协作更加优雅。


✍️ 思考与练习

  1. 比较两种延时:在支持阻塞的 RTOS 中,分别用 os_sleep(100)delay_ms(100) 实现 LED 闪烁,画出 CPU 占用率波形图,说明差异。
  2. 低功耗陷阱:如果空闲任务只执行 wfi,而系统中有多个任务频繁唤醒(比如每 1ms 唤醒一次),CPU 的实际功耗还会很低吗?为什么?
  3. 代码改进:当前阻塞队列扫描是 O(n)。如果任务数量很多,每次 Tick 都遍历会很耗时。请设计一个“分级时间轮”方案来优化唤醒效率。
  4. 思考:假设一个高优先级任务调用了 os_sleep,那么在它睡眠期间,中/低优先级任务是否可能运行?请用你熟悉的 RTOS 验证。

欢迎在评论区分享你对阻塞机制的理解或实验截图!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐