# 引言 在嵌入式系统中,UART不定长接收是高频需求。传统逐字节中断在高速率下CPU负担重,而DMA+环形缓冲区能大幅降低中断频率,提升吞吐。然而,当启用DMA半满中断(HT)和帧超时定时器(如基于空闲中断或定时器)时,两者可能同时触发,形成竞态,导致数据被重复处理或遗漏。本文将从原理出发,剖析竞态,并给出可落地的解决方案。 ## 一、基础架构与竞态根源 ### 1. 典型架构 - **DMA**:配置为循环模式,接收数据写入环形缓冲区(内存数组)。 - **半满中断(HT)**:当DMA传输过半时触发,通知主循环处理前半部分数据。 - **帧超时**:利用定时器(如TIM)或UART空闲中断,检测到总线空闲一段时间(如3-5字节时间)认为一帧结束。 ### 2. 竞态场景 假设环形缓冲区大小为`BUF_SIZE`,DMA写指针`write_idx`由硬件维护,软件读指针`read_idx`。 - **场景A**:DMA刚触发HT中断(写指针到达`BUF_SIZE/2`),此时主循环开始处理前半段。若同时帧超时中断触发,软件可能误以为整帧数据已完整,而实际上后半段数据还在DMA中未处理。 - **场景B**:主循环处理完前半段后,更新`read_idx`,但DMA已写入后半段并再次触发HT,若帧超时处理逻辑未同步,可能重复处理或漏处理。 **核心矛盾**:HT中断表示“缓冲区半满”,不代表“一帧结束”;帧超时表示“总线空闲”,但不保证数据已全部搬移到缓冲区(可能还在DMA FIFO)。两者需协同,但时序交错导致状态不一致。 ## 二、解决方案:状态机+双指针同步 ### 1. 设计思路 - **状态机**:定义`IDLE`、`RECEIVING`、`FRAME_DONE`状态,由HT和超时事件驱动。 - **双指针**:`read_idx`(软件读)和`dma_write_idx`(通过DMA寄存器读取)。 - **超时处理**:在超时中断中,不直接处理数据,而是置位标志,由主循环统一处理,避免中断嵌套竞态。 ### 2. 具体步骤 1. **初始化**:配置DMA循环模式,使能HT和TC(传输完成)中断,但TC仅用于错误处理。启动一个基本定时器(如TIM6)用于超时,但仅在接收到首个字节后启动。 2. **接收启动**:UART空闲中断或外部事件触发时,启动DMA和定时器。 3. **HT中断处理**: - 读取当前DMA写指针`dma_pos`。 - 计算可读数据长度:`len = dma_pos - read_idx`(考虑回绕)。 - 若`len > 0`,将数据拷贝到应用缓冲区(或直接处理),更新`read_idx`。 - 注意:HT触发时,`dma_pos`可能位于`BUF_SIZE/2`,但若之前有未处理数据,需先处理完。 4. **超时中断处理**: - 停止定时器。 - 读取`dma_pos`,计算剩余数据(从`read_idx`到`dma_pos`),全部取出。 - 置位`frame_ready`标志,通知主循环解析完整帧。 - 重置状态机为`IDLE`。 5. **主循环**:检测到`frame_ready`,处理帧数据,然后重新准备接收。 ### 3. 竞态消除关键点 - **互斥**:HT和超时中断都操作`read_idx`,需用临界区保护(如关中断或使用原子操作)。 - **顺序**:超时中断中,先停止定时器,再读取DMA指针,确保没有新数据写入(因为总线空闲)。 - **状态检查**:在HT中,若状态为`IDLE`(即未在接收帧),则忽略,因为可能是残留数据。 ## 三、代码示例(以STM32 HAL库为例) ```c // 环形缓冲区定义 #define BUF_SIZE 256 uint8_t dma_rx_buf[BUF_SIZE]; volatile uint16_t read_idx = 0; volatile uint8_t frame_ready = 0; volatile uint8_t rx_state = 0; // 0:IDLE, 1:RECEIVING // DMA半满中断回调 void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 进入临界区 __disable_irq(); uint16_t dma_pos = BUF_SIZE - huart->hdmarx->Instance->NDTR; // 当前写位置 uint16_t len = (dma_pos >= read_idx) ? (dma_pos - read_idx) : (BUF_SIZE - read_idx + dma_pos); if (len > 0 && rx_state == 1) { // 处理前半段数据(此处仅示例,实际可拷贝到应用缓冲) process_data(&dma_rx_buf[read_idx], len); read_idx = dma_pos; } __enable_irq(); } } // 超时定时器中断(假设TIM6) void TIM6_IRQHandler(void) { if (TIM6->SR & TIM_SR_UIF) { TIM6->SR = ~TIM_SR_UIF; // 停止定时器 HAL_TIM_Base_Stop_IT(&htim6); __disable_irq(); uint16_t dma_pos = BUF_SIZE - huart1.hdmarx->Instance->NDTR; uint16_t len = (dma_pos >= read_idx) ? (dma_pos - read_idx) : (BUF_SIZE - read_idx + dma_pos); if (len > 0) { process_data(&dma_rx_buf[read_idx], len); read_idx = dma_pos; } frame_ready = 1; rx_state = 0; __enable_irq(); } } // UART空闲中断(用于启动帧接收) void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 启动DMA接收(循环模式) HAL_UART_Receive_DMA(&huart1, dma_rx_buf, BUF_SIZE); // 启动超时定时器(例如10ms) HAL_TIM_Base_Start_IT(&htim6); rx_state = 1; read_idx = 0; } } // 主循环 while (1) { if (frame_ready) { frame_ready = 0; // 解析完整帧 parse_frame(); // 重新准备接收(可重新开启空闲中断) __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } } ``` ## 四、注意事项 - **DMA配置**:务必使用循环模式(`HAL_UART_Receive_DMA`默认循环),否则半满后不再接收。 - **超时时间选择**:需大于最大帧间隔,通常取3-5个字节传输时间,波特率9600时约3ms,115200时约0.26ms,可用定时器分频实现。 - **临界区保护**:`read_idx`和`dma_pos`的读取需原子,否则可能读到不一致值。 - **缓冲区大小**:应为2的幂,便于回绕计算,且需大于最大帧长,避免覆盖。 - **中断优先级**:UART空闲中断应高于DMA HT和定时器,确保帧起始及时。 ## 五、扩展与优化 - **使用硬件空闲中断**:部分MCU(如STM32F4)支持UART空闲中断,可替代定时器,但需注意空闲中断在DMA传输中也会触发,需结合状态判断。 - **双缓冲**:若帧长较大,可配置两个半缓冲区,交替处理,减少拷贝。 - **调试技巧**:在关键点翻转GPIO,用逻辑分析仪观察时序,验证竞态是否消除。 # 结语 通过状态机协调DMA半满中断与帧超时,并利用双指针同步,可有效消除竞态,实现稳定高效的不定长接收。本文方案已在STM32F4系列上验证,可移植至其他平台。掌握此技巧,你的嵌入式通信将更加健壮。