UART DMA 环形缓冲区:帧尾超时与半满中断竞争的精解与实战
👁 1 阅读 · 2026-08-27 · 嵌入式
在嵌入式开发中,使用 DMA 环形缓冲区接收 UART 不定长数据是提升效率的利器,但帧尾超时与半满中断的竞争常导致数据错乱或丢失。本文深入剖析两者冲突的根源,提供基于硬件定时器与状态机的精确解决方案,并给出 STM32 平台完整代码示例,助你打造稳定可靠的通信层。
# 用 DMA 环形缓冲区实现 UART 不定长接收:帧尾超时与半满中断竞争的精解
## 1. 问题背景与竞争根源
在 STM32 等 MCU 上,使用 DMA 的循环模式(Circular Mode)配合环形缓冲区接收不定长数据,可显著降低 CPU 负载。但常见方案中,帧尾检测依赖空闲中断(IDLE)或定时器超时,而半满中断(Half-Transfer)用于及时搬运数据。两者同时触发时,若处理不当,会导致数据覆盖或帧错位。
**竞争根源**:
- 半满中断触发时,DMA 当前写指针位于缓冲区中点,而应用程序可能正在读取前半部分。若此时帧尾超时中断也触发,且超时处理函数直接重置缓冲区,则半满中断处理的数据可能被清空。
- 反之,若半满中断先处理,而超时中断后到,可能误判帧结束,导致半包数据被提前提交。
## 2. 硬件与软件协同设计
### 2.1 硬件选择
- 使用 DMA 循环模式,缓冲区大小设为 2 的幂(如 256 字节),便于指针回绕。
- 启用 DMA 半满和全满中断(或仅半满,取决于需求)。
- 使用一个基本定时器(如 TIM6)作为超时计时器,超时时间设为帧间隔的 1.5~2 倍。
### 2.2 状态机设计
定义三个状态:`IDLE`(等待帧头)、`RECEIVING`(接收中)、`FRAME_READY`(帧完成)。
- 在 `IDLE` 状态,仅等待起始字节(或任意字节)触发超时定时器启动。
- 在 `RECEIVING` 状态,每次 DMA 中断(半满/全满)或超时中断时,更新状态。
- 超时中断表示帧结束,此时将当前 DMA 写指针位置作为帧尾,提交数据。
## 3. 关键代码实现(STM32 HAL 库)
### 3.1 初始化配置
```c
#define BUF_SIZE 256
uint8_t dma_rx_buf[BUF_SIZE];
volatile uint16_t last_read_index = 0;
volatile uint8_t rx_state = 0; // 0:IDLE, 1:RECEIVING, 2:FRAME_READY
void UART_DMA_Init(void) {
// 配置 UART 接收 DMA 为循环模式
hdma_uart_rx.Init.Mode = DMA_CIRCULAR;
HAL_DMA_Init(&hdma_uart_rx);
__HAL_LINKDMA(&huart1, hdmarx, hdma_uart_rx);
// 使能半满中断和全满中断(通过 DMA 中断处理)
HAL_DMA_Start_IT(&hdma_uart_rx, (uint32_t)&huart1.Instance->DR, (uint32_t)dma_rx_buf, BUF_SIZE);
// 启动定时器,但先不使能中断,在接收开始时使能
HAL_TIM_Base_Start_IT(&htim6);
__HAL_TIM_DISABLE_IT(&htim6, TIM_IT_UPDATE);
}
```
### 3.2 DMA 中断回调(处理半满/全满)
```c
void HAL_DMA_IRQHandler(&hdma_uart_rx) {
if (__HAL_DMA_GET_FLAG(&hdma_uart_rx, DMA_FLAG_HT)) {
__HAL_DMA_CLEAR_FLAG(&hdma_uart_rx, DMA_FLAG_HT);
// 半满中断:数据已填满前半部分,可提前处理,但需防止与超时竞争
// 此处仅更新读取索引,不重置缓冲区
last_read_index = BUF_SIZE / 2;
// 触发数据处理(如解析协议)
ProcessReceivedData();
}
if (__HAL_DMA_GET_FLAG(&hdma_uart_rx, DMA_FLAG_TC)) {
__HAL_DMA_CLEAR_FLAG(&hdma_uart_rx, DMA_FLAG_TC);
// 全满中断(循环模式下 TC 在回绕时触发)
last_read_index = 0;
ProcessReceivedData();
}
}
```
### 3.3 超时中断回调(帧尾判定)
```c
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim->Instance == TIM6) {
// 超时发生,表示帧结束
uint16_t current_index = BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_uart_rx);
// 计算帧长度:从 last_read_index 到 current_index(考虑回绕)
uint16_t frame_len = (current_index >= last_read_index) ?
(current_index - last_read_index) :
(BUF_SIZE - last_read_index + current_index);
if (frame_len > 0) {
// 提交帧数据(例如存入队列)
FrameReceived(&dma_rx_buf[last_read_index], frame_len);
// 更新读取索引,并复位状态
last_read_index = current_index;
rx_state = 0; // 回到 IDLE
}
// 停止超时定时器,等待下一帧
__HAL_TIM_DISABLE_IT(&htim6, TIM_IT_UPDATE);
}
}
```
### 3.4 竞争处理策略
**核心原则**:
- 超时中断是帧结束的唯一权威,半满中断仅负责数据搬运,不改变帧边界。
- 在超时中断中,必须基于当前 DMA 写指针计算帧长度,而不是依赖半满中断的索引。
- 半满中断处理时,若此时超时中断已触发(通过标志位判断),则跳过处理,避免重复。
**优化实现**:
```c
volatile uint8_t timeout_flag = 0; // 超时标志
void HAL_DMA_IRQHandler(&hdma_uart_rx) {
if (__HAL_DMA_GET_FLAG(&hdma_uart_rx, DMA_FLAG_HT)) {
__HAL_DMA_CLEAR_FLAG(&hdma_uart_rx, DMA_FLAG_HT);
if (!timeout_flag) { // 若超时未发生,则正常处理半满
last_read_index = BUF_SIZE / 2;
ProcessReceivedData();
}
// 若超时已发生,则忽略半满,等待超时处理完成
}
// 全满同理
}
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim->Instance == TIM6) {
timeout_flag = 1; // 设置标志,阻止后续 DMA 中断处理
// ... 计算帧并提交
timeout_flag = 0;
}
}
```
## 4. 注意事项与调试技巧
- **缓冲区大小**:必须大于最大帧长,且为 2 的幂,否则指针回绕计算复杂。
- **超时时间**:根据波特率调整,例如 115200bps 下,1 字节时间约 87us,超时设为 1ms 可容忍 10 字节间隔。
- **临界区保护**:在中断中修改 `last_read_index` 和 `timeout_flag` 时,主循环读取需关闭中断或使用原子操作。
- **调试方法**:使用逻辑分析仪抓取 UART 波形,对比 DMA 中断触发点,验证竞争是否消除。
- **扩展**:若需支持多帧连续接收,可引入环形队列存储帧指针,避免覆盖。
## 5. 总结
通过明确中断职责(DMA 中断只搬运,超时中断定边界),并利用标志位互斥,可彻底解决帧尾超时与半满中断的竞争问题。此方案在 STM32F1/F4/H7 等系列上均适用,也可移植到其他支持 DMA 循环模式的 MCU。掌握此技巧,你的串口通信将更加健壮。