一、问题背景与现象

在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA配合空闲中断(IDLE)接收不定长数据是一种常见方案。但许多开发者反馈:在波特率较高(如921600)或数据帧较长时,偶尔会丢失整包数据或部分字节。现象通常为:

  • 空闲中断触发后,Size参数偶尔为0或小于实际长度。
  • 连续多帧数据时,第二帧开始丢失。
  • 调试时单步运行正常,全速运行丢包。

二、丢包根因分析

2.1 HAL库的DMA接收机制

HAL_UARTEx_ReceiveToIdle_DMA内部启动DMA循环模式,并在UART空闲中断中调用HAL_UARTEx_RxEventCallback。关键问题在于:空闲中断触发时,DMA可能尚未完成当前数据块的搬运,导致Size计算基于DMA剩余计数器(NDTR),而NDTR的更新与CPU读取存在竞争。

2.2 中断处理时序缺陷

空闲中断服务函数中,HAL库会先清除IDLE标志,再读取NDTR计算长度。若此时DMA仍在搬运最后几个字节,NDTR尚未更新到最终值,计算出的长度偏小,造成尾部数据丢失。此外,若在回调中重新启动接收,可能因DMA未完全停止而覆盖缓冲区。

2.3 缓冲区管理问题

多数示例使用单缓冲区直接处理数据,若处理耗时超过下一帧到达时间,新数据会覆盖旧数据。且HAL库的HAL_UARTEx_RxEventCallback在DMA循环模式下可能被多次调用,缺乏环形缓冲区的解耦。

三、环形缓冲区改造方案

3.1 设计思路

  • 使用DMA循环模式,将UART数据持续搬运至固定大小的DMA缓冲区。
  • 空闲中断触发时,根据DMA当前写指针与上次读指针的差值,将新数据拷贝至软件环形缓冲区。
  • 主循环从环形缓冲区读取并处理,实现生产者-消费者解耦。
  • 关键:在空闲中断中先暂停DMA(或使用__HAL_DMA_DISABLE),确保NDTR稳定后再计算长度,拷贝完成后重新使能。

3.2 环形缓冲区实现

#define RING_BUF_SIZE 1024

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} RingBuffer_t;

RingBuffer_t uart_ring;

void RingBuffer_Init(RingBuffer_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

uint16_t RingBuffer_Write(RingBuffer_t *rb, const uint8_t *data, uint16_t len) {
    uint16_t i;
    for (i = 0; i < len; i++) {
        uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
        if (next == rb->tail) break; // 满
        rb->buffer[rb->head] = data[i];
        rb->head = next;
    }
    return i;
}

uint16_t RingBuffer_Read(RingBuffer_t *rb, uint8_t *data, uint16_t len) {
    uint16_t i;
    for (i = 0; i < len; i++) {
        if (rb->tail == rb->head) break; // 空
        data[i] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    }
    return i;
}

3.3 DMA与空闲中断配置

#define DMA_BUF_SIZE 256
uint8_t dma_buf[DMA_BUF_SIZE];
volatile uint16_t last_pos = 0;

void UART_Init(void) {
    // 启动DMA循环接收
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_buf, DMA_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 关闭半传输中断
}

// 空闲中断回调
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
    if (huart->Instance == USART1) {
        // 暂停DMA,确保NDTR稳定
        __HAL_DMA_DISABLE(huart->hdmarx);
        
        uint16_t curr_pos = DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);
        uint16_t len;
        if (curr_pos >= last_pos) {
            len = curr_pos - last_pos;
            RingBuffer_Write(&uart_ring, &dma_buf[last_pos], len);
        } else {
            // 回绕
            len = DMA_BUF_SIZE - last_pos;
            RingBuffer_Write(&uart_ring, &dma_buf[last_pos], len);
            len = curr_pos;
            RingBuffer_Write(&uart_ring, &dma_buf[0], len);
        }
        last_pos = curr_pos;
        
        // 重新使能DMA
        __HAL_DMA_ENABLE(huart->hdmarx);
        // 重新启动接收(HAL库需要)
        HAL_UARTEx_ReceiveToIdle_DMA(huart, dma_buf, DMA_BUF_SIZE);
    }
}

3.4 主循环处理

uint8_t process_buf[128];
while (1) {
    uint16_t n = RingBuffer_Read(&uart_ring, process_buf, sizeof(process_buf));
    if (n > 0) {
        // 处理数据
        ParseData(process_buf, n);
    }
}

四、注意事项

  • DMA暂停与恢复:在空闲中断中必须暂停DMA再读取NDTR,否则可能读到中间值。恢复后需重新调用HAL_UARTEx_ReceiveToIdle_DMA,否则HAL库状态机不更新。
  • 环形缓冲区大小:应大于最大帧长的2倍,避免生产过快覆盖。
  • 中断优先级:UART空闲中断优先级应高于DMA传输完成中断,确保及时响应。
  • 临界区保护:若主循环与中断同时操作环形缓冲区,需对head/tail使用原子操作或关中断。
  • HAL库版本差异:不同版本HAL_UARTEx_RxEventCallback的调用时机略有不同,建议实测验证。

五、总结

通过暂停DMA稳定NDTR、引入环形缓冲区解耦生产消费,可彻底解决HAL库DMA空闲中断接收的丢包问题。该方案已在实际项目中验证,在1Mbps波特率下连续接收无丢包,适用于高速、不定长串口通信场景。