RTOS 同步机制:从信号量到自旋锁
信号量就是一个计数器加一个等待队列——RTOS 的四种同步机制全是这个结构的变体,区别只在于要保护什么、用什么手段保护。
RTOS 多任务并发是常态,多个任务共享 CPU、共享内存、共享外设,不加同步就会踩彼此的脚。信号量是 嵌入式系统 中最基础的同步原语,但大多数人只知道 Take() 和 Give(),对背后的保护机制一知半解。用二值信号量做互斥,拿互斥量做通知,该用自旋锁的地方关中断——这些错误在嵌入式开发中屡见不鲜。
信号量的本质
核心数据结构简单得意外:
typedef struct {
volatile int count; // 计数值
Queue_t waitQueue; // 等待任务队列(按优先级排列)
} Semaphore_t;
只有两个原子操作——Dijkstra 最早定义的 P/V 操作:Take 请求资源,count--,不够就阻塞;Give 释放资源,count++,有人在等就唤醒。
以 Take 为例,完整流程比想象的要长。关中断,读 count,判断是否大于 0,减计数——资源够的话开中断返回成功。不够的话事情才真正开始:把当前任务从就绪队列移除、加入信号量等待队列、标记为 BLOCKED、记录超时时间,然后开中断,触发上下文切换让出 CPU。任务被唤醒后从切换点恢复执行,返回成功或超时。
这段操作有一个容易忽略的前提:从读 count 到修改等待队列,中间七八步必须作为整体完成。任何一步被打断,数据结构就进入不一致状态——count 减了但任务没标记为 BLOCKED,调度器可能重新调度它;任务从就绪队列移除了但没加入等待队列,任务就永远丢失。原子性保护的不是某个变量,而是整个操作链的逻辑完整性。
四种同步机制
二值信号量、互斥量、计数信号量、自旋锁,看似复杂,其实都是针对不同场景做的变体。
二值信号量:通知用的指示牌
计数值只有 0 和 1,核心用途是 同步和通知,不是互斥。典型场景是中断通知任务:UART 接收完成中断里调用 SemaphoreGive(),数据处理任务在 SemaphoreTake() 上等待,收到通知后开始干活。
二值信号量的特点是"谁都能 Give"——任务 A 可以 Give,任务 B 也可以 Give,没有所有权的概念。这恰恰是它和互斥量的根本区别。
互斥量:带所有权的共享锁
互斥量(Mutex)在二值信号量基础上加了两个关键特性。
所有权:只有 MutexTake() 的任务才能 MutexGive(),别的任务释放不了。语义清晰,不会出现 A 拿了锁、B 误释放导致 C 闯入临界区的情况。
优先级继承:解决经典的优先级反转。低优先级任务 L 持有 Mutex,高优先级任务 H 尝试获取被阻塞。此时中优先级任务 M 抢占了 L,H 就被间接阻塞——明明优先级最高,却要等 M。优先级继承把 L 的优先级临时提升到 H 的级别,让 L 尽快释放锁,然后恢复。AUTOSAR OS 的 Resource 管理采用优先级天花板协议(PCP),是这一思路的标准化实现。
互斥量还支持递归锁:同一任务可以多次 Take 同一 Mutex 不阻塞,内部用嵌套计数跟踪,Give 相同次数后才真正释放。
计数信号量:资源池管理员
计数值范围 0 到 N,管理有限数量的同类资源。连接池最多 5 个连接,计数信号量初始值设为 5,每次分配调用 Take(),归还调用 Give()。池满时 Take() 阻塞,直到有人归还。
自旋锁(Spinlock)是第四种机制,和前三者的核心区别在于等待方式——不是阻塞睡眠,而是死循环空转。它专为多核场景设计,后面展开。
掌握了四种机制的用途,下一个问题是:它们赖以运作的原子性,靠什么保证?
从单核到多核
前面反复提到"关中断"。一个自然的疑问是:关中断凭什么能保证原子性?答案取决于你用的是几核芯片。
单核:关中断就够了
RTOS 的任务切换只有两条触发路径——SysTick 定时器中断和 PendSV 上下文切换中断。关中断后两条路径都被堵死,没有任何机制能夺走当前代码的执行权。即使临界区涉及十几步内存读写,在单核上也天然是原子的。
这里有个常见误解:volatile 能保证原子性。实际上 volatile 只防止编译器优化(保证每次从内存读),不保证读-改-写的原子性。count-- 在 ARM 汇编中是三步操作:
LDR R0, [count] ; 从内存加载到寄存器
SUB R0, R0, #1 ; 寄存器中减 1
STR R0, [count] ; 写回内存
加载和写回之间如果发生中断,ISR 中也修改了 count,写回就会覆盖 ISR 的修改。
多核:关中断不够了
单核关中断之所以有效,是因为物理上只有一条执行流。多核 MCU 打破了这个前提——Core A 关了自己的中断,Core B 依然在独立执行。两个核同时 SemaphoreTake(),都读到 count 为 1,都判断资源可用,都把 count 改成 0,信号量的计数逻辑被彻底破坏。
多核需要两层保护,缺一不可。第一层是 Spinlock,用 ARM 独占访问指令(LDREX/STREX)保证同一时刻只有一个核进入临界区。LDREX 独占读的同时在总线互连的独占监控器里标记"我盯着这个地址",STREX 条件写时检查标记是否有效——如果期间有其他核写过同一地址,写操作失败,返回重试。第二层是关本地中断,防止拿了锁之后被本核 ISR 抢占——ISR 里如果也操作同一数据结构,就会和持锁代码冲突甚至死锁。
为什么 Spinlock 选择空转而不是阻塞睡眠?多核场景下临界区往往只有几条指令、几十纳秒,阻塞的上下文切换开销可能比直接等还大。空转确实浪费 CPU 时间,但等的时间比上下文切换短时反而是更优选择。铁律是临界区必须极短——在自旋锁里调用耗时函数,等于浪费一整颗核心。
下次打开 RTOS 代码,看到 SemaphoreTake() 和 MutexTake() 并排出现,花一分钟想想:这个场景是在做通知,还是在保护资源?选错同步机制比不用更危险——bug 不会立即暴露,而是在系统负载最高的那一刻爆发。