4. I2S音频数据传输:全双工模式、DMA配置与乒乓缓冲
好,咱们进入音频传输的核心环节。I2S总线怎么把数据从STM32送到WM8960,再从WM8960收回来?这里面的门道不少。我个人习惯把I2S传输比作一条双向高速公路——你得同时发数据、收数据,还不能堵车。
4.1 I2S全双工模式:为什么必须同时收发?
录音和播放是同时进行的。你想想看,耳机里听到自己的声音,这叫“本地回放”。如果I2S只发不收,那录音数据从哪来?如果只收不发,喇叭又怎么响?
STM32的I2S外设支持全双工模式。说白了,就是有两根数据线:SD(数据输出)和SD(数据输入)。注意,有些芯片把这两根线分开叫SD_OUT和SD_IN。我刚开始做项目时就被这个命名坑过——以为一根线就能搞定收发,结果焊完板子发现没声音。
全双工I2S的关键引脚:
- CK — 位时钟(BCLK),由主机提供
- WS — 帧同步(LRCK),左右声道切换
- SD — 串行数据输出(STM32发数据给WM8960)
- SD — 串行数据输入(WM8960发数据给STM32)
WM8960作为从机,时钟由STM32提供。STM32的I2S外设配置为主机模式,WM8960配置为从机模式。这个主从关系一旦搞反,数据就全乱了。我在项目中遇到过客户把主从设反,折腾了两天才找到原因。
4.2 DMA配置:别让CPU干苦力
音频数据是连续的,每秒几万次中断,CPU根本扛不住。所以必须用DMA。DMA就像个搬运工,把数据从内存搬到I2S发送寄存器,或者从I2S接收寄存器搬到内存,全程不占用CPU。
STM32的DMA控制器支持多个数据流。我建议给I2S的发送和接收各分配一个DMA通道。这样收发可以并行,互不干扰。
配置DMA时,有几个参数必须注意:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 数据宽度 | 半字(16位) | WM8960默认16位数据,与I2S数据宽度一致 |
| 传输方向 | 内存到外设(发送)/ 外设到内存(接收) | DMA方向必须与I2S方向匹配 |
| 循环模式 | 使能 | 音频数据是连续的,需要循环传输 |
| 增量模式 | 使能(内存地址递增) | 每次传输后自动指向下一个数据 |
我的经验:DMA优先级别设太高。如果DMA和CPU抢总线,其他外设可能卡死。我一般把音频DMA设为中等优先级,够用就行。
HAL库的DMA配置代码大致如下:
// 发送DMA配置
hdma_spi1_tx.Instance = DMA2_Stream3;
hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3;
hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
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_MEDIUM;
HAL_DMA_Init(&hdma_spi1_tx);
// 接收DMA配置
hdma_spi1_rx.Instance = DMA2_Stream0;
hdma_spi1_rx.Init.Channel = DMA_CHANNEL_3;
hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_spi1_rx.Init.Mode = DMA_CIRCULAR;
hdma_spi1_rx.Init.Priority = DMA_PRIORITY_MEDIUM;
HAL_DMA_Init(&hdma_spi1_rx);
注意,DMA的循环模式必须使能。否则传输完一轮就停了,音频就断了。我曾经忘了使能循环模式,结果录音只录了1秒钟就没了,排查了半天才发现是DMA停在了缓冲区末尾。
4.3 音频数据缓冲区管理:别让数据溢出
DMA只管搬运,但数据搬到哪里去?这就需要一个缓冲区。缓冲区的大小直接影响延迟和稳定性。
缓冲区太小,CPU来不及处理,数据就丢了。缓冲区太大,延迟就高,实时性变差。我一般这样选:
- 采样率48kHz,16位立体声:每秒数据量 = 48000 × 2 × 2 = 192KB/s
- 缓冲区大小:建议512或1024个采样点(每个采样点16位)
- 缓冲区数量:至少2个(乒乓缓冲)
为什么是2个?因为DMA在往缓冲区A写数据时,CPU可以处理缓冲区B的数据。等DMA写满A,自动切换到B,CPU就处理A。这样交替进行,数据永远不会断。
注意:缓冲区大小必须是2的整数次幂。DMA的地址对齐要求严格,不是2的幂次可能会触发硬件错误。我吃过这个亏,后来养成了习惯:缓冲区大小一律用512、1024、2048。
4.4 乒乓缓冲机制:双缓冲的实战技巧
乒乓缓冲,说白了就是两个缓冲区轮流用。一个叫“前台缓冲区”,一个叫“后台缓冲区”。DMA写后台,CPU读前台;写满后交换角色。
实现乒乓缓冲的关键是DMA的半传输完成中断和传输完成中断。当DMA写满半个缓冲区时,触发半传输中断;写满整个缓冲区时,触发传输完成中断。在这两个中断里,切换当前可读的缓冲区。
我画了一张图,帮你理解这个过程:
代码实现上,我习惯这样定义缓冲区:
#define AUDIO_BUF_SIZE 512 // 每个缓冲区512个采样点
// 两个缓冲区
int16_t audio_buffer_A[AUDIO_BUF_SIZE];
int16_t audio_buffer_B[AUDIO_BUF_SIZE];
// 当前可读缓冲区指针
volatile int16_t* current_read_buf = NULL;
// DMA半传输完成回调
void HAL_I2S_RxHalfCpltCallback(I2S_HandleTypeDef *hi2s) {
current_read_buf = audio_buffer_A; // 前半部分写满,可读A
}
// DMA传输完成回调
void HAL_I2S_RxCpltCallback(I2S_HandleTypeDef *hi2s) {
current_read_buf = audio_buffer_B; // 后半部分写满,可读B
}
我的习惯:在回调函数里只做指针切换,不做数据处理。数据处理放到主循环或任务里。这样中断服务函数执行时间短,不会影响其他中断。我曾经在回调里直接做音频滤波,结果中断嵌套把系统搞崩了。
主循环里这样处理数据:
while (1) {
if (current_read_buf != NULL) {
// 处理当前可读缓冲区
process_audio_data(current_read_buf, AUDIO_BUF_SIZE);
// 处理完后清空指针,等待下一次中断
current_read_buf = NULL;
}
// 其他任务...
}
嗯,这里要注意:current_read_buf是volatile的,因为它在中断里被修改。不加volatile,编译器可能优化掉这个变量,导致主循环永远读不到新数据。
4.5 实战中的几个坑
最后分享几个我踩过的坑:
- DMA数据流冲突:STM32的DMA数据流是共享的。如果I2S的发送和接收用了同一个数据流,会死锁。我建议发送用Stream3,接收用Stream0,错开。
- 缓冲区对齐:DMA要求缓冲区地址按半字对齐。如果缓冲区定义在结构体里,可能因为字节对齐问题导致DMA传输异常。我一般用
__attribute__((aligned(4)))强制对齐。 - 中断优先级:I2S中断优先级要高于普通任务,但低于系统时钟中断。否则音频数据可能被其他中断打断,产生爆音。
曾经踩过的坑:我在一个项目里用了乒乓缓冲,但忘了在中断里清空current_read_buf。结果主循环处理完一次数据后,指针还是指向旧缓冲区,导致重复处理同一批数据。音频听起来就像卡带一样。后来加了个NULL判断,问题解决。