3. STM32 I2S 外设驱动开发:从初始化到音频数据流
好,我们进入第三章。这一章是硬核的底层驱动开发,说白了就是让 STM32 的 I2S 外设真正跑起来,把音频数据从内存送到 WM8960 的 DAC,或者从 WM8960 的 ADC 收回来。
我个人习惯把 I2S 驱动拆成四个层次:模块初始化 → DMA 配置 → 双缓冲机制 → 数据流处理。每一层都有坑,咱们一个一个踩过去。
3.1 I2S 模块初始化:时钟、引脚与协议参数
先看 I2S 外设本身。STM32 的 I2S 其实复用了 SPI 的部分硬件,但工作模式完全不同。初始化时,我重点关注三个东西:时钟源、引脚映射、协议格式。
3.1.1 时钟树配置
I2S 的位时钟(BCLK)和主时钟(MCLK)都来自系统时钟分频。WM8960 作为从机,MCLK 由 STM32 提供,典型值是 256 × fs(采样率)。举个例子,采样率 48kHz 时,MCLK = 12.288MHz。
我在项目中遇到过一个问题:用内部 PLL 给 I2S 提供时钟,结果采样率偏差 0.5%,播放音乐时音调明显不对。后来改用外部晶振 + 精确分频才解决。嗯,音频应用对时钟精度要求很高,建议直接用外部晶振。
| 采样率 (fs) | MCLK (256×fs) | BCLK (32×fs × 通道数) |
|---|---|---|
| 44.1 kHz | 11.2896 MHz | 2.8224 MHz |
| 48 kHz | 12.288 MHz | 3.072 MHz |
| 96 kHz | 24.576 MHz | 6.144 MHz |
3.1.2 引脚与协议配置
I2S 需要 4 根信号线:CK(位时钟)、SD(数据)、WS(帧同步/左右声道选择)、MCK(主时钟)。STM32 的 I2S 引脚通常是固定的,比如 F4 系列用 PB12-CK、PB13-CK、PC3-SD、PC6-WS 等,具体查数据手册。
协议格式方面,WM8960 支持标准 I2S 格式(飞利浦格式),数据在 WS 变化后的第二个 BCLK 上升沿开始传输。我习惯用 24 位数据宽度,因为 WM8960 内部 DAC/ADC 是 24 位的,用 16 位会损失动态范围。
// I2S 初始化代码片段(HAL 库)
static void I2S_Init(void)
{
hi2s.Instance = SPI2; // I2S2 复用 SPI2
hi2s.Init.Mode = I2S_MODE_MASTER_TX; // 主发送模式
hi2s.Init.Standard = I2S_STANDARD_PHILIPS;
hi2s.Init.DataFormat = I2S_DATAFORMAT_24B;
hi2s.Init.MCLKOutput = I2S_MCLKOUTPUT_ENABLE;
hi2s.Init.AudioFreq = I2S_AUDIOFREQ_48K;
hi2s.Init.CPOL = I2S_CPOL_LOW;
if (HAL_I2S_Init(&hi2s) != HAL_OK)
{
Error_Handler(); // 初始化失败,检查时钟和引脚
}
}
3.2 DMA 配置:让数据自己跑起来
音频数据量大,用 CPU 逐字节搬运不现实。DMA 就是干这个的——它把内存中的数据自动搬到 I2S 的数据寄存器,或者反过来。CPU 只管填缓冲区和处理中断。
3.2.1 DMA 通道与流选择
STM32 F4/H7 系列有 DMA1 和 DMA2,每个控制器有 8 个流(Stream),每个流有多个通道(Channel)。I2S 的 DMA 请求映射到哪个通道,要看参考手册的 DMA 请求映射表。
举个例子,I2S2 的 TX 请求通常映射到 DMA1 的 Stream 4,Channel 0。RX 请求映射到 Stream 3,Channel 0。别记错了,我刚开始就搞混过 TX 和 RX 的流,结果数据发不出去。
3.2.2 双缓冲模式(Double Buffer)
双缓冲是音频驱动的核心技巧。说白了就是准备两个缓冲区:一个给 DMA 当前传输用,另一个给 CPU 填充数据用。两者交替,保证数据流不中断。
STM32 的 DMA 支持双缓冲模式(也叫乒乓缓冲)。配置时,需要设置两个内存地址:DMA_Memory0BaseAddr 和 DMA_Memory1BaseAddr。DMA 传输完一个缓冲区后,自动切换到另一个,并触发传输完成中断。
// DMA 双缓冲配置
static void DMA_Config(void)
{
hdma_tx.Instance = DMA1_Stream4;
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;
hdma_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_tx.Init.Mode = DMA_CIRCULAR; // 循环模式
hdma_tx.Init.Priority = DMA_PRIORITY_HIGH;
// 开启双缓冲
HAL_DMAEx_MultiBufferStart(&hdma_tx,
(uint32_t)audio_tx_buf0, // 缓冲区0
(uint32_t)&(SPI2->DR), // 外设地址
(uint32_t)audio_tx_buf1, // 缓冲区1
AUDIO_BUF_SIZE);
}
3.3 双缓冲模式实现:乒乓切换与中断处理
双缓冲模式的关键在于中断处理。DMA 每完成一个缓冲区的传输,就会触发中断。在中断服务函数里,我们要做两件事:
- 切换当前缓冲区指针:记录当前 DMA 正在使用哪个缓冲区。
- 通知应用层填充数据:通过回调函数或信号量,告诉主循环可以往空闲缓冲区写新数据了。
// DMA 传输完成中断回调
void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *hi2s)
{
// 判断当前 DMA 使用的是哪个缓冲区
uint32_t cur_addr = HAL_DMAEx_GetCurrentDestAddr(&hdma_tx);
if (cur_addr == (uint32_t)audio_tx_buf0)
{
// 缓冲区0 正在被 DMA 使用,CPU 填充缓冲区1
active_buf = 1;
// 触发填充回调
audio_fill_callback(audio_tx_buf1, AUDIO_BUF_SIZE);
}
else
{
// 缓冲区1 正在被 DMA 使用,CPU 填充缓冲区0
active_buf = 0;
audio_fill_callback(audio_tx_buf0, AUDIO_BUF_SIZE);
}
}
这里有个细节:HAL_DMAEx_GetCurrentDestAddr 返回的是 DMA 当前正在读取的内存地址。如果它等于 buf0,说明 buf0 正在被 DMA 使用,那 CPU 就应该往 buf1 写数据。反过来也一样。
memcpy 或复杂计算,导致中断执行时间过长,影响下一个 DMA 传输。后来我把数据填充逻辑放到任务级(RTOS 任务或主循环),中断里只设置一个标志位。记住,中断服务函数要短平快。
3.4 音频数据流处理:从 PCM 到 I2S 帧
数据流处理是最后一环。音频数据在内存中是 PCM 格式(裸采样点),但 I2S 传输时需要按帧组织。一个 I2S 帧包含左声道数据和右声道数据,WS 信号高电平表示左声道,低电平表示右声道。
3.4.1 数据格式转换
WM8960 支持 16 位、20 位、24 位、32 位数据宽度。我推荐用 24 位,因为:
- 24 位能充分利用 WM8960 的 DAC/ADC 动态范围(信噪比约 100dB)
- 16 位会损失低音量时的细节
- 32 位虽然更好,但传输带宽需求高,对 STM32 的 DMA 压力大
数据在内存中的排列方式要注意:STM32 是小端模式,而 I2S 协议要求数据高位在前(MSB first)。所以 24 位数据在内存中是低字节在前,但 I2S 传输时先发高字节。这个转换由硬件自动完成,我们只需要保证数据对齐即可。
3.4.2 数据流处理流程
整个数据流路径是这样的:
- 应用层:生成或解码 PCM 数据(比如从 SD 卡读取 WAV 文件)
- 缓冲区管理:将 PCM 数据填入空闲的 DMA 缓冲区
- DMA 传输:自动将缓冲区数据搬运到 I2S 数据寄存器
- I2S 外设:按帧格式将数据串行发送到 WM8960
- WM8960:接收数据,DAC 转换为模拟音频信号
3.4.3 实际代码中的数据处理
在填充缓冲区时,要注意左右声道数据的排列。对于 24 位数据,每个采样点占 3 个字节,左声道和右声道交替存放。举个例子:
// 填充音频缓冲区(24位立体声)
void audio_fill_callback(uint32_t *buf, uint32_t size)
{
// size 是采样点个数(每个采样点 24 位)
for (uint32_t i = 0; i < size; i += 2)
{
// 左声道数据
buf[i] = get_next_sample_left(); // 24 位数据,存放在 32 位变量的低 24 位
// 右声道数据
buf[i+1] = get_next_sample_right();
}
}
这里要注意:STM32 的 I2S 数据寄存器是 16 位的(对于 16 位数据格式)或 32 位的(对于 24/32 位格式)。如果配置为 24 位数据格式,每次传输 32 位数据(高 8 位无效或填充 0)。所以缓冲区元素类型用 uint32_t 比较方便。
3.5 本章小结
这一章我们走通了 I2S 驱动的四个关键环节:
- 初始化:时钟源、引脚映射、协议参数,一个都不能错
- DMA 配置:选对通道和流,开启循环模式
- 双缓冲模式:乒乓切换,中断里只做轻量操作
- 数据流处理:PCM 到 I2S 帧的转换,注意数据对齐
说实话,I2S 驱动本身不难,但细节多。时钟频率算错一位,声音就变调;DMA 配置错一个参数,数据就乱套。我刚开始做的时候,光是调通一个 48kHz 的播放就花了两天。但一旦跑通,后面的事情就顺了。