# 用 DMA 环形缓冲区实现 UART 不定长帧接收:半满与全满中断的边界竞态精确处理 ## 一、背景与问题定义 在 STM32 等 MCU 上,利用 DMA 的循环模式接收 UART 数据,可大幅降低 CPU 负载。通常配置 DMA 传输长度(如 256 字节),并开启半传输中断(HT)和传输完成中断(TC)。当缓冲区半满或全满时,CPU 在中断中搬运数据到应用缓冲区,形成环形缓冲。 然而,**边界竞态**发生在以下场景: - 数据恰好落在半满点(如第 128 字节)时,HT 中断触发,但此时 DMA 可能已写入第 129 字节。 - 数据跨越半满点后,TC 中断触发,但 CPU 尚未处理完 HT 中断的数据,导致重复读取或漏读。 - 更隐蔽的是,当数据长度恰好等于半满或全满时,中断标志可能同时或先后置位,若处理不当,会丢失边界字节。 ## 二、竞态根源分析 DMA 的 HT 和 TC 中断基于传输计数(DMA_CNDTR)判断。在循环模式下,CNDTR 从初值递减到半值或零时触发中断。但中断响应有延迟,且 CPU 读取 CNDTR 时可能已变化,导致判断不准确。 核心矛盾:**中断触发点与数据实际写入点不同步**。例如: - 配置 DMA 长度为 256,半满点为 128。当 CNDTR 从 128 减到 127 时,HT 中断置位,但此时 DMA 已写入第 129 个字节(索引 128)。若 CPU 在中断中只处理前 128 字节,则第 129 字节会被下一次处理覆盖。 - 同理,TC 中断在 CNDTR 从 1 减到 0 时触发,但 DMA 已写入第 256 字节,且可能已回绕到缓冲区开头写入新数据。 ## 三、解决方案:基于状态机的双缓冲处理 ### 3.1 设计思路 采用**双缓冲索引 + 状态机**,避免依赖中断标志的精确性,而是通过记录 DMA 当前写位置(CNDTR 反推)和上次处理位置,计算可安全处理的数据量。 核心变量: - `dma_buffer`:DMA 循环缓冲区,大小 `BUF_SIZE`(2 的幂,便于取模)。 - `last_pos`:上次处理到的位置(字节索引)。 - `state`:当前状态(IDLE、HALF、FULL)。 ### 3.2 中断处理逻辑 在 HT 和 TC 中断中,不直接处理数据,而是设置标志位,并在主循环或中断尾部调用统一处理函数。 ```c #define BUF_SIZE 256 #define HALF_SIZE (BUF_SIZE/2) volatile uint8_t dma_buffer[BUF_SIZE]; volatile uint32_t last_pos = 0; volatile uint8_t state = 0; // 0:IDLE, 1:HALF, 2:FULL void DMA_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT)) { DMA_ClearITPendingBit(DMA1_IT_HT); state = 1; // 标记半满 } if (DMA_GetITStatus(DMA1_IT_TC)) { DMA_ClearITPendingBit(DMA1_IT_TC); state = 2; // 标记全满 } } void Process_DMA_Buffer(void) { uint32_t current_pos = BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel4); uint32_t available = 0; if (state == 1) { // 半满:处理从 last_pos 到 HALF_SIZE 的数据 available = HALF_SIZE - last_pos; if (available > 0) { HandleData(&dma_buffer[last_pos], available); last_pos = HALF_SIZE; } state = 0; } else if (state == 2) { // 全满:处理从 last_pos 到 BUF_SIZE,再处理从 0 到 current_pos(可能已回绕) available = BUF_SIZE - last_pos; if (available > 0) { HandleData(&dma_buffer[last_pos], available); } // 处理回绕部分(从 0 到 current_pos) if (current_pos > 0) { HandleData(&dma_buffer[0], current_pos); } last_pos = current_pos; state = 0; } } ``` ### 3.3 消除竞态的关键点 - **使用 current_pos 而非中断标志**:通过 `DMA_GetCurrDataCounter()` 获取当前写位置,确保处理的数据量准确。 - **状态机防止重复处理**:每次中断只处理一次,且 `last_pos` 更新后,下次中断基于新位置计算。 - **处理回绕**:全满时,数据可能已回绕到缓冲区开头,需分段处理。 ## 四、完整代码示例(STM32F1 系列) ```c // 初始化 DMA void UART_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // USART 配置(略) DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)dma_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel4, &DMA_InitStructure); // 开启 HT 和 TC 中断 DMA_ITConfig(DMA1_IT_HT4, ENABLE); DMA_ITConfig(DMA1_IT_TC4, ENABLE); // 使能 DMA 和 USART DMA_Cmd(DMA1_Channel4, ENABLE); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); // 配置 NVIC(略) } // 数据处理函数(用户实现) void HandleData(uint8_t *data, uint32_t len) { // 例如:解析帧、存储到环形队列等 } // 主循环中调用 int main(void) { // 初始化... while (1) { if (state != 0) { Process_DMA_Buffer(); } // 其他任务 } } ``` ## 五、注意事项 - **缓冲区大小应为 2 的幂**,便于取模运算,提高效率。 - **中断优先级**:确保 DMA 中断优先级高于其他可能长时间阻塞的中断,避免数据覆盖。 - **临界区保护**:在更新 `last_pos` 和 `state` 时,可暂时关闭中断或使用临界区,防止主循环与中断冲突。 - **测试边界情况**:重点测试数据长度恰好为 128、256、129 等边界值,验证无数据丢失。 - **性能考量**:若数据速率极高,可在中断中直接处理,但需保证处理时间短于 DMA 写入一个字节的时间。 ## 六、总结 通过状态机与 DMA 当前计数器的结合,我们精确地解决了半满/全满中断的边界竞态。该方法不依赖中断标志的精确时序,而是基于实际写位置计算可处理数据量,从根本上避免了重复或遗漏。此模式可广泛应用于各种需要 DMA 接收不定长帧的嵌入式场景,如串口通信、传感器数据采集等。