一、问题背景:为什么会出现半包与粘包?

在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA(或自定义空闲中断)接收不定长数据是常见做法。但实际项目中,常遇到以下现象:

  • 半包:一帧数据被拆成两次空闲中断上报,例如发送{0x01,0x02,0x03},第一次收到{0x01},第二次收到{0x02,0x03}。
  • 粘包:两帧数据被合并到一次空闲中断中,例如连续发送A和B,却一次性收到AB。

这两种情况会导致协议解析错位,例如将半包误判为完整帧,或将粘包中的第二帧数据当作第一帧的延续。

根本原因:

  • 空闲中断的触发时机依赖于总线空闲时间(通常为1个字符时间),若发送方帧间隔过短或接收方处理不及时,就会粘包。
  • DMA传输完成中断与空闲中断的竞争,或DMA缓冲区未及时重置,可能导致半包。
  • 直接使用HAL_UART_Receive_DMA配合__HAL_UART_GET_FLAG(&huart, UART_FLAG_IDLE)的旧方法,若未正确清除标志或重启DMA,易丢失数据。

二、解决方案:环形缓冲区 + 帧边界检测

核心思想:不依赖单次空闲中断的完整性,而是将每次DMA接收到的数据追加到环形缓冲区,由应用层根据协议特征(如帧头、帧尾、长度字段)进行帧提取。这样即使半包或粘包,也能正确重组或拆分。

2.1 硬件与CubeMX配置

  1. 启用USART1,模式为Asynchronous。
  2. 开启USART1全局中断(NVIC中使能USART1 interrupt)。
  3. 配置DMA:USART1_RX,模式为Circular(循环模式),数据宽度Byte,优先级High。
  4. 生成代码。

注意:使用循环DMA可避免每次重启DMA,减少半包风险。

2.2 关键代码实现

2.2.1 环形缓冲区定义

#define RING_BUF_SIZE 512

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head; // 写入位置(DMA/中断写)
    volatile uint16_t tail; // 读取位置(应用读)
} RingBuffer_t;

RingBuffer_t uart_ring;

2.2.2 空闲中断回调(HAL_UARTEx_RxEventCallback)

HAL库在HAL_UARTEx_ReceiveToIdle_DMA模式下,空闲中断会调用HAL_UARTEx_RxEventCallback,参数Size为本次接收到的字节数。

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        // 将DMA缓冲区中的数据拷贝到环形缓冲区
        // 注意:此处DMA缓冲区为循环模式,需根据当前DMA写指针计算起始地址
        // 简化处理:假设使用线性缓冲区,每次重新启动DMA(不推荐,但便于理解)
        // 实际推荐使用循环DMA + 位置计算,见下文说明
        for (uint16_t i = 0; i < Size; i++) {
            uint16_t next = (uart_ring.head + 1) % RING_BUF_SIZE;
            if (next != uart_ring.tail) { // 防止溢出
                uart_ring.buffer[uart_ring.head] = huart->pRxBuffPtr[i];
                uart_ring.head = next;
            }
        }
        // 重新启动接收(若使用非循环DMA)
        // HAL_UARTEx_ReceiveToIdle_DMA(&huart1, huart1.pRxBuffPtr, BUFFER_SIZE);
    }
}

更优做法:使用循环DMA,在回调中通过__HAL_DMA_GET_COUNTER计算本次接收的数据在DMA缓冲区中的位置,避免拷贝整个缓冲区。

2.2.3 应用层帧提取函数

假设协议格式:帧头0xAA 0x55,长度字段len(1字节),数据len字节,校验和1字节。

// 从环形缓冲区读取一个字节,成功返回1,失败返回0
uint8_t RingBuffer_Read(RingBuffer_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) return 0;
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 1;
}

// 尝试解析一帧,成功返回帧长度,失败返回0
uint16_t Protocol_ParseFrame(uint8_t *frame, uint16_t max_len)
{
    static uint8_t state = 0;
    static uint8_t len = 0;
    static uint16_t idx = 0;
    uint8_t byte;

    while (RingBuffer_Read(&uart_ring, &byte)) {
        switch (state) {
            case 0: // 等待帧头1
                if (byte == 0xAA) state = 1;
                break;
            case 1: // 等待帧头2
                if (byte == 0x55) state = 2;
                else state = 0;
                break;
            case 2: // 长度字段
                len = byte;
                if (len > max_len - 4) { state = 0; break; } // 长度非法
                frame[0] = 0xAA; frame[1] = 0x55; frame[2] = len;
                idx = 3;
                state = 3;
                break;
            case 3: // 接收数据
                frame[idx++] = byte;
                if (idx >= 3 + len) state = 4;
                break;
            case 4: // 校验和(简单累加和)
                frame[idx++] = byte;
                uint8_t sum = 0;
                for (uint16_t i = 0; i < idx - 1; i++) sum += frame[i];
                if (sum == byte) {
                    state = 0;
                    return idx; // 返回完整帧长度
                } else {
                    state = 0; // 校验失败,丢弃
                }
                break;
        }
    }
    return 0;
}

2.2.4 主循环调用

uint8_t frame[64];
while (1) {
    uint16_t len = Protocol_ParseFrame(frame, sizeof(frame));
    if (len > 0) {
        // 处理完整帧
        ProcessFrame(frame, len);
    }
    HAL_Delay(1);
}

三、注意事项与优化建议

  • 使用循环DMA:避免每次空闲中断后重启DMA,减少半包概率。通过__HAL_DMA_GET_COUNTER获取当前DMA剩余计数,计算新数据起始位置。
  • 空闲中断标志清除:在HAL_UART_IRQHandler中HAL库已自动处理,但若使用自定义中断,需手动清除UART_FLAG_IDLE并读取USART->DR。
  • 缓冲区溢出保护:环形缓冲区写入时检查head+1 != tail,防止覆盖未读数据。
  • 协议设计:尽量包含帧头、长度、校验,便于应用层重组与纠错。
  • 中断优先级:DMA中断优先级应高于串口空闲中断,避免数据竞争。
  • 调试技巧:使用逻辑分析仪或串口打印环形缓冲区状态,定位半包/粘包发生点。

四、总结

通过环形缓冲区解耦DMA接收与协议解析,结合帧边界检测,可彻底解决半包与粘包导致的解析错位。该方法不依赖空闲中断的完整性,适用于高吞吐、不定长数据的工业场景。代码可直接移植到STM32F1/F4/H7等系列,只需调整HAL库版本对应的回调函数名。