一、为什么半包与粘包难以避免?

串口通信中,一帧数据往往由多个字节组成,且长度不固定。使用DMA接收时,DMA会在后台将数据搬运到缓冲区,而CPU通过空闲中断(IDLE)感知“一帧结束”。但问题在于:

  • 半包:一帧数据尚未接收完整,总线出现短暂空闲(如发送方延时),IDLE中断提前触发,导致只收到部分数据。
  • 粘包:两帧数据连续到达,中间没有空闲间隔,IDLE中断只触发一次,缓冲区中包含了多帧数据。

传统做法是在IDLE中断中直接读取DMA剩余计数并拷贝数据,但无法区分上述情况。要解决边界问题,必须理解IDLE中断与DMA计数的时序关系,并引入辅助判定逻辑。

二、IDLE中断与DMA计数的时序关系

STM32的USART空闲中断在检测到总线空闲(一个字节时间无新数据)后置位。此时DMA可能仍在搬运最后一个字节,也可能已经停止。关键点:

  • IDLE中断标志置位时,DMA的NDTR寄存器值反映了已接收但尚未被CPU取走的字节数(假设DMA循环模式或正常模式)。
  • 若在IDLE中断中立即读取NDTR,可能因DMA未完成最后一次传输而得到错误长度。
  • 正确做法:先清除IDLE标志,再短暂延时(或等待DMA传输完成标志),然后读取NDTR。

但仅靠一次读取仍无法区分半包与粘包。我们需要引入时间窗和长度差两个维度。

三、边界判定策略:时间窗+长度差

3.1 核心思想

  • 维护一个“上次接收完成时间戳”和“上次帧长度”。
  • 每次IDLE中断触发时,计算当前时间与上次时间差Δt,以及当前长度与上次长度差ΔL。
  • 若Δt小于阈值(如2个字节传输时间),且ΔL为负或异常,则判定为粘包,需拆分;若Δt较大且ΔL为正,则可能是半包后的续传,需合并。

实际工程中更简单有效的做法是:在IDLE中断中不立即处理数据,而是启动一个定时器(如100μs),在定时器回调中再读取DMA计数并处理。这样可过滤掉总线上的短暂抖动,同时给DMA留出完成时间。对于粘包,则通过协议层(如帧头帧尾)二次校验。

3.2 具体步骤

  1. 配置DMA为循环模式,串口接收缓冲区大小设为最大帧长×2。
  2. 使能串口IDLE中断,在中断中清除标志并启动单次定时器(如TIM6,100μs)。
  3. 定时器中断中:读取DMA当前写指针(通过__HAL_DMA_GET_COUNTER),计算已接收长度,与上次处理位置比较,得到新数据长度。
  4. 若新数据长度大于0,则拷贝数据到应用缓冲区,并更新上次位置。
  5. 应用层根据协议(如帧头0xAA、帧尾0x55)校验完整性,若校验失败则丢弃或请求重传。

四、完整代码示例(基于HAL库)

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

// 串口初始化后启动DMA接收
void uart_dma_init(void) {
    HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

// IDLE中断处理(在USART1_IRQHandler中调用)
void uart_idle_isr(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        // 启动单次定时器,延时100us后再处理
        HAL_TIM_Base_Start_IT(&htim6);
    }
}

// 定时器中断回调(100us后)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim == &htim6) {
        HAL_TIM_Base_Stop_IT(&htim6);
        uint16_t curr_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        if (curr_pos != last_pos) {
            uint16_t len;
            if (curr_pos > last_pos) {
                len = curr_pos - last_pos;
                memcpy(app_buf, &rx_dma_buf[last_pos], len);
            } else {
                // 环形缓冲区回绕
                len = RX_BUF_SIZE - last_pos + curr_pos;
                memcpy(app_buf, &rx_dma_buf[last_pos], RX_BUF_SIZE - last_pos);
                memcpy(&app_buf[RX_BUF_SIZE - last_pos], rx_dma_buf, curr_pos);
            }
            app_len = len;
            frame_ready = 1;
            last_pos = curr_pos;
        }
    }
}

// 应用层处理(主循环中)
void process_frame(void) {
    if (frame_ready) {
        frame_ready = 0;
        // 协议校验:帧头0xAA,帧尾0x55
        if (app_len >= 3 && app_buf[0] == 0xAA && app_buf[app_len-1] == 0x55) {
            // 有效帧,处理数据
            handle_valid_frame(app_buf, app_len);
        } else {
            // 半包或粘包,丢弃或缓存等待后续
            // 可在此处实现粘包拆分逻辑
        }
    }
}

五、注意事项与避坑指南

  • DMA模式选择:推荐循环模式,避免每次接收后重新启动DMA。但需注意缓冲区回绕处理。
  • IDLE标志清除顺序:必须先读SR再读DR,或使用HAL宏__HAL_UART_CLEAR_IDLEFLAG,否则可能无法清除。
  • 定时器延时时间:应大于1个字节传输时间(如115200bps下约87μs),建议100~200μs。
  • 半包处理:若协议允许,可在应用层设置超时机制,若超过一定时间未收到完整帧则丢弃。
  • 粘包拆分:若协议有明确帧头帧尾,可在应用层循环查找帧头并截取,但需注意跨缓冲区情况。
  • 中断优先级:IDLE中断优先级应高于定时器中断,确保及时响应。
  • 内存对齐:DMA缓冲区建议使用__attribute__((aligned(4)))对齐,提高访问效率。

通过上述策略,可显著降低半包与粘包导致的解析错误率,适用于Modbus、自定义二进制协议等场景。