4、I2S与DMA结合:I2S接口初始化、DMA传输配置、双缓冲机制(Ping-Pong Buffer)、音频数据流控制

好,咱们进入正题。这一章是实战中的硬骨头,也是很多音频项目成败的关键。I2S 和 DMA 这对搭档,配合好了,音频流就像丝绸一样顺滑;配合不好,那就是噼里啪啦的爆音和卡顿。

我个人习惯把这一套流程叫做「音频高速公路」的搭建。I2S 是路面,DMA 是自动驾驶的卡车,而双缓冲就是两个交替使用的卸货区。咱们一个一个来拆解。

4.1 I2S 接口初始化:铺好这条路

先说 I2S 接口。很多芯片的 I2S 外设功能强大,但寄存器也多。初始化时,我建议你重点关注这几个参数:

  • 主从模式:一般我们让 MCU 做主设备,提供时钟(BCLK)和帧同步(LRCK)。
  • 数据格式:标准 I2S、左对齐、右对齐。绝大多数音频编解码器用标准 I2S。
  • 数据位宽:16位、24位、32位。这个要和你的音频数据匹配。
  • 采样率:44.1kHz、48kHz 等。这决定了 BCLK 的频率。

嗯,这里要注意。我在项目中遇到过一个问题:初始化顺序搞反了。先开了 DMA,再去配 I2S,结果 DMA 一直拿不到有效数据。正确的顺序应该是:先配好 I2S 的引脚和时钟,再配置 DMA,最后使能 I2S 传输。

一个典型的 I2S 初始化代码片段(以 STM32 HAL 库为例):

// 1. 配置 I2S 引脚(GPIO)
// 2. 配置 I2S 外设
hi2s.Instance = SPI2; // I2S 复用 SPI 外设
hi2s.Init.Mode = I2S_MODE_MASTER_TX;
hi2s.Init.Standard = I2S_STANDARD_PHILIPS;
hi2s.Init.DataFormat = I2S_DATAFORMAT_16B;
hi2s.Init.AudioFreq = I2S_AUDIOFREQ_48K;
hi2s.Init.CPOL = I2S_CPOL_LOW;
HAL_I2S_Init(&hi2s);

说白了,这一步就是告诉硬件:「嘿,你要用这个频率、这个格式,开始准备接收/发送数据了。」

4.2 DMA 传输配置:让卡车自己跑

没有 DMA 的时候,CPU 得亲自把每个采样点搬到 I2S 的发送寄存器。对于 48kHz 16位立体声,每秒要搬 48000 * 2 * 2 = 192000 次。CPU 啥也别干了。

DMA 就是来解决这个问题的。你只需要告诉它三件事:

  1. 源地址:你的音频数据缓冲区在哪儿。
  2. 目的地址:I2S 的发送/接收数据寄存器。
  3. 传输长度:一次传多少个数据单元。

配置 DMA 时,我建议开启循环模式。这样 DMA 传完一轮后会自动从头开始,非常适合连续的音频流。

我曾经在配置 DMA 时犯过一个低级错误:源地址和目的地址的宽度没设对。我设了源地址是 16 位,目的地址是 32 位,结果数据全乱了。音频听起来就像外星人在说话。所以,地址宽度一定要和 I2S 的数据格式严格对应。

DMA 配置示例:

hdma_tx.Instance = DMA1_Stream3;
hdma_tx.Init.Channel = DMA_CHANNEL_0;
hdma_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_tx.Init.PeriphInc = DMA_PINC_DISABLE;   // 外设地址不变
hdma_tx.Init.MemInc = DMA_MINC_ENABLE;       // 内存地址递增
hdma_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 16位
hdma_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;    // 16位
hdma_tx.Init.Mode = DMA_CIRCULAR;            // 循环模式,关键!
HAL_DMA_Init(&hdma_tx);

// 将 DMA 句柄与 I2S 句柄关联
__HAL_LINKDMA(&hi2s, hdmatx, hdma_tx);

4.3 双缓冲机制(Ping-Pong Buffer):两个卸货区

好了,路铺好了,卡车也跑起来了。但还有一个问题:CPU 什么时候往缓冲区里放新数据?

如果只有一个缓冲区,DMA 正在读这块区域,CPU 又往里面写,数据就会打架。结果就是音频出现「咔咔」的爆音。

双缓冲(Ping-Pong Buffer)就是解决这个问题的经典方案。说白了,就是准备两个缓冲区:

  • Ping Buffer:DMA 当前正在读取/写入的缓冲区。
  • Pong Buffer:CPU 正在准备数据的缓冲区。

当 DMA 传完 Ping Buffer 后,会自动切换到 Pong Buffer。同时触发一个中断,告诉 CPU:「Ping 用完了,你可以往里面填新数据了。」CPU 填好数据后,等 DMA 下次切换回来时,数据就是新鲜的。

你想想看,这样 CPU 和 DMA 就永远不会同时操作同一个缓冲区。音频流自然就稳定了。

核心要点:双缓冲的本质是「空间换时间」,用多一倍的 RAM 开销,换来无中断的连续数据流。

实现上,通常利用 DMA 的「半传输完成中断」和「传输完成中断」来切换缓冲区指针。代码逻辑大致如下:

// 定义两个缓冲区
int16_t ping_buffer[BUFFER_SIZE];
int16_t pong_buffer[BUFFER_SIZE];
int16_t *current_buffer = ping_buffer;

// DMA 半传输完成中断回调
void HAL_I2S_TxHalfCpltCallback(I2S_HandleTypeDef *hi2s) {
    // DMA 读完了 ping 的前半段?不对,这里是半传输完成
    // 实际上,对于循环模式,半传输完成意味着 DMA 指针到了缓冲区中间
    // 我们可以在这里填充另一个缓冲区
    if (current_buffer == ping_buffer) {
        // 准备 pong_buffer 的数据
        prepare_audio_data(pong_buffer, BUFFER_SIZE);
    } else {
        prepare_audio_data(ping_buffer, BUFFER_SIZE);
    }
}

// DMA 传输完成中断回调
void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *hi2s) {
    // 同理,切换缓冲区
    current_buffer = (current_buffer == ping_buffer) ? pong_buffer : ping_buffer;
}

我的小技巧:在调试阶段,可以在两个缓冲区里填充不同的音频数据(比如一个正弦波,一个方波)。如果听到的声音在交替变化,说明双缓冲切换成功了。如果听到的是杂音,那就要检查中断标志和指针切换逻辑了。

4.4 音频数据流控制:让一切井然有序

有了 I2S、DMA 和双缓冲,音频数据流的基本框架就有了。但实际项目中,还需要考虑几个控制点:

控制点 说明 我的经验
启动流程 先准备数据,再启动 DMA,最后启动 I2S 顺序错了容易导致第一个缓冲区是空的,产生「噗」的一声
暂停/恢复 暂停时停止 DMA 和 I2S 时钟,恢复时重新同步 直接停 DMA 可能会导致 I2S 时钟停在某个电平上,恢复后相位错乱
数据欠载/溢出 CPU 来不及填充缓冲区,或者 DMA 来不及读取 这是最常见的故障。我一般会加大缓冲区,或者优化 CPU 填充数据的代码
采样率转换 如果音频源和 I2S 采样率不一致,需要软件或硬件重采样 软件重采样很吃 CPU,能用硬件就用硬件

关于数据欠载,我曾经在一个低功耗项目里吃过亏。为了省电,我把 CPU 频率降得很低,结果 DMA 缓冲区空了,音频断断续续。后来我用了更大的缓冲区(比如 1024 个采样点),并且把填充数据的任务优先级调高,问题才解决。

警告:千万不要在主循环里用阻塞方式填充音频缓冲区!一定要用中断或 RTOS 任务。否则,一旦主循环被某个操作卡住,音频就会断流。

最后总结一下。I2S 与 DMA 结合,核心就是三件事:

  1. I2S 初始化:定好路面的规格(频率、格式、主从)。
  2. DMA 配置:让卡车自动、循环地跑起来。
  3. 双缓冲:用两个卸货区,避免 CPU 和 DMA 抢路。

做到这三点,你的音频数据流就能稳定、高效地运转了。下一章,咱们聊聊更高级的话题——多通道音频的 DMA 调度。