os操作系统——第3讲:让任务学会“等待”
目录
第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. 关中断的代价与风险
关中断虽好,但不能滥用:
-
延长中断响应延迟
如果关中断时间过长(比如超过100微秒),外部硬件(网卡、串口)的数据可能丢失,实时系统会错过关键事件。
经验法则:临界区应尽量短,只保护最必要的几行代码。 -
不能嵌套
如果一个函数已经关了中断,调用的另一个函数又关一次,第二次关中断可能保存不正确的中断状态,导致恢复时误开中断。
正确做法:保存并恢复之前的中断使能状态。unsigned int saved = get_interrupt_state(); disable_interrupts(); // 临界区 restore_interrupt_state(saved); -
无法工作在多核
在多核系统里,关中断只能关掉当前核的中断,其他核依然可以访问共享内存。关中断对多核无效,必须用自旋锁(第8讲)。 -
代码可读性
过多的关中断会让代码中散布着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_critical将nesting--,如果减到0,才恢复中断。
这样可以避免一个库函数关中断后,用户代码又关一次导致错误恢复。
另外,对于ARM Cortex-M,中断有优先级。我们可以只屏蔽优先级低于某个阈值的中断,而不是全部中断。这样高优先级的中断(如硬件故障)仍可响应。
9. 本讲小结 & 下集预告
今天我们从一个令人抓狂的 count++ 丢失问题出发,理解了:
- 临界区:需要互斥访问的代码区域。
- 竞争条件:多任务交替执行导致的数据不一致。
- 关中断:单核系统中最简单、最底层的临界区保护方法。
- 代价:增加中断响应延迟,不能嵌套,无法扩展至多核。
有了关中断保护,我们的OS核心(TCB链表操作、调度器自身)终于可以安全地运行了。但关中断过于“重型”,而且会屏蔽所有中断,不适合保护那些可能阻塞的共享资源(比如等待键盘输入)。这时候我们需要更优雅的同步原语——信号量和互斥量。
下一讲:不要独占CPU——阻塞机制与空闲任务的诞生
我们将完善任务的状态机,让任务学会“主动睡觉”(阻塞延时),并引入空闲任务,让CPU在无事可做时能进入低功耗模式。敬请期待!
✍️ 思考与练习
- 分析Bug:写出一个两个任务共享一个链表的场景,画出没有关中断时链表断开的可能情况。
- 改进代码:给上一讲的
os_sleep实现加上临界区保护(注意:os_sleep里会调用schedule,而schedule可能在关中断的情况下被调用,会不会出问题?) - 思考:为什么在中断服务函数中,我们通常不需要手动关中断?(提示:CPU 进入中断后会自动屏蔽同级和低优先级中断,但不同优先级的中断仍可嵌套,如何避免中断内的临界区冲突?)
- 探索:查阅你熟悉的RTOS(如FreeRTOS)的
portDISABLE_INTERRUPTS是如何实现的,对比x86和ARM的差异。
欢迎在评论区分享你的临界区设计思路或遇到的真实竞争Bug!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)