第2章:原子操作:CPU原子指令原理,ARM Cortex-M的LDREX/STREX
各位同学,咱们今天聊点硬核的——原子操作。说白了,就是CPU怎么保证“读-改-写”这三步不被别人打断。
我刚开始做RTOS那会儿,总觉得原子操作是个很玄乎的东西。后来在项目里踩了坑,才明白这东西有多重要。嗯,咱们一步步来。
2.1 为什么需要原子操作?
你想想看,在多任务系统里,两个任务同时访问同一个变量,会发生什么?
举个最简单的例子:counter++。这行C代码看起来是一条语句,但在汇编层面,它其实是三步:
- 从内存读取counter到寄存器
- 寄存器加1
- 把寄存器写回内存
如果任务A刚读完counter,还没来得及写回去,任务B也来读——那结果就乱套了。两个任务都读到同一个值,都加1,最后counter只增加了1,而不是2。
核心问题:“读-改-写”这三步不是一体的,中间可能被中断或任务切换打断。
解决这个问题的办法,就是原子操作。让这三步要么全部执行完,要么一步都不执行。
2.2 关中断 vs 原子指令
我见过不少新手工程师,一遇到共享变量保护,第一反应就是关中断。这招在单核系统里确实管用,但代价太大了。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 关中断 | 简单粗暴,容易理解 | 影响中断响应延迟,不适合长时间操作 |
| 原子指令 | 不影响中断,硬件级保证 | 指令有限制,需要理解硬件行为 |
我个人习惯是:能用原子指令解决的,绝不用关中断。为什么呢?因为关中断就像把整个房间的灯都关了,就为了换一个灯泡。原子指令呢,就像只关掉那盏灯的开关——精准、高效。
2.3 ARM Cortex-M的LDREX/STREX指令
ARM Cortex-M系列处理器提供了一对特殊的指令:LDREX和STREX。它们就是实现原子操作的核心。
LDREX(Load Exclusive):从内存读取数据,同时标记这个地址为“独占访问”。
STREX(Store Exclusive):尝试写入数据,但只有该地址仍处于“独占”状态时,写入才会成功。
如果在这两条指令之间,有其他操作访问了同一个地址,独占标记就会被清除,STREX就会失败。
小提示:LDREX/STREX是成对使用的。单独用LDREX没问题,但单独用STREX就没意义了。
2.4 实战:用LDREX/STREX实现原子加1
咱们直接看代码。这是用C内嵌汇编实现的原子加1操作:
// 原子加1,返回旧值
int atomic_add_one(int *ptr) {
int old_val, new_val, status;
__asm volatile(
"1: LDREX %0, [%3]\n" // 读取旧值,标记独占
" ADD %1, %0, #1\n" // 计算新值
" STREX %2, %1, [%3]\n" // 尝试写入
" CMP %2, #0\n" // 检查是否成功
" BNE 1b\n" // 失败则重试
: "=&r" (old_val), "=&r" (new_val), "=&r" (status)
: "r" (ptr)
: "memory", "cc"
);
return old_val;
}
这段代码的核心逻辑很简单:
- 用LDREX读取旧值
- 计算新值
- 用STREX尝试写入
- 如果写入失败(status不为0),跳回第一步重试
我曾经在一个项目里,用这个函数替换了原来的关中断方案。结果呢?中断响应延迟从原来的几十微秒降到了几乎可以忽略不计。嗯,效果立竿见影。
2.5 避坑指南:LDREX/STREX的陷阱
这东西好用是好用,但坑也不少。我一个个说。
警告:LDREX/STREX不能用于非对齐访问!Cortex-M要求地址必须按字对齐(4字节对齐)。否则会触发异常。
陷阱一:嵌套使用
如果你在中断服务函数里也用LDREX/STREX,而主程序也在用——那就要小心了。中断里的LDREX会清除主程序的独占标记。我曾经因为这个bug,排查了整整两天。
解决办法:要么在中断里只用关中断的方式,要么确保中断和主程序不会操作同一个变量。
陷阱二:死循环风险
你看上面的代码,如果STREX一直失败,就会一直重试。正常情况下不会,但如果系统负载极高,或者有更高优先级的中断频繁访问同一个地址——嗯,理论上可能卡死。
我建议加一个重试次数限制,比如最多重试100次,超过就报错。这样至少不会让系统完全挂掉。
陷阱三:内存屏障
在Cortex-M上,LDREX/STREX本身带有内存屏障效果。但如果你用的是更高级的Cortex-A系列,那就得手动加DMB指令了。这个咱们后面讲多核的时候再细说。
2.6 原子操作在RTOS中的应用
在RTOS里,原子操作最常见的用途就是实现自旋锁。自旋锁说白了就是一个忙等待的锁,适合保护非常短的临界区。
// 自旋锁结构
typedef struct {
volatile int locked;
} spinlock_t;
// 初始化
void spinlock_init(spinlock_t *lock) {
lock->locked = 0;
}
// 获取锁
void spinlock_lock(spinlock_t *lock) {
while (1) {
int old = atomic_exchange(&lock->locked, 1);
if (old == 0) break; // 成功获取
}
}
// 释放锁
void spinlock_unlock(spinlock_t *lock) {
atomic_store(&lock->locked, 0);
}
这里用到了atomic_exchange——它也是用LDREX/STREX实现的。核心思想是:把旧值读出来,同时写入新值,整个过程不可分割。
关键点:自旋锁只适合保护非常短的代码段。如果你在锁里面做复杂操作,其他等待的任务就会白白消耗CPU。我见过有人把整个任务调度器都锁住——那性能简直惨不忍睹。
2.7 性能对比:关中断 vs 原子指令
咱们用数据说话。我在STM32F407上做过一个测试:
| 操作 | 耗时(CPU周期) | 影响 |
|---|---|---|
| 关中断 + 开中断 | 约12个周期 | 延迟所有中断 |
| LDREX/STREX(成功) | 约4个周期 | 无影响 |
| LDREX/STREX(失败重试) | 约8个周期 | 无影响 |
看到了吧?原子指令不仅更快,而且不会影响中断响应。这就是为什么我强烈推荐用它。
2.8 小结
今天咱们聊了原子操作的核心原理,重点就是ARM Cortex-M的LDREX/STREX指令。记住几个要点:
- 原子操作解决的是“读-改-写”的不可分割问题
- LDREX/STREX是硬件级的解决方案,比关中断更优雅
- 注意对齐、嵌套和死循环这三个陷阱
- 自旋锁是原子操作最经典的应用场景
下一章,咱们会深入讲信号量和互斥锁的设计。到时候你会发现,今天学的原子操作就是所有同步机制的地基。地基打不牢,楼盖得再高也得塌。
嗯,今天就到这儿。有问题随时找我。