
做嵌入式音频相关的项目第一步往往就是“让单片机出声”。前阵子我把一颗 WM8978 音频 Codec 移植到了基于 STM32F407 的 HAL 库工程里用 I2S 接口传音频数据用 I2C 配置寄存器从查手册、对原理图、调时钟到最终耳机能听到歌、麦克风能录音整个过程花了不少时间也踩了不少坑。这篇文章把整套移植过程的思路和实操记录完整写下来适合正在用 STM32 接 WM8978、或者准备接其他 I2S Codec 的朋友参考。先说清楚这个项目到底做了什么STM32 通过 I2S 总线把数字音频流发给 WM8978WM8978 内部完成 DAC 数模转换后驱动耳机或扬声器反向的录音通路则通过 WM8978 的 ADC 把模拟麦克风信号转成数字流送回 STM32。这套驱动搞定之后后面做语音识别前端、录音笔、数字对讲机、蓝牙音箱主机都可以直接复用同一份底层代码扩展性很强。1. 项目全貌WM8978在系统中到底扮演什么角色1.1 选型逻辑为什么是STM32I2SWM8978有朋友问过STM32 不是自带 DAC 吗直接输出模拟信号不就行了严格来说STM32 内置 DAC 精度和驱动能力都有限只能输出很弱的模拟信号而且采样率、信噪比和音频应用差距很大。要真正做音频设备几乎都要外接一颗音频 Codec 芯片。Codec 的全称是 Coder/Decoder作用就是把数字音频转成模拟音频播放或者把模拟音频转成数字音频录音。WM8978 是欧胜Wolfson推出的一颗低功耗立体声 Codec内部集成了立体声 ADC、DAC、耳机放大器、麦克风偏置电路、混音器、音量和 EQ 控制非常适合 MCU 类的嵌入式音频项目。一颗芯片基本涵盖了音频前端到后端的全部模拟环节外围电路只需要少量阻容和参考电压配置。为什么选 I2S 而不是 I2C 来传音频数据因为 I2C 是低速控制总线标准模式才 100kbps传个寄存器配置没问题但传连续的音频采样流完全不够用。I2S 本身是专门为数字音频设计的串行总线以位时钟同步方式连续传输采样数据配合 DMA 几乎不占 CPU 资源。所以常见的方案就是I2S 传数据I2C 传配置两条总线各干各的。选 WM8978 还有个现实原因很多 STM32F407 开发板上已经贴好了 WM8978 和配套的耳机座、麦克风、喇叭接口硬件不用自己画直接拿来练手最合适。即使是你自己画板WM8978 的封装和外围电路也不复杂数据手册里给了完整的参考设计照着抄就行。1.2 整体数据通路一块Codec的前半生从宏观上看WM8978 左边是模拟世界右边是数字世界中间是 ADC/DAC 和混音开关。播放通路的信号流向是这样STM32 从 SD 卡或者 Flash 里读出 WAV/MP3 数据送入 I2S 发送寄存器通过 I2S_SD 数据线逐位发送WM8978 收到数字流后经过数字滤波、DAC 转换变成模拟信号再经过内部耳放或者外部功放输出到喇叭。整个过程中I2S 的位时钟 BCLK 和帧时钟 LRCK 由 STM32 产生WM8978 作为从机跟随时序。录音通路的流向刚好相反麦克风拾取的微弱模拟信号经过 WM8978 内部的 PGA 可编程增益放大进入 ADC 采样量化再经过数字滤波后打包成 I2S 帧数据由 WM8978 输出STM32 通过 I2S 接口接收。理解了这条主链路后面所有寄存器配置的意图就很好猜了哪个寄存器管电源哪个管时钟哪个管音频接口格式哪个管音量本质上都是在为这条链路各环节开关和设置参数。2. 底层原理把I2S和WM8978之间的约定讲清楚2.1 I2S四根线每一根都别接错I2S 总线在 STM32F4 上常用的是四根线SCK也叫 BCLK位时钟、WS也叫 LRCK左右声道帧时钟、SD串行数据线和 MCK主时钟可选。BCLK 的频率等于采样率乘上每个声道的位数再乘上声道数。举个例子标准 I2S 模式、16 位数据、双声道BCLK 就是fs * 16 * 2 32 * fs但实际传输时多数 Codec 每声道会占 32 个位时钟槽位所以 BCLK 通常是64 * fs。LRCK 用来区分左右声道频率等于采样率。比如采样率 48kHzLRCK 就是 48kHz 的方波。标准飞利浦 I2S 协议里LRCK 低电平传输左声道高电平传输右声道而且数据要比 LRCK 沿晚一个 BCLK 周期开始最高位先发。移植驱动时不需要从零实现这个时序STM32 的 SPI 外设复用成 I2S 模式之后硬件会自动处理位对齐和帧同步你要做的只是把采样数据按顺序往发送寄存器里丢。MCK 这根线最容易被忽略。WM8978 内部需要主时钟来驱动数字滤波器和 Σ-Δ 调制器这个主时钟可以来自外部有源晶振也可以由 STM32 的 I2S_MCK 引脚直接输出。绝大多数开发板采用的是后者所以 CubeMX 里需要把 I2S 的 Master Clock Output 使能。MCK 频率一般设计为采样率的 256 倍也就是256 * fs但实际工程里因为 PLL 分频取整问题这个值往往不是刚好等于理论值后面我会专门讲这个误差怎么处理。2.2 WM8978是“两个世界之间的翻译官”WM8978 这类 Codec 芯片的内部结构一句话概括就是一个数字音频接口一组模拟输入输出通道中间夹着 ADC、DAC、混音器和各种增益/音量控制模块。数字音频接口负责跟 STM32 的 I2S 对接支持标准 I2S、左对齐、右对齐和 DSP 格式数据位宽支持 16/20/24/32 位。初始化时STM32 和 WM8978 两端的音频接口格式必须完全一致否则采样数据错位声音会变成刺耳的爆音。模拟部分比较常用的是三路输出HPOUT_L/HPOUT_R 给耳机LOUT1/ROUT1 和 LOUT2/ROUT2 可以接功放或者 Line Out。输入部分有两路麦克风输入和一个辅助输入。实际项目里要看开发板原理图确认你的耳机和麦克风具体接到了哪些引脚然后去配置对应的电源管理寄存器和混音开关只开你用到的那几个通道就够了不用的通道关掉还能省电。音量控制也是 WM8978 的一个强项。耳机输出和 DAC 输出都有独立的数字音量寄存器从 6dB 到 -73dB 可调步进 0.5dB。移植驱动时很多人会忽略音量寄存器的默认值结果是初始化完喇叭没声音其实不是没信号而是默认音量为静音状态。这个坑我在调配时踩过后面问题排查部分会细说。2.3 控制接口与寄存器读写机制WM8978 的控制接口支持两线 I2C 和三线 SPI 两种模式由芯片引脚决定开发板上几乎都是 I2C 模式。I2C 地址有讲究WM8978 的 7 位从机地址通常是0x1A但在 STM32 HAL 库函数HAL_I2C_Master_Transmit()中传地址参数时要注意 HAL 已经帮你去掉了 R/W 位所以直接填0x1A而不是0x34。不少新手在这里卡很久逻辑分析仪一抓全是 NACK其实地址就错了。写寄存器的格式是三字节第一个字节是寄存器地址后面两个字节是 16 位数据高位在前。比如要写寄存器 R2数据是0x01BF那么发送序列就是0x02 0x01 0xBF。封装一个基础写寄存器函数之后所有上层配置都用这个函数操作。#define WM8978_I2C_ADDR 0x1A static void WM8978_WriteReg(uint8_t reg, uint16_t val) { uint8_t buf[3]; buf[0] reg; buf[1] (val 8) 0xFF; buf[2] val 0xFF; HAL_I2C_Master_Transmit(hi2c1, WM8978_I2C_ADDR, buf, 3, 100); }读寄存器稍微麻烦一点需要先发寄存器地址再发起读操作同样注意 HAL 里的地址参数填 7 位地址。不过实际移植过程中绝大部分场景只需要写寄存器读寄存器主要是用来回读芯片 ID 验证通信链路是否正常。3. 驱动移植实操CubeMX配置到代码落地3.1 硬件连接与引脚规划我用的是一块 STM32F407ZET6 核心板加 WM8978 子板它们之间通过排针连接。如果你也用类似的开发板先别急着写代码拿起原理图对照一下 I2S 和 I2C 引脚到底接到了 STM32 的哪些 IO 上。以最常见的 F407 开发板为例SPI2/I2S2 和 I2C1 的引脚对应关系如下功能STM32引脚连接对象I2S2_SCK / BCLKPB13WM8978 BCLKI2S2_WS / LRCKPB12WM8978 LRCKI2S2_SDPB15WM8978 DIN / DOUTI2S2_MCKPC6WM8978 MCLKI2C1_SCLPB6WM8978 I2C_SCLI2C1_SDAPB7WM8978 I2C_SDA你的板子引脚未必完全一样比如有些板子把 WM8978 接到了 SPI3/I2S3有些 I2C 用的是 I2C2这些都要以原理图为准。需要特别注意的是 I2S_SD 的方向播放时 STM32 作为主机发送SD 引脚是输出录音时 WM8978 作为从机发送数据同一个物理引脚要切换方向。好在 WM8978 在标准 I2S 从机模式下DIN 接收 MCU 数据、DOUT 发送数据给 MCU 是分开的如果引脚定义只连了 DIN那录音就得改硬件。我用的这块子板把 DIN 和 DOUT 都引出来了只是默认只用了 DIN。3.2 CubeMX工程配置要点CubeMX 里配置项目时有几个地方容易出问题。首先是 RCC外部高速晶振 HSE 要打开因为内部 HSI 精度不够会影响音频采样率的准确性。然后是 I2C1模式选 I2C速度选 Standard Mode 或者 Fast Mode 都行WM8978 完全跟得上 400kHz。SPI2 这一路是重点。在 CubeMX 的 Pinout 视图里找到 SPI2模式那里不要选 Disabled而是选 I2S2。具体模式要看你的应用只做播放选Transmit Only Master只做录音选Receive Only Master播放录音切换使用CubeMX 里先按其中一个配置后面代码里改模式或者直接选Half-Duplex Master自己切换音频参数方面Standard 选 Philips 标准Data Format 选 16 位Audio Frequency 填 48kHzClock Polarity 保持 Low。Master Clock Output 这一项要看 MCK 来源如果 WM8978 的 MCLK 是 STM32 的 PC6 提供就必须 Enable如果是外部晶振提供就 Disable。时钟树配置是整个工程最关键的一环。F407 的 I2S 时钟源来自 PLLI2S如果 PLLI2S 配得不对后面的 AudioFreq 再准也没用。我常用的配置是HSE8MHzPLLM8PLLI2SN192PLLI2SR2这样 PLLI2S 输出时钟是 96MHzI2S2 的外设时钟就是 96MHz。CubeMX 会自动帮你计算 I2S 分频系数生成代码后你可以在HAL_I2S_MspInit()里看到 PLLI2S 的配置。3.3 时钟计算我想要的那个采样率怎么来很多人移植 WM8978 失败不是 I2C 不通也不是初始化序列不对而是 I2S 主时钟和采样率对不上导致声音变调或者干脆是刺耳的噪声。这里把计算方法完整展开一次。STM32F4 的 I2S 分频系数由 I2SDIV 和 ODD 两个参数决定实际分频数 2 * I2SDIV ODD。当 I2S 作为主机并输出 MCK 时I2S 输入时钟先经过这个分频得到256 * fs的 MCK如果不输出 MCK这个分频直接得到 BCLK 的整数倍关系具体要看 STM32 参考手册的 I2S 章节。以我实际用的 96MHz I2S 时钟源目标采样率 48kHz 来计算。采用标准 I2S、16 位数据格式时每声道占 32 个 BCLKBCLK 频率应该是64 * 48kHz 3.072MHz。96MHz 除以 3.072MHz 等于 31.25这不是整数怎么办I2S 分频寄存器只能写整数所以取最接近的 31即2 * 15 1 31此时实际 BCLK 96MHz / 31 3.0968MHz实际采样率 3.0968MHz / 64 48.387kHz和标称 48kHz 偏差约 0.8%。0.8% 的偏差对音乐播放来说耳朵基本听不出来但如果直接把 MCK 给 WM8978 当系统主时钟WM8978 内部的数字滤波器可能会因为时钟频率偏移而表现不稳定。稳妥的办法是开启 WM8978 内部 PLL让 Codec 从 MCLK 恢复出精确的内部时钟。很多开发板的例程其实没有严格计算 MCK照样能出声就是因为 WM8978 的 PLL 把误差吸收掉了。如果你不想依赖 PLL 兜底也可以改 PLLI2SR 来让分频数更理想。比如 PLLI2SR5 时 I2S 时钟为 38.4MHz对于 48kHz38.4MHz / 3.072MHz 12.5取 12 或 13 误差都很大反而不如 96MHz 的方案。我的建议是先用 96MHz 这套参数把声音调通再去优化时钟精度。常见采样率在 96MHz 时钟源下的分频表如下方便直接抄目标采样率目标BCLK(64fs)分频计算I2SDIV / ODD实际采样率偏差48kHz3.072MHz96/3.07231.2515 / 148.387kHz0.8%44.1kHz2.8224MHz96/2.822434.0117 / 044.118kHz0.04%8kHz512kHz96/0.512187.593 / 18.021kHz0.26%实际代码里你不需要手动写 I2SDIVCubeMX 会根据你填的 AudioFreq 自动计算。但你要会看生成代码里的参数如果偏差太大手动改 AudioFreq 或者 PLLI2SR 都可以。3.4 WM8978初始化序列参考WM8978 一共 48 个寄存器但不是每个都要动。初始化一般遵循固定的顺序软件复位、电源管理、音频接口配置、时钟配置、输出通路配置、音量配置。下面是我在工程里实测可用的一份精简初始化序列寄存器值以 WM8978 数据手册为标准如果你手里的板子电路有差异需要根据原理图调整对应通道开关。void WM8978_Init(void) { // 1. 软件复位 WM8978_WriteReg(0x00, 0x0000); // 2. 电源管理打开 DAC、ADC、VMID 和偏置 WM8978_WriteReg(0x01, 0x0000); // 电源管理1 先全关 WM8978_WriteReg(0x02, 0x01BF); // 电源管理2DACL/DACR/ADCL/ADCR/Bias 上电 WM8978_WriteReg(0x03, 0x006F); // 电源管理3输出级和输入缓冲使能 // 3. 音频接口标准I2S16位数据 WM8978_WriteReg(0x04, 0x0000); // 4. 时钟MCLK作为主时钟 WM8978_WriteReg(0x05, 0x0000); // 5. 关闭未用到的通路防止串音 WM8978_WriteReg(0x06, 0x0000); WM8978_WriteReg(0x0B, 0x0000); WM8978_WriteReg(0x0D, 0x0000); // 6. 耳机音量先设到一个适中值 WM8978_WriteReg(0x09, 0x0030); // 左耳机音量0dB 附近 WM8978_WriteReg(0x0A, 0x0030); // 右耳机音量 // 7. DAC 通路音量软复位避免静音 WM8978_WriteReg(0x0E, 0x0002); WM8978_WriteReg(0x0F, 0x00FF); // 左 DAC 音量最大 WM8978_WriteReg(0x10, 0x00FF); // 右 DAC 音量最大 // 8. 关闭 3D、EQ 等附加功能 WM8978_WriteReg(0x12, 0x0000); WM8978_WriteReg(0x13, 0x0000); WM8978_WriteReg(0x14, 0x0000); }这份序列只保证核心通路能出声、能录音。实际调板子时建议对照数据手册把每个寄存器的 bit 含义看一遍尤其是电源管理寄存器哪个 bit 对应哪一路放大器开关错了声音路径就断了。寄存器 R2/R3 的数值在不同开发板上可能有差异比如有些板子的耳机功放由 GPIO 控制还需要额外拉高那个 GPIO。3.5 播放与录音的收发实现初始化搞定后播放一首音频就很简单了。HAL 库提供了HAL_I2S_Transmit()的阻塞发送和HAL_I2S_Transmit_DMA()的非阻塞发送。实际项目基本都用 DMA否则 CPU 会被中断打断太多系统其他任务都没法跑。DMA 播放的初始化在 CubeMX 里可以直接配好I2S2_TX 的 DMA 请求默认映射到 DMA1 Stream3。发送时只需要准备一个 uint16_t 数组里面按左、右、左、右的顺序填充采样点static uint16_t audio_buf[2048]; void Audio_Play(uint16_t *buf, uint32_t len) { HAL_I2S_Transmit_DMA(hi2s2, buf, len); } void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *hi2s) { // 一帧发送完成可以在这里补充下一帧数据 }如果是单个固定数目的 WAV 数组阻塞发送也行HAL_I2S_Transmit(hi2s2, (uint16_t *)wav_data, wav_len / 2, HAL_MAX_DELAY);有些 WAV 是 16 位数据但 I2S 每个声道槽位是 32 位会不会丢数据不会。标准 I2S 模式下STM32 硬件会自动把 16 位数据放到 32 位槽的最高有效位WM8978 配置成 16 位接收格式后会从高位取数据低 16 位自动忽略。你只需要保证数据缓冲区是 16 位对齐即可。录音实现和播放类似差别在于调用HAL_I2S_Receive_DMA()DMA 把 I2S 接收到的数据搬到内存数组。但要注意 F4 的 I2S 是半双工模式同一时刻只能发送或者接收。想要边放边录比如做回音消除F4 单路 I2S 是做不到的需要用两路 I2S 配合或者换带 SAI 接口的芯片。做普通录音笔或者语音唤醒项目先录后放完全够用。4. 踩坑记录与调试方法声音不对时别慌4.1 常见问题速查表移植过程中我遇到和帮朋友排查过的问题整理成下面这张表基本涵盖了大多数 WM8978 驱动失败场景。现象可能原因解决办法I2C 通信失败读取 ID 错误I2C 地址填成了 8 位地址HAL 函数传 7 位地址 0x1A喇叭完全无声电源管理寄存器没开对应通道检查 R2/R3 的 DAC/耳放使能位有数据但音量极小音量寄存器默认是静音或最低将 R9/R10、R0F/R10 音量调到合适值声音变调、播放速度快I2S 采样率配置与实际音频不一致核对 AudioFreq 和 WAV 文件采样率声音夹杂爆音时钟偏差过大或者 GND 连接不良检查 PLLI2S 配置分频尽量取整播放正常录音全零I2S 工作模式是只发送切到接收模式并检查 I2S_SD 方向初始化后立刻发热模拟电源配置异常或短路检查 WM8978 供电和电路焊接4.2 用示波器和正弦波快速定位问题调音频最怕的问题就是“没有声音”因为你在听筒里什么都听不到根本没法判断是哪一环断了。我的经验是不要用音乐去测试初始化结果先用一段单频正弦波。在电脑上用 Python 或者直接生成一个 1kHz、持续 1 秒的正弦波 WAV 文件转成 C 数组放到工程里播放。如果 1kHz 正弦波能听到纯音说明 DAC 通路是通的如果声音发闷或者带杂音说明采样率或者滤波配置有问题。用音乐测试很难听出这种细微差别正弦波就像音频工程师的万用表能快速定位是哪一环节出错。示波器在这个阶段也非常有用。重点测几组波形I2C 的 SCL/SDA 有没有 ACKI2S 的 BCLK 频率对不对LRCK 是不是等于目标采样率SD 引脚有没有数据脉冲。只要数据线有波形WM8978 理论上就能收到数据剩下的问题基本都在寄存器配置和模拟通路上。4.3 几个调音频的通用心得第一每次只改一个变量。音频链路太长电源、时钟、格式、音量、通路开关任何一环都影响最终结果。很多人习惯一次改十几个寄存器然后上电试听出问题根本不知道是哪一步引起的。我一般是初始化后先固定音量然后逐个通路验证确认 DAC 出声后再去开麦克风通路每一步都确保稳定才继续下一步。第二先缩小链路范围。有时候耳机没声音可以先在 WM8978 的模拟输入端手动灌一个信号看耳机能不能听到直通的声音。如果直通有声音而 DAC 没声音问题就锁定在数字侧如果直通都没声音问题在模拟侧或者后端功放。第三多看数据手册而不是只抄例程。网上 WM8978 的例程很多但每个开发板的电路设计不一样例程里的寄存器值不一定适用你的硬件。认真读一遍数据手册里每个寄存器的说明之后遇到问题你会有自己的判断力而不是陷入“照着敲代码还是不出声”的困境。最后分享一个实际经验调音频环境时先用耳机听别急着接喇叭功放。耳机阻抗高、驱动电流小容易调试喇叭功放一接满板子电流噪声和地弹会把真实问题盖住。等耳机通路完全正常了再去接功放和喇叭这时候如果还有底噪重点查电源纹波和地线布局。音频调试是个慢活但只要你把数据链路和时钟摸透了WM8978 这颗芯片其实非常稳定很适合作为嵌入式音频的入门和主力方案。