用 DMA 环形缓冲区解析不定长串口协议:半满中断与空闲中断的竞争处理

一、问题背景与竞争根源

在STM32等MCU上,利用DMA接收串口数据并配合环形缓冲区,能大幅降低CPU负载。但处理不定长协议时,常同时启用DMA半满中断和串口空闲中断,以兼顾大数据块与短帧。然而,两者几乎同时触发时,若处理不当,会导致数据重复读取或丢失。

竞争根源

  • 半满中断在DMA传输达到缓冲区一半时触发,此时数据可能尚未完整到达;
  • 空闲中断在总线空闲(即一帧结束)时触发,但DMA可能已越过半满点;
  • 若在中断服务函数中直接读取DMA剩余计数并拷贝数据,两个中断可能操作同一批数据,造成竞态。

二、核心设计思路:状态机 + 双缓冲索引

解决竞争的关键是:将数据读取与缓冲区索引更新原子化,并用状态机区分当前处理阶段

2.1 环形缓冲区结构

定义缓冲区大小为2的幂次(如256字节),便于索引回绕。

#define BUF_SIZE 256
#define BUF_MASK (BUF_SIZE - 1)

uint8_t dma_buf[BUF_SIZE];
volatile uint16_t read_index = 0;  // 软件读取位置
volatile uint16_t write_index = 0; // DMA写入位置(由半满/空闲中断更新)

2.2 状态定义

typedef enum {
    IDLE,       // 等待帧开始
    RECEIVING,  // 接收中
    FRAME_READY // 一帧完整,待处理
} FrameState;

三、中断处理策略

3.1 半满中断处理

半满中断表示DMA已写入前一半缓冲区(或后一半,取决于方向)。此时更新write_index为当前DMA位置,并置位标志,但不立即解析数据,而是交由主循环处理。

void DMA1_Channel4_IRQHandler(void) {
    if (DMA_GetITStatus(DMA1_IT_HT4)) {
        DMA_ClearITPendingBit(DMA1_IT_HT4);
        // 更新写索引为当前DMA位置(即半满点)
        write_index = (BUF_SIZE / 2) * (DMA_GetCurrentMemoryTarget(DMA1_Channel4) ? 1 : 0) + (BUF_SIZE / 2);
        // 置位数据可用标志
        data_ready = 1;
    }
}

3.2 空闲中断处理

空闲中断在帧结束时触发,此时DMA可能已写入若干字节。需读取DMA剩余计数,计算实际接收长度,并更新write_index

void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1_IT_IDLE)) {
        USART_ClearITPendingBit(USART1_IT_IDLE);
        // 读取DMA剩余计数
        uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel4);
        uint16_t received = BUF_SIZE - remain;
        // 更新写索引(注意回绕)
        write_index = (write_index + received) & BUF_MASK;
        // 标记帧就绪
        frame_ready = 1;
    }
}

3.3 竞争消除:互斥与顺序

为避免半满和空闲同时操作write_index,采用以下措施:

  • 在中断中仅更新write_index,不读取数据;
  • 主循环中检测frame_readydata_ready,并一次性处理所有可用数据;
  • 使用临界区保护write_index的更新(如关中断)。
// 主循环处理
while (1) {
    if (frame_ready || data_ready) {
        __disable_irq();
        uint16_t current_write = write_index;
        frame_ready = 0;
        data_ready = 0;
        __enable_irq();
        
        // 解析从read_index到current_write之间的数据
        while (read_index != current_write) {
            uint8_t byte = dma_buf[read_index];
            // 状态机解析协议
            parse_byte(byte);
            read_index = (read_index + 1) & BUF_MASK;
        }
    }
}

四、完整代码示例(STM32F1xx)

以下为初始化及中断配置示例(使用HAL库简化):

// 初始化DMA和串口
void UART_DMA_Init(void) {
    // 使能时钟
    __HAL_RCC_DMA1_CLK_ENABLE();
    __HAL_RCC_USART1_CLK_ENABLE();
    
    // DMA配置
    DMA_HandleTypeDef hdma;
    hdma.Instance = DMA1_Channel4;
    hdma.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma.Init.MemInc = DMA_MINC_ENABLE;
    hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma.Init.Mode = DMA_CIRCULAR; // 循环模式
    hdma.Init.Priority = DMA_PRIORITY_HIGH;
    HAL_DMA_Init(&hdma);
    
    // 关联DMA到USART1
    __HAL_LINKDMA(&huart1, hdmarx, hdma);
    
    // 使能空闲中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    // 使能DMA半满/全满中断
    HAL_DMA_Start_IT(&hdma, (uint32_t)&huart1.Instance->DR, (uint32_t)dma_buf, BUF_SIZE);
}

// 中断回调(HAL库)
void HAL_UART_IDLE_Callback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 处理空闲中断,更新write_index
        uint16_t remain = __HAL_DMA_GET_COUNTER(huart->hdmarx);
        uint16_t received = BUF_SIZE - remain;
        write_index = (write_index + received) & BUF_MASK;
        frame_ready = 1;
    }
}

五、注意事项与调试技巧

  • 缓冲区大小必须为2的幂,否则回绕运算需取模,效率低且易错。
  • 半满中断与空闲中断可能同时发生:在空闲中断中应清除DMA半满标志,避免重复处理。
  • 主循环处理数据时,应关闭中断,防止write_index被修改导致数据不一致。
  • 协议解析建议采用状态机,如:等待帧头→接收长度→接收数据→校验,避免依赖中断时序。
  • 调试时可用逻辑分析仪抓取串口波形,对比中断触发时刻,验证竞争处理逻辑。

六、总结

通过将数据读取与索引更新分离,利用状态机协调半满和空闲中断,可有效消除竞争,实现稳定可靠的不定长串口协议解析。此方案在高速通信(如115200bps以上)下依然表现优异,是嵌入式开发中的经典实践。