# STM32F4 串口 DMA 双缓冲环形队列:指针回绕与半满中断竞态深度解析 ## 一、背景与挑战 在嵌入式系统中,串口接收不定长数据(如 GPS 报文、AT 指令响应)时,传统中断逐字节接收会频繁打断 CPU。STM32F4 的 DMA 控制器支持双缓冲模式(Double Buffer Mode),可自动在两个内存缓冲区之间切换,配合 DMA 传输完成中断(TC)和半传输中断(HT),能显著降低中断频率。但若将两个缓冲区抽象为环形队列,则必须处理**指针回绕**(写指针从尾部跳到头部)与**半满中断**(HT 触发时 DMA 正在写第二个缓冲区)之间的竞态。 ## 二、原理剖析:双缓冲与环形队列的冲突 ### 2.1 DMA 双缓冲机制 - 使能 DMA 的 DBM(Double Buffer Mode)后,DMA 使用 Memory0 和 Memory1 两个地址,每传输完一个缓冲区(大小为固定值)自动切换目标内存,并触发 HT(半传输,即第一个缓冲区完成)或 TC(全传输,即第二个缓冲区完成)中断。 - 对于串口接收,DMA 始终在后台搬运数据,CPU 只需在中断中处理已填满的缓冲区。 ### 2.2 环形队列设计 - 将两个 DMA 缓冲区(假设各为 `BUF_SIZE` 字节)视为环形队列的存储区,逻辑上连续。维护 `read_index`(读指针)和 `write_index`(写指针),其中 `write_index` 由 DMA 硬件更新(实际是 DMA 当前目标地址偏移)。 - 问题:当 DMA 完成一个缓冲区(HT 或 TC)时,软件需要更新 `write_index`,但此时 DMA 可能已经切换到另一个缓冲区并开始写入。若软件错误地认为 `write_index` 等于缓冲区边界,就会与正在进行的 DMA 写入产生竞态。 ### 2.3 竞态场景 - 假设缓冲区大小为 64 字节,DMA 先写 Memory0(0~63),触发 HT 中断时,DMA 已切换到 Memory1(64~127)并开始写入。若在 HT 中断中,软件将 `write_index` 设为 64,但此时 DMA 可能已写入 Memory1 的若干字节(如 10 字节),则 `write_index` 应指向 74 而非 64。若直接设为 64,后续读取会漏掉这 10 字节。 - 反之,若在 TC 中断中,DMA 已回绕到 Memory0,软件需正确计算回绕后的偏移。 ## 三、解决方案:基于硬件状态的同步策略 核心思想:**不在中断中直接计算 `write_index`,而是通过读取 DMA 当前目标地址寄存器(如 `NDTR` 或 `CMAR`)来获取实时写位置**,并利用双缓冲标志判断当前正在使用的缓冲区。 ### 3.1 关键寄存器 - `DMA_GetCurrentMemoryTarget()`:返回当前 DMA 正在使用的内存目标(0 或 1)。 - `DMA_GetCurrDataCounter()`:返回剩余待传输字节数(NDTR),则当前写位置 = 当前缓冲区基址 + (缓冲区大小 - NDTR)。 ### 3.2 环形队列结构定义 ```c typedef struct { uint8_t buffer[2][BUF_SIZE]; // 双缓冲 volatile uint16_t read_index; // 读指针(软件维护) volatile uint8_t current_mem; // 上次中断时的内存目标 } RingQueue; ``` ### 3.3 中断处理函数(HT 和 TC 共用) ```c void DMA1_Stream5_IRQHandler(void) { if (DMA_GetITStatus(DMA1_Stream5, DMA_IT_HT) != RESET) { DMA_ClearITPendingBit(DMA1_Stream5, DMA_IT_HT); UpdateWriteIndex(); } if (DMA_GetITStatus(DMA1_Stream5, DMA_IT_TC) != RESET) { DMA_ClearITPendingBit(DMA1_Stream5, DMA_IT_TC); UpdateWriteIndex(); } } void UpdateWriteIndex(void) { uint8_t mem_target = DMA_GetCurrentMemoryTarget(DMA1_Stream5); uint16_t ndtr = DMA_GetCurrDataCounter(DMA1_Stream5); uint16_t current_pos; // 计算当前 DMA 写到的绝对位置(相对于环形队列起始) if (mem_target == 0) { current_pos = (BUF_SIZE - ndtr); // 正在写 Memory0 } else { current_pos = BUF_SIZE + (BUF_SIZE - ndtr); // 正在写 Memory1 } // 更新写指针(注意:read_index 可能大于 current_pos,需处理回绕) // 这里假设 read_index 始终落后于 write_index,且队列未满 if (current_pos >= read_index) { write_index = current_pos; } else { // 回绕情况:写指针已绕过尾部,但读指针还在前面 write_index = current_pos + 2 * BUF_SIZE; // 扩展逻辑地址 } // 记录当前内存目标,供下次判断 queue.current_mem = mem_target; } ``` ### 3.4 数据读取函数(供应用层调用) ```c uint16_t ReadData(uint8_t *dest, uint16_t len) { uint16_t available = (uint16_t)(write_index - read_index); // 注意逻辑回绕 if (available == 0) return 0; if (len > available) len = available; for (uint16_t i = 0; i < len; i++) { uint16_t idx = read_index % (2 * BUF_SIZE); // 映射到物理缓冲区 dest[i] = queue.buffer[idx / BUF_SIZE][idx % BUF_SIZE]; read_index++; } return len; } ``` ## 四、配置步骤(以 USART1 + DMA2 Stream5 为例) 1. **使能时钟**:`RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE);` 和 `RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);` 2. **配置 DMA**: - 设置外设地址为 `&USART1->DR`,内存地址为 `queue.buffer[0]` 和 `queue.buffer[1]`。 - 方向:外设到内存;模式:循环模式(Circular);双缓冲使能。 - 数据宽度:字节;缓冲区大小:`BUF_SIZE`。 3. **配置 USART**:使能 DMA 接收请求(`USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE);`)。 4. **使能 DMA 中断**:HT 和 TC 中断,并编写中断服务函数。 5. **初始化队列**:`read_index = 0; write_index = 0; current_mem = 0;` 并启动 DMA。 ## 五、注意事项 - **缓冲区大小必须小于等于 DMA 最大传输量(65535)**,且建议为 2 的幂,便于取模运算。 - **竞态窗口**:在 `UpdateWriteIndex` 中,读取 `NDTR` 和内存目标时,DMA 可能仍在写入,但这两个寄存器是实时更新的,因此读取到的值总是最新的,不存在中间态。但需注意,在 HT 中断触发时,DMA 可能已写入第二个缓冲区少量字节,此时 `NDTR` 已反映该偏移,所以计算出的 `current_pos` 是准确的。 - **队列溢出**:若应用层读取速度跟不上 DMA 写入,`write_index` 可能追上 `read_index`,导致数据覆盖。需在写入前检查剩余空间,或使用更大的缓冲区。 - **内存屏障**:在读取 `write_index` 时,建议使用 `__DMB()` 或 `volatile` 确保编译器不优化乱序。 - **中断优先级**:确保 DMA 中断优先级高于可能访问队列的其他中断,避免嵌套竞态。 ## 六、总结 通过利用 DMA 的实时寄存器状态(`NDTR` 和当前内存目标),可以精确计算环形队列的写指针,从而规避指针回绕与半满中断的竞态。此方案无需禁用中断或加锁,适用于高波特率、大数据量的嵌入式场景。开发者需结合具体 STM32F4 型号的参考手册,调整 DMA 流和中断配置,并充分测试边界条件(如缓冲区恰好填满、回绕瞬间)。