一、问题背景:为什么半包与粘包难以避免

在STM32等MCU上,使用DMA+串口空闲中断(IDLE)接收变长帧是常见做法:DMA自动搬运数据,空闲中断在总线静默一个字节时间后触发,通知CPU取走数据。但实际场景中:

  • 半包:一帧数据尚未接收完整,总线因干扰或发送方分包出现短暂空闲,IDLE提前触发,CPU误认为帧结束。
  • 粘包:两帧数据背靠背到达,中间无空闲间隔,IDLE只在第二帧结束后触发一次,缓冲区包含多帧数据。

若解析器简单地将“一次IDLE中断=一帧”,必然错位。根本原因在于空闲中断只反映总线状态,不携带帧边界信息。

二、核心原理:DMA计数与空闲中断的配合

STM32的DMA通道在循环模式或普通模式下,可通过__HAL_DMA_GET_COUNTER()获取剩余传输字节数。设缓冲区总长BUF_SIZE,则已接收字节数:

recv_len = BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma);

空闲中断触发时,recv_len即为本次“疑似帧”的长度。但该长度可能小于真实帧长(半包)或大于单帧长(粘包)。因此,不能直接以recv_len作为帧长,而应将其视为“本次新增数据块”,交由上层协议解析器处理。

三、健壮接收策略:三级防护

3.1 第一级:环形缓冲区解耦DMA与解析

DMA直接写入环形缓冲区(Ring Buffer),空闲中断仅负责更新写指针并通知解析任务。这样即使半包/粘包,数据也不会丢失,解析器可按自身节奏消费。

3.2 第二级:帧头帧尾+长度字段校验

协议设计上增加帧头(如0xAA 0x55)、长度字段和帧尾(如0x0D 0x0A)或CRC。解析器在环形缓冲区中搜索帧头,读取长度字段,校验帧尾/CRC,只有完整且校验通过的帧才被提交。

3.3 第三级:超时重同步机制

若解析器在缓冲区中长时间找不到有效帧头,或帧长度字段异常(超过最大帧长),则丢弃当前字节并滑动窗口重新搜索帧头,避免死锁。

四、完整代码实现(基于STM32 HAL)

4.1 环形缓冲区定义

#define RING_BUF_SIZE 1024
#define MAX_FRAME_LEN 256

typedef struct {
    uint8_t  buf[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针(DMA/中断更新)
    volatile uint16_t tail;  // 读指针(解析任务更新)
} RingBuffer_t;

RingBuffer_t g_ring;

4.2 DMA+空闲中断初始化

uint8_t g_dma_buf[64];  // DMA临时缓冲区

void UART_DMA_Init(UART_HandleTypeDef *huart) {
    __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
    HAL_UART_Receive_DMA(huart, g_dma_buf, sizeof(g_dma_buf));
}

4.3 空闲中断处理:搬运数据到环形缓冲区

void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        uint16_t len = sizeof(g_dma_buf) - remain;
        if (len > 0) {
            RingBuffer_Write(&g_ring, g_dma_buf, len);
        }
        // 重启DMA接收(普通模式需重新启动)
        HAL_UART_AbortReceive(&huart1);
        HAL_UART_Receive_DMA(&huart1, g_dma_buf, sizeof(g_dma_buf));
    }
    HAL_UART_IRQHandler(&huart1);
}

4.4 环形缓冲区写入(考虑溢出)

void RingBuffer_Write(RingBuffer_t *rb, uint8_t *data, uint16_t len) {
    for (uint16_t i = 0; i < len; i++) {
        uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
        if (next != rb->tail) {  // 未满
            rb->buf[rb->head] = data[i];
            rb->head = next;
        } else {
            // 缓冲区满,丢弃新数据(或覆盖最旧数据,视需求)
            break;
        }
    }
}

4.5 帧解析状态机(在main循环或任务中调用)

// 协议:0xAA 0x55 | len(1B) | payload(len B) | crc16(2B)
#define FRAME_HEAD1 0xAA
#define FRAME_HEAD2 0x55
#define FRAME_MIN_LEN 5  // 头2+长度1+CRC2

typedef enum { WAIT_HEAD1, WAIT_HEAD2, WAIT_LEN, WAIT_PAYLOAD, WAIT_CRC } ParseState_t;

void Frame_Parser(void) {
    static ParseState_t state = WAIT_HEAD1;
    static uint8_t payload_len = 0, payload_idx = 0;
    static uint8_t frame_buf[MAX_FRAME_LEN];
    static uint16_t crc_calc = 0;
    uint8_t byte;

    while (RingBuffer_Read(&g_ring, &byte, 1) == 1) {
        switch (state) {
        case WAIT_HEAD1:
            if (byte == FRAME_HEAD1) state = WAIT_HEAD2;
            break;
        case WAIT_HEAD2:
            state = (byte == FRAME_HEAD2) ? WAIT_LEN : WAIT_HEAD1;
            break;
        case WAIT_LEN:
            payload_len = byte;
            if (payload_len > MAX_FRAME_LEN - FRAME_MIN_LEN) {
                state = WAIT_HEAD1;  // 长度非法,重同步
            } else {
                frame_buf[0] = FRAME_HEAD1;
                frame_buf[1] = FRAME_HEAD2;
                frame_buf[2] = payload_len;
                payload_idx = 0;
                crc_calc = 0xFFFF;  // 初始化CRC
                state = WAIT_PAYLOAD;
            }
            break;
        case WAIT_PAYLOAD:
            frame_buf[3 + payload_idx++] = byte;
            crc_calc = CRC16_Update(crc_calc, byte);
            if (payload_idx >= payload_len) state = WAIT_CRC;
            break;
        case WAIT_CRC:
            // 接收CRC低字节和高字节,比较
            static uint8_t crc_bytes[2], crc_cnt = 0;
            crc_bytes[crc_cnt++] = byte;
            if (crc_cnt == 2) {
                uint16_t crc_recv = crc_bytes[0] | (crc_bytes[1] << 8);
                if (crc_recv == crc_calc) {
                    // 完整帧,提交处理
                    Frame_Handle(frame_buf, 3 + payload_len + 2);
                }
                crc_cnt = 0;
                state = WAIT_HEAD1;
            }
            break;
        }
    }
}

五、注意事项与调试建议

  • DMA缓冲区大小:不宜过小(建议≥64字节),否则频繁中断增加CPU负载;也不宜过大,避免单次搬运延迟。
  • 空闲中断清除顺序:先清IDLE标志,再读DMA计数,最后重启DMA,防止漏数据。
  • 环形缓冲区临界区:head和tail的更新需考虑中断与主循环并发,建议写指针仅在中断中修改,读指针仅在主循环中修改,天然无锁。
  • 超时重同步:若长时间(如500ms)未解析出完整帧,可清空环形缓冲区或滑动丢弃一个字节,防止错误累积。
  • CRC校验:强烈建议启用,可有效过滤半包导致的残缺帧。
  • 调试技巧:用逻辑分析仪抓取串口波形,对比IDLE中断时刻与帧边界,验证半包/粘包场景。

通过上述“环形缓冲+状态机+校验+重同步”的组合,可彻底解决DMA+空闲中断接收变长帧时的解析错位问题,适用于工业控制、传感器网络等对可靠性要求高的嵌入式场景。