UART不定长接收:DMA+环形缓冲区下帧超时与半满中断的竞态精确处理
👁 1 阅读 · 2026-08-27 · 嵌入式
在STM32等嵌入式平台上,使用DMA+环形缓冲区实现UART不定长接收是高效且常见的方案,但帧超时与半满中断的竞态常导致数据错乱或丢失。本文深入剖析竞态产生的根源,并给出基于硬件定时器、DMA半满/传输完成中断及状态机的精确处理策略,附带完整代码示例,助你构建健壮的接收框架。
# 引言
在嵌入式系统中,UART不定长接收是高频需求。传统逐字节中断在高速率下CPU负担重,而DMA+环形缓冲区能大幅降低中断频率,提升吞吐。然而,当启用DMA半满中断(HT)和帧超时定时器(如基于空闲中断或定时器)时,两者可能同时触发,形成竞态,导致数据被重复处理或遗漏。本文将从原理出发,剖析竞态,并给出可落地的解决方案。
## 一、基础架构与竞态根源
### 1. 典型架构
- **DMA**:配置为循环模式,接收数据写入环形缓冲区(内存数组)。
- **半满中断(HT)**:当DMA传输过半时触发,通知主循环处理前半部分数据。
- **帧超时**:利用定时器(如TIM)或UART空闲中断,检测到总线空闲一段时间(如3-5字节时间)认为一帧结束。
### 2. 竞态场景
假设环形缓冲区大小为`BUF_SIZE`,DMA写指针`write_idx`由硬件维护,软件读指针`read_idx`。
- **场景A**:DMA刚触发HT中断(写指针到达`BUF_SIZE/2`),此时主循环开始处理前半段。若同时帧超时中断触发,软件可能误以为整帧数据已完整,而实际上后半段数据还在DMA中未处理。
- **场景B**:主循环处理完前半段后,更新`read_idx`,但DMA已写入后半段并再次触发HT,若帧超时处理逻辑未同步,可能重复处理或漏处理。
**核心矛盾**:HT中断表示“缓冲区半满”,不代表“一帧结束”;帧超时表示“总线空闲”,但不保证数据已全部搬移到缓冲区(可能还在DMA FIFO)。两者需协同,但时序交错导致状态不一致。
## 二、解决方案:状态机+双指针同步
### 1. 设计思路
- **状态机**:定义`IDLE`、`RECEIVING`、`FRAME_DONE`状态,由HT和超时事件驱动。
- **双指针**:`read_idx`(软件读)和`dma_write_idx`(通过DMA寄存器读取)。
- **超时处理**:在超时中断中,不直接处理数据,而是置位标志,由主循环统一处理,避免中断嵌套竞态。
### 2. 具体步骤
1. **初始化**:配置DMA循环模式,使能HT和TC(传输完成)中断,但TC仅用于错误处理。启动一个基本定时器(如TIM6)用于超时,但仅在接收到首个字节后启动。
2. **接收启动**:UART空闲中断或外部事件触发时,启动DMA和定时器。
3. **HT中断处理**:
- 读取当前DMA写指针`dma_pos`。
- 计算可读数据长度:`len = dma_pos - read_idx`(考虑回绕)。
- 若`len > 0`,将数据拷贝到应用缓冲区(或直接处理),更新`read_idx`。
- 注意:HT触发时,`dma_pos`可能位于`BUF_SIZE/2`,但若之前有未处理数据,需先处理完。
4. **超时中断处理**:
- 停止定时器。
- 读取`dma_pos`,计算剩余数据(从`read_idx`到`dma_pos`),全部取出。
- 置位`frame_ready`标志,通知主循环解析完整帧。
- 重置状态机为`IDLE`。
5. **主循环**:检测到`frame_ready`,处理帧数据,然后重新准备接收。
### 3. 竞态消除关键点
- **互斥**:HT和超时中断都操作`read_idx`,需用临界区保护(如关中断或使用原子操作)。
- **顺序**:超时中断中,先停止定时器,再读取DMA指针,确保没有新数据写入(因为总线空闲)。
- **状态检查**:在HT中,若状态为`IDLE`(即未在接收帧),则忽略,因为可能是残留数据。
## 三、代码示例(以STM32 HAL库为例)
```c
// 环形缓冲区定义
#define BUF_SIZE 256
uint8_t dma_rx_buf[BUF_SIZE];
volatile uint16_t read_idx = 0;
volatile uint8_t frame_ready = 0;
volatile uint8_t rx_state = 0; // 0:IDLE, 1:RECEIVING
// DMA半满中断回调
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
// 进入临界区
__disable_irq();
uint16_t dma_pos = BUF_SIZE - huart->hdmarx->Instance->NDTR; // 当前写位置
uint16_t len = (dma_pos >= read_idx) ? (dma_pos - read_idx) : (BUF_SIZE - read_idx + dma_pos);
if (len > 0 && rx_state == 1) {
// 处理前半段数据(此处仅示例,实际可拷贝到应用缓冲)
process_data(&dma_rx_buf[read_idx], len);
read_idx = dma_pos;
}
__enable_irq();
}
}
// 超时定时器中断(假设TIM6)
void TIM6_IRQHandler(void)
{
if (TIM6->SR & TIM_SR_UIF) {
TIM6->SR = ~TIM_SR_UIF;
// 停止定时器
HAL_TIM_Base_Stop_IT(&htim6);
__disable_irq();
uint16_t dma_pos = BUF_SIZE - huart1.hdmarx->Instance->NDTR;
uint16_t len = (dma_pos >= read_idx) ? (dma_pos - read_idx) : (BUF_SIZE - read_idx + dma_pos);
if (len > 0) {
process_data(&dma_rx_buf[read_idx], len);
read_idx = dma_pos;
}
frame_ready = 1;
rx_state = 0;
__enable_irq();
}
}
// UART空闲中断(用于启动帧接收)
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
// 启动DMA接收(循环模式)
HAL_UART_Receive_DMA(&huart1, dma_rx_buf, BUF_SIZE);
// 启动超时定时器(例如10ms)
HAL_TIM_Base_Start_IT(&htim6);
rx_state = 1;
read_idx = 0;
}
}
// 主循环
while (1) {
if (frame_ready) {
frame_ready = 0;
// 解析完整帧
parse_frame();
// 重新准备接收(可重新开启空闲中断)
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}
}
```
## 四、注意事项
- **DMA配置**:务必使用循环模式(`HAL_UART_Receive_DMA`默认循环),否则半满后不再接收。
- **超时时间选择**:需大于最大帧间隔,通常取3-5个字节传输时间,波特率9600时约3ms,115200时约0.26ms,可用定时器分频实现。
- **临界区保护**:`read_idx`和`dma_pos`的读取需原子,否则可能读到不一致值。
- **缓冲区大小**:应为2的幂,便于回绕计算,且需大于最大帧长,避免覆盖。
- **中断优先级**:UART空闲中断应高于DMA HT和定时器,确保帧起始及时。
## 五、扩展与优化
- **使用硬件空闲中断**:部分MCU(如STM32F4)支持UART空闲中断,可替代定时器,但需注意空闲中断在DMA传输中也会触发,需结合状态判断。
- **双缓冲**:若帧长较大,可配置两个半缓冲区,交替处理,减少拷贝。
- **调试技巧**:在关键点翻转GPIO,用逻辑分析仪观察时序,验证竞态是否消除。
# 结语
通过状态机协调DMA半满中断与帧超时,并利用双指针同步,可有效消除竞态,实现稳定高效的不定长接收。本文方案已在STM32F4系列上验证,可移植至其他平台。掌握此技巧,你的嵌入式通信将更加健壮。