3. DMA双缓冲机制原理

DMA双缓冲,说白了就是给音频数据流准备两个缓冲区,一个在后台装数据,一个给外设读数据。两个角色轮流切换,保证音频播放不会断流。我刚开始接触这个机制时也觉得有点绕,但搞明白之后就会发现——这其实是嵌入式音频里最优雅的数据搬运方案。

3.1 DMA循环模式与双缓冲对比

先说说DMA的循环模式。很多初学者会问:循环模式也能连续传输数据,为什么还要搞双缓冲?

嗯,这里有个关键区别。循环模式用的是单缓冲区,DMA传输完一轮后自动回到起始地址重新开始。听起来不错对吧?但问题来了——你没法在传输过程中安全地更新缓冲区内容。因为DMA随时可能正在读取你准备修改的位置。

对比项 DMA循环模式 DMA双缓冲模式
缓冲区数量 1个 2个
数据更新时机 只能在传输完成后更新 可在后台随时更新
中断频率 每轮一次 每半缓冲区一次
音频断流风险 高(更新不及时就断) 低(有缓冲余量)
CPU干预频率 低 适中

我在一个早期的项目里用过循环模式做音频播放,结果发现只要CPU稍微忙一点,没来得及更新缓冲区,喇叭里就会传出刺耳的爆音。后来换成双缓冲,这个问题就再没出现过。

3.2 双缓冲地址切换逻辑

双缓冲的核心思想其实很简单:两个缓冲区交替使用。但地址切换的细节,很多人第一次接触时会搞混。

STM32的DMA控制器里有两个关键的寄存器:CMAR(当前内存地址寄存器)和CMAR2(第二个内存地址寄存器)。双缓冲模式下,DMA会自动在这两个地址之间切换。

关键点:地址切换是硬件自动完成的,不需要软件干预。你只需要在初始化时设置好两个缓冲区的基地址,DMA会在每次传输完成后自动切换目标地址。

具体切换逻辑是这样的:

  1. DMA从缓冲区A开始读取数据,发送给WM8960的DAC
  2. 当缓冲区A的数据全部传输完毕,DMA自动将目标地址切换到缓冲区B
  3. 同时触发中断,通知CPU往缓冲区A写入新数据
  4. DMA继续从缓冲区B读取数据,CPU在后台填充缓冲区A
  5. 缓冲区B传输完毕,地址切回缓冲区A,触发中断,CPU填充缓冲区B

说白了就是「你读你的,我写我的」,两个操作互不干扰。

个人经验:我建议两个缓冲区的大小设置为音频帧的整数倍。比如音频帧大小是512字节,那每个缓冲区就设成1024字节(2帧)。这样中断频率适中,CPU负担也合理。

3.3 半传输与完全传输中断

双缓冲模式下,有两个中断事件特别重要:半传输中断(HTIF)和完全传输中断(TCIF)。

这两个中断的名字容易让人误解。我刚开始学的时候以为「半传输」是传输了一半数据,「完全传输」是全部传完了。其实在双缓冲语境下,它们的含义更具体:

中断类型 触发时机 在双缓冲中的含义
半传输中断(HTIF) 当前缓冲区传输到一半 缓冲区B已传输一半,缓冲区A即将可用
完全传输中断(TCIF) 当前缓冲区传输完成 缓冲区B传输完毕,切换回缓冲区A

实际应用中,我们通常只使用完全传输中断。因为半传输中断的时机比较尴尬——缓冲区还没完全空出来,你没法安全地写入新数据。

避坑指南:我曾经在一个项目里同时开启了HTIF和TCIF,结果中断频率翻倍,CPU负载飙升,音频反而出现了卡顿。后来只保留TCIF,一切正常。记住:双缓冲只需要一个中断点就够了。

下面这张图展示了双缓冲的完整工作流程:

DMA双缓冲音频传输流程图 缓冲区 A 地址: 0x20000000 状态: 空闲(可写入) 缓冲区 B 地址: 0x20000400 状态: 正在传输 STM32 DMA 控制器 当前目标地址: 缓冲区B WM8960 DAC I2S 数据输入 空闲 数据流 I2S 输出 TCIF 中断 当缓冲区B传输完成 → 触发TCIF中断 → CPU填充缓冲区A → DMA自动切换回缓冲区A

代码实现上,双缓冲的初始化配置是这样的:

// DMA双缓冲初始化示例(基于STM32H7)
DMA_HandleTypeDef hdma_spi1_tx;

void Audio_DMA_Init(void)
{
    // 定义两个缓冲区
    // 注意:缓冲区必须对齐到32字节(Cache Line大小)
    __ALIGN_BEGIN static uint16_t audio_buf_a[AUDIO_BUF_SIZE] __ALIGN_END;
    __ALIGN_BEGIN static uint16_t audio_buf_b[AUDIO_BUF_SIZE] __ALIGN_END;
    
    hdma_spi1_tx.Instance = DMA1_Stream0;
    hdma_spi1_tx.Init.Request = DMA_REQUEST_SPI1_TX;
    hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
    hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
    hdma_spi1_tx.Init.Mode = DMA_CIRCULAR;  // 循环模式
    hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;
    
    // 关键:设置双缓冲
    HAL_DMAEx_ConfigMuxSync(&hdma_spi1_tx, DMA_SYNC_FREE);
    HAL_DMAEx_MultiBufferConfig(&hdma_spi1_tx, 
                                 (uint32_t)audio_buf_a,  // 缓冲区A
                                 (uint32_t)audio_buf_b); // 缓冲区B
    
    // 使能完全传输中断
    HAL_DMA_RegisterCallback(&hdma_spi1_tx, 
                             HAL_DMA_XFER_CPLT_CB_ID, 
                             Audio_DMA_CompleteCallback);
    
    HAL_DMA_Init(&hdma_spi1_tx);
}

// 中断回调函数
void Audio_DMA_CompleteCallback(DMA_HandleTypeDef *hdma)
{
    // 判断当前DMA正在读取哪个缓冲区
    uint32_t current_buf = HAL_DMAEx_GetCurrentDestAddr(hdma);
    
    if (current_buf == (uint32_t)audio_buf_a)
    {
        // DMA正在读缓冲区A,填充缓冲区B
        FillAudioBuffer(audio_buf_b, AUDIO_BUF_SIZE);
    }
    else
    {
        // DMA正在读缓冲区B,填充缓冲区A
        FillAudioBuffer(audio_buf_a, AUDIO_BUF_SIZE);
    }
}

小技巧:用 HAL_DMAEx_GetCurrentDestAddr() 可以查询DMA当前正在读取哪个缓冲区。这个函数返回的是DMA内部寄存器的值,非常可靠。我习惯在中断里先调用这个函数,再决定填充哪个缓冲区。

最后说一个容易踩的坑:缓冲区大小必须一致。两个缓冲区的大小如果不同,DMA的地址切换逻辑会乱掉。我见过有人把缓冲区A设成512字节,缓冲区B设成1024字节,结果音频播放到一半就卡死了。嗯,这种问题排查起来特别费时间。

双缓冲机制说白了就是「空间换时间」——多花一倍的内存,换来流畅的音频播放。在STM32这种资源受限的MCU上,这笔交易非常划算。