前言
上一篇主要整理了 PCM 文件如何通过串口烧录到外部 Flash,并且在 Flash 里用索引把多条音频管理起来。
烧录完成以后,下一个问题就变成了:
Flash 里的 PCM 数据,怎么稳定地从 MCU 播出来?
当前目标是基于 STM32G030 做一个可移植的音频播放模块:
- 音频格式固定为
16KHz / 8bit / mono PCM - PCM 文件存放在外部 SPI Flash
- MCU 可以通过串口打印当前已有音频列表
- 可以通过串口选择某个音频 ID 播放
- 播放通过已经配置好的 PWM 引脚输出
- 音频播放不能阻塞主循环
- 应用逻辑单独封装,
main.c只做初始化和周期调用
这篇就把 PCM 原理、PWM 播放方式、MCU 驱动结构和这次调试遇到的问题整理一下。
PCM 是什么
PCM 全称是 Pulse Code Modulation,也就是脉冲编码调制。
放到 MCU 项目里,可以简单理解成:
PCM 是一串按固定时间间隔排列的音频采样值。
它不像 MP3、AAC 那样需要解码,也不像 WAV 那样带文件头。WAV 里通常会在 PCM 数据前面放一段格式说明,而 MCU 播放时真正需要的只是后面的采样数据。
这次使用的是:
16000 Hz
8 bit
mono
unsigned PCM
也就是说:
- 每秒有
16000个采样点 - 每个采样点占
1字节 - 只有一个声道
- 采样值范围是
0 ~ 255
因此一秒钟音频大概占用:
16000 sample/s * 1 Byte
= 16000 Byte/s
≈ 16KB/s
对于人声提示、状态播报、简单音乐片段来说,16KHz / 8bit 已经够用。它不追求 Hi-Fi,但胜在 MCU 处理简单、存储大小可控、播放时序容易保证。
为什么 MCU 上适合用 PCM
在资源比较紧的 MCU 上,PCM 最大的优点就是没有复杂解码。
如果播放 MP3,MCU 需要做压缩音频解码,对 RAM、Flash 和 CPU 都有要求;如果只是播放几句提示音,这个成本通常不值得。
PCM 的处理方式非常直接:
- 从 Flash 读取一个字节
- 把这个字节当作当前音频幅度
- 按固定采样率更新输出
- 重复这个过程直到文件结束
它的缺点也很明显:文件比压缩音频大。
例如 16KHz / 8bit / mono 是 16KB/s,如果有 60 秒音频,就接近 960KB。这也是为什么板载 Flash 不够用,需要外接 SPI Flash。
当前项目里使用外部 SPI Flash 保存 PCM 文件,前两个 4KB 扇区作为元数据区,真正的音频数据从 0x2000 后面开始存放。烧录和管理部分已经在上一篇文章里整理过,这里重点放在播放。
PWM 播放原理

STM32G030 没有 DAC,但有定时器 PWM,所以可以用 PWM 模拟音频输出。
基本链路是:
PCM采样值
↓
PWM占空比
↓
RC低通滤波 / 功放输入滤波
↓
功放
↓
喇叭
8bit unsigned PCM 的中点大约是 128。
如果采样值越大,就把 PWM 占空比调大;采样值越小,就把 PWM 占空比调小。经过低通滤波后,高频 PWM 载波被滤掉,留下随音频变化的平均电压。
这里最容易混淆的是两个频率:
采样率:每秒播放多少个 PCM 采样点PWM载波频率:PWM 本身翻转的频率
这两个不能混成一个。
当前音频采样率是 16KHz,所以 MCU 必须每 62.5us 更新一次 PWM 占空比:
1 / 16000 = 62.5us
但 PWM 载波频率不能也设成 16KHz。如果 PWM 本身只有 16KHz,载波已经进入可听范围,声音里会带明显尖锐噪声,而且低通滤波也不好处理。
所以当前实现把 PWM 载波频率设置为:
250KHz
这个值和常见建议的 200KHz ~ 250KHz 是一致的。它的好处是载波远高于语音频段,后面用 RC 滤波或功放输入滤波时更容易把载波成分压下去。
当前工程里系统时钟是 64MHz,PWM 使用 TIM14,采样节拍使用 TIM16。
定时器参数大概是这样:
TIM14 PWM载波:
64MHz / (255 + 1) = 250KHz
TIM16 采样节拍:
64MHz / (3999 + 1) = 16KHz
TIM14 的 ARR = 255 还有一个额外好处:刚好对应 8bit PCM 的 0 ~ 255 范围,换算占空比时很直观。
当前代码里实际做了通用计算,不直接写死比较值:
compare = sample * (ARR + 1) / 255;
如果后续换 MCU、换系统时钟、换定时器,只要 ARR 能提供足够分辨率,这个转换关系就还能继续用。
PWM 空闲电平和置中处理
8bit unsigned PCM 的静音中点是 128,所以播放过程中如果短时间没有取到新的 PCM 数据,最好不要把 PWM 占空比突然拉到 0。更稳妥的做法是临时输出中点值:
#define AUDIO_IDLE_SAMPLE 128U
static void Audio_SetOutputSample(uint8_t sample)
{
__HAL_TIM_SET_COMPARE(g_pwm_tim, g_pwm_channel, Audio_SampleToCompare(sample));
}
这样在环形缓冲短暂欠载时,输出会回到音频中点,相当于输出一小段静音,而不是从当前波形瞬间跳到低电平。后者很容易听成明显的“啪”或“咔”。
不过这里有一个和具体硬件相关的细节:播放结束以后不一定适合继续输出中点 PWM。这次板子实测时,如果播放结束后仍保持 128 对应的占空比,喇叭侧会一直有可闻底噪。因此最终策略是:
- 播放过程中正常按 PCM 采样更新 PWM
- 播放过程中如果临时欠载,输出
128中点值 - 播放停止或自然结束后,把 PWM 比较值归零
也就是:
static void Audio_SetOutputOff(void)
{
__HAL_TIM_SET_COMPARE(g_pwm_tim, g_pwm_channel, 0U);
}
这个策略兼顾了两件事:播放中的短暂异常不会产生很硬的低电平跳变,播放完成以后又不会让功放和喇叭一直处在有 PWM 激励的状态。
MCU 驱动方式
这次驱动拆分时,核心原则是:
应用逻辑单独写,
main.c只负责调用。
因此主程序里只保留配置结构体、初始化和循环处理:
PCM_AppConfig pcm_config;
pcm_config.uart = &huart1;
pcm_config.flash_spi = &hspi1;
pcm_config.flash_cs_port = FLASH_CS_GPIO_Port;
pcm_config.flash_cs_pin = FLASH_CS_Pin;
pcm_config.audio_tim = &htim14;
pcm_config.audio_tim_channel = TIM_CHANNEL_1;
pcm_config.audio_sample_tim_instance = TIM16;
pcm_config.audio_sample_tim_irqn = TIM16_IRQn;
PCM_App_Init(&pcm_config);
主循环里只需要:
while (1)
{
PCM_App_Process();
}
当前应用层主要分成几块:
| 模块 | 作用 |
|---|---|
pcm_app | 对外应用入口,统一初始化 Flash、索引、PWM 音频和串口协议 |
pcm_flash | SPI Flash 底层读写、擦除、读 JEDEC ID |
pcm_store | Flash 双区元数据、音频索引、CRC 和冲突检查 |
pcm_protocol | 串口二进制烧录协议和文本调试命令 |
pcm_audio_pwm | 16KHz 采样节拍、PWM 占空比更新、非阻塞播放 |
这样的拆分对移植比较友好。
后续如果搬到别的工程里,通常只需要替换:
- UART 句柄
- SPI 句柄
- Flash CS 引脚
- PWM 定时器和通道
- 采样定时器实例和中断号
播放逻辑本身不需要散落在 main.c 里。
非阻塞播放流程
音频播放不能直接在 play 命令里用一个大循环读 Flash、延时、改 PWM。
那样会阻塞串口、上位机通信和其他业务逻辑。更糟糕的是,播放几秒钟,主循环就会被卡几秒钟。
当前实现采用的是:
主循环负责从 Flash 补数据
定时器中断负责按 16KHz 消耗数据
播放流程大概是:
- 串口输入
play <id> pcm_store根据 ID 找到音频的 Flash 地址和长度pcm_audio_pwm清空环形缓冲区- 先从 Flash 预读一部分 PCM 数据
- 设置播放状态为 busy
TIM16每62.5us进入一次中断- 中断里取出 1 个 PCM 字节,并更新
TIM14->CCR - 主循环里发现缓冲区低于阈值,就继续从 Flash 读取数据补进去
- 输出采样数达到文件长度后自动停止
当前缓冲参数是:
环形缓冲区: 512 Byte
单次补充: 256 Byte
补充阈值: 256 Byte
也就是说,中断一直按固定节奏播放;Flash 读取放在主循环里做,避免在定时器中断里执行 SPI 读操作。
这也是非阻塞播放的关键。
串口调试命令
除了上位机使用的二进制烧录协议,MCU 侧还保留了简单文本命令,方便直接用串口助手调试。
启动后会打印当前格式和已有音频列表:
G030 UART Flash PCM ready
PCM format: 16000 Hz, 8-bit, mono
PCM files: 2
ID=0 ADDR=0x002000 LEN=138582 CRC=0x12345678
ID=1 ADDR=0x024000 LEN=80640 CRC=0x9ABCDEF0
Type 'help' for serial commands.
支持的命令是:
list
play <id>
stop
status
help
常用调试方式:
list
play 0
status
stop
播放成功时会看到类似:
PLAY_START ID=0 LEN=138582
如果找不到对应音频:
PLAY_NOT_FOUND
如果播放时缓冲来不及补数据,status 里会看到 underrun=1。这个标志很有用,说明 PWM 采样中断还在跑,但主循环补数据没有及时跟上。
调试踩坑
1. PWM 频率不是采样率
一开始最容易踩的坑就是把 PWM 频率和 PCM 采样率混在一起。
16KHz 是采样更新频率,不是 PWM 载波频率。
如果 PWM 也配置成 16KHz,即使每个采样点都更新了,占空比波形本身仍然会带很明显的可听载波。
最后修正为:
TIM14:250KHzPWM 载波TIM16:16KHz采样更新
这个结构才是比较合理的 PWM 音频播放方式。
2. Flash ID 不应作为烧录门槛
上位机和 MCU 都可以读 JEDEC ID,但这次调试后不再把 Flash ID 作为烧录的强制判断条件。
原因很简单:不同厂家兼容 W25Q 指令集的 Flash,ID 本来就可能不同。比如当前使用的 XT25F32FSSIGU,容量和指令集都适合这个方案,但它的 JEDEC ID 不会和 Winbond 的 W25Q32 完全一样。
所以 Flash ID 更适合做:
- 调试信息
- 硬件连线检查
- 判断 SPI 通信是否正常
而不适合做:
- 不匹配就禁止烧录
如果读 ID 失败,优先检查:
- SPI 模式是否正确
- CS 引脚是否正常拉低和拉高
WP#、HOLD#是否已经上拉- 供电和地是否稳定
- SPI 时钟是否过高
3. SPI 速度先保守
Flash 播放只需要大约 16KB/s 的持续读取速度,对 SPI 来说压力很小。
因此调试阶段没必要一开始就把 SPI 频率拉满。
当前工程里 SPI 预分频改成了更保守的配置,先保证烧录、读回和播放稳定。等功能确认以后,再根据板子走线和 Flash 手册逐步提高频率。
4. 无滤波也能响
PWM 引脚直接接后级,有时也能听到声音,但这并不是最终效果。
真正要让人声更干净,还是需要:
- PWM 后面加 RC 低通
- 或者接带输入滤波能力的功放模块
- 喇叭侧使用合适功率的功放,而不是直接靠 MCU IO 推
对提示音来说,不需要追求复杂音频链路,但至少要把 PWM 载波尽量滤掉。
5. 播放卡顿优先看 underrun
如果声音断续、变速、发闷,先不要急着怀疑音频文件。
可以先看 status:
STATUS burn=idle play=busy underrun=1
如果 underrun=1,说明播放过程中环形缓冲区被吃空过。
常见原因有:
- 主循环里有其他阻塞操作
- 串口打印太多
- SPI 读取异常或耗时过长
- 中断优先级设置不合理
- Flash 正在擦写时同时尝试播放
当前做法是在烧录、删除、擦除前先停止播放,避免 Flash 写/擦操作和播放读取抢同一颗 Flash。
音频文件转换
MCU 侧播放的是裸 PCM,因此准备音频文件时最好离线转换成统一格式。
例如使用 ffmpeg:
ffmpeg -i input.wav -ac 1 -ar 16000 -f u8 output.pcm
参数含义:
| 参数 | 说明 |
|---|---|
-ac 1 | 单声道 |
-ar 16000 | 采样率 16KHz |
-f u8 | 8bit unsigned PCM 裸流 |
转换后得到的 output.pcm 不带 WAV 文件头,可以直接通过上位机烧录到 Flash。
如果原始音频声音太小,可以在转换前先做归一化或增益处理。否则 MCU 播放链路没问题,但听起来会觉得声音偏轻。
对 8bit unsigned PCM 离线降低 6dB
这次实测中,如果原始 PCM 幅度太满,PWM 播放时容易出现削顶感、破音或爆音。一个有效的处理方式是在烧录 Flash 之前,先把 PCM 文件离线降低 6dB。
因为这里的格式是 8bit unsigned PCM,静音中点不是 0,而是 128。所以不能直接把字节值乘以 0.5,否则会把直流中心也一起拉偏。正确做法是围绕中点缩放:
out = 128 + (in - 128) * 10^(-6 / 20)
-6dB 对应的线性增益大约是:
10^(-6 / 20) ≈ 0.501187
处理逻辑可以写成:
import math
from pathlib import Path
src = Path("input.pcm")
dst = Path("input_-6dB.pcm")
gain = math.pow(10.0, -6.0 / 20.0)
data = bytearray(src.read_bytes())
for i, x in enumerate(data):
y = round(128 + (x - 128) * gain)
data[i] = max(0, min(255, y))
dst.write_bytes(data)
本次处理 小兔子乖乖.pcm 时,原始样本范围大约是 2 ~ 249,降低 6dB 后变为 65 ~ 189。文件长度保持不变,只是动态范围围绕 128 收窄。实际听感上,爆音会明显减少,但音量也会相应降低。
总结
这次 PCM 播放驱动最核心的结论是:
采样率决定多久更新一次音频值,PWM 载波频率决定用多高频率去模拟这个音频值。
当前实现里:
- PCM 格式是
16KHz / 8bit / mono - PWM 载波是
250KHz TIM14/PB1输出 PWMTIM16以16KHz中断更新采样- PCM 数据从外部 SPI Flash 读取
- 播放过程通过环形缓冲实现非阻塞
- 串口可以
list、play <id>、stop、status main.c只保留应用初始化和周期调用
这样一来,Flash 负责存储,PWM 负责输出,定时器负责节拍,主循环负责补数据,几个职责比较清楚。后面迁移到其他工程时,只要把 pcm_app、pcm_audio_pwm、pcm_flash、pcm_store 和 pcm_protocol 几个应用文件带过去,再重新绑定外设句柄即可。
Discussion
基于 GitHub Discussions。可以直接参与讨论、补充观点,或者留下你的问题。