一、为什么DMA+空闲中断会遭遇半包与粘包?

DMA+空闲中断的基本原理是:DMA自动将串口数据搬运到缓冲区,当总线空闲(超过1个字符时间无新数据)时触发空闲中断,此时读取DMA剩余传输量即可知道本帧长度。

但实际场景中:

  • 半包:发送方数据被拆成多段,或线路干扰导致空闲中断提前触发,一帧数据被分成多次接收。
  • 粘包:发送方连续发送多帧,帧间间隔小于空闲检测时间,多帧数据被合并成一次接收。

这两种情况都会破坏“一次空闲中断对应一帧完整数据”的假设,直接解析必然出错。

二、边界处理的核心思路

解决半包与粘包的关键是在应用层增加帧边界识别机制,而不是依赖空闲中断的天然分割。常用策略:

  • 固定帧头+长度字段:协议设计时包含帧头和长度信息,接收端据此判断帧是否完整。
  • 超时拼接:对半包设置拼接超时,超时内到达的数据视为同一帧。
  • 长度校验与滑动窗口:对粘包数据按协议长度字段逐帧拆分。

下面以“帧头(2字节)+长度(1字节)+数据+校验(1字节)”的协议为例,给出完整实现。

三、硬件与CubeMX配置

以STM32F103 + USART1为例:

  • 使能USART1,波特率115200,8N1。
  • 开启USART1全局中断(NVIC)。
  • 配置DMA1通道5(USART1_RX),模式为循环模式(Circular),数据宽度Byte。
  • 在NVIC中使能DMA通道中断(可选,用于错误处理)。

关键代码初始化:

#define RX_BUF_SIZE 256
uint8_t rx_dma_buf[RX_BUF_SIZE];
volatile uint16_t last_pos = 0;

void USART1_Init(void) {
    // HAL_UART_Init 已由CubeMX生成
    HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

四、空闲中断处理与数据提取

空闲中断中计算本次接收长度,并拷贝到应用缓冲区:

void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        uint16_t cur_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        if (cur_pos != last_pos) {
            uint16_t len;
            if (cur_pos > last_pos) {
                len = cur_pos - last_pos;
                feed_data(&rx_dma_buf[last_pos], len);
            } else {
                // 环形缓冲区回绕
                len = RX_BUF_SIZE - last_pos;
                feed_data(&rx_dma_buf[last_pos], len);
                len = cur_pos;
                feed_data(&rx_dma_buf[0], len);
            }
            last_pos = cur_pos;
        }
    }
    HAL_UART_IRQHandler(&huart1);
}

五、半包与粘包的边界处理实现

feed_data函数负责将数据送入解析状态机,核心是维护一个拼接缓冲区:

#define MAX_FRAME_LEN 128
uint8_t frame_buf[MAX_FRAME_LEN];
uint16_t frame_len = 0;
uint32_t last_rx_tick = 0;

void feed_data(uint8_t *data, uint16_t len) {
    for (uint16_t i = 0; i < len; i++) {
        // 超时重置(半包处理)
        if (frame_len > 0 && HAL_GetTick() - last_rx_tick > 50) {
            frame_len = 0; // 超时丢弃不完整帧
        }
        last_rx_tick = HAL_GetTick();

        if (frame_len < MAX_FRAME_LEN) {
            frame_buf[frame_len++] = data[i];
        } else {
            frame_len = 0; // 溢出保护
        }

        // 尝试解析完整帧(粘包处理)
        parse_frame();
    }
}

parse_frame函数根据协议逐帧提取:

void parse_frame(void) {
    while (frame_len >= 4) { // 最小帧长:帧头2+长度1+校验1
        // 查找帧头 0xAA 0x55
        if (frame_buf[0] != 0xAA || frame_buf[1] != 0x55) {
            // 滑动窗口:丢弃第一个字节
            memmove(frame_buf, frame_buf + 1, --frame_len);
            continue;
        }
        uint8_t data_len = frame_buf[2];
        uint16_t total_len = 2 + 1 + data_len + 1;
        if (frame_len < total_len) {
            break; // 半包,等待更多数据
        }
        // 校验(简单异或)
        uint8_t checksum = 0;
        for (uint16_t i = 0; i < total_len - 1; i++) {
            checksum ^= frame_buf[i];
        }
        if (checksum == frame_buf[total_len - 1]) {
            // 完整帧,交给应用处理
            handle_frame(&frame_buf[3], data_len);
            // 移除已处理帧(粘包拆分)
            memmove(frame_buf, frame_buf + total_len, frame_len - total_len);
            frame_len -= total_len;
        } else {
            // 校验失败,丢弃帧头
            memmove(frame_buf, frame_buf + 1, --frame_len);
        }
    }
}

六、注意事项

  • DMA循环模式下,last_pos需与DMA计数器同步,避免回绕时数据覆盖。
  • 空闲中断时间由波特率决定,115200下约87us,若发送方帧间隔小于此值,粘包不可避免,必须依赖协议解析。
  • 拼接超时需根据实际通信节奏调整,过短会误判半包,过长会增加延迟。
  • 内存操作(memmove)在中断上下文中应尽量轻量,建议将解析移至主循环,中断仅做数据搬运。
  • 若使用RTOS,可将feed_data放入消息队列,由任务处理,避免中断阻塞。

七、总结

DMA+空闲中断是高效收包的基础,但半包与粘包必须通过应用层协议解析来兜底。本文给出的“帧头+长度+校验”状态机配合超时拼接,能有效处理绝大多数边界情况。实际项目中,建议将解析逻辑与硬件解耦,便于测试与复用。