从中断到并发:STM32 裸机临界区、数据同步与资源互斥
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 等事件 | 收字节、保存捕获值、置标志 | 是 |
| SysTick | Cortex-M 内核定时器 | 毫秒节拍、软件超时、任务轮询 | 是 |
| DMA | 外设请求触发 DMA 控制器 | ADC 数据搬到 RAM、USART 数据搬运 | 是,且不需要 CPU 执行 ISR |
| NMI | 时钟失效或特殊故障 | 紧急故障处理 | 是,普通关中断挡不住 |
| HardFault 等异常 | 非法访问、总线错误、执行错误 | 记录错误或复位 | 可能 |
1.3 信息流示意
图中有两条容易混淆的路径:
- 中断路径:外设提出中断请求,NVIC 通知 CPU,CPU 执行 ISR。
- 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 代码就自动获得互斥性。
还要注意:
- 数据必须满足处理器的对齐要求。
- 变量宽度不能超过处理器一次可靠访问的能力。
- “单变量单次读写”安全,不代表“多个变量组成的状态”安全。
- 寄存器的读写副作用要看参考手册,不能只依据 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 是硬件访问者 |
period 和 high 是否属于同一次捕获 | 不能保证 | 需要快照、序列号或所有权 |
| 外设寄存器访问是否有正确顺序 | 不能单独保证 | 还要遵循参考手册和必要的屏障 |
可以把两者区分成:
volatile:让编译器承认“这个值可能在外部变化”
互斥/临界区:让执行流之间不要在错误的时刻同时修改它
内存屏障:约束处理器或编译器对访问顺序的重排
3. 共享资源和竞态条件
3.1 什么叫共享资源
只要两个执行流都可能访问某个对象,并且至少有一个执行流会修改它,就应该把它列入共享资源清单。
共享资源不只是普通变量:
| 资源类别 | 例子 |
|---|---|
| 标志和计数器 | rx_ready、event_count、系统 tick |
| 多字段状态 | 周期、高电平时间、状态码、更新时间 |
| 缓冲区 | UART 接收数组、ADC DMA 数组、日志队列 |
| 环形缓冲区元数据 | head、tail、满/空状态 |
| 外设寄存器 | GPIO ODR、USART 数据寄存器、DMA 控制寄存器 |
| 外设本身 | 同一条 I2C、SPI、UART 总线 |
| 软件资源 | 内存池、设备句柄、打印端口 |
3.2 竞态条件的本质
竞态条件是指程序最终结果依赖于执行顺序,而这个顺序又会受到中断、DMA、调度或总线时序影响。
常见表现:
偶尔出现,单步调试时消失;
降低波特率后问题变少;
加一条 printf 后问题改变;
高负载或高频输入时更容易出现;
变量看起来“明明加了 volatile”仍然错误。
printf 改变了执行时序,所以问题暂时消失并不代表问题解决了,反而常常说明存在时序竞态。
3.3 互斥和同步不是一回事
这两个词要分开理解:
互斥:同一时间谁能访问资源?
同步:一个执行流什么时候通知另一个执行流继续?
例如:
主循环读取 UART 环形缓冲区时,不能和 ISR 同时修改同一个下标
这是互斥问题。
USART 收到一行命令后,通知主循环“可以解析了”
这是同步问题。
一个完整设计经常同时需要两者:
ISR 写入缓冲区并发出事件通知
主循环收到通知后,在必要的短临界区内取走数据
4. 临界区和关中断
4.1 临界区是什么
临界区是访问共享资源时,必须避免被其他执行流打断或同时修改的一小段代码。
典型流程是:
保存进入前的中断状态
-> 关闭需要屏蔽的中断
-> 只完成必要的共享数据读写
-> 恢复原来的中断状态
-> 在临界区外做计算和业务处理
4.2 __disable_irq() 具体屏蔽什么
在 Cortex-M 中,CMSIS 的:
__disable_irq();
通常通过设置 PRIMASK,屏蔽大多数普通可屏蔽中断;而:
__enable_irq();
通常清除 PRIMASK,恢复普通中断响应。
它的边界如下:
| 机制 | 主要作用 | 一般能屏蔽 | 不能依赖它屏蔽 |
|---|---|---|---|
PRIMASK | 全局屏蔽普通可屏蔽 IRQ | USART、TIM、ADC、DMA IRQ、SysTick | NMI、Reset,通常也不能屏蔽 HardFault |
BASEPRI | 屏蔽低于某优先级的 IRQ | 指定优先级范围内的普通 IRQ | 更高优先级 IRQ、NMI、Reset |
NVIC_DisableIRQ() | 关闭某一路外设 IRQ | 指定 USART/TIM/ADC IRQ | DMA 硬件搬运、其他 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);
但要先确认:
- 没有其他 ISR 也会访问该资源。
- 不能因为关闭 NVIC 就认为 USART 硬件停止接收。
- 关闭期间如果硬件继续收到字节,可能发生溢出。
- 恢复中断后仍要正确处理已经挂起的状态标志。
全局关中断简单,但影响范围大;只关一路更精准,但要求工程师真正知道共享资源的来源。
4.5 临界区里不应该做什么
临界区应只完成“搬走数据、复制快照、交换指针、更新少量状态”。
不建议放入:
printf 或长串口发送
delay_ms
浮点运算和大循环滤波
等待 I2C/SPI 外设完成
等待 DMA 完成
复杂协议解析
动态内存分配
原因是临界区越长,其他中断等待越久,实时性越差,可能造成:
串口丢字节
定时器抖动
ADC 采样处理不及时
SysTick 延迟
高优先级保护动作响应变慢
看门狗误判或系统异常
5. NMI、HardFault 和普通中断的边界
5.1 NMI 是什么
NMI 是 Non-Maskable Interrupt,非屏蔽中断。它是 Cortex-M 为紧急硬件事件保留的特殊入口,普通的 PRIMASK 和 BASEPRI 不能像屏蔽普通 IRQ 那样屏蔽它。
常见来源取决于具体芯片和配置,可能包括:
| 来源 | 含义 |
|---|---|
| 时钟安全系统 CSS | HSE 外部时钟失效时报告故障 |
| 特定外部故障输入 | 部分器件或板级设计支持 |
| ECC 或安全相关错误 | 具体由芯片型号和参考手册决定 |
| 其他特殊硬件错误 | 需要查对应 STM32 参考手册 |
NMI 处理原则:
越短越好;
越少依赖其他外设越好;
不要 delay;
不要做复杂 printf;
尽量记录最小故障信息;
必要时进入安全状态或复位。
5.2 HardFault 和 NMI 不要混为一谈
| 类型 | 更适合怎样理解 |
|---|---|
| NMI | 硬件主动报告紧急事件,普通中断屏蔽不住 |
| HardFault | CPU 发现严重执行或访问错误后的异常入口 |
| 普通 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_cnt | CNT 的递增频率 |
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 是消费者:
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 之间的读改写覆盖。
但它不是万能锁:
- 两个执行流如果同时写同一个引脚,最终结果仍取决于先后顺序。
- 如果业务要求多个引脚必须组成同一时刻的总线值,仍需统一所有权或临界区。
- 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 消费这一块
-> 输出滤波结果或控制量
二者都要回答四个问题:
- 谁生产数据?
- 谁消费数据?
- 数据放在哪里?
- 什么时候把这块数据的所有权交给另一方?
只要这四个问题没有明确,后续的 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 死锁的四个必要条件
经典死锁通常需要同时满足:
- 互斥:资源一次只能被一个执行流占用。
- 请求并保持:执行流已经持有一个资源,同时继续请求另一个资源。
- 不可剥夺:已占用资源不能被系统强制收回,只能由持有者主动释放。
- 循环等待:形成环形等待关系。
缺少其中任意一个条件,经典死锁就不会成立。
14.2 STM32/RTOS 中的 I2C + SPI 死锁例子
假设两个任务:
任务 A:先拿 I2C mutex,再拿 SPI mutex
任务 B:先拿 SPI mutex,再拿 I2C mutex
可能出现:
任务 A 持有 I2C,等待 SPI
任务 B 持有 SPI,等待 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 实际解决方法
- 统一资源顺序
所有任务都先申请 I2C,再申请 SPI。
- 一次申请全部资源
如果不能同时获得,就先不持有任何一个资源,过一段时间再重试。
- 限制同时进入的任务数
用一个总管或单独的总线服务任务统一调度访问。
- 使用 try-lock 和超时
拿不到第二个资源时,释放第一个,避免永久保持。
- 减少资源数量
把“读取传感器并刷新显示”拆成两个阶段,避免同一时刻同时持有 I2C 和 SPI。
在 STM32 裸机里,最推荐的通常是“驱动独占外设 + 请求排队 + 状态机”,而不是让很多模块直接争用寄存器。
16. 银行家算法:理论模型和实际工程
16.1 银行家算法解决什么问题
银行家算法用于判断:在资源有限的情况下,批准某个资源请求后,系统是否仍然存在一个让所有进程最终完成的安全顺序。
它需要预先知道:
系统当前可用资源 Available
每个进程已经占用的资源 Allocation
每个进程可能需要的最大资源 Max
每个进程还需要的资源 Need
公式:
Need = Max - Allocation
16.2 一个小例子
假设有两类抽象资源:
R1:可用 DMA 缓冲块
R2:可用总线事务槽位
当前可用资源:
Available = (3, 2)
三个任务的状态:
| 任务 | Max | Allocation | Need |
|---|---|---|---|
| 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 核心 | 通常单核 | 通常多核 |
| 运行环境 | 裸机或轻量 RTOS | Linux、Windows 等完整 OS |
| 中断控制 | NVIC | APIC/中断控制器 |
| 中断入口 | 向量表中的异常入口 | IDT 中的中断门 |
| 地址空间 | 通常直接使用物理地址,可能有 MPU | 通常有 MMU 和虚拟地址 |
| 调度 | 裸机主循环或 RTOS 调度器 | 内核调度多个线程和进程 |
| 临界区 | 常见为关 IRQ、BASEPRI、原子指令 | 自旋锁、mutex、原子操作、中断屏蔽 |
| DMA | 外设和 DMA 可访问 RAM | 设备也可作为总线主设备访问内存 |
| Cache | F407 通常不涉及复杂 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 等 |
| 访问顺序约束 | DMB、DSB、ISB | MFENCE 等,具体由指令和内存模型决定 |
| 任务/中断同步 | 关 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 buffer | USART ISR | 主循环 |
| ADC buffer | DMA | 主循环或任务 |
| 捕获值 | 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。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)