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 kHz11.2896 MHz2.8224 MHz
48 kHz12.288 MHz3.072 MHz
96 kHz24.576 MHz6.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();  // 初始化失败,检查时钟和引脚
    }
}
注意:I2S 初始化顺序有讲究。必须先配置 GPIO 复用功能,再初始化 I2S 外设,最后使能 DMA。我曾经因为顺序搞反,导致 DMA 传输时数据错位,查了两天才发现是引脚模式没配好。

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);
}
我的经验:缓冲区大小建议设为 512 或 1024 个采样点(16 位或 24 位)。太小会导致中断频繁,CPU 负载高;太大会增加延迟,影响实时性。对于 48kHz 采样率,512 点缓冲区大约对应 10.6ms 的延迟,人耳基本感觉不到。

3.3 双缓冲模式实现:乒乓切换与中断处理

双缓冲模式的关键在于中断处理。DMA 每完成一个缓冲区的传输,就会触发中断。在中断服务函数里,我们要做两件事:

  1. 切换当前缓冲区指针:记录当前 DMA 正在使用哪个缓冲区。
  2. 通知应用层填充数据:通过回调函数或信号量,告诉主循环可以往空闲缓冲区写新数据了。
// 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 数据流处理流程

整个数据流路径是这样的:

  1. 应用层:生成或解码 PCM 数据(比如从 SD 卡读取 WAV 文件)
  2. 缓冲区管理:将 PCM 数据填入空闲的 DMA 缓冲区
  3. DMA 传输:自动将缓冲区数据搬运到 I2S 数据寄存器
  4. I2S 外设:按帧格式将数据串行发送到 WM8960
  5. WM8960:接收数据,DAC 转换为模拟音频信号
音频数据流处理流程 应用层 PCM 数据生成 缓冲区管理 双缓冲切换 DMA 传输 内存→外设 I2S 外设 帧格式封装 WM8960 DAC 转换 关键点:双缓冲保证数据流不中断,DMA 零拷贝减少 CPU 负载 中断服务函数只做标志位设置,数据填充在主循环或任务中完成

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 比较方便。

小技巧:调试时可以用示波器看 I2S 的 BCLK 和 WS 信号。BCLK 频率应该是 采样率 × 数据位数 × 通道数。比如 48kHz、24 位、立体声:BCLK = 48000 × 24 × 2 = 2.304MHz。如果频率不对,先查时钟配置。

3.5 本章小结

这一章我们走通了 I2S 驱动的四个关键环节:

  • 初始化:时钟源、引脚映射、协议参数,一个都不能错
  • DMA 配置:选对通道和流,开启循环模式
  • 双缓冲模式:乒乓切换,中断里只做轻量操作
  • 数据流处理:PCM 到 I2S 帧的转换,注意数据对齐

说实话,I2S 驱动本身不难,但细节多。时钟频率算错一位,声音就变调;DMA 配置错一个参数,数据就乱套。我刚开始做的时候,光是调通一个 48kHz 的播放就花了两天。但一旦跑通,后面的事情就顺了。