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 实际项目中的选择建议
说了这么多,到底该怎么选?我根据自己的经验,给几个建议:
- 简单场景用RT-Thread API:如果只是做计数器、标志位,直接用
rt_hw_atomic_xxx系列,省心。 - 复杂同步用C11:如果需要精细控制内存序,比如实现无锁队列、RCU等,建议用C11标准接口。
- 混合使用要小心:不要在一个项目中混用两套原子操作来操作同一个变量,否则内存序语义可能不一致。
- 性能敏感场景做测试:如果对性能有极致要求,建议在目标硬件上对比测试不同内存序的差异。
我记得有一次,一个同事问我:“为什么我用 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 总结
原子操作和内存序,是多核编程的必修课。说白了,原子操作解决的是“同时写”的问题,内存序解决的是“看到什么”的问题。两者缺一不可。
最后送大家一句话:能用原子操作解决的问题,就不要用锁;能用锁解决的问题,就不要用复杂的内存序技巧。简单可靠,才是嵌入式系统的王道。