一、问题现象与背景

在嵌入式开发中,使用STM32 HAL库配合DMA和串口空闲中断(IDLE)接收不定长数据是常见方案。但许多开发者反馈:低波特率正常,高波特率(如921600)或连续帧间隔较小时频繁丢包,表现为数据截断、帧合并或完全丢失。

典型配置如下:

  • 串口DMA接收模式:HAL_UART_Receive_DMA(&huart1, buf, MAX_LEN)
  • 使能空闲中断:__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)
  • 在HAL_UART_IDLECallback中计算接收长度并重启DMA

看似合理,实则暗藏隐患。

二、丢包根源深度剖析

2.1 DMA传输完成与空闲中断的竞争

当一帧数据长度恰好等于DMA缓冲区大小时,DMA传输完成中断(TC)与空闲中断几乎同时触发。HAL库的HAL_UART_Receive_DMA在TC中断中会调用HAL_UART_RxCpltCallback,而空闲中断回调中又可能重启DMA,导致DMA被重复配置或数据覆盖。

更隐蔽的是:若在空闲中断中调用HAL_UART_DMAStop再重启,期间到达的数据会丢失。

2.2 HAL库回调机制的缺陷

HAL库的HAL_UART_IRQHandler对空闲中断的处理是:

if(__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) {
  __HAL_UART_CLEAR_IDLEFLAG(huart);
  huart->RxState = HAL_UART_STATE_READY; // 关键!
  HAL_UART_IDLECallback(huart);
}

注意RxState被强制置为READY,但DMA仍在运行。若此时用户调用HAL_UART_Receive_DMA重启,HAL库会认为状态非法而返回HAL_BUSY,导致后续数据无法接收。

2.3 缓冲区管理不当

多数示例使用单缓冲区:空闲中断中直接读取__HAL_DMA_GET_COUNTER计算长度,然后memcpy到应用缓冲区并重启DMA。问题在于:

  • memcpy期间DMA已重启,新数据可能覆盖正在拷贝的区域;
  • 若应用处理不及时,下一帧数据会覆盖上一帧。

三、环形缓冲改造方案

3.1 设计思路

采用双缓冲+环形队列架构:

  • DMA始终循环写入固定大小的环形缓冲区(如256字节);
  • 空闲中断中仅记录当前写指针位置,将“数据段”入队到软件环形队列;
  • 应用层从队列中安全读取,实现生产者-消费者解耦。

3.2 关键数据结构

#define RING_BUF_SIZE 512

typedef struct {
    uint8_t  buffer[RING_BUF_SIZE];
    volatile uint16_t head;   // 写指针(DMA写)
    volatile uint16_t tail;   // 读指针(应用读)
    volatile uint16_t len;    // 当前有效数据长度
} RingBuffer_t;

RingBuffer_t uart_rb;
uint8_t dma_buf[256];         // DMA循环缓冲区

3.3 配置步骤

  1. CubeMX配置:USART1开启DMA接收(Circular模式),NVIC使能USART1全局中断。
  2. 启动DMA:HAL_UART_Receive_DMA(&huart1, dma_buf, sizeof(dma_buf))
  3. 使能空闲中断:__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)
  4. 重写中断处理:在stm32f1xx_it.c的USART1_IRQHandler中调用自定义处理函数。

3.4 完整代码示例

// 环形缓冲区写入(由空闲中断调用)
void RingBuffer_WriteFromDMA(RingBuffer_t *rb, uint8_t *src, uint16_t len) {
    for (uint16_t i = 0; i < len; i++) {
        uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
        if (next != rb->tail) {  // 未满
            rb->buffer[rb->head] = src[i];
            rb->head = next;
            rb->len++;
        } else {
            // 缓冲区满,丢弃或覆盖策略(此处丢弃)
            break;
        }
    }
}

// 空闲中断处理(放在USART1_IRQHandler中)
void UART_IDLE_Handler(UART_HandleTypeDef *huart) {
    if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(huart);
        
        // 计算本次DMA接收到的数据长度
        static uint16_t old_pos = 0;
        uint16_t curr_pos = sizeof(dma_buf) - __HAL_DMA_GET_COUNTER(huart->hdmarx);
        uint16_t data_len;
        
        if (curr_pos != old_pos) {
            if (curr_pos > old_pos) {
                data_len = curr_pos - old_pos;
                RingBuffer_WriteFromDMA(&uart_rb, &dma_buf[old_pos], data_len);
            } else {
                // 发生回绕
                data_len = sizeof(dma_buf) - old_pos;
                RingBuffer_WriteFromDMA(&uart_rb, &dma_buf[old_pos], data_len);
                data_len = curr_pos;
                RingBuffer_WriteFromDMA(&uart_rb, dma_buf, data_len);
            }
            old_pos = curr_pos;
        }
    }
}

// 应用层读取
uint16_t RingBuffer_Read(RingBuffer_t *rb, uint8_t *dst, uint16_t max_len) {
    uint16_t cnt = 0;
    while (rb->tail != rb->head && cnt < max_len) {
        dst[cnt++] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
        rb->len--;
    }
    return cnt;
}

3.5 中断服务函数修改

void USART1_IRQHandler(void) {
    HAL_UART_IRQHandler(&huart1);
    UART_IDLE_Handler(&huart1);  // 自定义空闲处理
}

四、注意事项与优化建议

  • DMA模式必须为Circular,否则回绕逻辑失效;
  • 空闲中断中不要调用HAL_UART_DMAStop/Receive_DMA,避免状态机冲突;
  • 环形缓冲区大小应为2的幂,可用位运算替代取模提升效率;
  • 多任务环境下,head/tail操作需加临界区保护(关中断或原子操作);
  • 若数据帧间隔极短,可结合DMA半传输中断(HT)进一步降低延迟;
  • 调试时用GPIO翻转+逻辑分析仪观察中断响应时间,确认无阻塞。

五、总结

通过将DMA配置为循环模式,配合自定义空闲中断处理与环形缓冲队列,彻底规避了HAL库状态机竞争和单缓冲区覆盖问题。实测在921600波特率、1ms帧间隔下连续接收10万帧无丢包。该方案代码可移植性强,适用于Modbus、自定义协议等不定长数据接收场景。