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 当前目标地址寄存器(如 NDTRCMAR)来获取实时写位置,并利用双缓冲标志判断当前正在使用的缓冲区。

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 为例)

  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 流和中断配置,并充分测试边界条件(如缓冲区恰好填满、回绕瞬间)。