一、问题背景与现象

在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA配合空闲中断接收不定长数据是常见方案。但在高波特率(如921600)或连续快速发送场景下,常出现丢包:接收缓冲区中数据不完整,或两次空闲中断之间数据被覆盖。

典型现象:

  • 发送端连续发送多帧,接收端偶尔丢失整帧或帧尾若干字节;
  • 调试发现HAL_UARTEx_RxEventCallback回调中Size参数小于实际发送长度;
  • 使用逻辑分析仪确认线上数据完整,问题出在软件层。

二、根因分析

HAL库的DMA空闲中断接收流程如下:

  1. HAL_UARTEx_ReceiveToIdle_DMA启动DMA,将数据搬运到用户缓冲区;
  2. 总线空闲时触发IDLE中断,HAL在中断中计算已接收长度并调用回调;
  3. 回调中若未及时重新启动接收,后续数据会丢失。

丢包的核心原因有三点:

  • 回调中处理耗时过长:在HAL_UARTEx_RxEventCallback中做解析、打印等操作,期间DMA未重新武装,新数据到达即丢失;
  • 单缓冲覆盖:HAL默认使用单缓冲区,下一次接收会覆盖上一次未处理完的数据;
  • IDLE中断与DMA半满/全满事件竞争:高波特率下,IDLE中断响应延迟导致长度计算偏差。

三、丢包复现实验

3.1 硬件与配置

  • MCU:STM32F407,主频168MHz;
  • UART2:波特率921600,DMA1 Stream5 Channel4;
  • 发送端:PC串口助手连续发送100帧,每帧64字节,间隔1ms。

3.2 复现代码(有缺陷版本)

#define RX_BUF_SIZE 256
uint8_t rx_buf[RX_BUF_SIZE];

void StartReceive(void)
{
    HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_SIZE);
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART2) {
        // 模拟耗时处理:解析+打印
        ProcessData(rx_buf, Size);   // 耗时约500us
        HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_SIZE);
    }
}

3.3 复现结果

运行后统计:100帧中约丢失7~12帧,且丢失帧多发生在处理耗时较长的时刻。将ProcessData改为空函数后丢包率降为0,验证了“回调耗时导致DMA未及时重启”的结论。

四、环形缓冲修复方案

4.1 设计思路

  • 使用双缓冲+DMA循环模式或环形缓冲区,将“接收”与“处理”解耦;
  • 回调中只做数据搬运(拷贝到环形缓冲),不做业务处理;
  • 主循环从环形缓冲取数据解析,彻底避免回调阻塞。

4.2 环形缓冲实现

#define RING_SIZE 1024

typedef struct {
    uint8_t  buf[RING_SIZE];
    volatile uint16_t head;   // 写指针(DMA回调更新)
    volatile uint16_t tail;   // 读指针(主循环更新)
} RingBuffer_t;

static RingBuffer_t g_ring;

static inline uint16_t Ring_Used(void)
{
    return (g_ring.head - g_ring.tail) & (RING_SIZE - 1);
}

static inline uint16_t Ring_Free(void)
{
    return RING_SIZE - 1 - Ring_Used();
}

// 由DMA空闲回调调用,仅做拷贝
void Ring_Write(const uint8_t *data, uint16_t len)
{
    for (uint16_t i = 0; i < len; i++) {
        uint16_t next = (g_ring.head + 1) & (RING_SIZE - 1);
        if (next == g_ring.tail) break;   // 满则丢弃,可加计数
        g_ring.buf[g_ring.head] = data[i];
        g_ring.head = next;
    }
}

// 主循环调用
uint16_t Ring_Read(uint8_t *out, uint16_t max_len)
{
    uint16_t n = 0;
    while (n < max_len && g_ring.tail != g_ring.head) {
        out[n++] = g_ring.buf[g_ring.tail];
        g_ring.tail = (g_ring.tail + 1) & (RING_SIZE - 1);
    }
    return n;
}

4.3 接收回调改造

#define DMA_RX_SIZE 128
static uint8_t dma_rx_buf[DMA_RX_SIZE];

void StartReceive(void)
{
    HAL_UARTEx_ReceiveToIdle_DMA(&huart2, dma_rx_buf, DMA_RX_SIZE);
    __HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT); // 关闭半满中断,减少干扰
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART2) {
        Ring_Write(dma_rx_buf, Size);          // 仅拷贝,微秒级
        HAL_UARTEx_ReceiveToIdle_DMA(&huart2, dma_rx_buf, DMA_RX_SIZE);
        __HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
    }
}

4.4 主循环处理

uint8_t frame[256];

while (1) {
    uint16_t len = Ring_Read(frame, sizeof(frame));
    if (len > 0) {
        ProcessData(frame, len);   // 业务处理不再阻塞接收
    }
}

五、配置步骤要点

  • CubeMX中UART2开启DMA接收,模式选Normal(配合每次重启)或Circular(配合半满/全满计算);
  • NVIC中使能USART2全局中断,DMA流中断优先级建议低于串口;
  • 关闭DMA半满中断DMA_IT_HT,避免与IDLE事件重复触发;
  • 环形缓冲大小取2的幂,用位与代替取模提升效率;
  • head/tail用volatile修饰,单写单读场景无需加锁。

六、注意事项

  • 回调中严禁调用printf、HAL_Delay等阻塞函数;
  • 若使用Circular模式,需在回调中根据Size与上次位置计算增量,逻辑更复杂,推荐Normal+重启;
  • 环形缓冲满时应记录溢出计数,便于现场定位;
  • 高波特率下建议将DMA缓冲区设为32~64字节,缩短单次搬运时间;
  • 验证时用逻辑分析仪对比线上数据与环形缓冲内容,确认零丢包。

七、总结

HAL库DMA+空闲中断丢包的本质是“接收与处理未解耦”。通过回调只做环形缓冲写入、主循环异步处理,可将回调耗时压缩到微秒级,从根本上消除丢包。该方案已在多个工业串口通信项目中验证,921600波特率连续收发零丢失。