4. 原子操作与内存屏障
好,咱们进入第四章。这一章讲的是多核编程里最核心、也最容易踩坑的地方——原子操作和内存屏障。
说实话,我刚接触 TC2xx 多核开发那会儿,觉得不就是读写个变量嘛,加个锁不就完了?结果被现实狠狠教育了一顿。后来才明白,在多核环境下,事情远没那么简单。你想想看,两个核同时读写同一个内存地址,如果没有硬件级别的保护,数据就乱套了。
4.1 为什么需要原子操作?
先问个问题:一个简单的 counter++,在 C 语言里是一行代码,但在汇编层面,它其实是三条指令:
- 从内存把 counter 读到寄存器
- 寄存器加 1
- 把结果写回内存
如果 Core0 执行到第 2 步时,Core1 也来读 counter,那 Core1 读到的就是旧值。两个核都加了 1,但 counter 只增加了 1,而不是 2。这就是经典的「读-改-写」竞争问题。
核心概念:原子操作就是「要么全部执行,要么完全不执行」的操作。在 TC2xx 上,硬件提供了专门的指令来保证这一点。
4.2 TC2xx 的原子指令:LDREX / STREX
TC2xx 支持 ARM 风格的独占访问指令——LDREX 和 STREX。这套机制说白了就是「先标记,再检查」。
工作原理:
LDREX:从内存加载数据,同时在该内存地址上设置一个「独占监视」标记STREX:尝试写入数据。如果独占标记还在(说明没有其他核动过这块内存),写入成功并返回 0;否则写入失败,返回 1
来看一个实际的例子。假设我们要实现一个全局计数器:
// 原子自增函数
int atomic_inc(uint32_t *ptr) {
uint32_t old_val;
uint32_t new_val;
uint32_t status;
do {
// 步骤1:独占加载
asm volatile ("ldrex %0, [%1]" : "=r"(old_val) : "r"(ptr));
// 步骤2:计算新值
new_val = old_val + 1;
// 步骤3:尝试独占存储
asm volatile ("strex %0, %1, [%2]"
: "=r"(status)
: "r"(new_val), "r"(ptr));
// 如果 status != 0,说明被其他核干扰了,重试
} while (status != 0);
return new_val;
}
我在项目中遇到过一个问题:有个同事用 __sync_fetch_and_add 这种编译器内置函数来做原子操作,看起来没问题,但在 TC2xx 上偶尔会死锁。后来排查发现,是编译器生成的代码里,LDREX/STREX 的配对使用不规范导致的。嗯,这里要注意:LDREX 和 STREX 必须成对出现,中间不能插入其他内存访问指令。
警告:LDREX/STREX 之间不要调用函数!函数内部可能包含其他内存操作,会破坏独占监视状态。我曾经见过有人在这中间插了个 printf 调试,结果程序跑飞了。
4.3 内存屏障:为什么需要它?
原子操作解决了「同时写」的问题,但还有一个更隐蔽的问题——内存序。
你想想看,现代处理器为了性能,会乱序执行指令。Core0 先写 A 再写 B,但 Core1 可能先看到 B 的新值,再看到 A 的新值。这在单核下没问题,但在多核通信时就是灾难。
TC2xx 提供了三种内存屏障指令:
| 指令 | 全称 | 作用 |
|---|---|---|
| DMB | Data Memory Barrier | 保证 DMB 之前的所有内存访问,在 DMB 之后的内存访问开始前完成 |
| DSB | Data Synchronization Barrier | 比 DMB 更强,等待所有内存访问完成,还会等待缓存、写缓冲排空 |
| ISB | Instruction Synchronization Barrier | 清空流水线,保证之后的指令从内存或缓存重新读取 |
说白了:
- DMB:只关心内存访问的顺序
- DSB:等所有内存操作真正完成
- ISB:让 CPU 重新取指令,常用于修改代码或切换上下文
4.4 实际用法:生产者-消费者模型
我拿一个实际场景来说明。假设 Core0 是生产者,Core1 是消费者,它们通过共享内存通信:
// 共享数据结构
volatile uint32_t data_ready = 0;
volatile uint32_t shared_data = 0;
// Core0(生产者)
void producer(void) {
shared_data = 42; // 步骤1:写入数据
// 关键:确保 shared_data 写入完成,再设置标志
asm volatile ("dmb" ::: "memory");
data_ready = 1; // 步骤2:设置标志
}
// Core1(消费者)
void consumer(void) {
while (data_ready == 0) { // 等待标志
// 忙等
}
// 关键:确保 data_ready 读取是最新的
asm volatile ("dmb" ::: "memory");
uint32_t val = shared_data; // 读取数据
}
如果没有 DMB,Core1 可能先看到 data_ready = 1,但 shared_data 还是旧值。我曾经调试过一个类似的 bug,现象是 Core1 偶尔读到 0,而不是 42。加了 DMB 后问题消失。
个人经验:我习惯在以下场景强制加 DMB:
- 一个核写、另一个核读的共享变量
- 中断服务程序和主循环之间的数据交换
- DMA 传输完成后的数据访问
宁可多加点,也别漏掉。性能损失通常可以忽略不计。
4.5 ISB 的特殊用途
ISB 用得少,但用的时候都是关键场合。我记得有一次做 Bootloader 升级,Core0 把新代码写到 Flash,然后跳转到新地址执行。结果程序跑飞了。为什么?因为指令缓存里还是旧代码。
正确的做法是:
// 写 Flash 完成后
flash_write_done();
// 确保写操作完成
asm volatile ("dsb" ::: "memory");
// 清空指令缓存和流水线
asm volatile ("isb" ::: "memory");
// 现在可以安全跳转了
jump_to_new_code();
DSB 保证 Flash 写入完成,ISB 保证 CPU 重新取指令时拿到的是新代码。缺一不可。
4.6 避坑指南
最后,我总结几个实战中容易犯的错误:
- 别把 DMB 和 DSB 搞混:DMB 只管顺序,DSB 等完成。如果你需要确保外设寄存器已经更新,用 DSB。
- volatile 不是内存屏障:很多人以为加了 volatile 就万事大吉。volatile 只告诉编译器不要优化,但硬件乱序执行它管不了。
- 中断里也要加屏障:如果中断和主循环共享数据,中断里也要加 DMB。我曾经以为中断是原子的,结果吃了大亏。
- LDREX/STREX 的循环次数:理论上可能无限循环。实际项目中建议加一个最大重试次数,防止硬件异常导致死锁。
嗯,这一章的内容就这些。原子操作和内存屏障是 TC2xx 多核编程的基石,理解透了,后面讲锁和通信机制就轻松多了。