# 用 DMA 环形缓冲区实现 UART 不定长帧接收:精准处理半满中断与空闲中断的竞争 ## 一、背景与挑战 在 STM32 等 MCU 上,UART 接收不定长帧时,常见方案是 DMA + 环形缓冲区。DMA 负责将数据从外设搬运到内存,CPU 通过中断感知数据到达。但 DMA 的半满中断(Half Transfer)和 UART 的空闲中断(IDLE)可能同时发生,若处理不当,会导致数据重复读取、丢失或帧错位。 ## 二、原理剖析 ### 1. DMA 半满中断与传输完成中断 - 当 DMA 配置为循环模式(Circular Mode)时,缓冲区被分为两半:上半区和下半区。 - 半满中断(HT)在 DMA 传输到缓冲区中点时触发,传输完成中断(TC)在传输到缓冲区末尾时触发。 - 这两个中断用于通知 CPU 可以安全处理已接收的数据,而不会与 DMA 写入冲突。 ### 2. UART 空闲中断 - 空闲中断在 UART 接收线上检测到空闲(即无数据)时触发,通常用于判断一帧数据结束。 - 空闲中断与 DMA 配合时,CPU 需要从 DMA 缓冲区中读取当前已接收的数据,但此时 DMA 可能仍在写入。 ### 3. 竞争场景 假设缓冲区大小为 256 字节,DMA 循环模式。 - **场景 A**:接收数据长度超过 128 字节,DMA 触发半满中断,CPU 处理上半区数据。此时若数据流短暂停顿,UART 触发空闲中断,CPU 又去处理整个缓冲区,导致上半区数据被重复处理。 - **场景 B**:接收数据长度小于 128 字节,DMA 未触发半满中断,空闲中断触发,CPU 需要读取当前 DMA 位置之前的数据,但 DMA 可能正在写入,导致读取不一致。 ## 三、处理策略 核心思想:**用状态机管理缓冲区读写位置,并利用 DMA 的当前数据指针(NDTR)来精确计算有效数据长度。** ### 1. 缓冲区设计 - 定义环形缓冲区,使用两个指针:读指针(CPU 使用)和写指针(DMA 更新)。 - 在 DMA 中断(HT/TC)中更新写指针,在空闲中断中根据当前 DMA 位置更新写指针并触发帧处理。 ### 2. 中断优先级与嵌套 - 将 DMA 中断优先级设为高于 UART 空闲中断,确保 DMA 中断能及时处理,避免数据覆盖。 - 在空闲中断中,先关闭 DMA 中断(或暂停 DMA),再读取数据,处理完后再恢复。 ### 3. 状态机实现 定义以下状态: - `IDLE`:等待数据。 - `RECEIVING`:正在接收数据。 - `FRAME_READY`:一帧数据完整,待处理。 在中断中更新状态,在主循环中处理帧数据。 ## 四、代码实现(基于 STM32 HAL 库) ### 1. 配置 DMA 和 UART ```c // 缓冲区定义 #define BUFFER_SIZE 256 uint8_t dma_buffer[BUFFER_SIZE]; // 环形缓冲区结构 typedef struct { uint16_t head; // 读指针 uint16_t tail; // 写指针(由中断更新) uint8_t buffer[BUFFER_SIZE]; } ring_buffer_t; ring_buffer_t rx_ring; // 初始化 DMA 接收 void uart_dma_init(void) { // 使能 UART 空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 启动 DMA 循环接收 HAL_UART_Receive_DMA(&huart1, dma_buffer, BUFFER_SIZE); // 初始化环形缓冲区 rx_ring.head = 0; rx_ring.tail = 0; } ``` ### 2. DMA 中断处理(半满和完成) ```c void DMA1_Channel5_IRQHandler(void) { if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_HT)) { __HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_HT); // 更新写指针到缓冲区中点 rx_ring.tail = BUFFER_SIZE / 2; // 处理上半区数据 process_data(0, BUFFER_SIZE / 2); } if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_TC)) { __HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_TC); // 更新写指针到缓冲区末尾 rx_ring.tail = BUFFER_SIZE; // 处理下半区数据 process_data(BUFFER_SIZE / 2, BUFFER_SIZE / 2); } } ``` ### 3. UART 空闲中断处理 ```c void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_IDLE); // 暂停 DMA,防止写入冲突 HAL_DMA_Abort(&hdma_usart1_rx); // 获取当前 DMA 剩余计数 uint16_t remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t current_pos = BUFFER_SIZE - remaining; // 更新写指针到当前位置 rx_ring.tail = current_pos; // 处理从上次读指针到当前写指针的数据 process_data(rx_ring.head, (rx_ring.tail - rx_ring.head + BUFFER_SIZE) % BUFFER_SIZE); // 重新启动 DMA 接收 HAL_UART_Receive_DMA(&huart1, dma_buffer, BUFFER_SIZE); // 重置读指针 rx_ring.head = rx_ring.tail; } } ``` ### 4. 数据解析函数 ```c void process_data(uint16_t start, uint16_t len) { // 从环形缓冲区中读取数据,进行帧解析 for (uint16_t i = 0; i < len; i++) { uint8_t byte = dma_buffer[(start + i) % BUFFER_SIZE]; // 处理字节,例如存入帧缓冲区 } // 更新读指针 rx_ring.head = (rx_ring.head + len) % BUFFER_SIZE; } ``` ## 五、注意事项 - **缓冲区大小**:应大于最大帧长,且为 2 的幂次方,便于取模运算。 - **临界区保护**:在中断中更新 `rx_ring.tail` 时,主循环可能正在读取,需使用临界区或原子操作。 - **DMA 暂停与恢复**:在空闲中断中暂停 DMA 后,必须确保在重新启动前,DMA 已经完全停止,否则可能丢失数据。 - **半满中断与空闲中断同时触发**:若两者同时发生,先处理半满中断(更新 tail),再处理空闲中断(根据当前 DMA 位置更新 tail),避免重复处理。 - **测试验证**:使用不同长度的数据帧(小于半满、等于半满、大于半满)进行压力测试,确保逻辑正确。 ## 六、总结 通过合理设计环形缓冲区、利用 DMA 的 HT/TC 中断和 UART 空闲中断,并采用状态机管理,可以有效解决半满中断与空闲中断的竞争问题。关键在于精确控制读写指针,并确保在中断处理中数据的一致性。此方案适用于大部分嵌入式平台,具有较高的可靠性和实时性。