# 用DMA环形缓冲区实现UART不定长帧接收:半满中断与空闲中断的竞争处理 ## 一、问题背景 在嵌入式系统中,UART接收不定长帧(如AT指令、Modbus协议)时,常用DMA+环形缓冲区来降低CPU负载。DMA将数据连续写入内存,当缓冲区半满或发生总线空闲时触发中断,CPU再批量处理。但半满中断(HT)和空闲中断(IDLE)可能同时或先后触发,若处理不当,会造成数据重复、丢失或指针错乱。 ## 二、原理剖析 ### 1. DMA环形缓冲区机制 - 环形缓冲区:固定大小(如256字节),DMA写指针(NDTR)递减,读指针由软件维护。 - 半满中断(HT):当DMA传输达到缓冲区一半时触发,用于及时搬运前半部分数据。 - 传输完成中断(TC):整个缓冲区写满时触发,但环形模式下通常不启用,而是依赖HT和IDLE。 ### 2. 空闲中断(IDLE) - 当UART接收线上空闲(即无新数据)超过一个字节时间时触发,表示一帧数据可能结束。 - 在不定长帧中,IDLE是判断帧结束的关键信号。 ### 3. 竞争根源 - 当数据长度恰好跨越半满点,且随后出现空闲,HT和IDLE几乎同时产生。 - 若在IDLE中断中直接读取DMA剩余计数(NDTR)来计算数据长度,而此时HT尚未处理,则可能漏掉前半部分数据。 - 反之,若HT先处理,但IDLE后到,则可能重复处理同一段数据。 ## 三、解决方案:状态机+双缓冲处理 核心思想:将缓冲区分为两个半区(A和B),用状态机记录当前处理位置,确保每个字节只被处理一次。 ### 1. 数据结构定义 ```c #define BUF_SIZE 256 #define HALF_SIZE (BUF_SIZE/2) typedef struct { uint8_t data[BUF_SIZE]; volatile uint8_t read_index; // 软件读指针(0~BUF_SIZE-1) volatile uint8_t half_flag; // 半满中断标志 volatile uint8_t idle_flag; // 空闲中断标志 } UART_RingBuf; UART_RingBuf uart_rb; ``` ### 2. 中断服务函数(以STM32 HAL为例) ```c void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 半满中断回调(注意:HAL中HT和TC共用此回调,需判断) if (huart->Instance->SR & USART_SR_TC) { // 处理半满(此处为简化,实际需区分HT和TC) uart_rb.half_flag = 1; } } void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { // 空闲中断在HAL中通过ErrorCallback的UART_ERROR_IDLE触发 if (huart->ErrorCode & HAL_UART_ERROR_IDLE) { uart_rb.idle_flag = 1; __HAL_UART_CLEAR_IDLEFLAG(huart); } } ``` ### 3. 主循环处理逻辑 ```c void ProcessUARTData(void) { static uint8_t current_half = 0; // 0表示处理A半区,1表示B半区 uint16_t data_len; uint8_t *data_ptr; if (uart_rb.half_flag) { uart_rb.half_flag = 0; // 处理当前半区(由current_half决定) if (current_half == 0) { data_ptr = &uart_rb.data[0]; data_len = HALF_SIZE; } else { data_ptr = &uart_rb.data[HALF_SIZE]; data_len = HALF_SIZE; } // 处理数据(如解析帧) ProcessFrame(data_ptr, data_len); // 切换半区 current_half ^= 1; } if (uart_rb.idle_flag) { uart_rb.idle_flag = 0; // 计算剩余数据长度(DMA当前计数) uint16_t remaining = __HAL_DMA_GET_COUNTER(huart.hdmatx); // 注意:这里应使用RX的DMA句柄 uint16_t total_received = BUF_SIZE - remaining; // 确定未处理的数据区域(从当前半区到DMA写指针) // 注意:此处需结合current_half和DMA写指针计算 uint8_t *start_ptr; uint16_t len; if (current_half == 0) { // 如果当前处理A半区,则未处理数据可能在B半区或A半区后半部分 if (total_received < HALF_SIZE) { // 数据都在A半区前半部分(但A半区可能已被处理过?需仔细判断) // 简化处理:直接从头到尾扫描(实际应更精细) start_ptr = &uart_rb.data[0]; len = total_received; } else { // 数据跨半区,但A半区已处理,只需处理B半区 start_ptr = &uart_rb.data[HALF_SIZE]; len = total_received - HALF_SIZE; } } else { // 类似逻辑,但需考虑B半区已处理的情况 // ... } ProcessFrame(start_ptr, len); // 重置DMA接收(重新启动) HAL_UART_Receive_DMA(&huart, uart_rb.data, BUF_SIZE); } } ``` ## 四、注意事项 - **中断优先级**:确保DMA的HT/TC中断优先级高于UART空闲中断,或反之,但必须保证处理顺序一致,建议HT高于IDLE,避免IDLE先处理导致数据未就绪。 - **临界区保护**:在中断中修改标志位时,主循环读取需关中断或使用原子操作。 - **DMA配置**:必须启用DMA循环模式(Circular),否则缓冲区写满后停止。 - **空闲中断清除**:在HAL中,空闲中断标志需手动清除,否则会反复触发。 - **边界情况**:当数据长度恰好等于半满或全满时,需特殊处理,避免重复或遗漏。 ## 五、优化与扩展 - 使用双缓冲(乒乓缓冲)替代环形缓冲区,可简化逻辑,但内存占用翻倍。 - 结合超时定时器,在IDLE未触发时(如连续数据流)也能及时处理。 - 对于高波特率,可将处理逻辑移至中断中,但需注意中断耗时。 ## 六、总结 半满中断与空闲中断的竞争是DMA环形缓冲区接收的经典难题。通过状态机记录处理位置,并合理设计中断优先级,可以确保每个字节被精确处理一次。实际项目中,还需根据具体协议调整处理逻辑,但核心思想不变:**明确每个中断的职责,用状态机避免重复或遗漏**。希望本文能帮助您构建更健壮的串口接收模块。