一、问题现象与背景

在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA()配合空闲中断(IDLE)接收不定长串口数据是一种常见方案。但在高波特率(如921600bps)或数据帧密集场景下,开发者常遇到偶发丢包:接收到的数据长度比实际发送少几个字节,或帧尾数据丢失。

典型现象:

  • 发送端连续发送多帧数据,接收端偶尔丢失最后一帧的部分字节。
  • 丢包概率随波特率升高而增加,低波特率下几乎不出现。
  • 使用HAL_UARTEx_ReceiveToIdle_DMA()时,回调HAL_UARTEx_RxEventCallback中Size参数偶尔小于实际帧长。

二、原理剖析:DMA+空闲中断接收机制

2.1 HAL库的接收流程

HAL_UARTEx_ReceiveToIdle_DMA()启动DMA接收,并开启空闲中断。当总线空闲(超过1个字符时间无数据)时触发IDLE中断,HAL库在中断中计算已接收数据长度,并调用HAL_UARTEx_RxEventCallback。

关键点:

  • DMA配置为循环模式(Circular)时,DMA计数器持续递减,空闲中断触发时通过__HAL_DMA_GET_COUNTER()计算已接收字节数。
  • 若DMA配置为正常模式(Normal),则每次接收完成后需重新启动,否则后续数据丢失。

2.2 丢包根因分析

根因1:DMA半传输中断干扰

HAL库默认开启DMA半传输中断(HT),当接收数据达到缓冲区一半时触发HAL_UARTEx_RxEventCallback(事件类型为HAL_UART_RXEVENT_HT)。若用户未区分事件类型,误将半传输事件当作完整帧处理,会导致数据被截断。

根因2:空闲中断响应延迟

高波特率下,一帧数据结束到下一帧开始间隔极短(可能仅几个us)。若CPU正在处理其他中断(如SysTick、DMA HT中断),IDLE中断可能被延迟响应,期间DMA继续写入新数据,导致上一帧尾部被覆盖。

根因3:缓冲区覆盖

若使用固定长度缓冲区且未及时处理数据,DMA循环写入会覆盖未读数据。尤其当HAL_UARTEx_RxEventCallback中处理耗时较长时,下一帧数据已开始写入。

根因4:HAL库内部状态机冲突

HAL_UARTEx_ReceiveToIdle_DMA()在回调中若未正确重启接收,或多次调用导致DMA状态混乱,也会引发丢包。

三、排查方法与实战定位

3.1 逻辑分析仪抓包

使用逻辑分析仪同时抓取TX和RX引脚波形,对比发送与接收数据。若RX波形完整但MCU接收数据缺失,则问题在软件侧。

3.2 代码级调试

在HAL_UARTEx_RxEventCallback中打印事件类型和Size:

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        printf("Event: %d, Size: %d\n", huart->RxEventType, Size);
    }
}

观察是否出现HAL_UART_RXEVENT_HT事件被误处理,或Size小于预期。

3.3 检查DMA配置

确认DMA模式为DMA_CIRCULAR,且缓冲区大小足够容纳最大帧长。若使用DMA_NORMAL,需在每次回调中重新启动接收。

四、环形缓冲区改造方案

4.1 设计思路

  • 使用双缓冲+环形队列:DMA直接写入环形缓冲区,空闲中断仅更新写指针,不拷贝数据。
  • 关闭DMA半传输中断,仅保留空闲中断和传输完成中断。
  • 在空闲中断中计算本次接收长度,更新环形缓冲区写索引,并通知应用层读取。

4.2 环形缓冲区结构

typedef struct {
    uint8_t *buffer;      // 数据区
    uint16_t size;        // 总大小
    volatile uint16_t head; // 写索引(DMA写入位置)
    volatile uint16_t tail; // 读索引(应用读取位置)
} ring_buffer_t;

4.3 关键代码实现

初始化与启动

#define RX_BUF_SIZE 512
static uint8_t dma_buf[RX_BUF_SIZE];
static ring_buffer_t ring;

void UART_DMA_Init(void)
{
    ring.buffer = dma_buf;
    ring.size = RX_BUF_SIZE;
    ring.head = 0;
    ring.tail = 0;
    // 启动DMA接收(循环模式)
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_buf, RX_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) {
        // 仅处理空闲事件和传输完成事件
        if (huart->RxEventType == HAL_UART_RXEVENT_IDLE ||
            huart->RxEventType == HAL_UART_RXEVENT_TC) {
            // 更新写索引(环形回绕)
            ring.head = (ring.head + Size) % ring.size;
            // 通知应用层(如置标志位或发送信号量)
            // ...
        }
    }
}

应用层读取

uint16_t RingBuffer_Read(ring_buffer_t *rb, uint8_t *data, uint16_t len)
{
    uint16_t available = (rb->head - rb->tail + rb->size) % rb->size;
    if (len > available) len = available;
    for (uint16_t i = 0; i < len; i++) {
        data[i] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) % rb->size;
    }
    return len;
}

4.4 注意事项

  • 关闭半传输中断:避免HT事件干扰,仅依赖IDLE和TC事件。
  • 缓冲区大小:应大于最大帧长的2倍,防止DMA回绕覆盖未读数据。
  • 临界区保护:head和tail的更新需考虑中断与主循环的并发,必要时使用__disable_irq()短暂保护。
  • 空闲中断标志清除:HAL库已自动处理,但若使用LL库需手动清除IDLE标志。
  • DMA循环模式:确保DMA配置为DMA_CIRCULAR,否则需在每次回调中重启接收。

五、总结

通过关闭DMA半传输中断、采用环形缓冲区解耦DMA写入与应用读取,并合理设置缓冲区大小,可有效解决HAL库DMA+空闲中断接收不定长数据时的丢包问题。该方案已在实际项目中验证,在921600bps下连续接收无丢包。开发者应根据具体场景调整缓冲区大小与中断优先级,确保系统实时性。