简介:STM32通过PWM接口播放SD卡中的WAV音频,是嵌入式音频中常用的软硬件结合案例。这个工程基于标准外设库,适合有一定STM32开发基础、希望了解PWM数模转换和FatFS文件系统移植的开发者。资源包共包含170个文件,压缩后体积只有1.05兆字节,大部分是C语言源文件,包括45个头文件和36个源程序文件,同时还有Keil工程配置文件、编译生成的列表文件、目标文件、镜像文件和内存映射文件,外加一个读取说明文本。代码层次清晰:先是SD卡底层驱动,再是文件系统封装,接着解析WAV音频文件头,最后将音频数据实时转换成PWM占空比输出,并实现播放暂停等控制功能。目前已有三千八百七十人学习使用,适合直接下载结合开发板验证,也可以作为学习定时器脉宽调制输出、文件系统调用和音频数据格式处理的参考。 我最初接触这个课题时,也觉得“STM32播放音频”应该得上 I2S 加编解码芯片,动不动就要外挂一片 WM8978 或者 VS1053。后来做了个小项目才发现,光是STM32的PWM定时器加一路低通滤波,就能直接放出清晰的 WAV 人声和提示音,资源占用极低,电路也简单到只用两个电阻一个电容。这篇文章就完整记录我从 WAV 文件解析、定时器配置到最终出声的全过程,把坑和细节一次说清楚。
这个方案特别适合三类人:一是手上只有核心板、想低成本搞语音播报的;二是刚学完定时器PWM但不知道怎么落到实际应用的;三是准备做毕业设计,想在“单片机播放音乐”这个烂大街题上做出点工程深度的人。看完你不仅能复现出一台能朗读文本的“电子发声器”,还能顺手掌握定时器的底层工作机制、DMA 搬运数据的玩法,以及 RC 滤波器在信号还原里到底扮演什么角色。
1. 方案定调:为什么偏偏用PWM来“放”音频
先说清楚思路。PWM 播放音频的原理其实就一句话:音频信号本身就是一条随时间变化的电压曲线,而 PWM 波的占空比决定了经过低通滤波后的平均电压,那么让占空比跟随音频采样点变化,滤波后就能还原出原始波形。你把它理解成“用开关电源的输出电压讲故事”,占空比一变,喇叭里的声音就跟着变。
那为什么不选 DAC 或者 I2S 接口?
- 普通 STM32(F1、F0)只有一路真正的 DAC,想要立体声或者同时出声加提示音,资源不够还得加芯片。
- I2S 接口虽然音质上限高,但要外接音频 Codec 芯片,电路成本、PCB 面积和代码复杂度都上来了,对很多场景来说是过度设计。
- PWM 是定时器的“附带能力”,STM32 的每个高级/通用定时器基本都能输出 4 路独立 PWM,也就是说你零成本就有“多路 DAC”可以用。
用 PWM 方案的另一个隐性好处是,它对 MCU 主频要求不高。我这个项目用的是 STM32F103C8T6,72 MHz 主频,跑 8kHz 采样率、8 bit 精度的 WAV 语音,CPU 占用率低于 5%,因为数据搬运全交给 DMA,中断只在一段音频播完时触发一次。这种资源占用水平,意味着你可以在同一颗芯片上同时跑传感器采集、屏幕显示和按键逻辑。
不过也要承认 PWM 方案的局限。它的音频保真度上限受限于 PWM 频率和 MCU 的定时器位数,F103 的定时器是 16 位,想做到 48kHz 采样率、16bit 精度会很吃力。所以我把定位放在“语音播报、提示音、简单音乐”这个范畴,音质水平相当于早年电子词典的发声效果,完全够用。
1.1 硬件电路:两个电阻一个电容就够
整体电路分为三块:MCU 的 PWM 输出引脚、RC 低通滤波器、功放(可选)。最简单版本只要一个 100Ω 电阻加一个 1uF 电容,直接驱动一个 8Ω 小喇叭就能听到声音,但音量很小。我实际项目里加了一颗 PAM8403 功放板,淘宝几块钱一片,供电 5V 即可,音量和清晰度立刻提升一个档次。
RC 低通滤波器的截止频率计算公式是:
[ f_c = \frac{1}{2\pi RC} ]
我用的 R=100Ω、C=1uF,算出来截止频率约 1.6kHz,对语音信号中高频部分有一些衰减,但可懂度没问题。如果你想提升高频清晰度,可以把截止频率提高到 3~4kHz,对应 R=50Ω、C=1uF,或者 R=100Ω、C=0.47uF。注意这里的滤波太大、截止频率太低会让声音“闷掉”,太小又滤不掉 PWM 载波,出现刺耳的“吱吱”声,需要你按实际听感微调。
1.2 开发环境与工具链选择
我用的 STM32CubeMX 配合标准外设库(Standard Peripheral Library),其实用 HAL 库也完全一样,原理相通。选标准库是习惯问题,因为早年写 F1 项目都是标准库,代码直观、可以直接操作寄存器,对于理解 PWM 播放原理反而更友好。如果你用 CubeMX 生成初始化代码,只需要注意:定时器的 PWM 输出配置、DMA 请求映射、定时器更新事件触发 DMA 这三个点设置正确即可。
下载调试我用 ST-Link V2。经常遇到“Error: No STM32 target found! If your product embeds debug authentication, please...”这个报错,绝大多数情况是接线松了或者 SWDIO/SWCLK 引脚被复用,后面会统一写排查方法。
2. WAV 文件准备:比你想象的讲究
播放的第一步,不是写代码,而是把音频文件准备对。WAV 是最简单的音频格式,本质就是一坨 PCM(脉冲编码调制)数据前面加了一个文件头。STM32 不认 MP3 或者 AAC,必须先把源音频转成 WAV 格式,这个过程建议在 PC 上用 Audacity 完成。
打开 Audacity,导入任意音频格式,然后依次设置:
- 轨道下拉菜单里把声道改成“单声道”(Mono)
- 低音音质下拉菜单里把采样率设为 8000 Hz 或 16000 Hz
- 导出为 WAV,编码选“无符号 8 位 PCM”
这三步每一步都影响最终效果。最容易被忽略的是位深。很多教程让你转成 16bit WAV,但我建议项目初期用 8bit 无符号,原因下面详细讲。
2.1 读懂 WAV 那 44 个字节的文件头
一个标准 PCM WAV 文件最前面固定是 44 字节的文件头,之后跟着的是裸采样数据。我直接列出在代码里需要读取的字段:
| 偏移地址 | 长度 | 内容 | 说明 |
|---|---|---|---|
| 0x00 | 4 | "RIFF" | 固定标识 |
| 0x08 | 4 | "WAVE" | 固定标识 |
| 0x16 | 2 | 音频格式 | 1 表示 PCM |
| 0x18 | 2 | 声道数 | 1 单声道,2 立体声 |
| 0x1A | 4 | 采样率 | 如 8000 |
| 0x1C | 4 | 字节速率 | 采样率 × 声道数 × 位深/8 |
| 0x22 | 2 | 位深 | 8 或 16 |
千万注意,data chunk 并不一定紧跟在 44 字节之后。部分软件会在文件头中间插入额外的 chunk(比如 LIST、fact 等),如果你盲目从偏移 44 开始读数据,读出来全是噪声。稳妥做法是扫描数据块:从 0x0C 开始逐个查 chunk 的 ID,如果是 "data",说明后面就是采样数据,同时读取它的长度。
2.2 为什么 8bit 无符号是“新手最佳选择”
8bit 无符号 WAV 的每个采样点正好是一个字节(0~255),恰好对应 STM32 定时器 CCR 寄存器的大致调节范围。我做数据映射时,CCR 值和采样值是线性比例关系,不需要做减法、符号扩展和符号处理,直接用 DMA 把一个字节搬运到 CCR 的高字节即可。如果是 16bit 有符号,你得先做一个偏移量 32768 的加法,再压缩到 CCR 的范围内,否则负半轴信号全会丢失,声音严重失真。
8bit 的缺点是动态范围小,信噪比大约 48dB,会有轻微底噪,但对于人声播报和提示音完全能接受。存储空间也更省,8kHz 采样率每秒只占 8KB,F103C8T6 的 64KB Flash 可以放 7 秒内容,挑一句简短提示语绰绰有余。
2.3 把音频数据变成 C 语言数组
制作方法很简单,用 Python 脚本或者二进制转数组工具(比如 Hex2Bin、COnSt 工具)把 WAV 的 data 部分导出成一个数组,粘贴进工程即可。我自己用 Python 写了个小脚本,解析文件头、跳过非 data chunk、输出 C 数组到 txt 文件,几十行代码的事。关键代码如下:
import struct def wav_to_c_array(path): f = open(path, 'rb') f.seek(0x0C) while True: chunk_id = f.read(4) chunk_size = struct.unpack('<I', f.read(4))[0] if chunk_id == b'data': data = f.read(chunk_size) break f.seek(chunk_size, 1) f.close() return list(data) samples = wav_to_c_array('voice.wav') print('const uint8_t audio_data[] = {') for i in range(0, len(samples), 16): line = ', '.join(f'0x{sample:02X}' for sample in samples[i:i+16]) print(' ' + line + ',') print('};')注意这个脚本假设数据块里每个采样是 8bit,如果你的 WAV 是 16bit,data 列表的元素需要按“每两个字节合成一个采样值”处理。实际操作中,转格式后我会再检查一下生成的数组长度和播放时长是否匹配:数据长度 ÷ 采样率 = 时长(秒),比如 64000 字节 ÷ 8000Hz = 8 秒,如果对不上说明格式配置有问题。
3. 核心机制:定时器、PWM 频率与采样率的三方配合
硬件准备好了,数据也转好了,接下来要解决的是“怎么让 PWM 占空比按音频节奏变化”。这里必须先理清两个容易混淆的频率概念:PWM 频率和采样率。
- PWM 频率指定时器产生方波的频率,它是载波,经低通滤波后被滤除。
- 采样率指每秒钟更新占空比到新音频采样值的次数。
在这个设计里,我选择让 PWM 频率等于采样率,也就是定时器每溢出一次,就产生一个周期方波,同时触发 DMA 把下一个采样值更新到 CCR 寄存器。这样每个 PWM 方波的占空比都对应一个采样点,经过低通滤波后,方波的平均值就还原成采样点的电压。
3.1 定时器参数计算:从 72MHz 倒推 ARR
以 STM32F103 为例,APB1 定时器时钟是 72MHz(因为 APB1 分频后定时器倍频)。我需要让定时器产生 8000Hz 的更新事件,即:
[ ARR = \frac{72{,}000{,}000}{8000} - 1 = 8999 ]
如果选择 16000Hz 采样率,ARR = 4499。注意定时器是 16 位,最大计数 65535,所以采样率最低不能低于 72MHz ÷ 65536 ≈ 1099 Hz,这个区间内完全够用。PWM 占空比输出模式选 PWM1:计数小于 CCR 时输出高电平,计数到 ARR 后清零重新计数。CCR 的计算公式是:
[ CCR = ARR \times \frac{采样值}{255} ]
8bit 采样值 255 对应占空比 100%,0 对应 0%。但实际使用中我建议占空比限制在 5%~95%,给 PWM 波形的上升下降沿留一点裕度,避免喇叭在极限占空比驱动时产生异常偏置。
3.2 DMA:让采样数据自己“流”进定时器
如果只用中断,在中断服务函数里修改 CCR,那么 8kHz 意味着每 125us 进一次中断,CPU 会被频繁打断,而且中断响应延迟会造成采样时序抖动,声音会变沙哑。更好的做法是用 DMA:定时器的更新事件映射到 DMA 通道,每次更新自动触发 DMA,把内存地址中的采样值搬运到 CCR 寄存器地址(对应 TIMx->CCR1 的地址)。
实现要点是:DMA 传输方向为 memory 到 peripheral,外设地址为 CCR1 寄存器地址,内存地址为音频数组地址,传输数据量等于音频数组长度,每次传输 1 字节,循环模式打开时可以实现无缝循环播放。DMA 的每次传输就是“一锤子买卖”——采样值被自动写入,处理器完全不用操心。
一个细节:如果采样值是 8bit,但 CCR 寄存器是 16bit 的,需要把 DMA 的数据宽度设为字节,外设数据宽度也设为字节。这样 MCU 会把 8bit 数据自动放到 CCR 的低 8 位,高位补 0。如果你想用 16bit 音频数据,DMA 数据宽度改成半字,CCR 也可以直接用 16bit 数据,但 CCR 只在 ARR 范围内有效,所以 ARR 也得相应增大。
3.3 低通滤波到底在滤什么
低通滤波器作用有两个:滤除 PWM 载波频率成分,以及平滑相邻采样点之间的电压跳变。采样率 8kHz、PWM 频率 8kHz,载波正好落在 8kHz 位置。人耳对 8kHz 的纯音敏感,所以如果不加滤波,耳朵会听到非常明显的“嘶嘶”高频声,这就是 PWM 载波泄漏。加上截止频率约 3kHz 的低通滤波后,8kHz 载波会被显著衰减,而语音信号的主要能量(300Hz~3.4kHz)基本保留,声音就变干净了。
实际听感测试中,我用示波器观察 PA8 引脚,PWM 波形则是标准的 8kHz 方波且占空比在随音频变化;经过 RC 滤波后,波形变成了平滑的模拟语音波形,幅值在 0~3.3V 之间摆动。这一步是验证整个链路是否正常的核心手段:如果滤波后波形还是方波,说明滤波截止频率或元件参数有问题;如果波形是一条平整直线,说明采样数据没有正常更新,DMA 配置有问题。
4. 完整代码实现:从初始化到发声
下面给出一套完整的核心代码,基于标准外设库,芯片为 STM32F103C8T6,使用 TIM2 的通道 1 输出 PWM。我把音频数据数组audio_data直接编译进 Flash,用 DMA 循环播放。
4.1 定时器与 PWM 初始化
void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // PA0 复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器时基:72MHz / 9000 = 8kHz TIM_TimeBaseStructure.TIM_Period = 8999; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // PWM 模式 1,初始占空比 50% TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 4500; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); }注意 TIM2 的捕获比较通道 1 默认输出引脚是 PA0,千万别把 PWM 信号焊接到 PA1(那是通道 2)上。TIM2 在 STM32F103 中也有部分引脚重映射情况,如果默认引脚冲突,可以用GPIO_PinRemapConfig(GPIO_Remap_TIM2, ENABLE)重映射到 PB10/PB11,但一般默认不用动。
4.2 DMA 配置与启动播放
void DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel5); // TIM2 更新事件映射到 DMA1 通道 5 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&TIM2->CCR1; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)audio_data; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = AUDIO_DATA_LEN; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); }接下来要在定时器初始化之后打开 DMA 请求:
TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE);这一条命令非常重要,它告诉定时器“每次更新事件时产生 DMA 请求”,把采样值自动搬到 CCR1。顺序上先开 DMA 再开定时器使能,可以避免启动瞬间出现一次不完整的空采样。
4.3 播放控制与多段音频切换
我用的是一个状态机来管理播放逻辑,因为项目中可能出现多段语音需要轮播。核心思路是:记录当前正在播放的音频 id,利用 DMA 通道的传输完成中断来判断是否播放完成,然后在中断中切换或停止。
volatile uint8_t audio_playing_flag = 0; void Audio_Play(const uint8_t *data, uint16_t len) { while (audio_playing_flag); // 等上一次播完 DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)data; DMA_InitStructure.DMA_BufferSize = len; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_ITConfig(DMA1_Channel5, DMA_IT_TC, ENABLE); audio_playing_flag = 1; DMA_Cmd(DMA1_Channel5, ENABLE); } void DMA1_Channel5_IRQHandler(void) { if (DMA_GetITStatus(DMA1_Channel5, DMA_IT_TC)) { DMA_ClearITPendingBit(DMA1_Channel5, DMA_IT_TC); DMA_Cmd(DMA1_Channel5, DISABLE); audio_playing_flag = 0; } }在循环播放场景中,DMA 模式改成DMA_Mode_Circular,需要注意此时保持传输完成中断会使每个周期连续触发。如果你想播完停止,就不要开循环模式,也不要在完成中断里重开 DMA。
4.4 音量调节:占空比缩放
调节音量最直接的办法是缩放采样值,也就是对 CCR 对应的原始值乘一个系数。比如音量百分比为 volume(0~100),那么实际写入 CCR 的采样值为:
uint8_t scaled_sample = (uint8_t)(sample * volume / 100);但是这个乘法有潜在性能问题:DMA 直接搬运是没办法做乘法的,所以要么用硬件 PWM 死区寄存器等高级功能辅助,要么在 DMA 搬运前对音频数据做一次预处理。更简单的办法是通过控制 PWM 占空比范围,即把 CCR 限制在 [CCR_min, CCR_max] 之间,降低整体输出功率。实际测试中,音量调节通过软件预处理数组实现最灵活,但闪存占用会翻倍,需要在设计时权衡。
5. 踩坑实录与调音技巧:从“有声音”到“好听”
这部分是我最想分享的,因为原理讲得再清楚,真到焊板子跑起来时,你遇到的问题一个都不会少。我把这段时间调试的典型问题都列在下面。
5.1 声音“沙哑”或“破音”的三大元凶
破音是最常见的现象,排查顺序如下:
第一,检查采样值是否“溢出了 ARR 的范围”。如果 CCR 值为 8999,而你的采样值是 16bit,直接作为 CCR 写进去就会溢出,造成周期性爆音。解决办法是让 CCR 最大值不超过 ARR,或者把采样值右移 8 位。
第二,检查 RC 滤波截止频率。如果截止频率太高,PWM 载波没有被滤干净,听到的“嘶嘶声”就会盖过语音,主观感受就是破音。测试方法:用示波器看滤波后的波形,如果还能看到明显的锯齿或方波轮廓,就把滤波电容调大一倍再试。
第三,检查音源本身是否失真。音频文件如果原本录音就爆音,那无解。用 Audacity 播放原始文件确认没有失真,再怀疑电路。
5.2 为什么会有“滋啦”的爆音声
这个通常不是连续的沙哑,而是每次播放开始和结束时的“咔哒”或“滋啦”声。原因是 DMA 启动瞬间,CCR 从 0 突变到第一个采样值,或者播放完成瞬间 CCR 突变到 0,产生一个阶跃信号。处理办法是做淡入淡出处理:在音频数据数组头和尾各加几十个采样点,幅值从 0 渐增到正常值、从正常值渐减到 0。具体实现可以在 Audacity 里选中开头/结尾,用“效果→淡入/淡出”直接生成,也可以把播放代码控制在启动时先输出一个过渡值。
5.3 下载报错 No STM32 target found 排查
调试时最窝火的不是代码问题,而是编译器提示Error: No STM32 target found! If your product embeds debug authentication, please...。我总结了一套排查顺序:
- 检查 ST-Link 与核心板接线,SWDIO、SWCLK、GND 三根线是必须的,3.3V 供电不要从 ST-Link 取,容易电流不够掉线。
- 在 MDK 或 CubeIDE 的 Debug 设置里把连接速度从最高降到 1MHz,很多杜邦线连接质量差导致高速握手失败。
- 按住核心板复位键,点下载,再松开复位,运气好能连上。
- 换一根 USB 线、换个 USB 口,排除电脑供电不足。F103 核心板很多用了 AMS1117 稳压,输入电压低于 5V 时输出可能不稳定,导致芯片工作不正常或者死机。
5.4 实际试听效果与优化方向
最终我实现的系统,8kHz采样率、8bit位深,播放一句“欢迎使用”的语音提示,人耳听感清晰可懂,背景噪声非常低,但高频细节有明显损失,男声比女声效果好一些,因为男声基频更低。想要提升音质,方向有两个:
一是把采样率从 8kHz 提升到 16kHz,声音清晰度会有质的飞跃,代价是 Flash 占用翻倍、PWM 载波变成 16kHz、RC 滤波器要重新调。
二是把位深从 8bit 换成 16bit,此时 DMA 传输宽度改为半字,CCR 直接装入 16bit 采样值,ARR 也要设到 65535。但这样 PWM 频率会掉到 72MHz/65536≈1099Hz,载波频率太低滤波困难。所以更合理是保留 8bit,用 Sigma-Delta 调制进行降位处理,不过这已经超出入门范畴了。
对于项目迭代,我还尝试过用定时器作为音频主时钟,通过 SPI 接口外扩一个串行 DAC,播放质量可以达到电话音质偏上的水平,芯路难度只增加一点。但这是后话了,先把 PWM 方案跑通,再考虑更复杂的方向。
最后分享一个我实测有效的小技巧:如果觉得声音发闷,把 RC 滤波的截止频率从 1.6kHz 提到 3kHz,并给喇叭并联一个 1uF 的电容,很多频段的高频细节会立刻“通透”起来。这不算什么高深理论,就是在听感调校时最容易见效的办法。
本文还有配套的精品资源,点击获取