4. 原子操作与内存屏障

好,咱们进入第四章。这一章讲的是多核编程里最核心、也最容易踩坑的地方——原子操作和内存屏障。

说实话,我刚接触 TC2xx 多核开发那会儿,觉得不就是读写个变量嘛,加个锁不就完了?结果被现实狠狠教育了一顿。后来才明白,在多核环境下,事情远没那么简单。你想想看,两个核同时读写同一个内存地址,如果没有硬件级别的保护,数据就乱套了。

4.1 为什么需要原子操作?

先问个问题:一个简单的 counter++,在 C 语言里是一行代码,但在汇编层面,它其实是三条指令:

  1. 从内存把 counter 读到寄存器
  2. 寄存器加 1
  3. 把结果写回内存

如果 Core0 执行到第 2 步时,Core1 也来读 counter,那 Core1 读到的就是旧值。两个核都加了 1,但 counter 只增加了 1,而不是 2。这就是经典的「读-改-写」竞争问题。

核心概念:原子操作就是「要么全部执行,要么完全不执行」的操作。在 TC2xx 上,硬件提供了专门的指令来保证这一点。

4.2 TC2xx 的原子指令:LDREX / STREX

TC2xx 支持 ARM 风格的独占访问指令——LDREXSTREX。这套机制说白了就是「先标记,再检查」。

工作原理:

  • 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 多核编程的基石,理解透了,后面讲锁和通信机制就轻松多了。