STM32 HAL库DMA+空闲中断接收串口不定长数据的丢包排查与环形缓冲区改造

一、为什么“DMA+空闲中断”会丢包

DMA+空闲中断是STM32串口接收不定长数据的经典组合:DMA负责搬运,CPU只在总线空闲时处理一帧。但在高吞吐或突发场景下,丢包几乎必然发生,根因有三类:

  • 空闲中断与DMA搬运的竞争:空闲中断触发时,DMA可能仍在搬运最后几个字节,若此时直接读取NDTR并重启DMA,会截断或覆盖数据。
  • 单缓冲覆盖:HAL库默认使用单缓冲,CPU处理上一帧期间,新帧到达会直接覆盖缓冲区。
  • 中断优先级与处理时长:空闲中断中做解析、打印等耗时操作,会阻塞后续DMA完成中断,导致NDTR计算错位。

二、丢包复现与定位方法

2.1 复现路径

  • 上位机以1Mbps连续发送长度不等的帧,帧间隔小于1ms。
  • 在HAL_UARTEx_RxEventCallback中打印Size,对比发送计数。
  • 观察是否出现Size为0、帧被截断或整帧丢失。

2.2 定位要点

  • 检查huart->RxState是否在回调中被意外置为READY。
  • 用逻辑分析仪抓TX/RX,确认是硬件层丢字节还是软件层丢帧。
  • 在空闲回调中只做“记录+重启”,把解析放到主循环,验证是否仍丢包。

三、环形缓冲区改造方案

核心思想:DMA只负责把数据写入一个足够大的环形缓冲区,空闲中断只更新写指针并唤醒处理任务,读写分离,彻底消除覆盖。

3.1 数据结构

#define RING_BUF_SIZE 2048

typedef struct {
    uint8_t  buf[RING_BUF_SIZE];
    volatile uint16_t head;   // 写指针,仅DMA/空闲中断修改
    volatile uint16_t tail;   // 读指针,仅主循环修改
    volatile uint16_t last_pos;// 上次DMA位置
} ring_t;

static ring_t g_ring;

3.2 初始化与启动

void ring_uart_init(UART_HandleTypeDef *huart)
{
    g_ring.head = 0;
    g_ring.tail = 0;
    g_ring.last_pos = 0;
    // 使用ReceiveToIdle_DMA,HAL会自动开启空闲中断
    HAL_UARTEx_ReceiveToIdle_DMA(huart, g_ring.buf, RING_BUF_SIZE);
    // 关闭半满中断,避免频繁打断
    __HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT);
}

3.3 空闲回调:只更新写指针

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance != USART1) return;

    uint16_t pos = RING_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);
    // 计算本次新增数据长度(处理回绕)
    uint16_t len = (pos >= g_ring.last_pos)
                 ? (pos - g_ring.last_pos)
                 : (RING_BUF_SIZE - g_ring.last_pos + pos);

    g_ring.head = (g_ring.head + len) % RING_BUF_SIZE;
    g_ring.last_pos = pos;

    // 重启DMA,保持接收不中断
    HAL_UARTEx_ReceiveToIdle_DMA(huart, g_ring.buf, RING_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT);
}

3.4 主循环读取

uint16_t ring_read(uint8_t *dst, uint16_t max_len)
{
    uint16_t head = g_ring.head;
    uint16_t tail = g_ring.tail;
    uint16_t avail = (head >= tail) ? (head - tail)
                                    : (RING_BUF_SIZE - tail + head);
    if (avail == 0) return 0;
    if (avail > max_len) avail = max_len;

    for (uint16_t i = 0; i < avail; i++) {
        dst[i] = g_ring.buf[(tail + i) % RING_BUF_SIZE];
    }
    g_ring.tail = (tail + avail) % RING_BUF_SIZE;
    return avail;
}

四、配置步骤

  • CubeMX中使能USART1的DMA RX(Circular模式关闭,Normal模式即可,因为每次回调重启)。
  • 空闲中断由HAL_UARTEx_ReceiveToIdle_DMA自动开启,无需手动使能。
  • 中断优先级:DMA RX中断优先级高于串口空闲中断,保证NDTR读取准确。
  • 主循环中调用ring_read,解析逻辑与接收解耦。

五、注意事项

  • 不要在半满中断中重启DMA,半满中断只适合流式处理,重启会破坏NDTR连续性。
  • head与tail必须用volatile,且读指针只由主循环修改,避免竞态。
  • 环形缓冲区容量应大于“一帧最大长度×2”,防止处理延迟导致回绕覆盖。
  • 回调中禁止耗时操作,解析、打印一律放到主循环。
  • 若使用ReceiveToIdle_DMA,HAL版本需≥1.8.0,旧版本需手动开启UART_IT_IDLE。
  • 调试时可用GPIO翻转测量回调执行时间,确认未超过帧间隔。

六、小结

丢包的本质是“单缓冲+回调内耗时”造成的读写竞争。通过DMA直写环形缓冲区、空闲中断只更新写指针、主循环异步读取,可将接收链路的可靠性提升到工业级水平。该方案同样适用于CAN、SPI等DMA接收场景。