一、为什么需要DMA+空闲中断接收不定长数据

在嵌入式开发中,串口接收不定长数据是常见需求。传统逐字节中断方式占用CPU资源高,而DMA+空闲中断组合能大幅降低CPU负载:

  • DMA自动将串口数据搬运到内存缓冲区,无需CPU干预
  • 空闲中断(IDLE)在总线空闲时触发,标志一帧数据接收完成
  • 结合两者可高效处理不定长数据帧

二、典型实现与隐藏陷阱

2.1 基础配置步骤

  1. 初始化USART,使能DMA接收(HAL_UART_Receive_DMA)
  2. 使能空闲中断(__HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE))
  3. 在USART中断服务函数中检测IDLE标志,计算接收长度
  4. 处理数据后重新启动DMA接收

2.2 常见陷阱:缓冲区切换错误

许多开发者会在空闲中断中直接调用HAL_UART_Receive_DMA重新启动接收,但忽略了DMA传输计数器的状态,导致:

  • 数据覆盖:未及时处理的数据被新数据覆盖
  • 长度计算错误:__HAL_DMA_GET_COUNTER返回值未正确重置
  • DMA指针未复位:重新启动时DMA从上次位置继续,而非缓冲区起始地址

根本原因:HAL库的HAL_UART_Receive_DMA在DMA传输完成前调用会先中止当前传输,但若未正确清除标志或重置计数器,可能引发竞争条件。

三、陷阱复现与原理分析

考虑以下典型错误代码:

void USART1_IRQHandler(void) {
    if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        uint16_t len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
        // 处理数据...
        HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUFFER_SIZE); // 重新启动
    }
    HAL_UART_IRQHandler(&huart1);
}

问题在于:HAL_UART_Receive_DMA内部会调用HAL_DMA_Start_IT,但若DMA尚未完成上一次传输(计数器未归零),直接重启会导致DMA状态机混乱。更隐蔽的是,在中断中处理数据时若耗时较长,新数据可能已开始写入缓冲区,造成数据撕裂。

四、可靠修复方案:双缓冲区+状态机

4.1 设计思路

  • 使用两个缓冲区交替接收,避免处理时被新数据覆盖
  • 在空闲中断中仅切换缓冲区并标记事件,数据处理放到主循环
  • 确保DMA重新启动前正确复位计数器

4.2 完整代码示例

#define BUF_SIZE 128

typedef struct {
    uint8_t buf[2][BUF_SIZE];
    volatile uint16_t len[2];
    volatile uint8_t active;   // 当前DMA写入的缓冲区索引
    volatile uint8_t ready;    // 待处理缓冲区索引,0xFF表示无
} UartDmaRing_t;

UartDmaRing_t uart_ring;

void UART_DMA_Init(void) {
    uart_ring.active = 0;
    uart_ring.ready = 0xFF;
    HAL_UART_Receive_DMA(&huart1, uart_ring.buf[0], BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

void USART1_IRQHandler(void) {
    if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        
        // 停止DMA以获取准确长度
        HAL_UART_DMAStop(&huart1);
        uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
        uint16_t len = BUF_SIZE - remain;
        
        if(len > 0) {
            uint8_t cur = uart_ring.active;
            uart_ring.len[cur] = len;
            uart_ring.ready = cur;
            uart_ring.active = cur ^ 1;  // 切换缓冲区
        }
        
        // 重新启动DMA到新缓冲区
        HAL_UART_Receive_DMA(&huart1, uart_ring.buf[uart_ring.active], BUF_SIZE);
    }
    HAL_UART_IRQHandler(&huart1);
}

// 主循环中处理数据
void process_uart_data(void) {
    if(uart_ring.ready != 0xFF) {
        uint8_t idx = uart_ring.ready;
        uint16_t len = uart_ring.len[idx];
        // 处理uart_ring.buf[idx]中的len字节数据
        // ...
        uart_ring.ready = 0xFF;
    }
}

4.3 关键修复点

  • 先停止DMA再计算长度:避免计数器在读取过程中变化
  • 双缓冲区交替:处理一个缓冲区时,DMA写入另一个,彻底避免覆盖
  • 中断中只做标记:将耗时数据处理移至主循环,减少中断延迟
  • 正确切换active索引:使用异或操作简洁切换

五、注意事项与优化建议

  • 缓冲区大小权衡:根据最大帧长和数据处理速度选择,过小易溢出,过大浪费RAM
  • 中断优先级:USART中断优先级应高于DMA,确保空闲中断及时响应
  • 错误处理:添加DMA传输错误中断回调,检测溢出(ORE)并恢复
  • 内存屏障:在中断与主循环共享变量时,使用volatile并考虑编译器优化屏障
  • HAL库版本差异:不同版本HAL的HAL_UART_DMAStop行为略有不同,建议测试验证
  • 空闲中断标志清除:必须先读SR再读DR,或使用__HAL_UART_CLEAR_IDLEFLAG宏

通过上述双缓冲区方案,可彻底解决DMA+空闲中断接收不定长数据时的缓冲区切换陷阱,实现稳定可靠的数据接收。