一、为什么DMA+空闲中断仍会丢包?

串口接收不定长数据时,传统逐字节中断方式CPU开销大。DMA+空闲中断(IDLE)是经典组合:DMA自动搬运数据,总线空闲时触发中断通知CPU取数。但实际项目中常出现两类问题:

  • 半包:一帧数据被拆成两次空闲中断上报,协议解析器收到不完整帧。
  • 粘包:两帧数据间隔极短,DMA连续搬运,一次空闲中断上报了多帧拼接数据。

根因在于:空闲中断只表示“当前总线空闲”,并不保证DMA已搬运完所有数据,也不保证两次空闲中断之间恰好是一帧。理解这一点是解决问题的前提。

二、原理剖析:IDLE标志与DMA的时序关系

STM32的USART在检测到接收线空闲(一个字符时间无新数据)时置位IDLE标志。关键时序如下:

  1. 数据字节到达,DMA请求搬运至缓冲区,NDTR递减。
  2. 总线空闲,硬件置位IDLE。
  3. 若使能IDLE中断,CPU进入ISR。
  4. 此时DMA可能仍在搬运最后一个字节(尤其高波特率下),直接读取NDTR计算长度可能少1字节。

因此,在ISR中必须:

  • 先清除IDLE标志(读SR再读DR);
  • 暂停DMA(或等待传输完成);
  • 再读取剩余计数NDTR,用缓冲区大小 - NDTR得到本次接收长度;
  • 重启DMA前重置NDTR。

三、边界处理核心技巧

3.1 长度快照 + 双缓冲

单缓冲在ISR处理期间若新数据到达会覆盖未处理数据。推荐双缓冲:DMA在Buffer A接收时,ISR处理Buffer B;交换时用原子操作或关中断保护。

3.2 半包处理:超时拼接

若协议帧有固定头尾,可在应用层维护“半包缓存”。当解析器发现帧不完整时,将数据暂存,等待下一次空闲中断数据拼接后再解析。注意设置超时清理,避免死等。

3.3 粘包处理:循环解析

一次上报的数据可能包含多帧。解析器应采用while循环:只要缓冲区剩余长度≥最小帧长且校验通过,就持续解析并前移读指针,直到剩余数据不足以构成完整帧,剩余部分作为半包缓存。

3.4 DMA传输完成中断兜底

当一帧长度恰好等于缓冲区大小时,IDLE可能不触发(DMA已满)。务必使能DMA传输完成中断(TC),在TC中断中同样执行“取长度-处理-重启”流程,与IDLE中断共用处理函数。

四、完整代码示例(STM32 HAL库,以USART1为例)

#define RX_BUF_SIZE 256
static uint8_t rx_dma_buf[RX_BUF_SIZE];
static volatile uint16_t rx_len = 0;
static volatile uint8_t rx_flag = 0;

/* 启动接收 */
void UART_DMA_Start(void)
{
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 关闭半传输中断,减少干扰
}

/* 空闲/传输完成回调(HAL库已封装IDLE与TC) */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        rx_len = Size;          // 本次接收长度快照
        rx_flag = 1;            // 通知应用层
        // 注意:HAL库内部已重启DMA,无需手动调用
    }
}

/* 应用层解析:处理粘包与半包 */
void UART_ParseTask(void)
{
    static uint8_t frame_cache[512];
    static uint16_t cache_len = 0;

    if (!rx_flag) return;
    rx_flag = 0;

    // 1. 将新数据追加到半包缓存(防粘包拆分)
    if (cache_len + rx_len > sizeof(frame_cache)) {
        cache_len = 0; // 溢出保护,丢弃旧数据
    }
    memcpy(frame_cache + cache_len, rx_dma_buf, rx_len);
    cache_len += rx_len;

    // 2. 循环解析完整帧(假设帧头0xAA,帧尾0x55,长度字段为第2字节)
    uint16_t pos = 0;
    while (cache_len - pos >= 4) {
        if (frame_cache[pos] != 0xAA) { pos++; continue; } // 找帧头
        uint8_t payload_len = frame_cache[pos + 1];
        uint16_t frame_len = payload_len + 3; // 头+长度+载荷+尾
        if (cache_len - pos < frame_len) break; // 半包,等待下次
        if (frame_cache[pos + frame_len - 1] != 0x55) { pos++; continue; }

        // 校验通过,处理一帧
        ProcessFrame(&frame_cache[pos + 2], payload_len);
        pos += frame_len;
    }

    // 3. 保留剩余半包数据
    if (pos > 0) {
        memmove(frame_cache, frame_cache + pos, cache_len - pos);
        cache_len -= pos;
    }
    // 超时清理可在此处用tick判断,超过500ms无新数据则清空cache_len
}

五、注意事项与避坑指南

  • 清除IDLE标志的顺序:必须“读SR再读DR”,否则标志无法清除,导致中断反复触发。HAL库已处理,但寄存器操作时需注意。
  • DMA重启时机:在ISR中处理完数据后尽快重启DMA,避免后续数据丢失。HAL的ReceiveToIdle_DMA会在回调前自动重启,但自定义寄存器版本需手动重置NDTR并使能通道。
  • 缓冲区大小与帧长:DMA缓冲区应大于最大帧长,否则TC中断与IDLE中断可能同时触发,需在回调中做去重判断。
  • 半包超时:协议层必须设置半包超时(如500ms),超时后丢弃不完整帧,防止内存泄漏或解析卡死。
  • 中断优先级:DMA与USART中断优先级需合理配置,避免高优先级中断打断取数流程导致长度计算错误。
  • 内存对齐:DMA缓冲区建议32字节对齐,提升搬运效率。

掌握以上边界处理技巧,你的串口通信框架即可稳定应对各种不定长收包场景。