第3讲:让任务学会“等待”——临界区与关中断保护

上一讲我们给每个任务发了“身份证”(TCB),还实现了优先级抢占。任务们看似和睦相处,各忙各的。
但有一天,共享内存里的一个计数器突然乱了——明明加了10次,结果却只有8。
两个任务同时修改同一个变量,一个刚读到旧值,还没来得及写回,另一个就插了进来……

这就是并发编程的第一大天敌:竞争条件(Race Condition)
而解决它的最简单武器,就是关中断


1. 一个触目惊心的Bug演示

假设我们有两个任务,一个负责累加计数器 count,另一个也会偶尔修改它。原本我们希望:

初始 count = 0
TaskA:count++  (执行1000次)
TaskB:count++  (执行1000次)
期望结果:count = 2000

但实际运行多次后,结果可能是 1999、1998,甚至更少。

为什么会这样?
因为 count++ 在底层不是一条指令,而是三步:

LOAD  R0, [count]    ; 1. 把 count 从内存加载到寄存器
ADD   R0, R0, #1     ; 2. 寄存器加1
STORE R0, [count]    ; 3. 把结果写回内存

如果在执行完第2步(ADD)后,定时器中断发生,调度器切换到TaskB,TaskB也执行同样的三步,那么当TaskA恢复时,它手里还捏着旧值,写回去就会覆盖TaskB的修改。后写的不一定看到前写的,这就是数据丢失(Lost Update)。

用一个比喻:两个人同时在一个账本上记账。第一个人看到余额100,准备加上50变成150;此时第二个人也看到100,加上30变成130;第一个人写完150,第二个人写完130。最后账本是130,丢失了第一个人加的20。


2. 临界区:不容打断的代码片段

凡是涉及多个任务共享资源(全局变量、硬件寄存器、数据结构)并且至少有一个任务是写操作的代码段,都是临界区(Critical Section)

规则很简单:

在任何时刻,只允许一个任务进入临界区。
当一个任务在临界区内时,其他任何任务都不能进入同一个临界区。

我们的目标就是保证这段代码的原子性——要么全部做完,要么一点不做。


3. 最简单粗暴的方法:关中断

在单核系统中,任务的切换只有一个源头:定时器中断(以及其他可能的中断)。如果我们能阻止中断的发生,调度器就不会被触发,当前任务就可以放心地独占CPU,执行完整个临界区。

这就是关中断(Disable Interrupts)

void enter_critical() {
    asm volatile("cpsid i");   // 关闭全局中断 (ARM)
}

void exit_critical() {
    asm volatile("cpsie i");   // 开启全局中断
}

使用场景:

enter_critical();
count++;          // 整个临界区
exit_critical();

只要临界区足够短,关中断是非常高效且直观的同步方法。大量RTOS(如FreeRTOS、Zephyr)的底层临界区就是这么干的。


4. 关中断的代价与风险

关中断虽好,但不能滥用:

  1. 延长中断响应延迟
    如果关中断时间过长(比如超过100微秒),外部硬件(网卡、串口)的数据可能丢失,实时系统会错过关键事件。
    经验法则:临界区应尽量短,只保护最必要的几行代码。

  2. 不能嵌套
    如果一个函数已经关了中断,调用的另一个函数又关一次,第二次关中断可能保存不正确的中断状态,导致恢复时误开中断。
    正确做法:保存并恢复之前的中断使能状态。

    unsigned int saved = get_interrupt_state();
    disable_interrupts();
    // 临界区
    restore_interrupt_state(saved);
    
  3. 无法工作在多核
    在多核系统里,关中断只能关掉当前核的中断,其他核依然可以访问共享内存。关中断对多核无效,必须用自旋锁(第8讲)。

  4. 代码可读性
    过多的关中断会让代码中散布着 enter/exit,难以维护。更好的做法是用互斥量(第5讲)将保护逻辑封装起来。


5. 除了关中断,还有别的办法吗?

关中断是“暴力禁止调度”,但它简单可靠。在单核嵌入式世界里,还有很多类似的原子操作技巧:

  • 使用原子指令:一些CPU提供了 LDM/STM(多寄存器加载存储)或 LDREX/STREX(独占访问)指令,可以让 count++ 变成硬件原子操作。但这对复杂的数据结构(如链表插入)无能为力。
  • 使用掩码禁用特定中断:如果只有定时器中断会导致调度,可以只关定时器中断,而不关其他外设中断。这样既能保护临界区,又不影响串口接收。

不过,作为操作系统原理的入门,关中断是理解临界区概念的最佳起点。


6. 完整的示例:保护一个共享队列

假设我们有一个全局的环形缓冲区,多个任务会往里面写入日志。没有保护时,会出现索引错乱。

#define BUFFER_SIZE 16
char buffer[BUFFER_SIZE];
int head = 0, tail = 0;

void write_log(char c) {
    // 临界区开始
    enter_critical();  
    int next_head = (head + 1) % BUFFER_SIZE;
    if (next_head != tail) {  // 不满
        buffer[head] = c;
        head = next_head;
    }
    exit_critical();
    // 临界区结束
}

如果不在临界区内,当 next_head 计算后刚进入 buffer[head]=c 前,另一个任务可能也已经计算了 next_head,导致覆盖。

注意:如果临界区内调用了可能会阻塞的函数(如 os_sleep),那就不能简单地用关中断——因为睡眠意味着主动让出CPU,但中断关了,调度器永远无法运行。所以关中断的临界区必须快速且无阻塞


7. 对调度器的影响:延迟调度

我们上一讲实现的 schedule() 通常是在定时器中断的末尾触发。如果关中断,定时器中断不会被响应,自然也不会发生调度。因此,临界区内的代码可以放心修改全局任务链表(就绪队列、阻塞队列),不用担心链表损坏。

但注意:如果临界区太长,且有一个高优先级任务正在等待执行,它的响应时间会变长。因此,RTOS 要求关中断的时间尽可能短,通常在几十个指令周期内。


8. 进阶话题:临界区的嵌套与IRQ掩码

实际OS中,我们会提供一对 taskENTER_CRITICAL() / taskEXIT_CRITICAL(),可以嵌套调用。实现方式是:

  • 每个任务或全局维护一个 critical_nesting 计数器。
  • enter_critical 先关中断,然后 nesting++
  • exit_criticalnesting--,如果减到0,才恢复中断。

这样可以避免一个库函数关中断后,用户代码又关一次导致错误恢复。

另外,对于ARM Cortex-M,中断有优先级。我们可以只屏蔽优先级低于某个阈值的中断,而不是全部中断。这样高优先级的中断(如硬件故障)仍可响应。


9. 本讲小结 & 下集预告

今天我们从一个令人抓狂的 count++ 丢失问题出发,理解了:

  • 临界区:需要互斥访问的代码区域。
  • 竞争条件:多任务交替执行导致的数据不一致。
  • 关中断:单核系统中最简单、最底层的临界区保护方法。
  • 代价:增加中断响应延迟,不能嵌套,无法扩展至多核。

有了关中断保护,我们的OS核心(TCB链表操作、调度器自身)终于可以安全地运行了。但关中断过于“重型”,而且会屏蔽所有中断,不适合保护那些可能阻塞的共享资源(比如等待键盘输入)。这时候我们需要更优雅的同步原语——信号量互斥量

下一讲:不要独占CPU——阻塞机制与空闲任务的诞生
我们将完善任务的状态机,让任务学会“主动睡觉”(阻塞延时),并引入空闲任务,让CPU在无事可做时能进入低功耗模式。敬请期待!


✍️ 思考与练习

  1. 分析Bug:写出一个两个任务共享一个链表的场景,画出没有关中断时链表断开的可能情况。
  2. 改进代码:给上一讲的 os_sleep 实现加上临界区保护(注意:os_sleep 里会调用 schedule,而 schedule 可能在关中断的情况下被调用,会不会出问题?)
  3. 思考:为什么在中断服务函数中,我们通常不需要手动关中断?(提示:CPU 进入中断后会自动屏蔽同级和低优先级中断,但不同优先级的中断仍可嵌套,如何避免中断内的临界区冲突?)
  4. 探索:查阅你熟悉的RTOS(如FreeRTOS)的 portDISABLE_INTERRUPTS 是如何实现的,对比x86和ARM的差异。

欢迎在评论区分享你的临界区设计思路或遇到的真实竞争Bug!

Logo

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

更多推荐