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

一、背景与问题现象

在STM32项目中,DMA + 串口空闲中断是接收不定长数据的常用方案。HAL库从F0/F3/L4起提供HAL_UARTEx_ReceiveToIdle_DMA(),配合HAL_UARTEx_RxEventCallback()使用非常方便。但在高波特率、连续帧、突发流场景下,常出现:

  • 偶发丢帧,尤其两帧间隔很短时;
  • 回调中处理稍慢,下一帧数据被覆盖;
  • 帧尾字节丢失或整帧错位;
  • 使用HAL_UART_Receive_IT重装时出现ORE溢出。

这些现象并非HAL库“有bug”,而是其接收模型与实时性约束不匹配。

二、丢包根因分析

1. 单缓冲覆盖模型

HAL_UARTEx_ReceiveToIdle_DMA()本质是“一次启动、一次回调”:DMA把数据写入用户缓冲区,空闲中断触发后回调通知。若在回调中重新调用接收函数之前,新数据已经到达,DMA仍指向旧缓冲区起始地址,新数据会覆盖尚未处理的数据。

2. 回调重装延迟

HAL库在UART_DMAReceiveCplt/UART_ReceiveToIdle_DMA路径中先停止DMA、再调用用户回调。用户回调里通常要拷贝数据、解析协议、再调用HAL_UARTEx_ReceiveToIdle_DMA()重启。这段“停止—处理—重启”窗口内到达的数据会丢失或触发ORE。

3. 中断优先级与长临界区

空闲中断与DMA传输完成中断若优先级配置不当,或回调中执行耗时操作(如printf、Flash写、长循环),会推迟中断响应,导致DMA已写满缓冲区而CPU未及时处理。

4. 半传输中断未利用

HAL默认只在“传输完成+空闲”时通知,未利用DMA半传输中断(HT)做流式处理,缓冲区大小成为硬上限。

三、环形缓冲改造思路

核心思想:DMA始终运行,环形缓冲只做“读指针推进”,不在中断里做重装。

  • 使用DMA循环模式(Circular),缓冲区即环形缓冲;
  • 开启DMA半传输中断与传输完成中断,配合空闲中断,在回调中只记录“写位置”;
  • 主循环或任务中根据读指针消费数据,实现生产者—消费者解耦;
  • 用__HAL_DMA_GET_COUNTER()计算DMA当前写偏移,避免依赖回调频率。

这样中断服务极短,DMA不停止,从根本上消除重装窗口。

四、CubeMX配置步骤

  1. USARTx 模式设为 Asynchronous,波特率按需设置;
  2. DMA Settings 添加 USARTx_RX,Mode 选 Circular,Data Width 选 Byte;
  3. NVIC 中使能 USARTx 全局中断、DMA通道中断;
  4. 优先级建议:DMA通道中断 ≥ 串口中断,均高于普通任务;
  5. 生成代码后,在main.c中调用初始化函数并启动接收。

五、完整代码示例

#include "main.h"
#include <string.h>

#define RB_SIZE 512u

static uint8_t dma_buf[RB_SIZE];
static volatile uint16_t rd_pos = 0;
static volatile uint32_t drop_cnt = 0;

/* 启动:DMA循环模式 + 空闲中断 */
void UartRx_Start(UART_HandleTypeDef *huart)
{
    HAL_UARTEx_ReceiveToIdle_DMA(huart, dma_buf, RB_SIZE);
    __HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT); /* 按需关闭HT */
}

/* 计算DMA当前写位置(0..RB_SIZE-1) */
static uint16_t UartRx_WritePos(UART_HandleTypeDef *huart)
{
    uint16_t cnt = (uint16_t)__HAL_DMA_GET_COUNTER(huart->hdmarx);
    return (uint16_t)(RB_SIZE - cnt) % RB_SIZE;
}

/* 从环形缓冲取一帧(以空闲/换行分隔,示例按字节流返回可用长度) */
uint16_t UartRx_Read(UART_HandleTypeDef *huart, uint8_t *out, uint16_t max)
{
    uint16_t wr = UartRx_WritePos(huart);
    uint16_t n = 0;

    while (rd_pos != wr && n < max) {
        out[n++] = dma_buf[rd_pos];
        rd_pos = (uint16_t)((rd_pos + 1u) % RB_SIZE);
    }
    return n;
}

/* 空闲/事件回调:只做统计,不做重装 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    (void)Size;
    /* 可选:记录事件时间戳,供协议层判断帧边界 */
}

/* 错误回调:溢出计数,必要时复位读指针策略 */
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart->ErrorCode & HAL_UART_ERROR_ORE) {
        drop_cnt++;
        __HAL_UART_CLEAR_OREFLAG(huart);
    }
}

主循环消费示例:

uint8_t frame[128];
while (1) {
    uint16_t len = UartRx_Read(&huart1, frame, sizeof(frame));
    if (len) {
        /* 协议解析:找帧头帧尾、校验、入队 */
    }
}

六、注意事项

  • 不要在回调里做耗时操作:回调只置标志或记录位置,解析放到主循环/RTOS任务;
  • 读写指针用volatile,跨中断与主循环共享;单生产者单消费者下无需加锁,但读写顺序要保证;
  • 缓冲区大小按最大突发长度×2设计,并留余量;
  • ORE处理:发生溢出时清除标志并评估是否丢弃半帧,避免错位;
  • DMA循环模式下HAL_UARTEx_ReceiveToIdle_DMA只需调用一次,不要反复重装;
  • 调试打印避免与接收同串口,防止自环干扰;
  • 若使用RTOS,可在空闲回调中释放信号量,由任务消费环形缓冲。

七、总结

HAL库的DMA+空闲中断方案在低速、稀疏帧场景足够用,但面对连续高速数据时,“单缓冲+回调重装”模型天然存在覆盖窗口。改为DMA循环模式+环形缓冲+读写指针解耦,把中断服务压缩到最小,即可显著降低丢包率,并为协议层提供稳定的流式数据源。