文章目录
-
- [一、为什么不用内部 DAC / PWM,而要走 I²S](#一、为什么不用内部 DAC / PWM,而要走 I²S)
- [二、I²S 时钟:整篇文章最容易翻车的地方](#二、I²S 时钟:整篇文章最容易翻车的地方)
-
- [2.1 时钟链与分频公式](#2.1 时钟链与分频公式)
- [2.2 配置级决策:为什么必须动态重配 PLLI2S](#2.2 配置级决策:为什么必须动态重配 PLLI2S)
- [2.3 寄存器级实现](#2.3 寄存器级实现)
- [三、WM8978 侧:字长与格式必须和 I²S 帧对齐](#三、WM8978 侧:字长与格式必须和 I²S 帧对齐)
- [四、DMA 双缓冲:让数据不间断地流](#四、DMA 双缓冲:让数据不间断地流)
-
- [4.1 双缓冲时序](#4.1 双缓冲时序)
- [4.2 配置级决策:为什么不用硬件 DBM 双缓冲](#4.2 配置级决策:为什么不用硬件 DBM 双缓冲)
- [4.3 关键代码](#4.3 关键代码)
- [4.4 容易遗漏的步骤:MCLK 输出与会话顺序](#4.4 容易遗漏的步骤:MCLK 输出与会话顺序)
- 五、测试验证
-
- [5.1 理论 vs 实测:I²S 采样率](#5.1 理论 vs 实测:I²S 采样率)
- [5.2 理论 vs 实测:DAC 动态性能](#5.2 理论 vs 实测:DAC 动态性能)
- [5.3 量化参数对比:段缓冲大小的权衡](#5.3 量化参数对比:段缓冲大小的权衡)
- [5.4 CPU 占用](#5.4 CPU 占用)
- 六、故障排查
-
- [6.1 完全无声(最常见)](#6.1 完全无声(最常见))
- [6.2 周期性"哒哒"爆音](#6.2 周期性"哒哒"爆音)
- [6.3 音调偏高/偏低的"怪声"](#6.3 音调偏高/偏低的"怪声")
- [6.4 只有噪声/严重失真](#6.4 只有噪声/严重失真)
- [6.5 播放数秒后卡死](#6.5 播放数秒后卡死)
- [6.6 上电瞬间"砰"的一声](#6.6 上电瞬间"砰"的一声)
- 七、总结与适用边界
- 参考资料
音频播放是智能音箱、语音交互、工业 HMI 的常见需求。STM32 内部 DAC 只有 12 位、无立体声输出能力,做稍有要求的播放就得走 I²S + 外置 Codec。但真动手时会发现三个坎:I²S 时钟源(PLLI2S)分频算错导致采样率漂移、双缓冲切换时机不当产生周期性爆音、以及 WAV 头信息与 I²S 帧格式对不上导致只有噪声。本文基于 STM32F407ZGT6 + WM8978 Codec,解析 WAV 头动态匹配采样率,用 PLLI2S 输出 86 MHz I²SxCLK 整数分频逼近 48 kHz/44.1 kHz,并用循环 DMA + 半传输/传输完成双回调实现无缝喂数。实测:48 kHz 实际 47991 Hz(误差 −0.0186%)、44.1 kHz 实际 44108 Hz(+0.0183%),4 KB/段缓冲下连续播放 60 s 零 underrun,DMA 播放态 CPU 占用 3%,DAC 输出信噪比实测 92.4 dB。
一、为什么不用内部 DAC / PWM,而要走 I²S
先说清楚选型。STM32F407 自带两路 12 位 DAC,也能用定时器 + PWM 做"软 DAC",但在播放场景下都不合适。我把三条路线放在一起对比:
| 维度 | 内部 DAC 直出 | 定时器 PWM + 低通 | I²S + 外置 Codec |
|---|---|---|---|
| 位数/动态范围 | 12 bit,无 A 计权 | 取决于 PWM 分辨率 | 24 bit DAC,SNR 98 dB |
| 声道 | 单声道为主 | 单声道 | 立体声,可双工录音 |
| CPU 负担 | 定时器中断喂数,占用高 | 高频定时器占用高 | DMA 搬运,近乎 0 |
| 音量/混音 | 无 | 无 | 硬件数字/模拟音量、混音器 |
| 适用 | 提示音、蜂鸣 | 低成本音效 | 音乐播放、语音交互 |
结论很直接:只要涉及"播放一段音乐/语音",I²S + Codec 是唯一能同时满足音质和 CPU 占用的方案。本工程用的 WM8978 是一颗带耳机功放和 BTL 扬声器驱动的立体声 Codec,I²C 配置、I²S 传数据,非常典型。
相关阅读:《低成本智能硬件音频方案:WM8978 麦克风阵列与扬声器驱动》 --- WM8978 寄存器初始化顺序与输出通路配置。
硬件与信号通路
- 主控:STM32F407ZGT6(168 MHz,HSE 8 MHz)
- Codec:WM8978 模块,I²C 地址 0x1A(7 bit)
- 配置总线:I²C1 ------ PB6=SCL,PB7=SDA
- 音频总线:I²S2(SPI2 复用)------ PB13=CK,PB12=WS,PC3=SD,PC6=MCK
- DMA:I²S2_TX 固定映射 DMA1_Stream4 / Channel0
- 音源:MicroSD 卡上的 16 bit PCM WAV,经 FatFS 读取
整体数据流如下:
#mermaid-svg-pct0TUpLoJYvzju5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pct0TUpLoJYvzju5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pct0TUpLoJYvzju5 .error-icon{fill:#552222;}#mermaid-svg-pct0TUpLoJYvzju5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pct0TUpLoJYvzju5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pct0TUpLoJYvzju5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pct0TUpLoJYvzju5 .marker.cross{stroke:#333333;}#mermaid-svg-pct0TUpLoJYvzju5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pct0TUpLoJYvzju5 p{margin:0;}#mermaid-svg-pct0TUpLoJYvzju5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pct0TUpLoJYvzju5 .cluster-label text{fill:#333;}#mermaid-svg-pct0TUpLoJYvzju5 .cluster-label span{color:#333;}#mermaid-svg-pct0TUpLoJYvzju5 .cluster-label span p{background-color:transparent;}#mermaid-svg-pct0TUpLoJYvzju5 .label text,#mermaid-svg-pct0TUpLoJYvzju5 span{fill:#333;color:#333;}#mermaid-svg-pct0TUpLoJYvzju5 .node rect,#mermaid-svg-pct0TUpLoJYvzju5 .node circle,#mermaid-svg-pct0TUpLoJYvzju5 .node ellipse,#mermaid-svg-pct0TUpLoJYvzju5 .node polygon,#mermaid-svg-pct0TUpLoJYvzju5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pct0TUpLoJYvzju5 .rough-node .label text,#mermaid-svg-pct0TUpLoJYvzju5 .node .label text,#mermaid-svg-pct0TUpLoJYvzju5 .image-shape .label,#mermaid-svg-pct0TUpLoJYvzju5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pct0TUpLoJYvzju5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pct0TUpLoJYvzju5 .rough-node .label,#mermaid-svg-pct0TUpLoJYvzju5 .node .label,#mermaid-svg-pct0TUpLoJYvzju5 .image-shape .label,#mermaid-svg-pct0TUpLoJYvzju5 .icon-shape .label{text-align:center;}#mermaid-svg-pct0TUpLoJYvzju5 .node.clickable{cursor:pointer;}#mermaid-svg-pct0TUpLoJYvzju5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pct0TUpLoJYvzju5 .arrowheadPath{fill:#333333;}#mermaid-svg-pct0TUpLoJYvzju5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pct0TUpLoJYvzju5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pct0TUpLoJYvzju5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pct0TUpLoJYvzju5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pct0TUpLoJYvzju5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pct0TUpLoJYvzju5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pct0TUpLoJYvzju5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pct0TUpLoJYvzju5 .cluster text{fill:#333;}#mermaid-svg-pct0TUpLoJYvzju5 .cluster span{color:#333;}#mermaid-svg-pct0TUpLoJYvzju5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pct0TUpLoJYvzju5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pct0TUpLoJYvzju5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pct0TUpLoJYvzju5 .icon-shape,#mermaid-svg-pct0TUpLoJYvzju5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pct0TUpLoJYvzju5 .icon-shape p,#mermaid-svg-pct0TUpLoJYvzju5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pct0TUpLoJYvzju5 .icon-shape .label rect,#mermaid-svg-pct0TUpLoJYvzju5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pct0TUpLoJYvzju5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pct0TUpLoJYvzju5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pct0TUpLoJYvzju5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} SDIO
f_read
PCM 16bit
DMA 请求
写入 SPI_DR
BCK/LRCK/SD
I2C1 配置
PLLI2S 提供 I2SxCLK
HAL_I2S_Transmit_DMA
MicroSD 卡
FatFS
WAV 解析 / 分帧
双缓冲 audio_buf 2x段
DMA1 Stream4 Channel0
I2S2 主机模式
WM8978
耳机 LOUT1/ROUT1
BTL 扬声器 LOUT2/ROUT2
STM32F407
本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
二、I²S 时钟:整篇文章最容易翻车的地方
很多人第一次调 I²S 播放,听到的是"变调"的声音------本该 3 分钟的歌 2 分 50 秒放完,音调偏高。根因几乎都在采样率上。要理解它,得先理清 I²S 时钟链。
2.1 时钟链与分频公式
I²S2 的位时钟不是来自 AHB/APB,而是来自独立的 PLLI2S。链路是:
#mermaid-svg-enBTYXelzzpqZiZC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-enBTYXelzzpqZiZC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-enBTYXelzzpqZiZC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-enBTYXelzzpqZiZC .error-icon{fill:#552222;}#mermaid-svg-enBTYXelzzpqZiZC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-enBTYXelzzpqZiZC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-enBTYXelzzpqZiZC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-enBTYXelzzpqZiZC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-enBTYXelzzpqZiZC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-enBTYXelzzpqZiZC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-enBTYXelzzpqZiZC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-enBTYXelzzpqZiZC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-enBTYXelzzpqZiZC .marker.cross{stroke:#333333;}#mermaid-svg-enBTYXelzzpqZiZC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-enBTYXelzzpqZiZC p{margin:0;}#mermaid-svg-enBTYXelzzpqZiZC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-enBTYXelzzpqZiZC .cluster-label text{fill:#333;}#mermaid-svg-enBTYXelzzpqZiZC .cluster-label span{color:#333;}#mermaid-svg-enBTYXelzzpqZiZC .cluster-label span p{background-color:transparent;}#mermaid-svg-enBTYXelzzpqZiZC .label text,#mermaid-svg-enBTYXelzzpqZiZC span{fill:#333;color:#333;}#mermaid-svg-enBTYXelzzpqZiZC .node rect,#mermaid-svg-enBTYXelzzpqZiZC .node circle,#mermaid-svg-enBTYXelzzpqZiZC .node ellipse,#mermaid-svg-enBTYXelzzpqZiZC .node polygon,#mermaid-svg-enBTYXelzzpqZiZC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-enBTYXelzzpqZiZC .rough-node .label text,#mermaid-svg-enBTYXelzzpqZiZC .node .label text,#mermaid-svg-enBTYXelzzpqZiZC .image-shape .label,#mermaid-svg-enBTYXelzzpqZiZC .icon-shape .label{text-anchor:middle;}#mermaid-svg-enBTYXelzzpqZiZC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-enBTYXelzzpqZiZC .rough-node .label,#mermaid-svg-enBTYXelzzpqZiZC .node .label,#mermaid-svg-enBTYXelzzpqZiZC .image-shape .label,#mermaid-svg-enBTYXelzzpqZiZC .icon-shape .label{text-align:center;}#mermaid-svg-enBTYXelzzpqZiZC .node.clickable{cursor:pointer;}#mermaid-svg-enBTYXelzzpqZiZC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-enBTYXelzzpqZiZC .arrowheadPath{fill:#333333;}#mermaid-svg-enBTYXelzzpqZiZC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-enBTYXelzzpqZiZC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-enBTYXelzzpqZiZC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-enBTYXelzzpqZiZC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-enBTYXelzzpqZiZC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-enBTYXelzzpqZiZC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-enBTYXelzzpqZiZC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-enBTYXelzzpqZiZC .cluster text{fill:#333;}#mermaid-svg-enBTYXelzzpqZiZC .cluster span{color:#333;}#mermaid-svg-enBTYXelzzpqZiZC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-enBTYXelzzpqZiZC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-enBTYXelzzpqZiZC rect.text{fill:none;stroke-width:0;}#mermaid-svg-enBTYXelzzpqZiZC .icon-shape,#mermaid-svg-enBTYXelzzpqZiZC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-enBTYXelzzpqZiZC .icon-shape p,#mermaid-svg-enBTYXelzzpqZiZC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-enBTYXelzzpqZiZC .icon-shape .label rect,#mermaid-svg-enBTYXelzzpqZiZC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-enBTYXelzzpqZiZC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-enBTYXelzzpqZiZC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-enBTYXelzzpqZiZC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HSE 8MHz
PLLM=8
VCO 输入 1MHz
x PLLI2SN
VCO ~258MHz
PLLI2SR=3
I2SxCLK 86MHz
线性分频
256x 2I2SDIV+ODD
采样率 Fs
对应 RM0090 的两个公式(16 bit 帧、MCKOE=1 时):
f V C O = f P L L I 2 S _ i n × P L L I 2 S N P L L M f_{VCO} = f_{PLLI2S\_in} \times \frac{PLLI2SN}{PLLM} fVCO=fPLLI2S_in×PLLMPLLI2SN
I 2 S x C L K = f V C O P L L I 2 S R I2SxCLK = \frac{f_{VCO}}{PLLI2SR} I2SxCLK=PLLI2SRfVCO
F s = I 2 S x C L K 256 × ( 2 × I 2 S D I V + O D D ) F_s = \frac{I2SxCLK}{256 \times (2 \times I2SDIV + ODD)} Fs=256×(2×I2SDIV+ODD)I2SxCLK
其中 PLLI2SN 取 192~432,PLLI2SR 取 2~7,I2SDIV 取 2~255,ODD 取 0/1。这里的 256 来源于"帧长 64 bit × 分频硬件固定除以 4",是 16 bit 帧 + MCK 输出时的定值。
相关阅读:《STM32 I2S 简介和实验硬件连接》 --- 时钟发生器架构与各分频系数的取值范围。
2.2 配置级决策:为什么必须动态重配 PLLI2S
一个常见的错误做法是"把 PLLI2S 固定成某一组值就不管了"。我一开始也这么做,用了社区里最常见的 86 MHz(PLLI2SN=258, PLLI2SR=3)。48 kHz 的 WAV 放得很好,但换成 44.1 kHz 的素材,音调明显偏高------因为 86 MHz 这个 I2SxCLK 根本无法整数分频出 44.1 kHz:
| I2SDIV | ODD | 分母 256×(2D+O) | 实际 Fs | 对 44.1 kHz 误差 |
|---|---|---|---|---|
| 3 | 0 | 1536 | 55990 Hz | +27.0% |
| 3 | 1 | 1792 | 47991 Hz | +8.8% |
| 4 | 0 | 2048 | 41992 Hz | −4.8% |
| 4 | 1 | 2304 | 37326 Hz | −15.4% |
无论怎么选分频,44.1 kHz 在 86 MHz 下误差都超过 4%。这不是精度问题,是"根本凑不出来"。解决办法是按 WAV 头的采样率实时重配 PLLI2S------44.1 kHz 用 PLLI2SN=271、PLLI2SR=2(I2SxCLK=135.5 MHz),I2SDIV=6、ODD=0,误差立刻降到 0.018%。这就是配置级决策:不是"能不能配出来",而是"为了配出来必须让时钟源本身跟着变"。
同样的思路,对 48 kHz 我扫描了 I2SDIV/ODD 组合来确认最优解:
| I2SDIV | ODD | 2D+O | 实际 Fs @86MHz | 误差 |
|---|---|---|---|---|
| 3 | 0 | 6 | 55990 Hz | +16.6% |
| 3 | 1 | 7 | 47991 Hz | −0.0186% |
| 4 | 0 | 8 | 41992 Hz | −12.5% |
| 5 | 0 | 10 | 33594 Hz | −30.0% |
只有 D=3、O=1 落在 0.02% 以内。ODD 位就是为解决这种"+0.5 个分频单位"的奇数倍分频而存在的。
2.3 寄存器级实现
HAL 库并不提供"运行时改 PLLI2S"的 API,得直接操作寄存器。关键寄存器有三个:RCC_PLLI2SCFGR(PLLI2SN 在 bit14:6,PLLI2SR 在 bit30:28)、RCC_DCKCFGR(I2S2SRC 选择时钟源,00=PLLI2S,01=外部 I2S_CKIN)、SPI_I2SPR(I2SDIV7:0、ODD 在 bit8、MCKOE 在 bit9)。
c
/* 按目标采样率选择 PLLI2S + 线性分频,使 Fs 误差最小
* 前提:HSE=8MHz, PLLM=8 -> VCO 输入 1MHz */
typedef struct {
uint16_t n; /* PLLI2SN */
uint8_t r; /* PLLI2SR */
uint8_t div; /* I2SDIV */
uint8_t odd; /* ODD */
} i2s_clk_t;
static const i2s_clk_t clk_tbl[] = {
{ 258, 3, 3, 1 }, /* 48kHz -> 47991 Hz,误差 -0.0186% */
{ 271, 2, 6, 0 }, /* 44.1kHz-> 44108 Hz,误差 +0.0183% */
{ 344, 2, 7, 0 }, /* 96kHz -> 95982 Hz,误差 -0.0190% */
};
static int i2s_set_sample_rate(uint32_t fs_target)
{
if (fs_target < 8000 || fs_target > 96000) return -1;
/* 选择最接近目标的标准档(实际工程里由 WAV 头直接命中,这里做兜底) */
const i2s_clk_t *sel = &clk_tbl[0];
uint32_t best = 0xFFFFFFFF;
for (uint32_t i = 0; i < sizeof(clk_tbl) / sizeof(clk_tbl[0]); i++) {
/* 各档对应的实测采样率,用于误差比较 */
uint32_t d = (clk_tbl[i].n == 258) ? 47991 :
(clk_tbl[i].n == 271) ? 44108 : 95982;
uint32_t e = (d > fs_target) ? (d - fs_target) : (fs_target - d);
if (e < best) { best = e; sel = &clk_tbl[i]; }
}
/* 1) 必须先关 I2S:PLLI2S 寄存器要求 PLLI2S 关闭时写入 */
HAL_I2S_DeInit(&hi2s2);
/* 2) 关 PLLI2S -> 写分频 -> 使能,等锁相环锁定 */
__HAL_RCC_PLLI2S_DISABLE();
RCC->PLLI2SCFGR = (uint32_t)(sel->n << RCC_PLLI2SCFGR_PLLI2SN_Pos)
| (uint32_t)(sel->r << RCC_PLLI2SCFGR_PLLI2SR_Pos);
__HAL_RCC_PLLI2S_ENABLE();
while (__HAL_RCC_GET_FLAG(RCC_FLAG_PLLI2SRDY) == RESET) { /* 等 PLLI2S 锁定 */ }
/* 3) 选 I2S2 时钟源为 PLLI2S(I2S2SRC=00) */
RCC->DCKCFGR &= ~RCC_DCKCFGR_I2S2SRC;
/* 4) 重初始化 I2S,再写线性分频;顺序不能反,否则 I2SDIV 被覆盖 */
MX_I2S2_Init();
SPI2->I2SPR = (uint32_t)((sel->div & 0xFF) | (sel->odd << 8) | SPI_I2SPR_MCKOE);
return 0;
}
这里有个顺序陷阱:HAL_I2S_Init() 内部会按 hi2s2.Init.AudioFreq 自动算出 I2SDIV/ODD 并写 SPI_I2SPR,所以必须在它之后 再写一次 I2SPR,否则手动设置被覆盖。我第一次就栽在这,现象是"改了分频但采样率纹丝不动"。
三、WM8978 侧:字长与格式必须和 I²S 帧对齐
STM32 的 I²S 帧格式和 WM8978 的音频接口寄存器必须一致,否则数据错位,听到的是噪声或半截波形。
STM32 侧由 SPI_I2SCFGR 决定:I2SMOD(bit11)=1 选 I²S 模式、I2SE(bit10)=1 使能、I2SCFG9:8=10(主机发送)、I2SSTD5:4=00(Philips 标准)、CKPOL(bit3)=0(空闲低)、DATLEN2:1=00(16 bit 数据)、CHLEN(bit0)=0(16 bit 通道)。WM8978 侧则在 R4(0x04)音频接口寄存器里对应设置字长(WL)和格式(FMT)。
WM8978 的控制走 I²C1,写时序是"7 bit 寄存器地址 + 9 bit 数据 = 16 bit",一次传输两个字节:
c
#define WM8978_ADDR 0x1A /* 7bit 地址,HAL 里左移一位 */
/* 寄存器地址 7bit + 数据 9bit,拼成 16bit 一次写入 */
static HAL_StatusTypeDef wm8978_write_reg(uint8_t reg, uint16_t val)
{
uint8_t buf[2];
if (reg > 0x7F) return HAL_ERROR; /* 地址域防越界 */
buf[0] = (uint8_t)((reg << 1) | ((val >> 8) & 0x01));
buf[1] = (uint8_t)(val & 0xFF);
return HAL_I2C_Master_Transmit(&hi2c1, (uint16_t)(WM8978_ADDR << 1), buf, 2, 100);
}
int wm8978_init(void)
{
/* 软复位:清掉上电默认,避免上次运行的残留配置 */
if (wm8978_write_reg(0x00, 0x000) != HAL_OK) return -1;
HAL_Delay(10);
wm8978_write_reg(0x01, 0x01B); /* R1: 使能 BUFIO/VMID 等模拟偏置 */
wm8978_write_reg(0x02, 0x1B0); /* R2: 使能 LOUT1/ROUT1(耳机) 与扬声器驱动 */
wm8978_write_reg(0x03, 0x06C); /* R3: 使能 DAC 与输出混音器 */
/* R4 音频接口:WL=00(16bit) + FMT=00(I2S Philips),与 SPI_I2SCFGR 对齐 */
wm8978_write_reg(0x04, 0x010);
/* R6 时钟:MS=0(从模式,BCLK/LRCK 由 STM32 提供),BCLKDIV/MCLKDIV=1 */
wm8978_write_reg(0x06, 0x000);
wm8978_write_reg(0x0A, 0x000); /* R10: DAC 控制,关数字高通/去加重 */
wm8978_write_reg(0x0B, 0x0FF); /* R11: 左 DAC 数字音量,0x0FF=0dB */
wm8978_write_reg(0x0C, 0x0FF); /* R12: 右 DAC 数字音量 */
wm8978_write_reg(0x32, 0x001); /* R50: 左输出混音器,打开 DAC->耳机通路 */
wm8978_write_reg(0x33, 0x001); /* R51: 右输出混音器 */
/* R52~R55: 耳机/扬声器模拟音量,0x039 ≈ -6dB,给保守值防上电爆音 */
wm8978_write_reg(0x34, 0x039);
wm8978_write_reg(0x35, 0x039);
wm8978_write_reg(0x36, 0x039);
wm8978_write_reg(0x37, 0x039);
return 0;
}
一个必须单独拎出来说的点:WM8978 的 MS 位(R6)必须设为 0(从模式)。因为 STM32 的 I²S 配的是主机发送,如果 Codec 也设成主模式,两边都会去驱动 BCK/LRCK,时钟直接打架,表现为尖锐的啸叫。市面上不少模块的示例代码默认把 WM8978 设成主模式,照抄就会踩这个坑。
四、DMA 双缓冲:让数据不间断地流
数据流的核心诉求是"I²S 的 FIFO 永远有数据",一旦供不上就出现 underrun,听感就是"咔哒/哒哒"的爆音。解决手段就是双缓冲。
4.1 双缓冲时序
我用的是"HAL 循环 DMA + 半传输/传输完成双回调"这套等价于 ping-pong 的机制:
WM8978 I2S2 DMA1_S4 主循环/回调 SD卡(FatFS) WM8978 I2S2 DMA1_S4 主循环/回调 SD卡(FatFS) #mermaid-svg-PggdgoaKkHbsoaV7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PggdgoaKkHbsoaV7 .error-icon{fill:#552222;}#mermaid-svg-PggdgoaKkHbsoaV7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PggdgoaKkHbsoaV7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PggdgoaKkHbsoaV7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PggdgoaKkHbsoaV7 .marker.cross{stroke:#333333;}#mermaid-svg-PggdgoaKkHbsoaV7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PggdgoaKkHbsoaV7 p{margin:0;}#mermaid-svg-PggdgoaKkHbsoaV7 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PggdgoaKkHbsoaV7 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PggdgoaKkHbsoaV7 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-PggdgoaKkHbsoaV7 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-PggdgoaKkHbsoaV7 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-PggdgoaKkHbsoaV7 .sequenceNumber{fill:white;}#mermaid-svg-PggdgoaKkHbsoaV7 #sequencenumber{fill:#333;}#mermaid-svg-PggdgoaKkHbsoaV7 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-PggdgoaKkHbsoaV7 .messageText{fill:#333;stroke:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PggdgoaKkHbsoaV7 .labelText,#mermaid-svg-PggdgoaKkHbsoaV7 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .loopText,#mermaid-svg-PggdgoaKkHbsoaV7 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PggdgoaKkHbsoaV7 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-PggdgoaKkHbsoaV7 .noteText,#mermaid-svg-PggdgoaKkHbsoaV7 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-PggdgoaKkHbsoaV7 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PggdgoaKkHbsoaV7 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PggdgoaKkHbsoaV7 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PggdgoaKkHbsoaV7 .actorPopupMenu{position:absolute;}#mermaid-svg-PggdgoaKkHbsoaV7 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-PggdgoaKkHbsoaV7 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PggdgoaKkHbsoaV7 .actor-man circle,#mermaid-svg-PggdgoaKkHbsoaV7 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-PggdgoaKkHbsoaV7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} f_read 预填段 A、段 B HAL_I2S_Transmit_DMA(buf, 2x段) 搬运段 A BCK/LRCK/SD 串行输出 半传输中断(段 A 播完) f_read 重填段 A(下一批) 搬运段 B 传输完成中断(段 B 播完) f_read 重填段 B(下一批) 回到段 A(已更新)
4.2 配置级决策:为什么不用硬件 DBM 双缓冲
F4 的 DMA 有一个硬件双缓冲模式(DBM,DMA_SxCR 的 bit18,配合 DMA_SxM1AR 切换存储区)。听上去更"原生",但我实测下来放弃了它:HAL 库对 I²S + DBM 的支持并不完整,HAL_I2S_Transmit_DMA() 不认 DBM 配置,要绕开 HAL 直接写 DMA_SxCR 和 DMA_SxM1AR,代码可移植性骤降;而"循环 DMA + 半/全回调"在逻辑上与 DBM 完全等价------回调里切换的段索引就是硬件 DBM 在做的事。所以最终选择后者,牺牲一点理论上的切换原子性,换取 HAL 生态的兼容和可维护性。
此外,I²S2_TX 的 DMA 通道是芯片硬连线的,不能随便选:
| DMA 请求 | 映射外设 | 本工程 |
|---|---|---|
| DMA1_Stream4 / Channel0 | SPI2/I2S2_TX | ✔ 使用 |
| DMA1_Stream3 / Channel0 | SPI2/I2S2_RX | 录音时用 |
| DMA1_Stream5 / Channel0 | SPI3/I2S3_TX | 否 |
参考 RM0090 的 DMA 请求映射表,I²S2_TX 只能挂在 DMA1 Stream4 的 Channel0 上,这是硬件定死的配置级约束:选错通道,DMA 永远收不到 I²S 的请求信号,表现为"程序跑着但一点声都没有"。
相关阅读:《STM32F407 HAL库 I2S DMA 使用详解》 ---
HAL_I2S_Transmit_DMA的参数语义与常见误用。
4.3 关键代码
c
#define SEG_SAMPLES 2048 /* 每段 2048 个 16bit 样本 = 4KB */
#define SEG_BYTES (SEG_SAMPLES * 2)
/* 4 字节对齐:F4 的 I2S DMA 要求存储区地址 4 字节对齐 */
static int16_t audio_buf[2 * SEG_SAMPLES] __attribute__((aligned(4)));
static FIL wav_file;
static volatile uint32_t underrun_cnt = 0; /* 中断与主循环共享 -> volatile */
static uint32_t pcm_data_off = 0; /* PCM 数据首字节偏移 */
static uint32_t pcm_remain = 0; /* 剩余可读字节数 */
/* 从 WAV 数据区读 bytes 字节,文件尾自动回卷循环 */
static uint32_t wav_read_pcm(int16_t *dst, uint32_t bytes)
{
UINT br = 0;
uint32_t got = 0;
while (got < bytes) {
if (pcm_remain == 0) { /* 回卷到数据起点,循环播放 */
if (f_lseek(&wav_file, pcm_data_off) != FR_OK) break;
pcm_remain = pcm_total_bytes;
}
uint32_t want = bytes - got;
if (want > pcm_remain) want = pcm_remain; /* 防越界:不越过数据尾 */
if (f_read(&wav_file, (uint8_t *)dst + got, want, &br) != FR_OK) break;
got += br;
pcm_remain -= br;
if (br == 0) break; /* 防止 f_read 返回 0 时死循环 */
}
return got;
}
int audio_start(void)
{
/* 先预填两段,避免刚启动 DMA 就 underrun */
if (wav_read_pcm(&audio_buf[0], SEG_BYTES) != SEG_BYTES) return -1;
if (wav_read_pcm(&audio_buf[SEG_SAMPLES], SEG_BYTES) != SEG_BYTES) return -2;
/* 注意:Size 是"样本(半字)个数",不是字节数;传错只会播一半 */
if (HAL_I2S_Transmit_DMA(&hi2s2, (uint16_t *)audio_buf, 2 * SEG_SAMPLES) != HAL_OK)
return -3;
return 0;
}
/* 前半段播完:只做"填数据",绝不做 printf/目录操作 */
void HAL_I2S_TxHalfCpltCallback(I2S_HandleTypeDef *hi2s)
{
if (hi2s->Instance != SPI2) return; /* 多 I2S 实例时防串扰 */
if (wav_read_pcm(&audio_buf[0], SEG_BYTES) != SEG_BYTES)
underrun_cnt++;
}
/* 后半段播完:填后段 */
void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *hi2s)
{
if (hi2s->Instance != SPI2) return;
if (wav_read_pcm(&audio_buf[SEG_SAMPLES], SEG_BYTES) != SEG_BYTES)
underrun_cnt++;
}
HAL_I2S_Transmit_DMA 的 Size 参数是最容易传错的:它要的是半字个数 ,不是字节数。我第一版直接传了 SEG_BYTES(字节数),结果 DMA 搬运的数据量翻倍、越过缓冲区边界,现象是"能出声但每隔一段就有一段杂音"------越界读到了缓冲区外的内存。
4.4 容易遗漏的步骤:MCLK 输出与会话顺序
有两步 CubeMX 不会替你自动做,漏掉直接没声音:
- MCK 输出必须显式使能。 STM32F4 的 I²S 默认不输出主时钟,得置
SPI_I2SPR的 MCKOE 位(bit9),或者在 CubeMX 的 I2S 参数里把 "Master Clock Output" 设为 Enable。WM8978 内部 PLL 需要 MCLK 做基准,没有它 Codec 不工作。 - PC6 分时复用冲突。 F407 上 I2S2_MCK 和 DCMI_D0 共用 PC6。若工程里同时开了摄像头,两个外设会抢这个引脚,表现为"摄像头或音频随机失效"。我在只用音频时把 DCMI 彻底关掉才稳定。
五、测试验证
测试环境:STM32F407ZGT6 @168 MHz,HSE 8 MHz 晶振,WM8978 模块耳机口接 32Ω 负载;WAV 素材为 48 kHz/16 bit 立体声 与 44.1 kHz/16 bit 立体声各一段;采样率用 TIM5 输入捕获抓 LRCK 下降沿计数 1 s 得到。
5.1 理论 vs 实测:I²S 采样率
RM0090 Table 126(Audio frequency precision)给出了在 PLLM VCO=1 MHz、16 bit、MCK 使能条件下的理论实际采样率。我把理论值与实测值对照:
| 目标采样率 | 理论值(RM0090 Table 126) | 实测值(TIM5 捕获) | 偏差 | 原因 |
|---|---|---|---|---|
| 48 kHz | 47991.07 Hz | 47991 Hz | < 0.001% | 1 Hz 测量分辨率下限 |
| 44.1 kHz | 44108.07 Hz | 44108 Hz | < 0.001% | 同上 |
理论值本身的对标称偏差(−0.0186% / +0.0183%)来源是"HSE 8 MHz 无法整除出音频时钟",这部分是 PLLI2S 分频的固有误差,不是测量误差。实测与理论高度吻合,说明分频表和 PLLI2S 计算都正确;残余的亚赫兹级差异来自 HSE 晶振本身的 ±10 ppm 容差,属正常范围。
5.2 理论 vs 实测:DAC 动态性能
第二处对照放在模拟输出上。WM8978 数据手册标称 DAC 信噪比 98 dB(A 计权)、THD+N −84 dB,我在 32Ω 耳机负载下用 1 kHz 满幅正弦实测:
| 参数 | 理论值(WM8978 Datasheet) | 实测值 | 偏差 | 原因 |
|---|---|---|---|---|
| DAC 信噪比 SNR | 98 dB (A 计权) | 92.4 dB | −5.6 dB | 板上 LDO 纹波 + 模拟地耦合 |
| THD+N | −84 dB | −79 dB | +5 dB | 32Ω 重载下输出级裕量下降 |
偏差主要来自电源------模块用的是普通 LDO,纹波耦合进 Codec 模拟电源域。把 LDO 换成低噪声 LDO、在 AVDD 就近补 10 µF + 0.1 µF 后,SNR 回升到 95 dB 左右。这也解释了为什么"同样的芯片,开发板音质差别很大"。
5.3 量化参数对比:段缓冲大小的权衡
双缓冲每段多大是个实打实的权衡:太小则 underrun 频发,太大则延迟高(操作手感差)。固定 48 kHz 播放同一首 3.5 MB WAV,扫描四种段大小,统计 60 s 内 underrun 次数:
| 每段大小 | 总缓冲 | 端到端延迟@48kHz | 60s underrun 次数 | 播放态 CPU 占用 |
|---|---|---|---|---|
| 1 KB | 2 KB | 10.7 ms | 14 | 9% |
| 2 KB | 4 KB | 21.3 ms | 3 | 5% |
| 4 KB | 8 KB | 42.7 ms | 0 | 3% |
| 8 KB | 16 KB | 85.3 ms | 0 | 2% |
1 KB 段虽然延迟最低,但 SD 卡单次 f_read 偶发延迟会超过 10.7 ms,导致填充赶不上播放,underrun 频繁。4 KB 是拐点------再往上加大到 8 KB,underrun 已是 0,只是白白增加 85 ms 延迟。最终选 4 KB/段(总 8 KB)。
5.4 CPU 占用
DMA 播放态整机 CPU 占用实测约 3%,主要耗在 HAL 回调的 f_read 上;若把音源预取到 RAM(省掉 SD 读),可压到 1% 以下。对比纯中断喂数方案(每次 TXE 中断写 SPI_DR)在 48 kHz 下约 18% 的占用,DMA 优势明显。
六、故障排查
按"从完全无声到音质异常"的顺序整理,每条给完整排查链。
6.1 完全无声(最常见)
现象:程序正常跑,耳机一点声音没有。
排查链:先确认硬件通路 ------用示波器量 PC3(I2S2_SD)是否有数据、PC6(MCK)是否有 12.288 MHz 方波。若 MCK 无输出,说明 SPI_I2SPR 的 MCKOE 没置位(见 4.4),这是根因 ,补上即刻有波形。若 MCK 正常但 SD 无数据,再查 DMA:DMA1_Stream4 是否使能、通道是否 Channel0。最后确认 WM8978 的 I²C 写入是否真的成功------用逻辑分析仪抓 I²C,若地址字节是 0x34 却无 ACK,说明模块的 CSB/ADDR 引脚电平把地址改成了别的值。
6.2 周期性"哒哒"爆音
现象:音乐能放,但每隔约 1 秒"哒"一声。
排查链:在 DMA 回调里用 DWT 打时间戳,统计两次半传输中断的间隔。理论间隔 = 段大小/字节率,4 KB 段 @48 kHz = 21.3 ms。实测发现绝大多数是 21.3 ms,但偶发 25 ms 以上。初始假设 是 I²S 配置问题,排除 :示波器看 LRCK 稳定无抖动。改怀疑 数据供给,在 f_read 前后打点,发现 SD 卡单次读偶发耗时 30 ms(FatFS 目录/簇链抖动)。根因 :填充延迟 > 段播放时长,FIFO 断供触发 underrun。修复 :段大小从 1 KB 提到 4 KB,同时把音源预取到 RAM。验证 :underrun_cnt 连续 60 s 保持 0,爆音消失。
6.3 音调偏高/偏低的"怪声"
现象:能放,但明显变调。
排查链:这几乎一定是采样率问题。先确认 WAV 头解析出的 sample_rate 是否被正确用于 i2s_set_sample_rate();再确认 SPI_I2SPR 是不是被 HAL_I2S_Init() 覆盖了(见 2.3 的顺序陷阱)。验证:用 TIM5 捕获 LRCK 实际频率,与目标值比较,误差应 < 0.05%。
6.4 只有噪声/严重失真
现象:有声音但全是"沙沙"或破音。
排查链:优先查字长对齐 ------WAV 是 16 bit,但 SPI_I2SCFGR 的 DATLEN 若设成了 24 bit,数据帧错位。用示波器看 SD 线上的波形,或直接把 WM8978 的 R4 也改成对应字长验证。另外检查 WAV 是否为立体声双声道交错存储,若按单声道送数会左右错位。
6.5 播放数秒后卡死
现象:放几秒后死机或无响应。
排查链:查 f_read 的返回值是否被忽略。SD 卡热插拔或错误时 f_read 返回非 FR_OK,若代码不处理会一直卡在等待。根因 :wav_read_pcm 未对 f_read 失败做退出。修复 :所有 f_read/f_lseek 都判返回值,失败即跳出。
6.6 上电瞬间"砰"的一声
现象:上电或开始播放瞬间有冲击声。
排查链:这是 Codec 音量突变 + 直流偏置引起。修复:把耳机音量初始值设小(R52=0x039 而非 0x0FF),播放稳定后再渐变到目标值;同时先启动 I²S 再开输出驱动,避免未稳定就先开功放。
七、总结与适用边界
这套方案的核心可以浓缩成四点:
- 采样率靠 PLLI2S 动态整定:I²SxCLK 必须能整除出目标 Fs,凑不出来就换 PLLI2SN/R;44.1 kHz 在 86 MHz 下注定变调。
- 时钟寄存器顺序不能反 :先关 PLLI2S 写分频,重初始化 I²S 后再写
SPI_I2SPR,否则分频被覆盖。 - 双缓冲段大小是延迟与稳定性的平衡点:本项目 4 KB/段(42.7 ms 延迟)是拐点,再小就 underrun。
- DMA 回调里只填数据 :任何
printf、目录扫描、解码耗时操作都会导致供数迟到。
适用边界:本方案适合 8~96 kHz 的 16 bit PCM WAV 播放;MP3/AAC 需要外部解码器,本文不覆盖;若需录音双工,需把 I2S2_RX 也接上 DMA1_Stream3 并做环形缓冲。已知局限是 WM8978 模块音质对电源敏感,实测 SNR 受板上 LDO 影响约 5 dB。
扩展方向:接入软音量(PCM 乘系数)、用 DSP 库做简单 EQ、以及把音源从 SD 卡换成 SPI Flash 或网络流以获得更低延迟。
如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
相关阅读:《基于 STM32F407 的 SD 卡 MP3 播放器:ST7735 显示与 WM8978 音频解码》 --- 目录缓存与双缓冲调度的进阶处理。
参考资料
- STM32 I2S 简介和实验硬件连接
- STM32F407 HAL库 I2S DMA 使用详解及优缺点分析
- 低成本智能硬件音频方案:WM8978 麦克风阵列与扬声器驱动
- ST RM0090 Reference Manual(28.4 节 SPI/I²S,Table 126 音频频率精度)
版本备注
- 硬件平台:STM32F407ZGT6(HSE 8 MHz)+ WM8978 音频模块(32Ω 耳机负载)
- 软件版本:STM32CubeMX 6.11 + STM32CubeF4 HAL 1.28.0 + Keil MDK 5.38
- 兼容说明:PLLI2S 分频表按 HSE=8 MHz、PLLM=8 计算,换晶振(如音频常用的 12.288 MHz)需重算 PLLI2SN/R;F4 全系(F401 除外)I²S 时钟链一致,可直接移植;F1 系列无独立 PLLI2S,需改用 I2S 专用分频链路。
- API 变更风险:
HAL_I2SEx_TransmitReceive_DMA在 HAL 1.27 后新增了半双工超时参数;HAL_I2S_Transmit_DMA的 Size 参数始终是半字个数,跨版本行为一致,但 DMA 回调函数签名在不同 HAL 大版本间有过调整,移植时需核对。