摘要:你以为写了 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 操作):

  1. LDR:把 g_Counter 从 RAM 读到寄存器 R0。

  2. ADD:CPU 计算 R0 = R0 + 1

  3. STR:把 R0 写回 RAM。

灾难时刻

当主循环刚执行完 ADD(R0 变了,但还没写回 RAM),中断来了

中断强行切走 CPU,执行了 g_Counter--,把 RAM 里的旧值减 1 并写回。

中断结束,回到主循环。主循环继续执行 STR,把刚才计算的值覆盖回去。

结果:中断的那次修改,被主循环无情地**覆盖(丢失)**了。


二、 Volatile 的谎言

很多面试题会问:“如何在中断和主循环间共享变量?”

标准答案通常是:“加 volatile”。

这是嵌入式领域最大的谎言之一。

Volatile 到底干了什么?

它只做了一件事:告诉编译器,别自作聪明把这个变量缓存到寄存器里。

每次读写 volatile 变量,必须生成访问 RAM 的指令,不能只操作寄存器。

它不能干什么?

  1. 它不保证原子性:它阻止不了中断在 LDRSTR 之间插入。

  2. 它不保证顺序性:在乱序执行的 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)。 这不是“锁”,这是一种 “预约机制”

原理

  1. LDREX (Load Exclusive):读取内存,并告诉总线监控器:“我要监视这个地址”。

  2. 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 停止乱序。

  1. DMB (Data Memory Barrier):保证 DMB 之前的内存访问,一定在 DMB 之后的内存访问之前完成。(常用于多核/DMA 同步)

  2. DSB (Data Synchronization Barrier):比 DMB 更狠。等前面的访存指令全部执行完,CPU 才能执行后续指令。

  3. ISB (Instruction Synchronization Barrier):清洗流水线。保证取指逻辑重新读取。

正确写法

data = 100;
__DMB();       // 就像一堵墙,挡住 CPU 乱序
flag = 1;

六、 致命陷阱:优先级翻转 (Priority Inversion)

既然有了原子操作和锁,是不是就安全了? 不,还有 RTOS 里的噩梦

场景

  1. 低优先级任务 (Low) 获取了互斥锁 Mutex,正在访问 I2C。

  2. 高优先级任务 (High) 醒了,也要访问 I2C,试图拿锁 -> 被阻塞,去睡觉。

  3. 中优先级任务 (Medium) 醒了,它不需要锁,疯狂抢占 CPU。

结果Low 拿着锁,但被 Medium 压着跑不动,锁还不了。 High 等着锁,虽然优先级最高,却被 Medium 间接堵死了。 火星探路者 (Mars Pathfinder) 就曾因此 Bug 在火星上无限重启。

解药优先级继承 (Priority Inheritance)。 当 High 等待锁时,RTOS 临时把 Low 的优先级提拔到和 High 一样高。让 Low 赶紧跑完把锁还回来,然后再把优先级降回去。


七、 结语:对物理层的敬畏

并发编程的难点在于,它挑战了我们线性的思维方式。 在并发的世界里,顺序是错觉,原子是假象。

作为嵌入式架构师,你必须具备 “透视眼”: 透过 C 代码看到汇编,透过汇编看到流水线,透过流水线看到总线仲裁。

  • volatile 抑制优化。

  • LDREX/STREX 处理竞争。

  • DMB 规避乱序。

  • Mutex 避免翻转。

只有理解了硅晶片上的微观秩序,你才能构建出宏观上坚不可摧的系统。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐