【并发之痛】Volatile 的谎言与原子操作的真相:嵌入式多线程数据竞争的底层解剖
摘要:你以为写了
volatile int cnt;就线程安全了吗?大错特错。在多核或高并发中断的 MCU(如 Cortex-M7)上,简单的cnt++包含着三次致命的寄存器搬运。本文将粉碎 Volatile 的神话,深度剖析 Read-Modify-Write (RMW) 过程中的竞态风险,解释 DMB/DSB 内存屏障 的必要性,并演示如何利用 ARM 的 独占访问指令 (Exclusive Access) 实现真正的无锁队列。
一、 薛定谔的变量:竞态条件 (Race Condition)
假设你有一个全局变量 g_Counter。
-
主循环:每秒
g_Counter++。 -
定时器中断:每毫秒
g_Counter--。
运行一段时间后,你会发现 g_Counter 的值根本对不上。
为什么?因为 C 语言的一行代码 $\neq$ 汇编的一条指令。
解剖 g_Counter++
在汇编层面,它分为三步(RMW 操作):
-
LDR:把
g_Counter从 RAM 读到寄存器 R0。 -
ADD:CPU 计算
R0 = R0 + 1。 -
STR:把 R0 写回 RAM。
灾难时刻:
当主循环刚执行完 ADD(R0 变了,但还没写回 RAM),中断来了。
中断强行切走 CPU,执行了 g_Counter--,把 RAM 里的旧值减 1 并写回。
中断结束,回到主循环。主循环继续执行 STR,把刚才计算的值覆盖回去。
结果:中断的那次修改,被主循环无情地**覆盖(丢失)**了。
二、 Volatile 的谎言
很多面试题会问:“如何在中断和主循环间共享变量?”
标准答案通常是:“加 volatile”。
这是嵌入式领域最大的谎言之一。
Volatile 到底干了什么?
它只做了一件事:告诉编译器,别自作聪明把这个变量缓存到寄存器里。
每次读写 volatile 变量,必须生成访问 RAM 的指令,不能只操作寄存器。
它不能干什么?
-
它不保证原子性:它阻止不了中断在
LDR和STR之间插入。 -
它不保证顺序性:在乱序执行的 CPU(如 Cortex-M7)上,硬件可能会把 volatile 变量的写入顺序打乱。
结论:volatile 是防止编译器优化的,不是用来做线程安全的。
三、 暴力的代价:临界区 (Critical Section)
解决竞态最简单的方法:关中断。
void Safe_Increment() {
__disable_irq(); // 关中断 (PRIMASK)
g_Counter++; // 临界区
__enable_irq(); // 开中断
}
缺点: 这叫 “大锁”。 为了保护一个变量,你把整个系统的实时响应(SysTick、串口、CAN)都停了。 如果 Safe_Increment 被高频调用,或者临界区代码太长,系统的 中断延迟 (Interrupt Latency) 会飙升,导致数据丢包。
四、 优雅的手术刀:原子指令 (LDREX / STREX)
ARM v7-M (Cortex-M3/4/7) 提供了一套硬件级的解决方案:独占访问 (Exclusive Access)。 这不是“锁”,这是一种 “预约机制”。
原理
-
LDREX (Load Exclusive):读取内存,并告诉总线监控器:“我要监视这个地址”。
-
STREX (Store Exclusive):尝试写入。
-
如果在 LDREX 和 STREX 之间,没有任何其他主控(中断或DMA)改写过这个地址,写入成功,返回 0。
-
如果有别人动过这个地址,写入失败,返回 1。
-
代码实现 (无锁 CAS)
void Atomic_Increment(volatile uint32_t *addr) {
uint32_t val;
uint32_t status;
do {
val = __LDREXW(addr); // 1. 读,并标记监视
val++; // 2. 改
status = __STREXW(val, addr); // 3. 尝试写
} while (status != 0); // 如果失败(被中断抢了),重试!
}
优势: 我们没有关闭中断! 如果中断来了,我们的 STREX 会失败,然后 while 循环重试一次即可。 系统实时性不受任何影响。 这就是 无锁编程 (Lock-Free) 的基石。
五、 看不见的敌人:内存乱序与屏障 (Barriers)
在 Cortex-M0/M3/M4 上,指令基本上是顺序执行的。 但在高性能的 Cortex-M7 (如 STM32H7, i.MX RT) 上,为了压榨性能,CPU 会进行 乱序执行 (Out-of-Order Execution) 和 推测执行。
场景
flag = 1; // 写标志位
data = 100; // 写数据
在 M7 上,CPU 可能会觉得 data 的地址就在手边,先写 data 再写 flag。 这在单核没事。但如果 flag 是用来通知 DMA 或另一个核的,对方可能先看到了 flag=1,去读 data,结果读到了旧值(因为 data=100 还没执行)。
解决方案:DMB / DSB / ISB
我们需要 内存屏障 (Memory Barrier) 指令来勒令 CPU 停止乱序。
-
DMB (Data Memory Barrier):保证 DMB 之前的内存访问,一定在 DMB 之后的内存访问之前完成。(常用于多核/DMA 同步)
-
DSB (Data Synchronization Barrier):比 DMB 更狠。等前面的访存指令全部执行完,CPU 才能执行后续指令。
-
ISB (Instruction Synchronization Barrier):清洗流水线。保证取指逻辑重新读取。
正确写法:
data = 100;
__DMB(); // 就像一堵墙,挡住 CPU 乱序
flag = 1;
六、 致命陷阱:优先级翻转 (Priority Inversion)
既然有了原子操作和锁,是不是就安全了? 不,还有 RTOS 里的噩梦。
场景:
-
低优先级任务 (Low) 获取了互斥锁
Mutex,正在访问 I2C。 -
高优先级任务 (High) 醒了,也要访问 I2C,试图拿锁 -> 被阻塞,去睡觉。
-
中优先级任务 (Medium) 醒了,它不需要锁,疯狂抢占 CPU。
结果: Low 拿着锁,但被 Medium 压着跑不动,锁还不了。 High 等着锁,虽然优先级最高,却被 Medium 间接堵死了。 火星探路者 (Mars Pathfinder) 就曾因此 Bug 在火星上无限重启。
解药:优先级继承 (Priority Inheritance)。 当 High 等待锁时,RTOS 临时把 Low 的优先级提拔到和 High 一样高。让 Low 赶紧跑完把锁还回来,然后再把优先级降回去。
七、 结语:对物理层的敬畏
并发编程的难点在于,它挑战了我们线性的思维方式。 在并发的世界里,顺序是错觉,原子是假象。
作为嵌入式架构师,你必须具备 “透视眼”: 透过 C 代码看到汇编,透过汇编看到流水线,透过流水线看到总线仲裁。
-
用
volatile抑制优化。 -
用
LDREX/STREX处理竞争。 -
用
DMB规避乱序。 -
用
Mutex避免翻转。
只有理解了硅晶片上的微观秩序,你才能构建出宏观上坚不可摧的系统。
更多推荐

所有评论(0)