STM32 PWM播放WAV音频:从定时器配置到RC滤波的完整实现 简介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 接口普通 STM32F1、F0只有一路真正的 DAC想要立体声或者同时出声加提示音资源不够还得加芯片。I2S 接口虽然音质上限高但要外接音频 Codec 芯片电路成本、PCB 面积和代码复杂度都上来了对很多场景来说是过度设计。PWM 是定时器的“附带能力”STM32 的每个高级/通用定时器基本都能输出 4 路独立 PWM也就是说你零成本就有“多路 DAC”可以用。用 PWM 方案的另一个隐性好处是它对 MCU 主频要求不高。我这个项目用的是 STM32F103C8T672 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} ]我用的 R100Ω、C1uF算出来截止频率约 1.6kHz对语音信号中高频部分有一些衰减但可懂度没问题。如果你想提升高频清晰度可以把截止频率提高到 3~4kHz对应 R50Ω、C1uF或者 R100Ω、C0.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 字节的文件头之后跟着的是裸采样数据。我直接列出在代码里需要读取的字段偏移地址长度内容说明0x004RIFF固定标识0x084WAVE固定标识0x162音频格式1 表示 PCM0x182声道数1 单声道2 立体声0x1A4采样率如 80000x1C4字节速率采样率 × 声道数 × 位深/80x222位深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 采样率每秒只占 8KBF103C8T6 的 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 bdata: 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(f0x{sample:02X} for sample in samples[i:i16]) print( line ,) print(};)注意这个脚本假设数据块里每个采样是 8bit如果你的 WAV 是 16bitdata 列表的元素需要按“每两个字节合成一个采样值”处理。实际操作中转格式后我会再检查一下生成的数组长度和播放时长是否匹配数据长度 ÷ 采样率 时长秒比如 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 对应的原始值乘一个系数。比如音量百分比为 volume0~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 的电容很多频段的高频细节会立刻“通透”起来。这不算什么高深理论就是在听感调校时最容易见效的办法。本文还有配套的精品资源点击获取