16、内存序与原子操作:C11原子操作、内存序模型、RT-Thread原子API

各位同学,今天我们来聊聊多核编程里最绕不开的话题——内存序与原子操作。说实话,我早年做单核开发时,对原子操作的理解就是“关中断”。但到了多核时代,事情就没那么简单了。你想想看,两个核同时访问一个变量,光靠关中断可管不了另一个核。

16.1 为什么需要原子操作?

先从一个最经典的场景说起。假设两个核都在执行 counter++,这个操作在汇编层面其实是三步:读、改、写。如果两个核同时读到同一个值,各自加1再写回去,结果就少了一次累加。这就是所谓的“丢失更新”。

我在项目中遇到过类似的问题。当时调试一个多核通信模块,数据包计数总是对不上。查了两天才发现,问题就出在一个共享计数器上。嗯,从那以后,我对原子操作就格外上心了。

核心要点:原子操作是不可分割的操作。要么全部执行完,要么完全不执行。在多核环境下,这是保证数据一致性的基石。

16.2 C11标准原子操作

C11标准引入了 <stdatomic.h> 头文件,提供了一套跨平台的原子操作接口。我个人习惯用这套接口,因为可移植性好,代码也清晰。

常用的原子类型有:

类型 说明
atomic_int 原子整型
atomic_long 原子长整型
atomic_flag 原子布尔标志(无锁)
atomic_uintptr_t 原子指针

基本操作函数:

#include <stdatomic.h>

atomic_int counter = ATOMIC_VAR_INIT(0);

// 原子加载
int val = atomic_load(&counter);

// 原子存储
atomic_store(&counter, 10);

// 原子加1,返回旧值
int old = atomic_fetch_add(&counter, 1);

// 比较并交换(CAS)
int expected = 5;
int desired = 10;
bool success = atomic_compare_exchange_strong(&counter, &expected, desired);

个人经验:我建议在嵌入式多核项目中,优先使用 atomic_compare_exchange_strong 而不是弱版本。强版本保证要么成功要么失败,逻辑更清晰。弱版本在某些架构上性能更好,但需要额外处理失败重试。

16.3 内存序模型:没那么玄乎

内存序,说白了就是告诉编译器和CPU:这个原子操作周围的访存顺序该怎么处理。为什么需要这个?因为CPU和编译器都会对指令进行重排,以提高性能。但在多核环境下,重排可能导致一个核看到的数据顺序和另一个核不一致。

C11定义了六种内存序,常用的就三种:

内存序 含义 使用场景
memory_order_relaxed 最宽松,只保证原子性 计数器、统计量
memory_order_acquire 防止后面的读操作被重排到前面 读取锁标志
memory_order_release 防止前面的写操作被重排到后面 释放锁、发布数据
memory_order_seq_cst 最严格,全局顺序一致 默认值,通用场景

举个例子,生产者-消费者模型:

atomic_flag ready = ATOMIC_FLAG_INIT;
int data = 0;

// 生产者(核0)
data = 42;
atomic_flag_test_and_set(&ready, memory_order_release);

// 消费者(核1)
while (atomic_flag_test_and_set(&ready, memory_order_acquire));
// 此时保证能看到 data = 42
process(data);

为什么会这样?release 保证前面的 data = 42 不会被重排到 flag 设置之后。acquire 保证后面的 process(data) 不会被重排到 flag 读取之前。两者配合,就形成了“happens-before”关系。

避坑指南:我曾经在一个项目中,为了追求极致性能,大量使用了 memory_order_relaxed。结果调试时发现,一个核写入了数据,另一个核读到的却是旧值。排查了半天才发现是内存序用错了。所以我的建议是:除非你非常清楚自己在做什么,否则就用默认的 memory_order_seq_cst。性能损失通常不大,但能避免很多诡异的bug。

16.4 RT-Thread的原子API

RT-Thread封装了一套自己的原子操作API,底层会根据架构自动选择最优实现。在ARM Cortex-A系列上,它会使用LDREX/STREX指令;在RISC-V上,它会使用AMO指令。

常用API一览:

#include <rtatomic.h>

// 原子读
long rt_hw_atomic_read(volatile long *addr);

// 原子写
void rt_hw_atomic_write(volatile long *addr, long val);

// 原子加
long rt_hw_atomic_add(volatile long *addr, long val);

// 原子减
long rt_hw_atomic_sub(volatile long *addr, long val);

// 原子交换
long rt_hw_atomic_xchg(volatile long *addr, long val);

// 比较并交换
long rt_hw_atomic_cmpxchg(volatile long *addr, long old, long new);

使用示例:

volatile long shared_counter = 0;

void thread_entry(void *param)
{
    for (int i = 0; i < 1000; i++) {
        rt_hw_atomic_add(&shared_counter, 1);
    }
}

// 创建两个线程,最终 shared_counter 一定是 2000

关键区别:RT-Thread的原子API默认使用最严格的内存序(相当于 memory_order_seq_cst)。这样做的好处是简单安全,坏处是性能上可能不如C11的精细控制。但在大多数嵌入式场景中,这点性能差异可以忽略不计。

16.5 实际项目中的选择建议

说了这么多,到底该怎么选?我根据自己的经验,给几个建议:

  1. 简单场景用RT-Thread API:如果只是做计数器、标志位,直接用 rt_hw_atomic_xxx 系列,省心。
  2. 复杂同步用C11:如果需要精细控制内存序,比如实现无锁队列、RCU等,建议用C11标准接口。
  3. 混合使用要小心:不要在一个项目中混用两套原子操作来操作同一个变量,否则内存序语义可能不一致。
  4. 性能敏感场景做测试:如果对性能有极致要求,建议在目标硬件上对比测试不同内存序的差异。

我记得有一次,一个同事问我:“为什么我用 rt_hw_atomic_add 实现的计数器,性能比用关中断的方式还差?”我一看代码,原来他在一个高频中断里调用了这个函数。原子操作虽然比关中断轻量,但也不是零成本的。在ARM上,LDREX/STREX指令对总线有额外要求,频繁使用会影响整体性能。

小技巧:如果你需要在一个循环中频繁做原子操作,可以考虑先本地累加,最后再一次性原子写入。比如:

int local = 0;
for (int i = 0; i < 1000; i++) {
    local++;
}
rt_hw_atomic_add(&counter, local);

这样就把1000次原子操作减少到了1次,性能提升很明显。

16.6 总结

原子操作和内存序,是多核编程的必修课。说白了,原子操作解决的是“同时写”的问题,内存序解决的是“看到什么”的问题。两者缺一不可。

最后送大家一句话:能用原子操作解决的问题,就不要用锁;能用锁解决的问题,就不要用复杂的内存序技巧。简单可靠,才是嵌入式系统的王道。