本文记录在「悦聆」多媒体中枢上新增本地 MP3 播放的完整过程:SDMMC1 读 SD 卡(FATFS)→ minimp3 软解码 → 与网络收音机共用同一条 I2S6/WM8904 音频管道出声。功能本身不复杂,价值在三个 BSP 层的典型坑、一个 GPIO 极性反转的坑,以及最后"低音破声"的定量排查方法——这套方法在任何嵌入式音频项目里都通用。

1. 架构
SD 卡(FAT32,根目录 *.mp3) │ SDMMC1 4bit(PLL2_S 135.5MHz 内核时钟, IDMA) ▼ mp3_player(解码线程, prio 6): FATFS 读文件组帧 → minimp3 │ pcm_util: 线性插值重采样到 48117Hz + Goertzel 频谱 ▼ (pcm_q 阻塞反压,与收音机共用) audio_sai(发送线程, prio 5) → GPDMA 循环 → I2S6 → WM8904 → 耳机口
设计要点是复用而不是新造:网络收音机里的重采样和频谱分析这次抽成了公共模块 pcm_util.c/h,两个解码线程(收音机/MP3)共用。因为重采样器内部有跨帧相位状态(全局单例),同一时刻只允许一路音源,由上层 UI 互斥保证:
/* 点电台:先停本地播放 */
static void station_btn_cb(lv_event_t *e)
{
mp3_stop();
radio_play((int)(intptr_t)lv_event_get_user_data(e));
}
/* 点 MP3 曲目:先停收音机 */
static void mp3_track_btn_cb(lv_event_t *e)
{
radio_stop();
mp3_play((int)(intptr_t)lv_event_get_user_data(e));
}切换音源前还要调 pcm_resample_reset() 清掉上一路的插值相位和跨帧尾样本,否则换源瞬间会有一个样本级的跳变杂音。
2. SD 卡接入的三个坑
坑一:ext_flash_app 变体没有 sdmmc1 节点
板子基础 dts(stm32h7s78_dk.dts)里有完整的 sdmmc1 配置,但外部 Flash 启动变体 stm32h7s78_dk_stm32h7s7xx_ext_flash_app.dts 只 #include 了 common.dtsi,SD 节点不在里面。直接开 FATFS 会在编译期报 Wrong FF_VOLUMES setting——FATFS 的卷号是从设备树里 status=okay 的 disk 节点数出来的,一个都没有就是 0。
在应用 overlay 里把基础 dts 的配置补齐:
&sdmmc1 {
pinctrl-0 = <&sdmmc1_d0_pc8 &sdmmc1_d1_pc9
&sdmmc1_d2_pc10 &sdmmc1_d3_pc11
&sdmmc1_ck_pc12 &sdmmc1_cmd_pd2>;
pinctrl-names = "default";
clocks = <&rcc STM32_CLOCK(AHB5, 8)>,
<&rcc STM32_SRC_PLL2_S SDMMC_SEL(0)>;
bus-width = <4>;
hw-flow-control;
cd-gpios = <&gpiom 14 GPIO_ACTIVE_LOW>;
status = "okay";
sdmmc {
compatible = "zephyr,sdmmc-disk";
disk-name = "SD";
status = "okay";
};
};两个 clocks 条目含义不同:第一个是 AHB5 总线时钟(寄存器访问),第二个是内核时钟源选择——SDMMC 外设的实际工作时钟,这里选了 PLL2_S。
坑二:PLL2 缺 div-s,内核时钟选源被拒
配完节点后启动报 Failed to enable SDHC domain clock。根因:common.dtsi 里 PLL2 只配了 div-q(那是给 I2S6 出 271MHz 用的),没配 div-s。Zephyr 的 STM32 时钟驱动用这个宏判断 PLL 某路输出是否可用:
/* include/zephyr/drivers/clock_control/stm32_clock_control.h */ #define STM32_PLL2_S_ENABLED DT_NODE_HAS_PROP(DT_NODELABEL(pll2), div_s)
没有 div-s 属性就视为未使能,选源直接返回 -ENOTSUP。补一行即可——PLL2 VCO 为 24MHz/12×271=542MHz,除 4 得 135.5MHz 给 SDMMC:
&pll2 {
div-s = <4>;
};验证不靠猜,直接 GDB 读寄存器:RCC_PLL2DIVR2(0x580244C4)低 3 位为 3,即 DIVS=3+1=4,和预期一致。
坑三:新旧两个 SDMMC 驱动互斥
sdmmc1 使能后,legacy disk 驱动 SDMMC_STM32 和新 SDHC 驱动 SDHC_STM32_SDMMC 同时满足 default y 条件。legacy 驱动会让 FATFS 直接在 st,stm32-sdmmc 节点上找 disk-name 属性(我们没有,属性在 zephyr,sdmmc-disk 子节点上),编译报 'DT_N_S_soc_S_sdmmc_52007000_P_disk_name' undeclared。我们走的是新驱动路线(SDHC 子系统 + IDMA + cache 维护都更完善),显式关掉旧的:
CONFIG_DISK_ACCESS=y CONFIG_FILE_SYSTEM=y CONFIG_FAT_FILESYSTEM_ELM=y CONFIG_FS_FATFS_LFN=y # 不开这个文件名显示成 CANON_~1.MP3 短名 CONFIG_SDMMC_STM32=n # 用新 SDHC 驱动 CONFIG_HEAP_MEM_POOL_SIZE=8192 # SDHC 驱动的 DMA 对齐缓冲走系统堆
FS_FATFS_LFN 值得单独说:不开长文件名支持,FATFS 只认 8.3 短名,canon_in_d.mp3 在曲目列表里显示为 CANON_~1.MP3——功能是通的,但体验上一个时代的差距。
3. 解码线程:和流媒体的同与不同
解码主循环和收音机几乎同构,但有一个本质区别:文件可以断点续播,广播流不能。收音机暂停时必须继续消费网络数据并丢弃(否则服务器端断开),MP3 暂停则是完全不读文件,位置天然保持:
while (req_track == cur_track && !stop_req) {
if (pause_flag) {
/* 暂停:不消费文件(断点续播),音频层自动填静音 */
mp3_state = MP3_ST_PAUSED;
spectrum_decay();
k_msleep(50);
continue;
}
mp3_state = MP3_ST_PLAYING;
if (!ibuf_fill(&file)) {
break; /* EOF,播完自动切下一首 */
}
int samples = mp3dec_decode_frame(&mp3dec, ibuf, (int)ibuf_len,
pcm_frame, &info);
if (info.frame_bytes > 0) {
ibuf_len -= info.frame_bytes; /* 滑窗前移 */
memmove(ibuf, ibuf + info.frame_bytes, ibuf_len);
} else if (ibuf_len >= IBUF_SIZE - 1) {
/* 缓冲满还找不到帧头(常见于大 ID3 标签):丢一半重新同步 */
ibuf_len /= 2;
memmove(ibuf, ibuf + ibuf_len, ibuf_len);
}
if (samples > 0) {
size_t out_n = pcm_resample_to_playback(pcm_frame, samples,
info.hz, info.channels, rs_buf);
spectrum_feed(rs_buf, out_n);
(void)audio_write_pcm(rs_buf, out_n); /* 阻塞反压,天然限速 */
}
}几处值得展开:
minimp3 的容错设计:mp3dec_decode_frame 的返回值分两部分——frame_bytes 是这次消耗掉的输入字节数(含跳过的垃圾数据),samples 是解出的 PCM 样本数。哪怕遇到 ID3v2 标签、垃圾填充,只要按 frame_bytes 滑动窗口就能自动重新同步。输入滑窗 5KB,远大于最大 MP3 帧约 1.4KB。
不需要文件缓冲池:网络流需要 24KB pipe 吸收抖动,文件读取是确定性的,audio_write_pcm 的阻塞反压(队列满就等)天然把解码速率对齐到播放速率,一个 5KB 滑窗就够。
播完自动切歌:play_one() 返回后线程检查退出原因——stop_req 是用户停止,req_track 变了是切歌,否则就是自然播完,索引加一回卷继续播。
4. 热插拔:一个 ACTIVE_LOW 极性反转的坑
挂载/扫描只在开机做一次显然不够——用户换卡后列表不会更新。实现是主循环每秒轮询卡检测脚(PM14,dts 里声明为低有效):
void mp3_card_poll(void)
{
bool present = gpio_pin_get_dt(&cd_pin) != 0;
if (present && !card_present) {
k_msleep(300); /* 触点防抖/卡上电稳定 */
present = gpio_pin_get_dt(&cd_pin) != 0;
}
if (!present) {
if (card_present) {
mp3_stop();
k_msleep(100); /* 等解码线程退出当前曲目 */
if (card_mounted) {
fs_unmount(&mountpoint);
card_mounted = false;
}
track_count = 0;
mp3_state = MP3_ST_NO_CARD;
}
card_present = false;
return;
}
card_present = true;
if (!card_mounted) {
if (fs_mount(&mountpoint) == 0) { /* 失败则下个周期(2s 节流)重试 */
card_mounted = true;
mp3_state = scan_tracks() > 0 ? MP3_ST_IDLE : MP3_ST_EMPTY;
}
}
}写完烧进去,卡明明插着却报"未检测到SD卡"。GDB 直接读 GPIOM IDR(0x58023010):bit14=0,物理电平是低,卡在位——硬件没问题,是软件判断反了。
根因是个 API 语义陷阱:gpio_pin_get_dt() 返回的不是物理电平,而是应用了 GPIO_ACTIVE_LOW 反转后的逻辑值。引脚物理低 → 逻辑"激活"→ 返回 1。我第一版写的是 == 0 表示插卡,正好全反。这个函数名字起得像读电平,实际读的是逻辑状态,修完后在代码里留了注释:
/* 卡检测脚(低有效)。注意 gpio_pin_get_dt 已含 ACTIVE_LOW 反转: * 返回 1 = 卡插入 */ static const struct gpio_dt_spec cd_pin = GPIO_DT_SPEC_GET(DT_NODELABEL(sdmmc1), cd_gpios);
UI 侧配合:曲目列表从"建页面时一次性填充"改成可重建,主循环发现曲目数变化就在 lvgl_lock 下 lv_obj_clean + 重建按钮,插卡后列表一秒左右自动出现。
5. 低音破声:一次定量排查实录
现象:音量拉大后低音有破声,音箱已排除。软件音量最大 100% 是单位增益(sample * 100 / 100),数学上不会削波,那是哪里的问题?
第一步:加测量,不靠猜
在音频发送线程加两个调试计数器,统计实际发给 DMA 的 PCM 峰值:
volatile uint32_t dbg_peak; /* 已发 PCM 峰值 */
volatile uint32_t dbg_clip_samples; /* 达到满幅(>=32760)的样本数 */
static void measure_peak(const int16_t *samples, size_t count)
{
uint32_t peak = 0;
for (size_t i = 0; i < count; i++) {
uint32_t a = samples[i] < 0 ? (uint32_t)-samples[i]
: (uint32_t)samples[i];
if (a > peak) {
peak = a;
}
if (a >= 32760) {
dbg_clip_samples++;
}
}
if (peak > dbg_peak) {
dbg_peak = peak;
}
}让破声复现,然后 openocd + GDB 在运行中的板子上直接读:
(gdb) print dbg_peak → 29431 (gdb) print dbg_clip_samples → 0
峰值 29431(约 90% 满幅),满幅样本 0——数字域完全干净,削波假设被排除。排查方向立刻收窄到 codec 的 DAC/输出放大级。
第二步:A/B 实验收敛
改一行代码烧一版,用听感做二分:
void audio_set_volume(uint8_t vol)
{
cur_volume = MIN(vol, 100) / 2; /* 实验1: -6dB → 破声消失 */
cur_volume = MIN(vol, 100) * 7 / 10; /* 实验2: -3dB → 还有破声 */
cur_volume = MIN(vol, 100) * 55 / 100; /* 实验3: -5.2dB → 干净,定版 */
}结论:接近满幅的低音信号把 WM8904 输出级顶失真了(耳机驱动电流/电荷泵在大动态低音下供不上是典型的模拟域现象),修复就是软件音量留余量,最大响度损失约 5dB 由音箱端补偿。
两个调试计数器留在代码里不删——以后再遇到任何音质问题,第一步就是读 dbg_peak 判断数字域是否干净,比反复试听猜方向快得多。
6. 无仪器验证方法总结
这次开发全程没用示波器/逻辑分析仪,手段就是 GDB 读变量和寄存器(串口被终端占用时尤其好用):
| SD 时钟修复 | 读 RCC_PLL2DIVR2 @0x580244C4 | 低 3 位 = 3(div-s=4) |
| 卡检测脚 | 读 GPIOM IDR @0x58023010 | bit14=0 插卡 / 1 拔卡 |
| 挂载扫描结果 | 读 mp3_state / track_count | IDLE / 实际曲目数 |
| 播放是否满速 | 读 dbg_tx_blocks 增速 | ~21 块/秒(48ms/块) |
| 读取速率匹配 | 读 cur_pos 增速 | ≈ 码率(256kbps ≈ 32KB/s) |
| 数字域是否削波 | 读 dbg_peak / dbg_clip_samples | <32760 / 0 |
工程位于 app/net_radio(本次新增 mp3_player.c/h、pcm_util.c/h),构建烧录命令与前几篇一致:
cd /d/luglZephyrproject export ZEPHYR_SDK_INSTALL_DIR="D:/luglZephyrproject/zephyr-sdk-1.0.1/zephyr-sdk-1.0.1" export ZEPHYR_TOOLCHAIN_VARIANT=zephyr west build -b stm32h7s78_dk/stm32h7s7xx/ext_flash_app --sysbuild \ -s D:/STM32/stm32H7S78-dk/app/net_radio \ -d D:/STM32/stm32H7S78-dk/app/net_radio/build -p west flash -d D:/STM32/stm32H7S78-dk/app/net_radio/build --domain net_radio
我要赚赏金
