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

串口空闲中断(IDLE)在总线连续空闲一个字节时间后触发,常与DMA配合实现不定长收包。但实际工程中,以下场景会导致问题:

  • 半包:一帧数据被拆成多次传输。例如上位机分两次发送同一帧,中间出现短暂空闲,IDLE提前触发,DMA只搬运了部分数据。
  • 粘包:多帧数据连续到达,中间无空闲间隔,IDLE仅在最后一帧结束后触发一次,DMA缓冲区中包含了多帧数据。

根本原因在于:IDLE中断只代表“当前总线空闲”,并不携带帧边界信息。若直接以IDLE触发作为一帧的结束标志,必然无法应对上述情况。

二、工程化处理思路:环形缓冲区+帧边界标记

核心思想:将DMA接收与协议解析解耦。DMA负责将数据搬运至环形缓冲区,IDLE中断仅记录“当前写入位置”作为候选帧边界,由应用层根据协议规则(如帧头、帧尾、长度字段)判断完整帧。

具体步骤:

  1. 配置DMA为循环模式,串口持续接收数据至环形缓冲区。
  2. 使能IDLE中断,在中断中计算本次DMA搬运的数据长度,并更新环形缓冲区的写指针。
  3. 应用层从环形缓冲区读取数据,依据协议提取完整帧,处理半包(缓存等待)与粘包(循环提取)。

三、硬件与软件配置

以STM32F4系列、HAL库为例,串口配置为115200-8-N-1,DMA循环模式,开启IDLE中断。

3.1 关键初始化代码

#define RX_BUF_SIZE 256

uint8_t rx_dma_buf[RX_BUF_SIZE]; // DMA目标缓冲区
volatile uint16_t rx_write_pos = 0; // 环形缓冲区写指针(由IDLE中断更新)
volatile uint8_t rx_frame_flag = 0; // 帧边界标记(可选)

void UART_DMA_Init(void) {
    // 启动DMA接收(循环模式)
    HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
    // 使能IDLE中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

3.2 IDLE中断处理

在stm32f4xx_it.c的USART1_IRQHandler中调用自定义处理函数:

void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        UART_IDLE_Handler();
    }
    HAL_UART_IRQHandler(&huart1);
}

UART_IDLE_Handler实现:

void UART_IDLE_Handler(void) {
    // 获取DMA当前剩余计数
    uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
    // 计算本次DMA搬运的数据量(考虑循环模式)
    static uint16_t last_pos = 0;
    uint16_t curr_pos = RX_BUF_SIZE - remain;
    uint16_t len;
    if (curr_pos >= last_pos) {
        len = curr_pos - last_pos;
    } else {
        len = RX_BUF_SIZE - last_pos + curr_pos;
    }
    if (len > 0) {
        // 更新环形缓冲区写指针(此处简化为直接累加,实际应使用环形缓冲区结构)
        rx_write_pos = (rx_write_pos + len) % RX_BUF_SIZE;
        last_pos = curr_pos;
        rx_frame_flag = 1; // 标记有新的数据段到达
    }
}

注意:上述代码中rx_write_pos与DMA缓冲区的关系需根据实际环形缓冲区设计调整。更稳健的方式是维护一个独立的环形缓冲区,将DMA数据拷贝进去,但会增加内存开销。本文为简化,直接使用DMA缓冲区作为环形存储,通过读写指针管理。

四、应用层帧提取:处理半包与粘包

应用层从环形缓冲区读取数据,依据协议(假设帧格式为:帧头0xAA 0x55 + 长度 + 数据 + 校验)提取完整帧。

#define FRAME_HEADER1 0xAA
#define FRAME_HEADER2 0x55
#define MAX_FRAME_LEN 128

uint8_t frame_buf[MAX_FRAME_LEN];
uint16_t frame_len = 0;

void Process_Rx_Data(void) {
    static uint16_t read_pos = 0;
    while (read_pos != rx_write_pos) {
        uint8_t byte = rx_dma_buf[read_pos];
        read_pos = (read_pos + 1) % RX_BUF_SIZE;
        
        // 状态机解析帧
        if (frame_len == 0) {
            if (byte == FRAME_HEADER1) {
                frame_buf[frame_len++] = byte;
            }
        } else if (frame_len == 1) {
            if (byte == FRAME_HEADER2) {
                frame_buf[frame_len++] = byte;
            } else {
                frame_len = 0; // 帧头错误,重置
            }
        } else {
            frame_buf[frame_len++] = byte;
            // 假设第3字节为长度字段(不含帧头与长度本身)
            if (frame_len >= 3) {
                uint8_t data_len = frame_buf[2];
                uint16_t total_len = 3 + data_len + 1; // 帧头2+长度1+数据+校验1
                if (frame_len == total_len) {
                    // 完整帧,提交处理
                    Handle_Frame(frame_buf, frame_len);
                    frame_len = 0;
                } else if (frame_len > total_len) {
                    frame_len = 0; // 长度异常,重置
                }
            }
        }
    }
}

半包处理:当数据不足一帧时,frame_len保留已接收部分,等待后续数据。 粘包处理:循环读取直到read_pos == rx_write_pos,一次处理多帧。

五、注意事项与优化建议

  • DMA循环模式与缓冲区溢出:若应用层处理不及时,写指针可能追上读指针,导致数据覆盖。建议增加缓冲区大小或使用双缓冲。
  • IDLE中断频率:高频IDLE中断可能增加CPU负载,可在中断中仅置标志,由主循环处理。
  • 临界区保护:读写指针的更新需考虑原子性,在中断与主循环间共享时,可使用__disable_irq()短暂保护。
  • 超时机制:对于半包,可增加超时定时器,若长时间未收全则丢弃,避免死等。
  • 校验与容错:完整帧需校验,失败则丢弃并重新同步帧头。

六、总结

DMA+空闲中断是STM32串口不定长收包的高效方案,但必须配合环形缓冲区与协议状态机,才能妥善处理半包与粘包。本文给出的工程化框架将数据搬运与帧解析分离,具备良好的可扩展性,适用于大多数嵌入式通信场景。开发者可根据实际协议调整帧提取逻辑,并注意缓冲区管理与异常保护,即可构建稳定可靠的串口接收模块。