一、问题背景与现象

在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA配合空闲中断接收不定长串口数据是一种常见方案。但许多开发者会遇到:

  • 高波特率(如115200以上)连续发送时偶发丢包
  • 数据帧间隔极短时,空闲中断来不及处理,下一帧已覆盖缓冲区
  • 主循环处理耗时较长时,DMA目标缓冲区被新数据覆盖

二、原理与丢包根因分析

2.1 DMA+空闲中断工作流程

  1. 调用HAL_UARTEx_ReceiveToIdle_DMA启动DMA接收,数据写入用户缓冲区
  2. 总线空闲时触发USART空闲中断,HAL库在中断中计算已接收长度并调用回调
  3. 回调中通常再次启动接收

2.2 丢包根因

  • 中断响应延迟:空闲中断到回调执行期间,若新数据到达且DMA已重新启动,可能覆盖未处理数据
  • 单缓冲区覆盖:HAL库默认使用单一目标缓冲区,回调中若处理时间过长,DMA继续写入导致旧数据被覆盖
  • HAL库内部状态机竞争:在回调中直接重启接收,若前一帧未处理完,新帧数据会覆盖
  • DMA半传输/传输完成中断与空闲中断的时序问题:高负载下中断嵌套或优先级配置不当导致空闲中断丢失

三、环形缓冲改造方案

3.1 设计思路

  • 使用DMA循环模式(Circular)持续接收,数据写入固定大小的DMA缓冲区
  • 利用DMA半传输/传输完成中断与空闲中断共同维护写指针
  • 用户层通过环形缓冲区读取数据,实现生产者-消费者解耦
  • 空闲中断仅更新写指针并通知,不直接处理数据

3.2 配置步骤

  1. CubeMX中配置USART1为异步模式,波特率115200,开启DMA接收(Circular模式),使能USART全局中断
  2. 在NVIC中使能DMA通道中断与USART中断,设置合适优先级(DMA高于USART)
  3. 定义环形缓冲区结构体与DMA缓冲区
  4. 实现空闲中断回调与DMA中断回调,更新写指针
  5. 用户层实现环形缓冲区读取函数

四、完整代码示例

4.1 环形缓冲区定义

#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;
uint8_t dma_buf[256];  // DMA循环缓冲区
volatile uint16_t dma_last_pos = 0;

4.2 初始化与启动

void UART_DMA_Init(void) {
    uart_ring.head = 0;
    uart_ring.tail = 0;
    dma_last_pos = 0;
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_buf, sizeof(dma_buf));
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 可选:关闭半传输中断
}

4.3 空闲中断与DMA中断回调

// 空闲中断回调(HAL_UARTEx_RxEventCallback)
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
    if (huart->Instance == USART1) {
        uint16_t pos = sizeof(dma_buf) - __HAL_DMA_GET_COUNTER(huart->hdmarx);
        // 将DMA缓冲区中从dma_last_pos到pos的数据写入环形缓冲区
        uint16_t len = (pos >= dma_last_pos) ? (pos - dma_last_pos) : (sizeof(dma_buf) - dma_last_pos + pos);
        for (uint16_t i = 0; i < len; i++) {
            uint16_t idx = (dma_last_pos + i) % sizeof(dma_buf);
            uint16_t next = (uart_ring.head + 1) % RING_BUF_SIZE;
            if (next != uart_ring.tail) { // 防止溢出
                uart_ring.buffer[uart_ring.head] = dma_buf[idx];
                uart_ring.head = next;
            }
        }
        dma_last_pos = pos;
        // 注意:循环模式下无需重启接收,但需清除空闲标志
        __HAL_UART_CLEAR_IDLEFLAG(huart);
    }
}

// DMA传输完成中断回调(可选,用于处理半传输/完成事件)
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) {
    // 可在此更新写指针,逻辑同上,略
}

4.4 用户层读取函数

uint16_t RingBuffer_Read(uint8_t *data, uint16_t max_len) {
    uint16_t len = 0;
    while (len < max_len && uart_ring.tail != uart_ring.head) {
        data[len++] = uart_ring.buffer[uart_ring.tail];
        uart_ring.tail = (uart_ring.tail + 1) % RING_BUF_SIZE;
    }
    return len;
}

五、注意事项

  • DMA循环模式下,HAL_UARTEx_ReceiveToIdle_DMA只需调用一次,无需在回调中重启,否则会破坏循环
  • 空闲中断回调中必须清除空闲标志,否则会反复进入中断
  • 环形缓冲区大小应大于单帧最大长度,并留有余量;读写指针操作需保证原子性(单生产者单消费者可免锁)
  • DMA中断优先级应高于USART中断,确保写指针及时更新
  • 若数据量极大,可考虑双缓冲或DMA链表模式,但环形缓冲已能满足多数场景
  • 调试时可用GPIO翻转或计数统计验证丢包是否消除

通过上述改造,将数据接收与处理解耦,可有效解决HAL库默认方案在高负载下的丢包问题,提升串口通信的可靠性。