# 用 DMA 环形缓冲区解析不定长串口协议:半满中断与空闲中断的竞争处理 ## 一、问题背景与竞争根源 在STM32等MCU上,利用DMA接收串口数据并配合环形缓冲区,能大幅降低CPU负载。但处理不定长协议时,常同时启用DMA半满中断和串口空闲中断,以兼顾大数据块与短帧。然而,两者几乎同时触发时,若处理不当,会导致数据重复读取或丢失。 **竞争根源**: - 半满中断在DMA传输达到缓冲区一半时触发,此时数据可能尚未完整到达; - 空闲中断在总线空闲(即一帧结束)时触发,但DMA可能已越过半满点; - 若在中断服务函数中直接读取DMA剩余计数并拷贝数据,两个中断可能操作同一批数据,造成竞态。 ## 二、核心设计思路:状态机 + 双缓冲索引 解决竞争的关键是:**将数据读取与缓冲区索引更新原子化,并用状态机区分当前处理阶段**。 ### 2.1 环形缓冲区结构 定义缓冲区大小为2的幂次(如256字节),便于索引回绕。 ```c #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 状态定义 ```c typedef enum { IDLE, // 等待帧开始 RECEIVING, // 接收中 FRAME_READY // 一帧完整,待处理 } FrameState; ``` ## 三、中断处理策略 ### 3.1 半满中断处理 半满中断表示DMA已写入前一半缓冲区(或后一半,取决于方向)。此时更新`write_index`为当前DMA位置,并置位标志,但不立即解析数据,而是交由主循环处理。 ```c 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`。 ```c 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_ready`或`data_ready`,并一次性处理所有可用数据; - 使用临界区保护`write_index`的更新(如关中断)。 ```c // 主循环处理 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库简化): ```c // 初始化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以上)下依然表现优异,是嵌入式开发中的经典实践。