# 用 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。掌握此技巧,你的串口通信将更加健壮。