在 STM32 开发(以及所有嵌入式系统和多任务环境)中,原子操作是一个核心概念,对于保证系统的可靠性和数据一致性至关重要。


一、 什么是原子操作?

  • 核心含义: “原子”这个词来源于希腊语,意思是“不可分割的”。在编程语境下,一个原子操作是指一个或多个指令序列在执行过程中不会被任何中断或其他并发执行流(如其他任务、线程或中断服务程序)打断的操作
  • 关键特性:
    • 不可分割性: 操作要么完全执行,要么完全不执行,不存在中间状态被外界观察到的可能性。
    • 隔离性: 在操作执行期间,它所访问的共享资源(如内存变量、寄存器)不会被其他并发执行流修改或读取到不一致的状态。
      在嵌入式C语言开发中,理解操作的原子性对保证多任务/中断环境下的数据安全至关重要。以下是常见的原子操作和非原子操作总结:

(1)常见的原子操作(在特定条件下)
这些操作在满足 32位或更小、自然对齐、单核无中断打断 的条件下可能是原子的:

  1. 标量类型单次读写

    uint8_t a = 0xAA;  // 8位写(原子)
    uint16_t b = val;   // 16位写(地址2字节对齐时原子)
    uint32_t c = val;   // 32位写(地址4字节对齐时原子)
    
  2. 指针赋值(指针本身≤32位):

    void* ptr = (void*)0x20000000;  // 32位指针赋值(原子)
    
  3. 位带别名区操作(ARM Cortex-M特有):

    // 对位带别名区的访问是原子的
    *((volatile uint32_t*)0x42000000) = 1;  // 原子位操作
    
  4. 特殊硬件寄存器访问

    GPIOA->ODR = 0xFFFF;  // 硬件寄存器单次写(原子)
    

(2)绝对非原子操作(需要保护)
这些操作 无论何时 都需要同步机制保护:

  1. 读-修改-写(RMW)操作

    counter++;        // 自增
    value |= FLAG;    // 位或赋值
    var += 10;        // 复合赋值
    
  2. 多步骤操作

    // 结构体赋值
    typedef struct { uint16_t x, y; } Point;
    Point p = {10, 20};  // 可能分多次写入
    
    // 数组操作
    uint32_t arr[2] = {val1, val2};  // 非原子
    
  3. 大于32位的操作

    uint64_t large_var = 0x1122334455667788;  // 需要两次32位写
    double fp_val = 3.1415926;                // 非原子
    
  4. 非对齐访问

    #pragma pack(1)
    struct { char c; uint32_t i; } s;  // i可能非对齐
    s.i = 100;                         // 非原子
    
  5. 函数调用返回值赋值

    result = calculate();  // 计算过程可能被中断
    

(3)需要特殊注意的场景

  1. 编译器优化破坏原子性

    // 编译器可能拆分成多个操作
    uint64_t a = 0;
    a = 0x100000002;  // 可能编译为两条32位存储指令
    

    解决方案:使用volatile禁止优化

    volatile uint64_t a = 0;
    
  2. Cortex-M0/M0+的限制

    • 没有LDREX/STREX指令
    • 64位操作必然非原子
    • 建议使用关中断保护临界区
  3. 多核处理器(如STM32H7)

    // 需要硬件级同步
    __atomic_add_fetch(&counter, 1, __ATOMIC_SEQ_CST);
    

(4)原子性保证技术对比*

技术 适用场景 性能影响 示例
关中断 单核+中断共享 __disable_irq()
原子指令 Cortex-M3/M4/M7 __atomic_fetch_add()
互斥锁 RTOS多任务 xSemaphoreTake()
硬件信号量 多核系统 HAL_HSEM_Take()
位带操作 单比特控制 极低 位带别名区访问

关键

  1. 天然原子操作

    • ≤32位的对齐标量读写
    • 硬件寄存器单次访问
    • 位带别名区操作
  2. 必然非原子操作

    • 任何读-修改-写操作(++, |=, +=
    • 大于32位的数据访问
    • 结构体/数组等复合类型操作
  3. 最佳实践

    // 正确示例:使用原子指令保护自增
    #include <stdatomic.h>
    atomic_uint safe_counter = 0;
    atomic_fetch_add(&safe_counter, 1);
    
    // 无原子指令时:关中断保护
    void protected_increment(void) {
        uint32_t primask = __get_PRIMASK();
        __disable_irq();
        unsafe_counter++;
        __set_PRIMASK(primask);
    }
    

📌 终极建议

  • 共享变量必须加volatile
  • 多字节操作显式使用保护机制
  • 优先使用C11原子操作(<stdatomic.h>
  • 关中断时间控制在5μs以内以保证实时性

通过正确识别原子/非原子操作并实施保护,可显著提高嵌入式系统的可靠性。

二.、有什么含义和作用?

  • 含义: 原子操作是构建并发安全代码的基础模块。它确保了对共享资源(尤其是简单的标量数据,如标志位、计数器、状态变量、外设寄存器位)的访问在并发环境下是安全、一致、可预测的。
  • 作用:
    • 防止竞态条件: 这是最主要的作用。当多个执行流(主循环、中断、其他RTOS任务)试图同时读写同一个共享资源时,如果没有保护,它们的操作序列可能会相互交错,导致结果依赖于不可控的执行时序,从而产生错误或数据损坏。原子操作消除了这种交错的可能性。
    • 保证数据一致性: 确保共享资源在任何时刻都处于一个逻辑上一致的状态。例如,一个32位的变量在被写入时,不会被另一个执行流在写入中途读取,导致读取到一半旧值一半新值的无效数据。
    • 实现无锁数据结构的基础: 更高级的无锁队列、环形缓冲区等高效数据结构,其核心依赖于底层的原子操作(如比较并交换)。

三、 什么情况下需要使用原子操作?

只要存在多个执行流(并发)访问同一个共享资源,且至少有一个执行流会修改该资源,就需要考虑使用原子操作或其它同步机制(如互斥锁、信号量)。 在 STM32 开发中,常见的场景包括:

  • 全局变量的访问:

    • 主循环和一个或多个中断服务程序共享的标志位 (volatile bool flag;)。
    • 主循环和中断共享的计数器 (volatile uint32_t count;)。
    • 多个RTOS任务共享的状态变量或数据缓冲区指针。
  • 位带别名区的操作: 虽然对位带别名区的单次读写本身是原子的(因为映射到一次总线访问),但如果你需要对位带别名区代表的位进行“读-修改-写”操作(如置位、清零、翻转),这个组合操作就不是原子的,需要保护。

  • 外设寄存器的访问:

    • 非简单赋值: 对寄存器的某个字段进行读-修改-写操作。例如,配置 GPIO 的 MODER 寄存器时,你通常需要读取当前值,修改其中几个位,然后再写回去。如果这个过程中被中断打断,而中断也修改了同一个寄存器,那么中断的修改可能会被主程序的回写覆盖掉,或者主程序的修改可能覆盖中断的修改。
    • 多个相关寄存器的配置: 配置一个外设可能需要按特定顺序写入多个寄存器。需要确保这一系列写入作为一个整体不被中断打断,以保证配置逻辑的正确性。
  • 任务间通信的简单标志: 在RTOS中,任务之间使用简单的标志变量进行同步或状态传递。

  • 实现轻量级锁或信号量: 原子操作本身可以用来构建更复杂的同步原语。

  • 错误举例:

  • 多个中断服务例程(ISR)和主循环(或RTOS任务)会修改g_counter
  • 自增操作(g_counter++)需要是原子的
volatile uint32_t g_counter = 0;  // 共享计数器

// 错误实现:在并发环境下可能丢失更新
void non_atomic_increment(void) {
    g_counter++;  // 实际编译为多条汇编指令
}

问题分析

ldr r3, [pc, #offset]    ; 加载g_counter地址到r3
ldr r2, [r3]             ; 从内存加载值到r2 (步骤1)
adds r2, r2, #1          ; 增加r2的值 (步骤2)
str r2, [r3]             ; 存回内存 (步骤3)

如果中断发生在步骤1-3之间,会导致更新丢失。

四、 使用需要注意什么?

在 STM32 上实现和使用原子操作需要特别注意以下几点:

  1. 选择正确的实现方法:

    • 关中断/开中断: 这是最常用、最直接的方法。在访问共享资源前关闭全局中断 (__disable_irq()),访问完成后立即开启全局中断 (__enable_irq())。这是保证原子性的强力手段,但会增加中断延迟。
    • Cortex-M 硬件原子指令: Cortex-M3/M4/M7 及更高版本支持 LDREX (Load Exclusive) 和 STREX (Store Exclusive) 指令,可用于实现更复杂的原子操作(如原子加法、原子比较交换),而无需完全关中断。这需要内联汇编或使用编译器提供的原子内置函数(如 GCC 的 __atomic_* 系列函数, ARMCC/Keil 的 __ldrex, __strex)。这种方法中断延迟更小。
    • RTOS 提供的原子 API: 如果使用 RTOS(如 FreeRTOS, Zephyr, ThreadX),应优先使用其提供的专用原子操作 API(如 taskENTER_CRITICAL()/taskEXIT_CRITICAL(), atomic_set_bit, atomic_get 等)。这些 API 通常针对该 RTOS 进行了优化,并能正确处理任务调度。
    • 单条指令操作: 对于某些架构上本身就是单条指令的操作(如对齐的32位/64位加载/存储),在特定条件下可能是原子的。但在 STM32 编程中,不要依赖于此,除非你非常清楚硬件总线特性和编译器的行为。显式使用关中断或原子指令更安全。
  2. 临界区尽量短: 特别是使用关中断方法时,临界区(原子操作覆盖的代码区域)必须尽可能短。长时间的关中断会阻塞所有中断响应,破坏系统的实时性,可能导致事件丢失、通信超时、电机控制失步等严重问题。只把真正需要原子保护的共享资源访问放在临界区内。

  3. 避免在临界区内调用可能引起阻塞或调度的函数: 绝对不要在关中断的临界区内调用任何可能阻塞(如等待信号量、延时)或引起任务调度(如 RTOS 的 vTaskDelay, xQueueSend)的函数。这会导致系统死锁或行为异常。

  4. 嵌套临界区: 关中断/开中断通常是可嵌套的。确保开中断的次数与关中断的次数匹配。RTOS 的临界区 API 通常也支持嵌套。理解你所用机制的具体行为。

  5. 正确使用 volatile 对于在中断和主循环(或其他任务)之间共享的变量,必须使用 volatile 关键字声明。这告诉编译器:

    • 该变量的值可能在任何时候被意外改变(如被中断修改),禁止编译器对该变量的访问进行优化(如缓存到寄存器、删除“冗余”读取)。
    • 确保每次访问都真正从内存中读取或写入。
    • 注意: volatile 只解决编译器优化问题,它本身并不提供任何原子性或并发保护! 原子操作解决的是执行流被打断导致的并发访问问题。两者需要结合使用。
  6. 访问粒度: 原子操作通常用于保护对单个标量数据(整数、布尔值、指针)或的访问。如果需要保护对复杂数据结构(如结构体、数组)的访问,或者操作涉及多个相关的共享变量,原子操作往往不够用,需要使用互斥锁(Mutex)或信号量(Semaphore)等更重量级的同步机制。尝试用原子操作保护复杂操作通常会导致逻辑错误。

  7. 区分原子性与可见性: 原子操作确保操作不可分割,但不保证修改的结果立即对其他核心(如果有多核)或 DMA 可见。在 STM32(通常是单核)中,内存一致性模型相对简单,原子操作通常也保证了修改的可见性。但在涉及 DMA 时,可能需要显式的内存屏障指令 (__DSB(), __DMB()) 来确保 CPU 和 DMA 看到一致的内存视图。使用硬件原子指令 (LDREX/STREX) 通常隐含了必要的屏障。

  8. 优先使用库/RTOS 提供的机制: 除非有特殊需求或深入理解底层,否则应优先使用标准外设库/HAL 库中提供的位操作函数(它们内部可能实现了临界区保护)以及 RTOS 提供的同步原语(互斥锁、信号量、事件标志)或原子 API。它们通常经过了充分测试。
    实现
    下面我将通过具体代码示例展示在STM32开发中实现原子操作的几种常用方法。我们以一个共享的32位计数器(g_counter)的自增操作为例,演示不同实现方式。


五、举例实现

1. 关中断实现(最常用方法)

volatile uint32_t g_counter = 0;

void atomic_increment_irq(void) {
    __disable_irq();      // 关全局中断 (Cortex-M的CPSID I指令)
    g_counter++;
    __enable_irq();       // 开中断 (CPSIE I指令)
}

// 使用CMSIS的函数版本(更规范)
#include "core_cmFunc.h"
void atomic_increment_cmsis(void) {
    uint32_t primask = __get_PRIMASK();  // 保存当前中断状态
    __disable_irq();
    g_counter++;
    __set_PRIMASK(primask);  // 恢复之前的中断状态
}

优点:简单可靠,适用于所有Cortex-M系列
缺点:增加中断延迟,临界区内不能调用阻塞函数


2. LDREX/STREX硬件原子操作(无锁实现)

volatile uint32_t g_counter = 0;

void atomic_increment_ldrex(void) {
    uint32_t res;
    do {
        uint32_t val = __LDREXW(&g_counter);  // 独占加载
        val++;
        res = __STREXW(val, &g_counter);      // 独占存储
    } while(res != 0);  // 若存储失败(被中断),重试
}

原理

  1. LDREX标记内存地址为独占访问
  2. 修改值
  3. STREX尝试存储:
    • 成功返回0
    • 若期间有其他访问(如中断修改),返回1并重试

优点:不关闭中断,实时性好
缺点:代码稍复杂,Cortex-M3/M4/M7+支持


3. 编译器内置原子操作(推荐方法)

volatile uint32_t g_counter = 0;

// GCC编译器 (ARM GCC 10+)
void atomic_increment_gcc(void) {
    __atomic_add_fetch(&g_counter, 1, __ATOMIC_SEQ_CST);
}

// IAR编译器
void atomic_increment_iar(void) {
    __atomic_add_fetch(&g_counter, 1, __ATOMIC_SEQ_CST);
}

// Keil MDK (AC6)
#include <cmsis_compiler.h>
void atomic_increment_keil(void) {
    __atomic_add_fetch(&g_counter, 1, __ATOMIC_SEQ_CST);
}

内存序参数

  • __ATOMIC_RELAXED:无顺序保证
  • __ATOMIC_ACQUIRE:本操作前的读写不能重排到后面
  • __ATOMIC_RELEASE:本操作后的读写不能重排到前面
  • __ATOMIC_SEQ_CST:严格顺序一致性(最安全)

优点:可移植性强,编译器自动优化为最佳指令


4. RTOS专用API(FreeRTOS示例)

#include "FreeRTOS.h"
#include "task.h"

volatile uint32_t g_counter = 0;

void atomic_increment_rtos(void) {
    taskENTER_CRITICAL();   // 进入临界区(可能关中断或调度)
    g_counter++;
    taskEXIT_CRITICAL();    // 退出临界区
}

// FreeRTOS的原子操作扩展
#include "atomic.h"
Atomic_t counter = ATOMIC_INIT(0);

void better_rtos_atomic(void) {
    atomic_add(&counter, 1);  // 真正的原子操作API
}

5. 位带操作(针对单个位的原子操作)

// 位带别名地址计算(STM32F4示例)
#define BITBAND(addr, bit) ((0x42000000 + ((uint32_t)(addr)-0x40000000)*32 + (bit)*4))

volatile uint32_t *flags_reg = (volatile uint32_t*)0x40020014; // GPIO ODR
uint32_t *flag_bit0 = (uint32_t*)BITBAND(flags_reg, 0); // 位0别名

void set_flag_atomic(bool state) {
    *flag_bit0 = state;  // 单指令修改位
}

特点

  • 对位带别名区的写操作是原子的
  • 只适用于特定内存区域(外设寄存器和SRAM位带区)

关键注意事项代码示例

1. 临界区中调用阻塞函数(错误)

void dangerous_function(void) {
    __disable_irq();
    g_counter++;
    HAL_Delay(10);  // 在关中断时调用阻塞函数!
    __enable_irq(); 
}  // 将导致系统死锁

2. 变量未声明volatile

uint32_t counter;  // 缺少volatile!

void isr_handler(void) {
    counter++;  // 编译器可能优化掉"冗余"操作
}

3. 复杂结构体保护不足

struct SensorData {
    uint32_t value;
    uint8_t status;
} sensor;

// 错误:原子操作无法保护整个结构体
void update_sensor(void) {
    __disable_irq();
    sensor.value = read_adc();
    sensor.status = (sensor.value > 1000) ? 1 : 0;
    __enable_irq();
}
// 正确:使用互斥锁
osMutexId_t sensor_mutex;

void safe_update_sensor(void) {
    osMutexAcquire(sensor_mutex, osWaitForever);
    sensor.value = read_adc();
    sensor.status = (sensor.value > 1000) ? 1 : 0;
    osMutexRelease(sensor_mutex);
}

方法选择建议

场景 推荐方法
简单应用(无RTOS) 关中断(__disable_irq/__enable_irq)
实时性要求高 LDREX/STREX或编译器原子操作
RTOS环境 RTOS提供的原子API或临界区函数
单个位操作 位带别名
新项目开发 编译器内置原子操作(__atomic_xxx)

最佳实践:优先使用编译器内置原子操作或RTOS API,其次考虑关中断方案。对于外设寄存器操作,STM32 HAL库的函数(如HAL_GPIO_WritePin())内部已实现必要的原子保护。
总结:

在 STM32 开发中,原子操作是应对并发访问(主要是主循环与中断、任务与中断、任务与任务之间)共享简单资源(标志、计数器、寄存器位)导致竞态条件的关键技术。其主要通过关中断或利用 Cortex-M 的硬件原子指令来实现。使用时务必牢记:**临界区要短、避免阻塞、正确使用 volatile、区分原子与锁的适用场景、优先使用库/RTOS 机制。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐