用 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 初始化配置

#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 中断回调(处理半满/全满)

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 超时中断回调(帧尾判定)

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 写指针计算帧长度,而不是依赖半满中断的索引。
  • 半满中断处理时,若此时超时中断已触发(通过标志位判断),则跳过处理,避免重复。

优化实现

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_indextimeout_flag 时,主循环读取需关闭中断或使用原子操作。
  • 调试方法:使用逻辑分析仪抓取 UART 波形,对比 DMA 中断触发点,验证竞争是否消除。
  • 扩展:若需支持多帧连续接收,可引入环形队列存储帧指针,避免覆盖。

5. 总结

通过明确中断职责(DMA 中断只搬运,超时中断定边界),并利用标志位互斥,可彻底解决帧尾超时与半满中断的竞争问题。此方案在 STM32F1/F4/H7 等系列上均适用,也可移植到其他支持 DMA 循环模式的 MCU。掌握此技巧,你的串口通信将更加健壮。