STM32 裸机并发与互斥:从临界区到生产者消费者、死锁与 ARM/x86 对比

本文把 STM32 裸机开发中常见的中断、DMA、共享变量、临界区、环形缓冲区和外设资源竞争,和 408 操作系统、系统编程中常见的生产者消费者、信号量、互斥锁、死锁、哲学家进餐、银行家算法联系起来。

STM32 是本文的主线。 408 和 x86 的内容用于建立更完整的知识地图、帮助理解抽象概念和进行对比,不应直接把操作系统中的实现方式原封不动地套到 STM32 裸机程序中。

0. 先看结论

学习这部分内容时,最容易混淆的是下面几组概念:

容易混淆的概念更准确的区分
裸机没有线程,所以没有并发没有线程不等于没有并发。主循环、中断和 DMA 仍可能交错访问同一资源。
volatile 可以保证变量安全volatile 只约束编译器不要随意缓存访问,不提供互斥,也不保证复合操作原子。
关中断就是万能锁关中断主要阻止普通可屏蔽中断抢占 CPU,不能阻止 DMA,也不能屏蔽 NMI。
二值信号量就是 mutex二值信号量可以表达“资源有/无”,但 mutex 还包含所有权和递归/优先级继承等语义。
UART 接收和生产者消费者完全相同抽象关系相似,但裸机通常没有阻塞等待和调度器,需要自己处理缓冲区满、丢包和轮询。
STM32 的死机就是操作系统死锁硬件 BUSY、优先级阻塞、死循环和经典多任务死锁可能表现相似,但原因和处理方式不同。
ARM 和 x86 的并发模型完全不同生产者消费者、死锁等是通用并发模型;两种架构的主要差异在中断、原子指令、内存系统、缓存和运行环境。

整篇文章可以先记住这条主线:

识别执行流
    -> 找出共享资源
    -> 判断读写操作是否可被打断
    -> 设计所有权和数据流向
    -> 选择临界区、原子操作、缓冲区或 RTOS 同步机制
    -> 缩短阻塞时间
    -> 为溢出、超时和故障设计恢复路径

1. 为什么 STM32 裸机也存在并发

1.1 并发不等于同时执行

在操作系统中,多个线程可能由调度器交替运行;在多核 x86 上,多个 CPU 核还可能真正同时执行。

STM32F4 裸机通常是单核 Cortex-M4,CPU 在某一个时刻只能执行一条指令,但执行顺序可能发生交错:

主循环执行一半
    -> USART 接收中断到来
    -> CPU 暂停主循环,进入 USART ISR
    -> ISR 修改共享变量
    -> ISR 返回
    -> 主循环从原来的位置继续执行

这种“被插入”的执行方式已经足以产生竞态条件。因为主循环原本以为共享资源没有变化,但中断可能在任意一条可中断的指令之间改变它。

因此可以这样概括:

STM32 裸机并发
= 主循环与中断的交错
  + DMA 等硬件模块在后台访问内存
  + 必要时还包括 SysTick、NMI、Fault 等异常

1.2 STM32 中常见的执行流

执行流触发来源典型工作是否可能访问共享资源
主循环main() 中的 while(1)解析命令、计算、刷新显示、控制外设
普通外设中断USART、TIM、ADC、EXTI 等事件收字节、保存捕获值、置标志
SysTickCortex-M 内核定时器毫秒节拍、软件超时、任务轮询
DMA外设请求触发 DMA 控制器ADC 数据搬到 RAM、USART 数据搬运是,且不需要 CPU 执行 ISR
NMI时钟失效或特殊故障紧急故障处理是,普通关中断挡不住
HardFault 等异常非法访问、总线错误、执行错误记录错误或复位可能

1.3 信息流示意

外部信号或外设事件

片上外设状态寄存器

是否产生中断请求

NVIC 仲裁

向量表定位 ISR

CPU 暂停主循环并执行 ISR

ISR 读寄存器/写共享状态

返回主循环

DMA 请求

DMA 直接搬运到 RAM

半传输/全传输事件

主循环

读取共享状态或 DMA 缓冲区

解析、计算和控制

图中有两条容易混淆的路径:

  1. 中断路径:外设提出中断请求,NVIC 通知 CPU,CPU 执行 ISR。
  2. DMA 路径:外设请求 DMA,DMA 控制器直接在外设和内存之间搬运数据,CPU 不需要逐字节参与。

所以:

关中断可以阻止或延迟 ISR 执行,
但不能自动阻止 DMA 正在进行的内存写入。

这也是为什么 ADC + DMA 不能只靠 __disable_irq() 解决数据一致性问题。

2. 从 C 代码看到硬件时序

2.1 一句 C 代码不一定对应一条机器指令

下面的代码看起来只有一句:

counter++;

在硬件层面可以抽象成:

1. 从内存读取 counter
2. 把 counter 放入 CPU 寄存器
3. ALU 执行加一
4. 把结果写回内存

如果主循环和中断都修改同一个变量,可能发生下面的时序:

主循环读取 counter = 10
中断到来
ISR 读取 counter = 10
ISR 加一并写回 11
ISR 返回
主循环把自己计算出的 11 写回

两次加法最后只剩一次,结果从预期的 12 变成了 11。这就是丢失更新

2.2 原子操作和复合操作

“原子”表示从其他执行流看来,这个操作不可再分,不能看到中间状态。

在 STM32F407 的 Cortex-M4 上,某些满足对齐要求的单次 8 位、16 位或 32 位读写通常可以作为一个不可拆分的总线访问来理解。但下面这些仍然是复合操作:

counter++;
counter = counter + 1;
if (flag == 0) 
{
    flag = 1;
}

它们分别包含读、判断或计算、写回等多个步骤,不能因为写成一行 C 代码就自动获得互斥性。

还要注意:

  1. 数据必须满足处理器的对齐要求。
  2. 变量宽度不能超过处理器一次可靠访问的能力。
  3. “单变量单次读写”安全,不代表“多个变量组成的状态”安全。
  4. 寄存器的读写副作用要看参考手册,不能只依据 C 语言直觉。

2.3 volatile 做了什么

volatile 的作用是告诉编译器:

这个对象可能被当前代码之外的因素改变,
每次使用时都要真正执行内存访问,不要擅自缓存或删除访问。

典型对象包括:

volatile uint8_t uart_rx_ready;
volatile uint32_t systick_ms;
volatile uint16_t adc_dma_buffer[64];

但是 volatile 不等于锁:

问题volatile 能解决吗原因
ISR 修改标志,主循环能看到吗通常能改善可见性编译器会重新读取内存
counter++ 会不会丢更新不能保证++ 仍然是读-改-写
主循环复制数组时 ISR 能否插入不能阻止它不改变中断调度
DMA 会不会覆盖正在读取的数组不能阻止DMA 是硬件访问者
periodhigh 是否属于同一次捕获不能保证需要快照、序列号或所有权
外设寄存器访问是否有正确顺序不能单独保证还要遵循参考手册和必要的屏障

可以把两者区分成:

volatile:让编译器承认“这个值可能在外部变化”
互斥/临界区:让执行流之间不要在错误的时刻同时修改它
内存屏障:约束处理器或编译器对访问顺序的重排

3. 共享资源和竞态条件

3.1 什么叫共享资源

只要两个执行流都可能访问某个对象,并且至少有一个执行流会修改它,就应该把它列入共享资源清单。

共享资源不只是普通变量:

资源类别例子
标志和计数器rx_readyevent_count、系统 tick
多字段状态周期、高电平时间、状态码、更新时间
缓冲区UART 接收数组、ADC DMA 数组、日志队列
环形缓冲区元数据headtail、满/空状态
外设寄存器GPIO ODR、USART 数据寄存器、DMA 控制寄存器
外设本身同一条 I2C、SPI、UART 总线
软件资源内存池、设备句柄、打印端口

3.2 竞态条件的本质

竞态条件是指程序最终结果依赖于执行顺序,而这个顺序又会受到中断、DMA、调度或总线时序影响。

常见表现:

偶尔出现,单步调试时消失;
降低波特率后问题变少;
加一条 printf 后问题改变;
高负载或高频输入时更容易出现;
变量看起来“明明加了 volatile”仍然错误。

printf 改变了执行时序,所以问题暂时消失并不代表问题解决了,反而常常说明存在时序竞态。

3.3 互斥和同步不是一回事

这两个词要分开理解:

互斥:同一时间谁能访问资源?
同步:一个执行流什么时候通知另一个执行流继续?

例如:

主循环读取 UART 环形缓冲区时,不能和 ISR 同时修改同一个下标

这是互斥问题。

USART 收到一行命令后,通知主循环“可以解析了”

这是同步问题。

一个完整设计经常同时需要两者:

ISR 写入缓冲区并发出事件通知
主循环收到通知后,在必要的短临界区内取走数据

4. 临界区和关中断

4.1 临界区是什么

临界区是访问共享资源时,必须避免被其他执行流打断或同时修改的一小段代码。

典型流程是:

保存进入前的中断状态
    -> 关闭需要屏蔽的中断
    -> 只完成必要的共享数据读写
    -> 恢复原来的中断状态
    -> 在临界区外做计算和业务处理

发现共享资源

明确谁读谁写

划出最短关键读写

保存中断状态

屏蔽需要防止的 IRQ

复制快照或交换所有权

恢复原状态

临界区外解析和计算

4.2 __disable_irq() 具体屏蔽什么

在 Cortex-M 中,CMSIS 的:

__disable_irq();

通常通过设置 PRIMASK,屏蔽大多数普通可屏蔽中断;而:

__enable_irq();

通常清除 PRIMASK,恢复普通中断响应。

它的边界如下:

机制主要作用一般能屏蔽不能依赖它屏蔽
PRIMASK全局屏蔽普通可屏蔽 IRQUSART、TIM、ADC、DMA IRQ、SysTickNMI、Reset,通常也不能屏蔽 HardFault
BASEPRI屏蔽低于某优先级的 IRQ指定优先级范围内的普通 IRQ更高优先级 IRQ、NMI、Reset
NVIC_DisableIRQ()关闭某一路外设 IRQ指定 USART/TIM/ADC IRQDMA 硬件搬运、其他 IRQ
FAULTMASK屏蔽更多 Fault 类异常大多数异常NMI、Reset

这里要特别区分:

屏蔽中断请求 != 停止外设工作
屏蔽中断请求 != 停止 DMA
屏蔽中断请求 != 清除外设标志

例如 DMA 仍然可以把 ADC 数据写到 RAM,只是 DMA 完成中断可能暂时不能得到 CPU 处理。

4.3 推荐的嵌套安全写法

简单写法:

__disable_irq();
critical_update();
__enable_irq();

在没有嵌套调用时可能能工作,但它有一个问题:如果调用这个函数之前外层已经关闭了中断,函数返回时直接 __enable_irq() 会把外层保护提前解除。

更稳妥的写法是保存并恢复原状态:

uint32_t primask;

primask = __get_PRIMASK();
__disable_irq();

/* 只做很短的共享数据操作 */
shared_value = new_value;

__set_PRIMASK(primask);

执行逻辑:

原来开中断 -> 保存 0 -> 关闭 -> 恢复 0 -> 仍然开中断
原来关中断 -> 保存 1 -> 关闭 -> 恢复 1 -> 仍然关中断

这不是为了让代码看起来复杂,而是为了不破坏调用者原本的中断状态。

4.4 只屏蔽某一路 IRQ

如果共享资源只会被 USART2 中断修改,可以考虑:

NVIC_DisableIRQ(USART2_IRQn);
/* 访问只与 USART2 ISR 共享的短数据 */
NVIC_EnableIRQ(USART2_IRQn);

但要先确认:

  1. 没有其他 ISR 也会访问该资源。
  2. 不能因为关闭 NVIC 就认为 USART 硬件停止接收。
  3. 关闭期间如果硬件继续收到字节,可能发生溢出。
  4. 恢复中断后仍要正确处理已经挂起的状态标志。

全局关中断简单,但影响范围大;只关一路更精准,但要求工程师真正知道共享资源的来源。

4.5 临界区里不应该做什么

临界区应只完成“搬走数据、复制快照、交换指针、更新少量状态”。

不建议放入:

printf 或长串口发送
delay_ms
浮点运算和大循环滤波
等待 I2C/SPI 外设完成
等待 DMA 完成
复杂协议解析
动态内存分配

原因是临界区越长,其他中断等待越久,实时性越差,可能造成:

串口丢字节
定时器抖动
ADC 采样处理不及时
SysTick 延迟
高优先级保护动作响应变慢
看门狗误判或系统异常

5. NMI、HardFault 和普通中断的边界

5.1 NMI 是什么

NMI 是 Non-Maskable Interrupt,非屏蔽中断。它是 Cortex-M 为紧急硬件事件保留的特殊入口,普通的 PRIMASKBASEPRI 不能像屏蔽普通 IRQ 那样屏蔽它。

常见来源取决于具体芯片和配置,可能包括:

来源含义
时钟安全系统 CSSHSE 外部时钟失效时报告故障
特定外部故障输入部分器件或板级设计支持
ECC 或安全相关错误具体由芯片型号和参考手册决定
其他特殊硬件错误需要查对应 STM32 参考手册

NMI 处理原则:

越短越好;
越少依赖其他外设越好;
不要 delay;
不要做复杂 printf;
尽量记录最小故障信息;
必要时进入安全状态或复位。

5.2 HardFault 和 NMI 不要混为一谈

类型更适合怎样理解
NMI硬件主动报告紧急事件,普通中断屏蔽不住
HardFaultCPU 发现严重执行或访问错误后的异常入口
普通 IRQ外设通知 CPU“有事件需要处理”,可以被屏蔽或延迟

__disable_irq() 不是“CPU 完全停止响应任何异常”。如果程序访问非法地址、栈溢出或发生严重总线错误,HardFault 仍可能发生。

6. STM32 场景一:USART 中断接收和生产者消费者

6.1 先确定数据流向

USART 接收的典型数据流是:

片外模块 TX
    -> STM32 GPIO 复用引脚
    -> USART 接收器
    -> 接收数据寄存器
    -> RXNE 中断或 DMA
    -> RAM 缓冲区
    -> 主循环/任务解析

这里的“生产者”和“消费者”是:

生产者:USART ISR 或 DMA,把新收到的字节放入缓冲区
消费者:主循环或 RTOS 任务,从缓冲区取字节并解析协议

6.2 为什么不能直接在 ISR 中解析完整命令

接收中断应该尽快返回。若在 ISR 中完成复杂字符串查找、浮点转换或执行电机控制,可能导致:

下一个字节到来时 ISR 还没有退出;
其他定时器中断被延迟;
接收寄存器溢出;
协议解析和硬件响应互相纠缠;

更合理的分工是:

位置工作
USART ISR读数据寄存器、放进缓冲区、记录溢出、置事件
主循环/任务从缓冲区取数据、按状态机组帧、校验和执行命令
发送模块在独立的发送缓冲区中排队,必要时使用 TXE/TC 中断或 DMA

6.3 一个适合裸机的单生产者单消费者环形缓冲区

下面的代码展示的是逻辑重点,不依赖具体初始化代码。假设只有 USART ISR 写入,主循环读取:

#include <stdint.h>

#define UART_RX_RING_SIZE 128u

static volatile uint8_t  g_uart_rx_buf[UART_RX_RING_SIZE];
static volatile uint16_t g_uart_rx_head = 0u;
static volatile uint16_t g_uart_rx_tail = 0u;
static volatile uint32_t g_uart_rx_overflow = 0u;

static uint16_t uart_next_index(uint16_t index)
{
    index++;
    if (index >= UART_RX_RING_SIZE) 
    {
        index = 0u;
    }
    return index;
}

/* 只由 USART2 ISR 调用:生产一个字节 */
static void uart_rx_push_from_isr(uint8_t data)
{
    uint16_t next_head;

    next_head = uart_next_index(g_uart_rx_head);

    if (next_head == g_uart_rx_tail) 
    {
        /*
         * 缓冲区满。
         * 这里选择丢弃新数据,也可以根据协议选择覆盖旧数据。
         */
        g_uart_rx_overflow++;
        return;
    }

    g_uart_rx_buf[g_uart_rx_head] = data;
    /*
     * 先写数据,再推进 head。
     * 消费者看到新的 head 时,数据已经写入。
     */
    g_uart_rx_head = next_head;
}

/* 只由主循环调用:消费一个字节 */
static int uart_rx_pop(uint8_t *data)
{
    uint16_t tail;

    if (data == 0) 
    {
        return 0;
    }

    tail = g_uart_rx_tail;
    if (tail == g_uart_rx_head) 
    {
        return 0;
    }

    *data = g_uart_rx_buf[tail];
    g_uart_rx_tail = uart_next_index(tail);
    return 1;
}

这段设计为什么可以减少临界区:

ISR 只修改 head;
main 只修改 tail;
双方读取对方的下标来判断满或空;

这是单生产者、单消费者模型。它不是因为 volatile 自动安全,而是因为我们明确设计了每个下标的所有权,并且下标读写满足处理器的单次访问条件。

如果出现下面情况,就需要重新评估:

多个 ISR 同时写入;
多个主循环任务同时读取;
下标宽度超过一次可靠访问宽度;
需要同时修改 count、head、tail 多个字段;
消费者要复制一段连续区域,而生产者可能覆盖这段区域。

此时可以使用短临界区、队列封装或更明确的所有权协议。

6.4 处理一行命令的状态机

环形缓冲区解决“字节暂存”,但不解决“协议边界”。主循环还需要状态机:

#define CMD_LINE_SIZE 64u

static char g_cmd_line[CMD_LINE_SIZE];
static uint16_t g_cmd_length = 0u;

static void command_parser_poll(void)
{
    uint8_t ch;

    while (uart_rx_pop(&ch)) 
    {
        if (ch == '\r') 
        {
            /*
             * 忽略回车。
             * 有些串口工具发送的是 "\r\n",只保留换行作为结束标志。
             */
            continue;
        }

        if (ch == '\n') 
        {
            g_cmd_line[g_cmd_length] = '\0';
            command_execute(g_cmd_line);
            g_cmd_length = 0u;
            continue;
        }

        if (g_cmd_length + 1u < CMD_LINE_SIZE) 
        {
            g_cmd_line[g_cmd_length] = (char)ch;
            g_cmd_length++;
        } 
        else 
        {
            /*
             * 行太长,丢弃当前行,等待下一次换行重新同步。
             */
            g_cmd_length = 0u;
        }
    }
}

这里 ch == '\r' 时使用空语句或 continue,含义是“这个字符不参与命令内容,也不触发任何操作”。它常用于兼容串口工具发送的回车换行组合。

6.5 环形缓冲区的满和空

若使用一个空槽区分满和空:

空:head == tail
满:next(head) == tail
可用容量:数组长度 - 1

例如数组长度为 128,实际可存 127 个字节。若必须使用全部 128 个字节,可以增加:

count;
full 标志;
生产者和消费者之间的计数信号量;

但共享字段越多,互斥要求越复杂。

缓冲区满时要提前决定策略:

策略适用情况风险
丢弃新数据新数据可以重发,旧数据更重要需要记录溢出
覆盖旧数据只关心最新状态,例如传感器可能丢失完整命令
停止接收协议允许流控需要硬件或软件流控
扩大缓冲区突发数据短、内存充足不能解决持续生产大于消费

核心判断不是“数组够不够大”,而是:

长期平均生产速度 <= 长期平均消费速度

如果生产速度长期大于消费速度,任何有限缓冲区最终都会满。

7. STM32 场景二:TIM 输入捕获的快照一致性

7.1 输入捕获到底在测什么

定时器输入捕获的本质是:

外部边沿到来
    -> 把当前 CNT 的值锁存到 CCRx
    -> 产生捕获事件或中断

如果计数器频率是 1 MHz:

计数器每 1 us 加一
CCR 中的 100 表示经过约 100 us

需要注意,1 MHz 不是定时器必须使用的固定频率。它只是为了让计数值和微秒时间直接对应,便于计算和调试。

计数频率一般为:

f_cnt = f_TIM / (PSC + 1)

其中:

参数含义
f_TIM定时器实际输入时钟
PSC预分频寄存器值
f_cntCNT 的递增频率
1 / f_cnt一个计数单位代表的时间

在 STM32F4 中,f_TIM 要根据 APB 总线分频和定时器时钟规则确定,不能只看系统主频。常见配置下 APB2 为 84 MHz、分频不为 1 时,APB2 定时器时钟可能为 168 MHz,此时:

PSC = 168 - 1 = 167
f_cnt = 168 MHz / 168 = 1 MHz

7.2 只用 IC1 和 PWM 输入模式的区别

如果只测周期:

每个上升沿捕获一次
两次捕获值相减

只需要 IC1。

如果只测高电平脉宽,也可以在软件中切换同一个通道的捕获极性:

先捕获上升沿得到 t1
改成下降沿
捕获下降沿得到 t2
高电平时间 = t2 - t1

如果要连续同时测量周期和高电平时间,可以启用 PWM 输入模式:

IC1:直接映射 TI1,捕获上升沿,得到周期
IC2:间接映射 TI1,捕获下降沿,得到高电平时间
从模式 Reset:每个上升沿自动把 CNT 复位

这时硬件完成:

上升沿 -> 锁存周期并复位 CNT
下降沿 -> 锁存高电平持续时间

中断只需读取 CCR1 和 CCR2,不必在 ISR 中做捕获值减法。

7.3 为什么多个捕获值要做一致快照

假设 ISR 更新:

g_period_counts
g_high_counts
g_capture_update

主循环如果这样读取:

period = g_period_counts;
high = g_high_counts;

中断可能恰好发生在两次读取之间,造成:

period 来自第 N 个周期;
high 来自第 N+1 个周期;

即使每个变量的单次读写都没有被拆开,两个变量的组合仍然不一致。

一种简单的快照方法:

volatile uint16_t g_period_counts = 0u;
volatile uint16_t g_high_counts = 0u;
volatile uint8_t  g_capture_update = 0u;

static int capture_take_snapshot(uint16_t *period, uint16_t *high)
{
    uint32_t primask;
    uint8_t updated;

    if ((period == 0) || (high == 0)) 
    {
        return 0;
    }

    primask = __get_PRIMASK();
    __disable_irq();

    updated = g_capture_update;
    if (updated != 0u) 
    {
        *period = g_period_counts;
        *high = g_high_counts;
        g_capture_update = 0u;
    }

    __set_PRIMASK(primask);
    return updated != 0u;
}

退出临界区后再计算:

uint16_t period;
uint16_t high;

if (capture_take_snapshot(&period, &high)) 
{
    if ((period != 0u) && (high <= period)) 
    {
        float frequency_hz = 1000000.0f / (float)period;
        float duty_percent = 100.0f * (float)high / (float)period;

        /* 在临界区外显示、打印或执行控制 */
        use_capture_result(frequency_hz, duty_percent);
    }
}

这段代码体现了一个非常通用的结构:

临界区内:只复制相关变量并清除事件
临界区外:做除法、浮点计算、协议处理和输出

若采样频率很高,单个 update_flag 仍可能丢事件。此时需要:

事件计数器;
捕获值队列;
DMA;
只保留最新值的状态模型;

选择哪一种取决于业务是否要求“每个周期都不能丢”。

8. STM32 场景三:ADC + DMA 的生产者消费者

8.1 DMA 改变了共享资源模型

ADC 采样时,常见路径是:

ADC 转换
    -> ADC 数据寄存器
    -> DMA 读取数据寄存器
    -> DMA 写入 RAM 数组
    -> 半传输或全传输事件
    -> CPU 处理已经完成的区域

这里 DMA 是生产者,CPU 是消费者:

前半区写完

后半区写完

ADC 转换结果

DMA 写入循环数组

写到哪里

HT 事件

TC 事件

CPU 获得前半区所有权

CPU 获得后半区所有权

处理前半区

处理后半区

处理完成,等待 DMA 下一轮

8.2 为什么关中断不能保护 DMA buffer

__disable_irq() 影响的是 CPU 是否响应普通中断。DMA 不需要 CPU 执行一条一条的搬运指令,它可以作为总线主设备完成外设到内存的写入。

因此下面的代码仍有风险:

__disable_irq();
for (i = 0; i < 64; i++) 
{
    sum += adc_buffer[i];
}
__enable_irq();

即使 CPU 没有被 ISR 打断,DMA 仍可能在循环中改写 adc_buffer,于是 sum 可能混合了两个采样时刻的数据。

正确方向是管理所有权:

DMA 正在写哪一半,CPU 就不要读哪一半;
DMA 写完一半后,通过 HT/TC 事件把这一半交给 CPU;
CPU 处理完成后,等待 DMA 下一轮重新使用。

8.3 双半缓冲的伪代码

#define ADC_BUFFER_LENGTH 64u

static volatile uint16_t g_adc_buffer[ADC_BUFFER_LENGTH];
static volatile uint8_t g_adc_half_ready = 0u;
static volatile uint8_t g_adc_full_ready = 0u;
static volatile uint32_t g_adc_dma_error = 0u;

void DMA2_Stream0_IRQHandler(void)
{
    if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_HTIF0) != RESET) 
    {
        DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_HTIF0);

        /*
         * DMA 已经完成前 32 个采样。
         * CPU 可以在主循环中处理 [0, 32)。
         */
        g_adc_half_ready = 1u;
    }

    if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TCIF0) != RESET) 
    {
        DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TCIF0);

        /*
         * DMA 已经完成后 32 个采样。
         * CPU 可以在主循环中处理 [32, 64)。
         */
        g_adc_full_ready = 1u;
    }

    if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TEIF0) != RESET) 
    {
        DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TEIF0);
        g_adc_dma_error = 1u;
    }
}

static void adc_dma_process_poll(void)
{
    uint8_t half_ready;
    uint8_t full_ready;

    /*
     * 若两个标志还可能被 ISR 同时改变,可以用很短临界区一次取走。
     * 这里只取走标志,不在临界区中处理数组。
     */
    uint32_t primask = __get_PRIMASK();
    __disable_irq();
    half_ready = g_adc_half_ready;
    full_ready = g_adc_full_ready;
    g_adc_half_ready = 0u;
    g_adc_full_ready = 0u;
    __set_PRIMASK(primask);

    if (half_ready != 0u) 
    {
        adc_process_block((const uint16_t *)&g_adc_buffer[0], 32u);
    }

    if (full_ready != 0u) 
    {
        adc_process_block((const uint16_t *)&g_adc_buffer[32], 32u);
    }
}

注意:真实工程中还需要确认 DMA 配置的 Stream、Channel、标志位和中断向量是否与芯片型号一致。上面重点展示的是并发逻辑,不是替代数据手册的完整初始化代码。

8.4 ADC DMA 的处理速度约束

如果 DMA 写入速度长期大于 CPU 处理速度,即使使用双缓冲也会出现覆盖:

DMA 写完 A
CPU 处理 A
DMA 写 B
CPU 处理 B
DMA 又要写 A,但 CPU 还没有处理完 A

需要从系统层面调整:

降低采样率;
增大缓冲区;
减少每个样本的计算量;
使用硬件触发;
改用更高效的滤波算法;
提高 CPU 或 DMA 配置效率;

F407 通常不需要像 F7/H7 那样重点处理数据缓存一致性;在带 D-Cache 的 Cortex-M7 芯片上,还要考虑 cache clean/invalidate 和内存屏障。

9. STM32 场景四:SysTick、软件计时和事件计数

9.1 系统 tick 是状态还是事件

一个毫秒计数器:

volatile uint32_t g_ms_tick = 0u;

void SysTick_Handler(void)
{
    g_ms_tick++;
}

主循环可以用差值实现不会受无符号回绕影响的超时判断:

uint32_t start = g_ms_tick;

if ((uint32_t)(g_ms_tick - start) >= 100u) 
{
    /* 经过至少 100 ms */
}

这里主循环只是单次读取 g_ms_tick,通常不需要为了读取一个对齐的 32 位值而长时间关中断;但如果主循环也要修改它,就需要保护读-改-写整体。

9.2 0/1 标志可能丢事件

假设 1 ms 内中断发生三次:

ISR 第一次:ready = 1
ISR 第二次:ready = 1
ISR 第三次:ready = 1
主循环读取一次并清零

主循环只知道“至少发生过一次”,不知道发生了三次。

如果每个事件都重要,使用计数器:

volatile uint32_t g_event_count = 0u;

void event_isr(void)
{
    g_event_count++;
}

static uint32_t event_take_one(void)
{
    uint32_t has_event = 0u;
    uint32_t primask = __get_PRIMASK();

    __disable_irq();
    if (g_event_count != 0u) 
    {
        g_event_count--;
        has_event = 1u;
    }
    __set_PRIMASK(primask);

    return has_event;
}

它与 408 中计数信号量的思想相似:

每来一个事件,计数加一;
每消费一个事件,计数减一;

但裸机的这个计数器通常没有任务阻塞、唤醒和调度功能,所以它只是“计数信号量的核心数据思想”,不是完整 RTOS 信号量。

10. STM32 场景五:GPIO 的 ODR 读改写和 BSRR

10.1 ODR 读改写为什么可能冲突

假设主循环想设置 PF0,ISR 想设置 PF1:

主循环读取 ODR
ISR 读取 ODR
ISR 修改 PF1 并写回
主循环修改 PF0 并写回

主循环最后写回的旧副本可能覆盖 ISR 对 PF1 的修改。

这就是典型的读-改-写竞态:

读整个端口
    -> 修改其中一位
    -> 写回整个端口

10.2 BSRR 的意义

STM32 GPIO 的 BSRR 允许通过一次写操作完成置位或复位:

GPIOF->BSRR = (1u << 0);        /* PF0 置高 */
GPIOF->BSRR = (1u << (0 + 16)); /* PF0 置低 */

低 16 位写 1 通常表示置位,高 16 位写 1 通常表示复位。标准库的 GPIO_SetBits()GPIO_ResetBits() 也会使用适合的置位/复位寄存器机制,具体以芯片库头文件为准。

BSRR 的优点是:

不用先读取整个 ODR;
不需要修改无关的 GPIO 位;
单次寄存器写完成一个位的置位或复位;
减少主循环与 ISR 之间的读改写覆盖。

但它不是万能锁:

  1. 两个执行流如果同时写同一个引脚,最终结果仍取决于先后顺序。
  2. 如果业务要求多个引脚必须组成同一时刻的总线值,仍需统一所有权或临界区。
  3. GPIO 寄存器的位语义要以具体 STM32 参考手册为准。

11. STM32 场景六:I2C/SPI 等外设资源的互斥

11.1 共享总线和共享变量不是同一种资源

如果多个任务都访问同一个 I2C 总线,冲突不只是某个变量被改写:

任务 A 发出设备地址和寄存器地址
任务 B 插入自己的地址和数据
总线上的事务被拼接,两个设备都可能收到错误序列。

因此要保护的是一次完整总线事务

发送起始条件
    -> 发送设备地址
    -> 发送寄存器地址
    -> 读写数据
    -> 检查 ACK
    -> 发送停止条件

如果只是裸机主循环加中断,常见做法不是直接拿 RTOS mutex,而是:

让 I2C 驱动拥有总线;
其他模块提交事务请求;
驱动用状态机逐步完成;
完成后通过标志或回调通知请求方。

如果使用 FreeRTOS,多个任务共享 I2C/SPI 时通常使用 mutex:

任务拿 mutex
    -> 完成完整事务
    -> 释放 mutex

ISR 不应该调用会阻塞等待的普通 mutex API。

11.2 总线锁和中断屏蔽的区别

关中断:防止 CPU 被普通 ISR 抢占
mutex:防止多个任务同时进入一段资源访问代码
驱动状态机:通过所有权和事件流转管理硬件事务

如果 ISR 也会直接操作同一 I2C 外设,仅仅给任务加 mutex 还不够,因为 ISR 不会遵守任务锁。此时要重新设计外设所有权,尽量让一个驱动上下文独占外设。

12. 生产者消费者模型:从 操作系统(408)学习 到 STM32

12.1 操作系统(408) 中的抽象模型

生产者消费者模型包含:

生产者:生成数据
缓冲区:暂存数据
消费者:取出并处理数据

有界缓冲区存在两个约束:

缓冲区为空时,消费者不能取;
缓冲区已满时,生产者不能再放。

经典操作系统教材常用三个同步对象:

empty:空槽数量,初始值为 N
full:已有数据数量,初始值为 0
mutex:保护缓冲区本身,初始值为 1

生产者逻辑:

P(empty)
P(mutex)
把数据放入缓冲区
V(mutex)
V(full)

消费者逻辑:

P(full)
P(mutex)
从缓冲区取出数据
V(mutex)
V(empty)

其中 P 可以理解为等待并减少信号量,V 可以理解为增加信号量并唤醒等待者。

12.2 STM32 UART 的对应关系

408 抽象STM32 UART 实例
生产者USART RX ISR 或 USART RX DMA
消费者主循环、协议任务
有界缓冲区UART 环形缓冲区
empty剩余可用槽位
full已收到但未处理的字节数
mutex保护多个执行流同时修改环形缓冲区元数据
P(full)主循环判断是否有数据,或 RTOS 阻塞等待通知
V(full)ISR 收到数据后发送事件通知

但两者不能完全等同:

学习408 给出模型通常允许线程阻塞;
裸机 ISR 不能阻塞等待;
裸机主循环通常是轮询;
裸机没有调度器负责唤醒任务;
缓冲区满时必须由程序决定丢弃、覆盖或流控。

因此在裸机中,生产者遇到满缓冲区时不能简单执行一个可能永久阻塞的 P(empty)。更常见的是:

检测满 -> 记录溢出 -> 丢弃或覆盖 -> 尽快退出 ISR

12.3 ADC DMA 也是生产者消费者

UART 是“字节流生产者”,ADC DMA 是“采样块生产者”:

ADC + DMA 生产一个采样块
    -> HT/TC 通知
    -> CPU 消费这一块
    -> 输出滤波结果或控制量

二者都要回答四个问题:

  1. 谁生产数据?
  2. 谁消费数据?
  3. 数据放在哪里?
  4. 什么时候把这块数据的所有权交给另一方?

只要这四个问题没有明确,后续的 volatile 或关中断往往只是补丁。

13. 信号量、mutex、queue:相似但不能混为一谈

13.1 一张表先建立边界

机制主要表达是否有所有者是否适合传递数据任务能否阻塞等待
mutex某个共享资源当前由谁使用通常有通常可以
二值信号量某个事件或资源“有/无”通常没有通常可以
计数信号量事件数量或资源数量通常没有通常可以
queue数据或消息排队传递不强调资源所有权通常可以
裸机 flag软件状态通知没有完整所有权语义不能真正阻塞
裸机 ring buffer字节或数据块暂存通过 head/tail 约定所有权通常靠轮询

13.2 信号量能否帮助理解 STM32 资源互斥

可以,但只能做有边界的类比。

例如:

g_rx_ready = 0/1

可以帮助理解二值事件:

0:还没有一行完整命令
1:至少有一行完整命令等待处理
g_event_count++

可以帮助理解计数信号量:

每来一个事件,数量加一;
每处理一个事件,数量减一。
I2C 总线只能被一个任务使用

可以帮助理解 mutex 的资源互斥:

空闲 -> 某个任务获得所有权 -> 事务完成 -> 释放所有权

但不要把裸机变量直接称为完整的 RTOS 信号量或 mutex,因为它通常缺少:

阻塞等待;
任务唤醒;
调度器参与;
所有者检查;
优先级继承;
超时等待 API。

13.3 mutex 为什么不能随便在 ISR 中使用

任务可以因为等待 mutex 而阻塞,让调度器运行其他任务;ISR 不能像任务一样睡眠等待。

如果 ISR 中发现资源被占用,通常应该:

记录事件;
把请求放入队列;
通知某个任务;
立即退出。

在 FreeRTOS 中,ISR 应使用专门的 FromISR API 发送队列或释放信号量;普通 mutex 获取函数可能会尝试阻塞,不能在 ISR 中调用。

14. 死锁和几个容易混淆的故障

14.1 死锁的四个必要条件

经典死锁通常需要同时满足:

  1. 互斥:资源一次只能被一个执行流占用。
  2. 请求并保持:执行流已经持有一个资源,同时继续请求另一个资源。
  3. 不可剥夺:已占用资源不能被系统强制收回,只能由持有者主动释放。
  4. 循环等待:形成环形等待关系。

缺少其中任意一个条件,经典死锁就不会成立。

14.2 STM32/RTOS 中的 I2C + SPI 死锁例子

假设两个任务:

任务 A:先拿 I2C mutex,再拿 SPI mutex
任务 B:先拿 SPI mutex,再拿 I2C mutex

可能出现:

任务 A 持有 I2C,等待 SPI
任务 B 持有 SPI,等待 I2C
两者都不释放已经持有的资源

任务 A 持有 I2C

任务 A 等待 SPI

任务 B 持有 SPI

任务 B 等待 I2C

这就是循环等待。

14.3 裸机中也可能出现“死锁式卡死”

严格说,只有一个主循环、没有任务阻塞队列时,经典教材中的多线程死锁不一定直接出现。但裸机仍可能出现功能上类似的永久等待:

while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) != RESET) {}

如果 I2C 状态因为异常一直没有清除,程序就会卡在这里。

又例如:

主循环等待 ISR 置 flag;
ISR 因为主循环关闭了相关中断或没有清除前一个标志而无法执行;
主循环和 ISR 互相等。

这种现象可以称为“死锁式卡死”或“永久等待”,但排查时要说明它和多任务锁死的差别。

14.4 死锁、饥饿、活锁、优先级反转

概念表现例子
死锁一组执行流永久互相等待A 等 B,B 等 A
饥饿某个执行流长期得不到资源,但系统整体仍在运行低优先级任务一直抢不到总线
活锁执行流都在动作,但没有有效进展两个任务反复让出资源又立即重试
优先级反转高优先级任务被低优先级任务间接阻塞低优先级任务持锁,中优先级任务持续运行
忙等CPU 不断检查条件,不睡眠while(flag == 0)
硬件卡死外设状态机停在异常状态I2C SDA 被设备拉低

看门狗可以发现“系统长时间没有进展”,但它不能自动说明是哪一种原因。工程上仍需要记录状态、超时和故障现场。

14.5 避免死锁的工程方法

STM32 裸机或 RTOS 项目更常用这些方法,而不是等死锁发生后再处理:

减少共享资源;
让一个驱动模块独占一个外设;
所有任务按统一顺序获取多个锁;
尽量一次申请完成一组资源;
持锁期间不调用未知模块;
设置超时,不无限等待;
失败后释放已经拿到的资源;
把阻塞硬件事务改成状态机;
对 I2C 等总线设计恢复流程。

15. 哲学家进餐问题如何映射到 STM32

15.1 问题的抽象

五位哲学家围着一张桌子,每人需要同时拿到左右两把叉子才能进餐。每把叉子只能被一个人使用。

如果每个人都先拿左叉,再等待右叉,就可能形成:

每个人都持有一把叉子;
每个人都等待下一把叉子;
没有人能完成进餐并释放资源。

15.2 STM32 中的类似场景

可以把叉子替换成共享外设:

哲学家模型STM32/RTOS 对应
哲学家任务或功能模块
叉子I2C、SPI、DMA 通道、显示控制器、日志端口
进餐完成一次需要多个资源的业务操作
拿叉子获取资源所有权或 mutex
放下叉子释放资源

例如:

任务 A:拿 I2C 读取传感器,再拿 SPI 刷新显示
任务 B:拿 SPI 写 Flash,再拿 I2C 记录状态

若获取顺序相反,就可能形成循环等待。

15.3 实际解决方法

  1. 统一资源顺序
所有任务都先申请 I2C,再申请 SPI。
  1. 一次申请全部资源

如果不能同时获得,就先不持有任何一个资源,过一段时间再重试。

  1. 限制同时进入的任务数

用一个总管或单独的总线服务任务统一调度访问。

  1. 使用 try-lock 和超时

拿不到第二个资源时,释放第一个,避免永久保持。

  1. 减少资源数量

把“读取传感器并刷新显示”拆成两个阶段,避免同一时刻同时持有 I2C 和 SPI。

在 STM32 裸机里,最推荐的通常是“驱动独占外设 + 请求排队 + 状态机”,而不是让很多模块直接争用寄存器。

16. 银行家算法:理论模型和实际工程

16.1 银行家算法解决什么问题

银行家算法用于判断:在资源有限的情况下,批准某个资源请求后,系统是否仍然存在一个让所有进程最终完成的安全顺序。

它需要预先知道:

系统当前可用资源 Available
每个进程已经占用的资源 Allocation
每个进程可能需要的最大资源 Max
每个进程还需要的资源 Need

公式:

Need = Max - Allocation

16.2 一个小例子

假设有两类抽象资源:

R1:可用 DMA 缓冲块
R2:可用总线事务槽位

当前可用资源:

Available = (3, 2)

三个任务的状态:

任务MaxAllocationNeed
A(3, 2)(1, 0)(2, 2)
B(2, 2)(1, 1)(1, 1)
C(2, 1)(1, 0)(1, 1)

先令:

Work = Available = (3, 2)

检查任务 A:

Need(A) = (2, 2) <= Work(3, 2)

A 可以完成。A 完成后释放已经占用的资源:

Work = Work + Allocation(A)
     = (3, 2) + (1, 0)
     = (4, 2)

检查任务 B:

Need(B) = (1, 1) <= Work(4, 2)

B 完成后:

Work = (4, 2) + (1, 1) = (5, 3)

检查任务 C:

Need(C) = (1, 1) <= Work(5, 3)

所以存在安全序列:

A -> B -> C

这个状态称为安全状态。安全不代表所有任务同时完成,而是存在一个顺序可以让它们依次完成。

16.3 为什么 STM32 裸机通常不用完整银行家算法

银行家算法适合资源动态申请、进程数量较多、最大资源需求可以预先估计的系统。STM32 裸机项目通常具有这些特点:

任务数量少,甚至没有线程;
内存和 DMA 通道在编译期或初始化时固定;
外设资源由模块静态分配;
很难准确知道每个业务未来最大资源需求;
算法本身的运行开销和复杂度不一定值得。

因此实际更常用:

静态资源分配;
一个外设一个所有者;
统一锁顺序;
超时和错误恢复;
禁止递归获取同一资源;
设计阶段画资源依赖图。

银行家算法仍然很有价值,因为它能训练你问一个关键问题:

现在批准这个资源请求后,系统是否还存在完成所有任务的可能?

这比只记“加锁、解锁”更深入。

17. ARM Cortex-M 与 x86:哪些可以类比,哪些不能混

17.1 模型是通用的,底层机制不同

生产者消费者、互斥、死锁、信号量和银行家算法属于并发与操作系统层面的模型,不专属于 x86 或 ARM。

真正与架构有关的部分包括:

异常和中断入口;
中断屏蔽寄存器;
原子读改写指令;
内存排序和屏障;
缓存与 DMA 一致性;
单核/多核;
是否有 MMU、操作系统和调度器。

17.2 STM32F4 Cortex-M4 和典型 x86 的对比

对比项STM32F4 / Cortex-M4 裸机典型 x86 PC/Linux
CPU 核心通常单核通常多核
运行环境裸机或轻量 RTOSLinux、Windows 等完整 OS
中断控制NVICAPIC/中断控制器
中断入口向量表中的异常入口IDT 中的中断门
地址空间通常直接使用物理地址,可能有 MPU通常有 MMU 和虚拟地址
调度裸机主循环或 RTOS 调度器内核调度多个线程和进程
临界区常见为关 IRQ、BASEPRI、原子指令自旋锁、mutex、原子操作、中断屏蔽
DMA外设和 DMA 可访问 RAM设备也可作为总线主设备访问内存
CacheF407 通常不涉及复杂 D-Cache 一致性多级 Cache 和多核一致性更重要
资源故障常见是外设状态机和超时还可能有进程、内核和多核锁问题

17.3 PRIMASK 和 x86 cli 的类比边界

在单核 STM32 中:

关普通中断 -> 防止当前 CPU 被普通 ISR 抢占

在 x86 中,cli 也主要影响当前 CPU 的本地中断响应。若系统有多个 CPU 核:

当前核心执行 cli,并不能阻止其他核心同时访问同一内存。

所以多核 x86 必须使用:

原子指令;
自旋锁;
mutex;
内存屏障;

STM32F4 单核场景下,关中断对“主循环与 ISR 共享变量”很有用,但对 DMA 仍然无效。若是带多个核心的 MCU,也不能把单核经验直接套用到核间共享内存。

17.4 原子指令的对比

目的Cortex-M 常见机制x86 常见机制
原子条件更新LDREX / STREX 独占访问LOCK CMPXCHG
访问顺序约束DMBDSBISBMFENCE 等,具体由指令和内存模型决定
任务/中断同步关 IRQ、BASEPRI、RTOS 原语内核锁、原子变量、futex、mutex

通常不建议为了普通 STM32 业务代码手写这些底层指令;应优先使用 CMSIS、编译器原子接口或 RTOS 提供的机制,并理解它们的边界。

17.5 volatile 在两种架构上都不是锁

无论 ARM 还是 x86:

volatile int counter;
counter++;

都不能单独保证多个执行流的读改写不丢失。x86 的强内存序也不能把一个普通的读-改-写自动变成完整的互斥操作。

volatile 常用于:

内存映射外设寄存器;
ISR 和主程序共享的状态;
DMA 相关的状态标志。

真正的线程同步要使用原子操作、锁、队列或中断安全的数据结构。

17.6 内存屏障和 DMA

当 CPU、DMA、外设和缓存同时参与数据交换时,除了“谁能访问”还要关注“什么时候对方能看到”。

例如:

CPU 先填写 DMA 描述符
    -> 再把 DMA 使能

逻辑上必须保证“填写完成”发生在“通知 DMA”之前。某些场景需要内存屏障来约束访问顺序。

在 F407 这类通常没有复杂数据缓存的芯片上,主要先掌握:

数据写入顺序;
DMA 缓冲区所有权;
中断标志清除顺序;

到了带 D-Cache 的 Cortex-M7 或 PC 驱动开发,还必须把 cache 一致性和屏障纳入设计。

18. 从 STM32 程序设计角度建立一套通用流程

18.1 七步分析法

面对 UART、ADC、TIM、I2C、SPI 或 GPIO 的并发问题,可以按下面的顺序分析:

第一步:列出执行流
主循环?
哪个 ISR?
DMA?
RTOS 哪些任务?
NMI 或 Fault 是否可能修改状态?
第二步:列出共享资源
变量、数组、head/tail、寄存器、外设总线、DMA 描述符。
第三步:标记读者和写者
资源生产者/写者消费者/读者
UART RX ring bufferUSART ISR主循环
ADC bufferDMA主循环或任务
捕获值TIM ISR主循环
I2C 总线多个任务/驱动多个任务/驱动
第四步:判断数据性质
它是状态还是事件?
事件是否允许丢失?
是单个值还是必须保持一致的一组值?
是否需要处理每一个样本?
第五步:选择数据结构
需求常见选择
只关心最新状态一个 volatile 状态变量
只关心是否发生二值 flag
每次事件都重要事件计数器或计数信号量
字节流环形缓冲区
消息边界明确消息队列
连续高速采样DMA 双半缓冲或双缓冲
多任务共享设备mutex 或单独的服务任务
第六步:选择保护方式
单次对齐读写且所有权清楚 -> 可能不需要全局临界区
多个相关变量快照 -> 短临界区或序列号
主循环和 ISR 读改写同一变量 -> 短临界区或原子操作
DMA 与 CPU 共用数组 -> 所有权和缓冲区分区
多个任务共享总线 -> mutex 或驱动仲裁
第七步:设计异常路径
缓冲区满怎么办?
DMA 处理不及时怎么办?
I2C 一直 BUSY 怎么办?
中断丢失怎么办?
超时后是否复位外设?
看门狗什么时候允许喂?

18.2 通用编程逻辑模板

初始化阶段:
    使能 GPIO、外设和 DMA 时钟
    配置 GPIO 复用
    配置外设参数
    配置 DMA/中断/NVIC
    清除旧标志
    启动外设

中断/硬件事件阶段:
    判断事件来源
    读取必须及时读取的寄存器
    把数据放入缓冲区或锁存区
    更新最少量的状态
    记录错误和溢出
    尽快退出

主循环/任务阶段:
    取走事件或交换缓冲区所有权
    在临界区外解析和计算
    执行业务动作
    发送结果或更新显示
    对超时和错误进行恢复

这套模板的关键是把“实时采集”和“复杂处理”分开。

19. 工程排错清单

现象可能原因建议检查
串口偶尔乱码或丢字节ISR 太长、临界区太长、缓冲区溢出RXNE 清除、环形缓冲区、溢出计数、波特率
printf 后问题改变时序竞态或实时性不足删除长打印,记录计数和时间戳
标志位始终不变缺少 volatile、中断未使能、清错标志变量声明、外设 IRQ、NVIC、向量表
一行命令偶尔被截断ISR 写缓冲和主循环复制同时发生使用环形缓冲区或短快照
事件偶尔少几次用 0/1 flag 表示多次事件改为计数器、队列或 DMA
TIM 周期跳变捕获值不一致、溢出未处理、计数频率理解错误快照、计数时钟、PSC、ARR、输入滤波
ADC 数据半新半旧CPU 读取 DMA 正在写的区域HT/TC、双缓冲、所有权
GPIO 其他位莫名变化ODR 读改写覆盖BSRR 或统一端口所有权
I2C 一直 BUSY设备拉低 SDA、状态机未复位、无限等待超时、总线恢复、外设复位
RTOS 任务卡住错误使用 mutex、锁顺序反转锁依赖图、超时、统一顺序
低优先级任务影响高优先级任务优先级反转mutex 优先级继承、缩短持锁时间
看门狗没有复位异常系统无条件喂狗或只检查主循环按模块汇总健康状态再喂狗

20. 工程原则

可以把下面原则当作 STM32 并发设计的检查表:

1. 尽量减少共享资源。
2. 让共享数据尽量单向流动:ISR/DMA 生产,主循环/任务消费。
3. 明确每个变量、缓冲区和外设的所有权。
4. ISR 只做读寄存器、存数据、清标志、置事件等最小工作。
5. 临界区只复制快照、交换指针或更新少量元数据。
6. volatile 解决编译器可见性,不代替互斥。
7. DMA 缓冲区要用半传输、全传输或双缓冲管理所有权。
8. 多个变量必须来自同一时刻时,做原子快照。
9. 多任务共享 I2C/SPI 等外设时,保护完整事务,不只保护某一行代码。
10. 多锁场景统一获取顺序,必要时使用超时和回滚。
11. 不在 ISR 或临界区中 printf、delay、等待外设或做大规模计算。
12. 对缓冲区满、DMA 来不及处理、硬件 BUSY 和通信超时设计恢复路径。
13. 看门狗应检查系统健康条件,而不是只要 CPU 还在循环就喂狗。
14. 把 408 的模型用于提问和推理,不要把任务阻塞 API 直接套到裸机 ISR。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐