STM32原子操作实战:中断并发、LDREX/STREX与临界区选型

发布时间:2026/10/29 6:26:40
STM32原子操作实战:中断并发、LDREX/STREX与临界区选型

做 STM32 这行久了会发现,真正让项目返工的往往不是外设初始化写错寄存器,而是几个变量在中断和主循环之间来回撕扯。前阵子有位 A 同学拿着一份代码来找我,说他那个统计脉冲个数的变量,在定时器中断里自增,主循环里读出来总比实际少几个数,示波器数得清清楚楚,代码就是不对。我扫了一眼就知道问题出在哪:counter++在 C 语言里是一行,编译出来是三条汇编,中间任何一次中断插入,都会让这次自增变成一次"读旧值、写回旧值"的覆盖。这就是 STM32 平台上最典型的原子操作问题,也是这篇内容要聊的核心。原子操作说白了就是"要么全部做完,要么一点都不做"的操作语义,它在 STM32 上的落地方式与桌面平台完全不同——没有操作系统兜底、没有 MMU、没有多核缓存一致性协议(大部分型号),你手里能用的只有内核指令、中断控制和一个编译器。这篇内容会把 Cortex-M 内核提供的原子原语、四种工程上常用的实现路径、真实代码写法以及我踩过的坑一次讲清楚,适合已经能点灯、写过中断服务函数,但还没系统梳理过并发安全的嵌入式开发者;刚入门的新手也能看懂前半部分的原理,后半部分的实操建议直接抄。

1. 一条 i++ 为什么会出错:先把"原子"这件事说透

1.1 从三条汇编指令看非原子的本质

很多人对原子操作的误解,来自 C 语言语法的欺骗性。a++、flag = 1、if (state == READY)这些写法看起来都是一个"动作",但编译器翻译出来完全不是一回事。以a++为例,在 Cortex-M 上典型的三条指令是LDR把 a 从内存读进寄存器、ADD在寄存器里加一、STR把结果写回内存。三条指令之间是允许被打断的,中断服务程序如果在这个窗口里改动了 a,主循环的STR就会把中断的修改直接覆盖掉——这就是所谓的"丢失更新"。

我用一个具体的数字例子说明会更直观。假设 a 初值为 100,主循环执行到LDR之后拿到 100,此时定时器中断触发,中断里执行了一次a++,内存里 a 变成 101,中断返回,主循环继续执行ADD得到 101,然后STR写回内存,a 依然是 101。两次自增,结果只加了一次。这个问题在低频中断下出现概率很低,测试阶段可能跑一整天都碰不到,一旦产品上量、中断频率提高,就会变成偶发的数据错乱,排查成本极高。

这里要强调一个容易被忽略的点:出错的根源不是"中断里写了同一个变量"这件事本身,而是"读"和"写"这两个动作之间存在可被插入的时间窗口。原子操作的本质就是消灭这个窗口,或者让窗口内的冲突被硬件检测出来并重试。理解了这个模型,后面所有的实现手段都能归类:要么不让别人插进来(关中断),要么让硬件帮你检测冲突(LDREX/STREX),要么把读写合并成一条指令(位带),要么干脆让一个执行者负责写(单写者模型)。

1.2 STM32 上哪些操作天生原子,哪些天生不原子

判断一个操作是否原子,第一看位宽,第二看对齐,第三看编译器有没有把它拆开。在 Cortex-M 上,对 8 位、16 位、32 位变量的单次对齐访问,硬件层面是原子的——一条LDR或STR指令完成,总线事务不可分割。所以一个uint32_t类型的变量,如果只有一个写者(比如只有中断写,主循环只读),那么"读"和"写"本身不会撕裂,你读到的要么是旧值要么是新值,不会是半新半旧。这是很多场景下可以放心使用的基础。

但下面这几类就是明确的非原子操作,必须小心:第一类是读-改-写组合,a++、a |= mask、a &= ~mask、a = a * 2,统统不原子;第二类是位域(bit-field)操作,struct里定义uint8_t flag : 1;然后赋值,编译器会生成读整个字节、改一位、写回整个字节的序列,改动某一位会连带影响同字节的其他位,如果另一位被中断修改,就会互相覆盖;第三类是超过 32 位的数据,uint64_t、double、float(在 M0 上软件浮点更是灾难),必然拆成两次 32 位访问;第四类是非对齐访问,虽然 Cortex-M3/M4/M7 硬件支持非对齐的LDR/STR,但硬件内部会把它拆成两次对齐的总线访问,原子性就没了,所以用#pragma pack压缩过的结构体、或者把uint32_t指针强转到奇地址上,都是隐患。

注意:volatile关键字不提供任何原子性保证。它只告诉编译器"这个变量可能被外部改变,每次都必须从内存重新读、每次赋值都必须真的写回",防止优化器把循环里的读操作提到循环外。它阻拦的是编译器优化,不是中断抢占。把volatile当原子用,是新手最常见的认知错误。

还有一类隐蔽的坑是编译器的"聪明"优化。比如你在临界区里写flag = 1;然后马上flag = 0;,编译器可能直接把第一条语句删掉;又比如你精心构造的关中断、改值、开中断序列,编译器完全可以把改值操作挪到关中断之前,因为它不知道那几条内联汇编意味着什么。这类问题必须靠编译器屏障来堵,后面第 2.3 节会专门讲。

2. 内核给你准备的原子武器:独占访问与位带

2.1 LDREX/STREX 的工作机制与独占监视器

Cortex-M3、M4、M7、M23、M33 这些内核都实现了 ARMv7-M 或 ARMv8-M 的独占访问指令LDREX和STREX,这是硬件层面最正统的原子原语。工作原理不难理解:LDREX除了完成一次普通读取,还会在处理器内部的"独占监视器"上给这块地址打个标记;随后执行STREX时,硬件先检查这个标记还在不在,如果在就完成写入并返回 0 表示成功,如果标记已经被清除(比如中间发生了异常返回、上下文切换、或者另一条LDREX覆盖了监视器状态),就拒绝写入并返回非 0,调用者需要重新来一遍。

在 CMSIS 里这几个指令被封装成了内建函数,用起来比写汇编舒服得多。GCC 版本的cmsis_gcc.h里提供了__LDREXW、__STREXW、__CLREX,写一个原子的自增大概长这样:

static inline uint32_t atomic_inc_u32(volatile uint32_t *p) { uint32_t old, ret; do { old = __LDREXW(p); ret = __STREXW(old + 1, p); } while (ret != 0); /* 0 表示写入成功,非 0 需要重试 */ return old; /* 返回自增前的旧值 */ }

这段代码看着不长,但有几个细节决定了它能不能用。第一,重试循环必须有,而且里面不能干耗时的事,否则会拉长独占窗口。第二,LDREX和STREX之间不能调用任何可能触发异常返回的函数,也不能嵌套使用另一对LDREX/STREX,因为很多内核只有一个全局的独占监视器,嵌套会互相破坏状态。第三,也是最容易翻车的一点:如果在LDREX和STREX之间被高优先级中断打断,中断返回后STREX一定失败,于是循环重来;如果这个中断频率很高、周期又和循环接近,就会出现反复失败、任务饿死的活锁现象。我曾经在一个 20kHz 的 PWM 中断场景里见过这个问题,那段原子自增代码看起来完全正确,但主循环里的计数器偶尔会"卡"几十毫秒才更新一次,最后是靠临时关中断包住这个循环解决的。

还有一点必须提醒:Cortex-M0和Cortex-M0+是 ARMv6-M 架构,没有LDREX/STREX指令。这两个内核在低端 STM32 型号上很常见,如果你打算写一套跨型号的原子操作库,M0分支必须单独处理,通常只能退回到关中断方案。这个差异在选型阶段很容易被忽略,等到移植时才发现整个底层要重写。

2.2 位带操作:把读改写压成一条指令

位带(bit-band)是 Cortex-M3/M4 上一个非常实用的硬件特性,它把 SRAM 和外设区的每一个比特"映射"成一个 32 位字,你访问这个 32 位字就等价于访问原来那一位,而硬件保证这个"读-改-写"过程是原子的。计算别名地址的公式是固定的:

别名地址 = 别名区基址 + (字节偏移 × 32) + (位序号 × 4)

其中 SRAM 区的别名基址是 0x22000000,对应 0x20000000 起的 1MB 空间;外设区的别名基址是 0x42000000,对应 0x40000000 起的 1MB 空间。写成宏大概是这样:

#define BITBAND_PERIPH(addr, bit) \ (*(volatile uint32_t *)(0x42000000UL + (((uint32_t)(addr) - 0x40000000UL) << 5) + ((bit) << 2))) #define BITBAND_SRAM(addr, bit) \ (*(volatile uint32_t *)(0x22000000UL + (((uint32_t)(addr) - 0x20000000UL) << 5) + ((bit) << 2)))

用它操作 GPIO 的输出寄存器特别顺手,比如BITBAND_PERIPH(&GPIOA->ODR, 5) = 1;就是一次原子的置位,不会像GPIOA->ODR |= (1 << 5)那样先生成一个完整的 32 位读-改-写序列,把其他引脚的状态一起带进去。在多任务或者中断里同时操作同组 GPIO 不同引脚的场景下,这个差别非常关键——用|=写法,主循环置 PA5 的同时中断置 PA6,很可能丢一个。

但位带有个硬性限定必须记牢:它存在于 Cortex-M3 和 M4 内核,而 Cortex-M7 已经移除了位带别名区,所以 STM32F7、H7 这些型号是没有位带的,代码里用了位带宏会直接跑飞或者访问到无效地址。另外 Cortex-M0/M0+ 同样不支持。第二点是位带的原子性只对"单一位的访问"成立,如果你需要同时原子地修改多位(比如一次性把一个 8 位字段写进去),位带帮不上忙。第三点,位带操作的性能并不比普通写快,它只是把三条指令变成了一条,但对总线的访问次数是一样的,别指望它提速。

提示:位带别名区那 1MB 外设空间只覆盖 0x40000000 到 0x400FFFFF,也就是说大部分 APB 外设和 AHB1 上的 GPIO 都在范围内,但 AHB 上一些高地址外设(比如部分型号的以太网、USB OTG 寄存器)落在范围之外,不能位带访问。查参考手册的内存映射表确认一下比较稳妥。

2.3 内存屏障:DMB、DSB 与编译器屏障

原子性解决的是"操作不被拆开",但还有另一个维度的问题:顺序性。现代编译器会重排指令,Cortex-M7 这种带超标量流水线和写缓冲的内核,硬件也可能让内存访问的实际完成顺序和程序顺序不一致。在没有缓存一致性的单核系统里,这个问题不像多核那么严重,但在两个场景下依然会咬人:一是临界区边界的代码被编译器挪出去,二是 DMA 与 CPU 共享缓冲区时写缓冲未刷出。

先说要区分两种屏障。编译器屏障是__asm volatile("" ::: "memory");,它不生成任何机器指令,只是告诉编译器"这行之后的内存访问不能提到这行之前,之前的也不能压到这行之后",用来阻止优化跨越临界区边界。ARM 内核屏障是__DMB()(数据内存屏障,保证屏障前的内存访问对后续访问可见)、__DSB()(数据同步屏障,等待之前的所有内存访问真正完成)、__ISB()(指令同步屏障,清流水线,改完系统控制寄存器后必须用),这几个 CMSIS 都有封装。

实际写代码时,关中断的临界区前后最好都加一条编译器屏障,因为你不知道优化器会怎么安排:

#define ENTER_CRITICAL() do { \ __asm volatile("" ::: "memory"); \ primask_save = __get_PRIMASK(); \ __disable_irq(); \ } while (0) #define EXIT_CRITICAL() do { \ __set_PRIMASK(primask_save); \ __asm volatile("" ::: "memory"); \ } while (0)

用__get_PRIMASK保存、__set_PRIMASK恢复,而不是简单地__enable_irq(),是为了支持嵌套。如果你的临界区嵌套在另一个已经关中断的临界区里,__enable_irq()会提前打开中断,直接破坏外层的保护。这个坑我在不止一个项目里见过,表现是"偶发丢数据",而且只在多层调用时出现,极难复现。

3. 四种落地手段的对比与选型

3.1 关中断:最暴力但最不容易出错

关中断是所有方案里最土也最可靠的一种,一条CPSID i下去,中断全灭,临界区里的任何操作都成了"事实上的原子"。它的优点是实现简单、跨内核无差别(M0 到 M7 通用)、不依赖编译器内建函数,而且开销完全可预测——就是关中断和开中断两条指令加上流水线冲刷的时间,通常在十几个周期量级。

缺点同样明显。第一,关中断期间所有中断都被延迟,包括那些对时间敏感的(串口接收、编码器计数、ADC 采样完成)。如果临界区里有函数调用、有浮点运算、有循环,关中断时间就会失控,表现为通信误码、脉冲丢失。第二,它破坏了系统的实时性保证,在功能安全相关的设计里,中断延迟是一个必须论证的指标,不能随便拉长。

我的经验是把临界区控制在 10 条指令以内,只做最必要的那几个赋值,绝不在里面调函数。如果发现临界区包住的代码越来越多,那说明数据结构设计有问题,应该考虑换成单写者模型或者无锁结构,而不是继续加长关中断时间。另外有个细节:CMSIS 的__disable_irq()只影响可屏蔽中断,NMI 和 HardFault 是关不掉的,所以别指望它保护一切,只能说保护"常规中断"。

3.2 BASEPRI 精准屏蔽:兼顾实时性

比关中断更精细的手段是操作BASEPRI寄存器,它能让"优先级数值大于等于某个阈值"的中断被屏蔽,而优先级更高的中断照常响应。这正好对应 RTOS 里的临界区需求:任务级临界区需要屏蔽那些会调用系统 API 的中断(否则会破坏内核数据),但希望能让最高优先级的紧急中断继续响应。FreeRTOS 的taskENTER_CRITICAL()在 Cortex-M3/M4/M7 端口上就是用BASEPRI实现的,阈值由configMAX_SYSCALL_INTERRUPT_PRIORITY决定。

裸机项目里也可以直接用,CMSIS 提供了__set_BASEPRI()和__get_BASEPRI():

uint32_t basepri_save = __get_BASEPRI(); __set_BASEPRI(0x60); /* 屏蔽优先级数值 >= 0x60 的中断(数值越大优先级越低) */ /* 临界区代码 */ __set_BASEPRI(basepri_save);

这里有个非常容易搞反的点:Cortex-M 的优先级是"数值越小优先级越高"。所以BASEPRI = 0x60屏蔽的是数值大于等于 0x60 的那些低优先级中断,而数值 0x00 到 0x50 的高优先级中断不受影响。写代码时如果不确定,把NVIC_SetPriority的实参和这个阈值对一遍,比在调试器里瞎试快得多。

注意:BASEPRI的生效前提是PRIMASK为 0。如果之前有代码调用了__disable_irq()没恢复,BASEPRI设置了也没用,整个系统还是全屏蔽状态。这类"忘记开中断"的 bug,现象是外设全部失联,但单步调试又一切正常,非常典型。

3.3 编译器内建原子函数:C11 与 __atomic

GCC 和基于 Clang 的工具链提供了一套__atomic_*内建函数,配合 C11 的<stdatomic.h>使用,写出来最接近"现代并发编程"的样子:

#include <stdatomic.h> atomic_uint g_rx_count; void TIM_ISR(void) { atomic_fetch_add_explicit(&g_rx_count, 1, memory_order_relaxed); } uint32_t get_rx_count(void) { return atomic_load_explicit(&g_rx_count, memory_order_acquire); }

在 Cortex-M3/M4/M7 上,工具链会把这几个内建函数展开成LDREX/STREX序列加必要的屏障,性能和手写差不多,但可读性和正确性都好得多。memory_order参数在单核系统上的实际作用主要是控制编译器重排和是否插入DMB,relaxed最轻、acquire/release用于跨执行单元的同步、seq_cst最重但最省心。日常写中断与主循环通信,relaxed配合显式屏障就够用了。

但有两个坑必须提前知道。第一,Cortex-M0/M0+上没有独占指令,工具链会退化成调用libatomic库里的__atomic_compare_exchange_4,而这个库函数在裸机环境下往往没有实现或者链接不进来,编译能过、链接报错,或者更糟——链接进来但实现是空的。第二,老一些的 ARMCC v5 不完全支持 C11 原子操作,需要用__sync_*系列或者自己封__ldrex/__strex。如果项目用的是 AC5,先确认工具链版本再决定方案,别照着网上的例子抄。

3.4 RTOS 临界区与信号量:别用错场景

上了 RTOS 之后,很多人第一反应是"有信号量了,原子操作不用管了"。这个理解只在"任务与任务之间"成立,在"任务与中断之间"就未必。信号量的take通常在中断里不可用(除非用FromISR版本),而且一次信号量操作的代价是几十个周期加上可能的上下文切换,用它来保护一个 32 位计数器,成本高得离谱。

我的判断标准很简单:保护的是几个字节的数据、操作只有一两条语句、调用方包含中断服务程序——用临界区或者原子指令;保护的是一段较长的逻辑(比如文件操作、协议栈状态迁移)、涉及阻塞等待——用互斥量或信号量。把这两类混用,要么性能崩掉,要么死锁。

还有一个 RTOS 特有的坑:taskENTER_CRITICAL()只能在任务上下文里用,在中断里必须用taskENTER_CRITICAL_FROM_ISR();而且临界区的嵌套计数保存在任务控制块里,中断里调用会读到错误的计数值。这类 API 用错,现象是系统跑一段时间后莫名卡死,堆栈回溯还看不出问题。

3.5 选型对照表

把上面几种方案按维度拉平对比,选型时直接查表会快很多:

方案适用内核单次开销实时性影响适用数据规模主要风险
关中断(PRIMASK)全系列低,十几周期大,屏蔽全部1~8 字节临界区过长导致丢中断
BASEPRI 屏蔽M3/M4/M7/M33低中,按优先级分层1~8 字节优先级数值方向搞反
LDREX/STREXM3/M4/M7/M23/M33中,可能重试无1~4 字节高中断率下活锁
位带操作M3/M4极低无单比特M7/M0 不支持
C11 原子取决于工具链中无1~8 字节M0 上链接失败
信号量/互斥量全系列(需 RTOS)高,含调度无任意中断中误用、优先级反转

表格里"适用数据规模"这一列要特别留意:超过 32 位的数据,除了用 RTOS 互斥量或者双缓冲加序号校验,没有特别优雅的办法,第 4.4 节会讲具体做法。

4. 工程实操:把原子操作写进真实代码

4.1 编译环境与选项准备

动手之前先把工具链确认清楚,这一步能省掉后面一半的调试时间。用 GCC 的话,-std=gnu11或-std=c11打开 C11 支持,如果要使用<stdatomic.h>需要确认 newlib 版本足够新;用__atomic_*内建函数则不需要头文件,但要注意-mcpu参数必须和实际内核匹配,否则工具链可能生成错误的指令序列。比如-mcpu=cortex-m4和-mcpu=cortex-m0生成的原子内建展开结果完全不同。

用 AC6(基于 Clang)的话,__atomic_*内建是支持的,<stdatomic.h>也基本可用。用 AC5 的话走__sync_*或者 CMSIS 的__LDREXW/__STREXW更稳妥。这里有个实操技巧:写完原子操作代码后,用arm-none-eabi-objdump -d把对应的.o文件反汇编出来看一眼,确认生成的确实是ldrex/strex而不是一个bl __atomic_fetch_add_4,这一步花三十秒,能避免上线后才发现链接了空实现。

再补一个容易忽略的配置:优化等级。-O0下代码不会被重排,很多并发问题藏得住;开到-O2或-Os之后,编译器开始大展拳脚,之前能跑的代码可能就挂了。所以原子相关的代码一定要在最终发布的优化等级下测试,别在-O0下调通了就以为万事大吉。

4.2 计数器与标志位的三种写法

计数器是最常见的场景。假设有一个串口接收字节计数的需求,中断里自增,主循环里读取并清零。写法一,关中断:

static volatile uint32_t s_rx_bytes; static uint32_t s_primask; void USART1_IRQHandler(void) { /* ... 读数据 ... */ __disable_irq(); s_rx_bytes++; __enable_irq(); }

这个写法在中断里其实没必要关中断(中断里不会被同优先级打断),除非有更高优先级的中断也会改这个变量。真正的必要性在于主循环的"读并清零":

uint32_t take_rx_bytes(void) { uint32_t v; uint32_t pm = __get_PRIMASK(); __disable_irq(); v = s_rx_bytes; s_rx_bytes = 0; __set_PRIMASK(pm); return v; }

如果不加这层保护,主循环刚读完s_rx_bytes、还没来得及清零,中断又加了一个字节,清零操作就会把这个字节的记录抹掉。这类"读后清"操作是典型的必须原子的复合操作,比单纯的自增更容易出错。

写法二,用__atomic内建:

static atomic_uint s_rx_bytes; void USART1_IRQHandler(void) { atomic_fetch_add_explicit(&s_rx_bytes, 1, memory_order_relaxed); } uint32_t take_rx_bytes(void) { /* 用 exchange 一步完成"读旧值并写 0" */ return atomic_exchange_explicit(&s_rx_bytes, 0, memory_order_acquire); }

atomic_exchange正好对应"读并清零"这个语义,硬件上用LDREX/STREX循环实现,比关中断优雅,而且不会拉长中断延迟。

写法三,标志位场景。如果只是一个"有新数据"的布尔标志,其实不需要原子操作——单写者单读者、32 位对齐的情况下,读写本身就是原子的,加个volatile防止编译器缓存就够了。但如果是"置位并检查之前是否已置位"这种复合语义,就必须用atomic_exchange或者atomic_fetch_or。我见过一个案例:中断里判断"如果标志已经是 1 就丢弃数据",主循环里读标志并清零,两边都用普通赋值,结果在高负载下会丢包——根因就是判断和置位不是一个原子动作。

4.3 单生产者单消费者环形缓冲区

串口、SPI、DMA 收数据的场景,环形缓冲区几乎必用。如果只有一个生产者(比如 DMA 或中断)和一个消费者(主循环),可以做到完全无锁,代价是缓冲区大小必须是 2 的幂,方便用掩码取模。

核心思路是让读写指针各由一个执行者独占写:生产者的head只有中断改,消费者的tail只有主循环改。判断空满时读对方的指针,读到旧值也没关系,最坏情况是延迟一拍,不会出错。

#define BUF_SIZE 256 #define BUF_MASK (BUF_SIZE - 1) static uint8_t s_buf[BUF_SIZE]; static volatile uint32_t s_head; /* 只由生产者写 */ static volatile uint32_t s_tail; /* 只由消费者写 */ /* 生产者:中断上下文 */ bool ring_put(uint8_t byte) { uint32_t head = s_head; uint32_t next = (head + 1) & BUF_MASK; if (next == s_tail) { return false; /* 满了 */ } s_buf[head] = byte; __DMB(); /* 保证数据先写入,再更新 head */ s_head = next; return true; } /* 消费者:主循环上下文 */ bool ring_get(uint8_t *out) { uint32_t tail = s_tail; if (tail == s_head) { return false; /* 空了 */ } __DMB(); /* 保证先看到 head,再读数据 */ *out = s_buf[tail]; s_tail = (tail + 1) & BUF_MASK; return true; }

这段代码的要点在于那两条__DMB()。没有它们,编译器可能把s_buf[head] = byte挪到s_head = next之后,消费者就会读到还没写入的数据。在 Cortex-M7 这种带写缓冲的内核上,硬件也可能让写操作乱序完成,DMB是必须的。用 C11 的话,把s_head和s_tail声明成atomic_uint,用memory_order_release写、memory_order_acquire读,语义等价且更清晰。

这个结构有一个前提必须满足:真的只有一个生产者、一个消费者。如果两个中断都往同一个缓冲区写,s_head就有了两个写者,上面的分析全部失效,必须加锁。另外s_head和s_tail如果放在同一个缓存行里,在带 D-Cache 的 M7 上会互相踢缓存,虽然单核不会有假共享的性能问题,但 DMA 一致性维护时会被波及,把它们分开放或者手动对齐到 32 字节边界是个好习惯。

4.4 64 位数据与结构体的原子化封装

32 位内核处理 64 位数据(比如编码器累计计数、时间戳、累计流量)时,读写必然是两次 32 位访问,中间可能被插入。atomic_ullong在 M3/M4 上会用LDREX/STREX对实现,但那要求内存里有一个额外的锁变量,而且并发性能一般。工程上更常见的做法是"序号校验":

typedef struct { volatile uint32_t seq; /* 奇数表示正在写,偶数表示稳定 */ uint32_t lo; uint32_t hi; } safe_u64_t; void set_u64(safe_u64_t *s, uint64_t v) { s->seq++; /* 变奇数 */ __DMB(); s->lo = (uint32_t)v; s->hi = (uint32_t)(v >> 32); __DMB(); s->seq++; /* 变偶数 */ } uint64_t get_u64(safe_u64_t *s) { uint32_t seq1, seq2; uint64_t v; do { seq1 = s->seq; if (seq1 & 1u) continue; /* 正在写,重试 */ __DMB(); v = ((uint64_t)s->hi << 32) | s->lo; __DMB(); seq2 = s->seq; } while (seq1 != seq2); return v; }

这个模式叫"序列锁",优点是不需要关中断、写者不会阻塞读者、读者重试概率极低;缺点是写者必须单写,而且读者在极端情况下可能一直重试(概率很小,但理论上存在)。适合"一次写、多次读"的场景,比如系统运行时间、累计里程这种。

如果数据结构是普通的struct(多个字段要一起更新),而且更新频率不高,我的建议反而是老老实实关中断,或者用双缓冲加指针交换。为了追求"无锁"而把代码写得别人看不懂,维护成本远超那点性能收益。

4.5 DMA 与 Cache 场景的额外注意事项

用 DMA 搬运数据的型号(比如带以太网、SDMMC、大容量 ADC 的)上,原子操作和 DMA 之间还有一层关系要理清。DMA 是独立的总线主设备,它访问内存不受 CPU 的LDREX/STREX约束,也不受中断屏蔽影响。所以指望用原子操作去和 DMA "抢"同一个缓冲区是行不通的,必须靠协议设计:要么让 DMA 和 CPU 操作不同的缓冲区(双缓冲),要么用 DMA 的传输完成中断作为同步点。

带 D-Cache 的型号(如 Cortex-M7 系列)更麻烦。CPU 写完描述符、启动 DMA 之前,必须把 Cache 里的数据清出去(SCB_CleanDCache_by_Addr),否则 DMA 读到的是内存里的旧数据;DMA 接收完成后,CPU 读数据之前必须把对应 Cache 行无效化(SCB_InvalidateDCache_by_Addr),否则读到的是 Cache 里的旧值。这两个调用前后要有__DSB()保证内存访问真正完成。我在一个高速采集项目上就因为漏了清理这一步,现象是 DMA 收到的数据前几十个字节永远是对的,后面全是上一次的残留——查了两天才定位到 Cache。

提示:如果 DMA 缓冲区和普通变量放在同一块 SRAM 区域,Cache 无效化操作可能把邻近变量的未写回数据一起丢掉。稳妥的做法是把 DMA 缓冲区单独放到一个按 32 字节对齐的段里,并在链接脚本中给它独立的位置。

还有一种情况是"部分双核型号"提供的硬件信号量外设,它用寄存器级别的原子读改写给两个核提供互斥,用法和普通的软件标志位类似,但由硬件保证原子。如果你的型号恰好有,用它比软件方案省心;没有的话,跨主设备的互斥只能靠协议或者外设本身的支持,别硬凑。

5. 踩坑实录与排查清单

5.1 volatile 到底能保证什么

关于volatile的误解太多,值得单独拎出来说。它能保证三件事:每次读都从内存取,不从寄存器缓存;每次写都真的写回内存,不被优化掉;多个volatile访问之间不会被编译器重排顺序(这条其实也不是完全可靠,严格来说 C 标准只保证volatile访问相对于其他volatile访问的顺序)。它不能保证:操作是原子的;中断不会在中间插入;多核之间的可见性;编译器不会把非volatile变量重排到volatile访问附近。

实践中的判断标准是:只要这个变量可能被"当前执行流之外的东西"修改(中断、DMA、另一个任务),就应该加volatile;但只要涉及读-改-写,加了volatile之后依然需要原子保护。两个条件互不替代。我见过有人把环形缓冲区的head加了volatile就认为万事大吉,结果在多中断写入的场景下依然丢数据——因为他忽略了head有两个写者。

5.2 典型故障现象与排查路径

把常见问题整理成一张表,遇到类似现象时可以快速对照:

现象可能原因排查方法
计数值偶发偏小自增非原子,被中断覆盖反汇编看是否三条指令,用atomic_fetch_add替换
标志位改了但没生效编译器把赋值优化掉,或读写顺序错乱加volatile和编译器屏障,看反汇编
同组 GPIO 其他引脚状态乱跳用|=操作 ODR,位带或 BSRR 更合适改用BSRR或位带别名
位带宏在新型号上跑飞Cortex-M7 无位带别名区查内核型号,改用BSRR或普通写
临界区嵌套后中断全关用__enable_irq而非恢复PRIMASK改成保存/恢复PRIMASK
原子自增代码偶发卡顿高优先级中断频繁打断,STREX 反复失败缩短中断周期,或改用关中断
DMA 数据部分正确D-Cache 未清理或未无效化加CleanDCache/InvalidateDCache
M0 上链接报__atomic_*未定义内核无独占指令,libatomic 未提供改用关中断实现

排查这类问题的通用套路是"三看":一看反汇编,确认生成的指令和你想的一致;二看时序,用 GPIO 翻转加示波器,把临界区的进入和退出打出来,能直观看到关中断时长和中断延迟;三看压力,把中断频率、任务负载调到极限,原子性问题几乎都是被压出来的,正常测试跑不出来不代表没有。我一般的做法是在开发阶段就故意把相关中断频率提高五到十倍跑一段时间,能提前暴露大部分竞态。

5.3 性能开销实测与取舍经验

最后聊点实际数字,虽然不同型号差异不小,但量级上有参考价值。在 168MHz 的 M4 上,一次__disable_irq()加__enable_irq()的组合,算上流水线冲刷,大概十几个周期,换算下来不到 0.1 微秒;一次LDREX/STREX成功路径大约十几到二十个周期,失败重试一次翻倍;位带写和普通写开销基本相同,一条STR的时间。

这意味着什么?一个 20kHz 的中断(周期 50 微秒),如果你在里面加了 0.1 微秒的关中断,占用率只有 0.2%,完全无感。但如果临界区里有函数调用、有除法、有内存拷贝,很容易拉长到几微秒,占用率就上到 10% 以上,通信误码开始出现。所以我的原则是:能确定临界区不超过 10 条指令,就放心用关中断;超过就换方案,别抱侥幸心理。

还有一条经验:不要为了"零开销"过度设计。我见过一个项目为了实现无锁队列,写了三百多行带内存序的代码,最后性能反而不如关中断版本,因为LDREX/STREX的重试在高中断率下把收益吃光了,还引入了两个难以复现的 bug。原子操作是工具,不是信仰,单核 MCU 上"关中断 + 极短临界区"能解决 90% 的问题,剩下 10% 才需要上独占指令或者无锁结构。判断标准是"临界区长度"和"中断频率",而不是"哪种方案更高级"。

另外提醒一句关于测试的体会:并发问题在实验室里几乎测不出来,因为实验室的时序太"干净"了。真正有效的验证方式是在代码里临时插入随机延时、人为拉长中断处理时间、把优化等级开到最高,制造最恶劣的时序环境。我在几个项目里都是这么干的,虽然麻烦,但比在现场被客户追着问"为什么偶尔丢数据"要舒服得多。

如果后续要扩展,这套原子操作的封装其实可以再往上抽一层,做成一个统一的osal_atomic.h,内部按内核型号用条件编译切换实现(M0 走关中断、M3/M4/M7 走独占指令),上层业务代码只调atomic_inc、atomic_exchange这些语义化的接口,将来换型号或者换工具链,只改一个头文件就够了。这个做法我在两个跨型号的产品线上用过,移植时确实省了不少事。