一、为什么需要DMA+空闲中断?

传统串口接收采用单字节中断,每收到一个字节就进一次中断,在高波特率下CPU开销巨大。DMA+空闲中断的组合让DMA自动搬运数据,仅在总线空闲时触发一次中断,极大降低CPU负载。

  • DMA:自动将串口数据寄存器(DR)的内容搬运到内存缓冲区,无需CPU干预。
  • 空闲中断:当串口总线连续一个字节时间无新数据时触发,标志一帧数据结束。

但问题在于:空闲中断只表示“当前没有新数据”,并不保证一帧数据完整。如果数据发送方分多次发送,或总线受到干扰,就会出现半包(一帧被拆成多段)或粘包(多帧连在一起)的情况。

二、半包与粘包的成因分析

2.1 半包

发送方调用两次HAL_UART_Transmit发送“Hello”和“World”,中间有微小间隔。若间隔超过一个字节时间,接收方会触发两次空闲中断,第一次收到“Hello”,第二次收到“World”,但应用层期望的是“HelloWorld”。

2.2 粘包

发送方连续发送“AAA”和“BBB”,中间无间隔。DMA将“AAABBB”一次性搬入缓冲区,空闲中断只触发一次,应用层收到“AAABBB”,无法区分两帧。

三、边界处理核心策略

3.1 环形缓冲区+双指针

使用环形缓冲区存储DMA搬运的数据,维护write_index(DMA写入位置)和read_index(应用读取位置)。空闲中断时计算本次新增数据长度:

uint16_t new_len = (write_index - read_index + BUF_SIZE) % BUF_SIZE;

但仅靠长度无法区分半包与粘包,需结合时间戳或协议帧头。

3.2 时间戳判定半包

记录每次空闲中断的时间戳,若两次中断间隔小于预设阈值(如5ms),则认为是同一帧的延续,合并处理。

if (HAL_GetTick() - last_tick < FRAME_GAP_MS) {
    // 合并到上一帧
} else {
    // 新帧
}

3.3 协议帧头判定粘包

若应用层协议有固定帧头(如0xAA 0x55),可在接收数据中搜索帧头,按帧头切分粘包数据。

for (int i = 0; i < len - 1; i++) {
    if (buf[i] == 0xAA && buf[i+1] == 0x55) {
        // 找到新帧起始
    }
}

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

4.1 初始化配置

#define BUF_SIZE 256
uint8_t rx_buf[BUF_SIZE];
uint16_t write_index = 0;
uint32_t last_tick = 0;

void UART_DMA_Init(void) {
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

4.2 空闲中断回调

void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        uint16_t dma_remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        uint16_t new_write = BUF_SIZE - dma_remain;
        uint16_t len = (new_write - write_index + BUF_SIZE) % BUF_SIZE;
        if (len > 0) {
            uint32_t now = HAL_GetTick();
            if (now - last_tick < FRAME_GAP_MS) {
                // 半包:合并到上一帧(此处简化,实际需维护帧缓冲区)
                process_frame_append(&rx_buf[write_index], len);
            } else {
                // 新帧
                process_frame_new(&rx_buf[write_index], len);
            }
            last_tick = now;
            write_index = new_write;
        }
    }
    HAL_UART_IRQHandler(&huart1);
}

4.3 粘包切分函数

void process_frame_new(uint8_t *data, uint16_t len) {
    uint16_t start = 0;
    for (uint16_t i = 0; i < len - 1; i++) {
        if (data[i] == 0xAA && data[i+1] == 0x55) {
            if (i > start) {
                handle_single_frame(&data[start], i - start);
            }
            start = i;
        }
    }
    if (start < len) {
        handle_single_frame(&data[start], len - start);
    }
}

五、注意事项

  • DMA缓冲区大小:需大于最大可能帧长,否则会覆盖未处理数据。建议256~1024字节。
  • 空闲中断清除顺序:先清空闲标志再读DMA计数器,避免数据竞争。
  • 半包合并超时:若半包后长时间无后续数据,应强制超时处理,避免内存泄漏。
  • 粘包切分效率:逐字节搜索帧头在高速数据下可能成为瓶颈,可考虑状态机或硬件CRC辅助。
  • 多串口场景:每个串口独立维护环形缓冲区和时间戳,避免交叉干扰。

通过上述策略,可有效解决DMA+空闲中断接收中的半包与粘包问题,提升嵌入式串口通信的可靠性。