3. DMA双缓冲机制原理
DMA双缓冲,说白了就是给音频数据流准备两个缓冲区,一个在后台装数据,一个给外设读数据。两个角色轮流切换,保证音频播放不会断流。我刚开始接触这个机制时也觉得有点绕,但搞明白之后就会发现——这其实是嵌入式音频里最优雅的数据搬运方案。
3.1 DMA循环模式与双缓冲对比
先说说DMA的循环模式。很多初学者会问:循环模式也能连续传输数据,为什么还要搞双缓冲?
嗯,这里有个关键区别。循环模式用的是单缓冲区,DMA传输完一轮后自动回到起始地址重新开始。听起来不错对吧?但问题来了——你没法在传输过程中安全地更新缓冲区内容。因为DMA随时可能正在读取你准备修改的位置。
| 对比项 | DMA循环模式 | DMA双缓冲模式 |
|---|---|---|
| 缓冲区数量 | 1个 | 2个 |
| 数据更新时机 | 只能在传输完成后更新 | 可在后台随时更新 |
| 中断频率 | 每轮一次 | 每半缓冲区一次 |
| 音频断流风险 | 高(更新不及时就断) | 低(有缓冲余量) |
| CPU干预频率 | 低 | 适中 |
我在一个早期的项目里用过循环模式做音频播放,结果发现只要CPU稍微忙一点,没来得及更新缓冲区,喇叭里就会传出刺耳的爆音。后来换成双缓冲,这个问题就再没出现过。
3.2 双缓冲地址切换逻辑
双缓冲的核心思想其实很简单:两个缓冲区交替使用。但地址切换的细节,很多人第一次接触时会搞混。
STM32的DMA控制器里有两个关键的寄存器:CMAR(当前内存地址寄存器)和CMAR2(第二个内存地址寄存器)。双缓冲模式下,DMA会自动在这两个地址之间切换。
关键点:地址切换是硬件自动完成的,不需要软件干预。你只需要在初始化时设置好两个缓冲区的基地址,DMA会在每次传输完成后自动切换目标地址。
具体切换逻辑是这样的:
- DMA从缓冲区A开始读取数据,发送给WM8960的DAC
- 当缓冲区A的数据全部传输完毕,DMA自动将目标地址切换到缓冲区B
- 同时触发中断,通知CPU往缓冲区A写入新数据
- DMA继续从缓冲区B读取数据,CPU在后台填充缓冲区A
- 缓冲区B传输完毕,地址切回缓冲区A,触发中断,CPU填充缓冲区B
说白了就是「你读你的,我写我的」,两个操作互不干扰。
个人经验:我建议两个缓冲区的大小设置为音频帧的整数倍。比如音频帧大小是512字节,那每个缓冲区就设成1024字节(2帧)。这样中断频率适中,CPU负担也合理。
3.3 半传输与完全传输中断
双缓冲模式下,有两个中断事件特别重要:半传输中断(HTIF)和完全传输中断(TCIF)。
这两个中断的名字容易让人误解。我刚开始学的时候以为「半传输」是传输了一半数据,「完全传输」是全部传完了。其实在双缓冲语境下,它们的含义更具体:
| 中断类型 | 触发时机 | 在双缓冲中的含义 |
|---|---|---|
| 半传输中断(HTIF) | 当前缓冲区传输到一半 | 缓冲区B已传输一半,缓冲区A即将可用 |
| 完全传输中断(TCIF) | 当前缓冲区传输完成 | 缓冲区B传输完毕,切换回缓冲区A |
实际应用中,我们通常只使用完全传输中断。因为半传输中断的时机比较尴尬——缓冲区还没完全空出来,你没法安全地写入新数据。
避坑指南:我曾经在一个项目里同时开启了HTIF和TCIF,结果中断频率翻倍,CPU负载飙升,音频反而出现了卡顿。后来只保留TCIF,一切正常。记住:双缓冲只需要一个中断点就够了。
下面这张图展示了双缓冲的完整工作流程:
代码实现上,双缓冲的初始化配置是这样的:
// 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上,这笔交易非常划算。