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 环形队列结构定义
typedef struct {
uint8_t buffer[2][BUF_SIZE]; // 双缓冲
volatile uint16_t read_index; // 读指针(软件维护)
volatile uint8_t current_mem; // 上次中断时的内存目标
} RingQueue;
3.3 中断处理函数(HT 和 TC 共用)
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 数据读取函数(供应用层调用)
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 为例)
-
使能时钟:
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE);和RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); -
配置 DMA:
- 设置外设地址为
&USART1->DR,内存地址为queue.buffer[0]和queue.buffer[1]。 - 方向:外设到内存;模式:循环模式(Circular);双缓冲使能。
- 数据宽度:字节;缓冲区大小:
BUF_SIZE。
- 设置外设地址为
-
配置 USART:使能 DMA 接收请求(
USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE);)。 - 使能 DMA 中断:HT 和 TC 中断,并编写中断服务函数。
-
初始化队列:
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 流和中断配置,并充分测试边界条件(如缓冲区恰好填满、回绕瞬间)。