Blog Post

[嵌入式] 16KHz 8bit PCM音频的PWM播放驱动

2026/8/1
嵌入式PCM音频

前言

上一篇主要整理了 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 的处理方式非常直接:

  1. 从 Flash 读取一个字节
  2. 把这个字节当作当前音频幅度
  3. 按固定采样率更新输出
  4. 重复这个过程直到文件结束

它的缺点也很明显:文件比压缩音频大。

例如 16KHz / 8bit / mono16KB/s,如果有 60 秒音频,就接近 960KB。这也是为什么板载 Flash 不够用,需要外接 SPI Flash。

当前项目里使用外部 SPI Flash 保存 PCM 文件,前两个 4KB 扇区作为元数据区,真正的音频数据从 0x2000 后面开始存放。烧录和管理部分已经在上一篇文章里整理过,这里重点放在播放。

PWM 播放原理

image-20260801161439712

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

TIM14ARR = 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_flashSPI Flash 底层读写、擦除、读 JEDEC ID
pcm_storeFlash 双区元数据、音频索引、CRC 和冲突检查
pcm_protocol串口二进制烧录协议和文本调试命令
pcm_audio_pwm16KHz 采样节拍、PWM 占空比更新、非阻塞播放

这样的拆分对移植比较友好。

后续如果搬到别的工程里,通常只需要替换:

  • UART 句柄
  • SPI 句柄
  • Flash CS 引脚
  • PWM 定时器和通道
  • 采样定时器实例和中断号

播放逻辑本身不需要散落在 main.c 里。

非阻塞播放流程

音频播放不能直接在 play 命令里用一个大循环读 Flash、延时、改 PWM。

那样会阻塞串口、上位机通信和其他业务逻辑。更糟糕的是,播放几秒钟,主循环就会被卡几秒钟。

当前实现采用的是:

主循环负责从 Flash 补数据
定时器中断负责按 16KHz 消耗数据

播放流程大概是:

  1. 串口输入 play <id>
  2. pcm_store 根据 ID 找到音频的 Flash 地址和长度
  3. pcm_audio_pwm 清空环形缓冲区
  4. 先从 Flash 预读一部分 PCM 数据
  5. 设置播放状态为 busy
  6. TIM1662.5us 进入一次中断
  7. 中断里取出 1 个 PCM 字节,并更新 TIM14->CCR
  8. 主循环里发现缓冲区低于阈值,就继续从 Flash 读取数据补进去
  9. 输出采样数达到文件长度后自动停止

当前缓冲参数是:

环形缓冲区: 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,即使每个采样点都更新了,占空比波形本身仍然会带很明显的可听载波。

最后修正为:

  • TIM14250KHz PWM 载波
  • TIM1616KHz 采样更新

这个结构才是比较合理的 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 u88bit 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 输出 PWM
  • TIM1616KHz 中断更新采样
  • PCM 数据从外部 SPI Flash 读取
  • 播放过程通过环形缓冲实现非阻塞
  • 串口可以 listplay <id>stopstatus
  • main.c 只保留应用初始化和周期调用

这样一来,Flash 负责存储,PWM 负责输出,定时器负责节拍,主循环负责补数据,几个职责比较清楚。后面迁移到其他工程时,只要把 pcm_apppcm_audio_pwmpcm_flashpcm_storepcm_protocol 几个应用文件带过去,再重新绑定外设句柄即可。

参考

音频 PCM / WAV 格式详解 - 知乎

Discussion

基于 GitHub Discussions。可以直接参与讨论、补充观点,或者留下你的问题。