简介:面向STM32嵌入式开发者的PWM音频播放完整工程,基于ARM Cortex-M内核实现从SD卡读取WAV文件并通过PWM接口实时输出声音。资源围绕PWM占空比控制、FatFS文件系统挂载、WAV文件头解析、PCM数据量化以及定时器中断更新展开,适合需要掌握嵌入式音频播放、数字信号与存储设备协同工作的中高级开发者。整个压缩包共170个文件,大小1.05MB,包含45个头文件与36个C源码,可直接查看SD卡驱动、WAV解码、PWM配置和系统初始化模块;其余编译生成的.o、.crf、.d及UV2工程配置方便在Keil环境中导入重建。已有3891人学习下载,工程内模块划分清晰,可对照学习中断服务程序设计、PWM波形生成与文件系统调用流程,也可迁移至其他基于PWM的波形输出项目。
1. PWM 播放 WAV 音频:凭什么 STM32 不挂 DAC 也能出声
很多开发者一听到“STM32 播放音频”,第一反应是外挂一颗 DAC 芯片,或者至少得有个 I2S 接口的音频解码器。但在大量真实产品里,语音提示、开机音乐、报警音这样的场景并不需要高保真,只需要“能听清人声和旋律”。这时候用定时器输出的 PWM 脉冲,按照 WAV 文件里的采样值匀速改变占空比,再经过一个简单的电阻电容低通滤波,就能把 0~3.3V 的方波还原成模拟电压,直接驱动一个小功放或耳机。整套方案不加任何外部 DAC,成本几乎为零,而且 STM32 自带的定时器和 DMA 就能跑起来。
这其实就是把数字音频的“采样值”转成“脉冲宽度”的过程:每个采样点对应一个占空比,采样率决定多久更新一次占空比,而 PWM 的载波频率决定了方波本身能被滤波器滤得多干净。下文从 WAV 解析开始,到定时器配置、DMA 搬运、RC 滤波,再到实测排错,把这条链路完整拆开。适合手里正好有一块 STM32 开发板、想给项目加语音提示的人,也适合想搞清楚 PWM 音频边界在哪的嵌入式开发者。
2. 拆开 WAV 文件:从文件头到采样值的完整解析
2.1 用 C 结构体一次读完 44 字节文件头
WAV 是 RIFF 格式的一种,文件开头是固定布局的头部。对 PCM 编码、44 字节标准头的 WAV 来说,前 44 个字节就把关键信息全部暴露出来了:采样率、位深、通道数、数据区长度。用 C 写一个紧凑结构体,直接按偏移读即可,注意加上packed属性,否则编译器对齐会让字段错位,这是新手最容易栽的第一个坑。
typedef struct __attribute__((packed)) { uint32_t riff_id; // "RIFF" uint32_t riff_size; // 文件总长度 - 8 uint32_t wave_id; // "WAVE" uint32_t fmt_id; // "fmt " uint32_t fmt_size; // fmt 块长度,PCM 通常是 16 uint16_t audio_format; // 1 表示 PCM uint16_t num_channels; // 1 单声道,2 双声道 uint32_t sample_rate; // 采样率,如 22050 uint32_t byte_rate; // 每秒字节数 = 采样率 * 块对齐 uint16_t block_align; // 一个采样帧的字节数 uint16_t bits_per_sample; // 位深,如 16 } WavHeaderTpl;拿到结构体后,执行memcpy(&wav_hdr, buf, sizeof(WavHeaderTpl)),然后直接读wav_hdr.sample_rate和wav_hdr.bits_per_sample即可。注意 RIFF 是小端格式,在 x86 和 ARM 的大端模式下需要自行翻转,而 STM32 默认就是小端,所以可以直接读。
2.2 别让附加块骗了你:遍历找到真正的 data 块
常见的 44 字节头是“RIFF + fmt + data”三段连续排布,但很多 WAV 文件在 fmt 和 data 之间会插入LIST、fact等附加块。如果盲目认为 data 一定从 44 字节开始,解析出来的“音频数据”里就混着大量非音频字节,播放出来是一段刺耳的噪声,而不是你预期的声音。
uint32_t data_offset = 0, data_len = 0; uint32_t offset = 20 + wav_hdr.fmt_size; // 跳过 RIFF 头和 fmt 块内容 while (offset + 8 <= file_size) { uint32_t id = *(uint32_t *)(buf + offset); uint32_t len = *(uint32_t *)(buf + offset + 4); if (id == 0x61746164) { // "data" 的小端数值 data_offset = offset + 8; data_len = len; break; } // 块头 8 字节 + 内容 len 字节,奇数长度要对齐到偶数 offset += 8 + len + (len & 1); }这段代码里,0x61746164是字符'd' 'a' 't' 'a'按小端拼接出的数值。len & 1处理的是 RIFF 规范要求每块按偶数边界对齐的问题,如果内容长度是奇数,下一个块头要跳过补齐的 1 字节。这不是小众问题,市面上确实存在带fact块的 WAV,遍历查找才是稳妥做法。
2.3 16 位有符号 PCM 转 8 位无符号 PWM 样本
WAV 里 16 位 PCM 的取值是有符号的,范围是 -32768 到 32767,而 STM32 定时器的占空比寄存器 CCR 一般按 0~255 映射。直接把有符号数塞进 CCR,负半周会变成 0 附近的值,声音会严重偏置甚至削波。所以要在烧录或加载前做一次线性映射:先右移 8 位取高字节,再加上 128 的无符号偏移。
import wave, sys w = wave.open(sys.argv[1], 'rb') nch, sampwidth, framerate, nframes = w.getparams()[:4] print("channels=%d width=%d rate=%d frames=%d" % (nch, sampwidth, framerate, nframes)) raw = w.readframes(nframes) out = bytearray() for i in range(0, len(raw), 2): s = int.from_bytes(raw[i:i+2], 'little', signed=True) out.append(((s >> 8) & 0xff) + 128) # 有符号16位 -> 无符号8位 with open("audio_pcm8.raw", "wb") as f: f.write(out)为什么要((s >> 8) & 0xff) + 128?右移 8 位后,s 的范围变成 -128 到 127,加 128 后变成 0 到 255。这样数字静音(s=0)正好对应 128,也就是 50% 占空比,PWM 输出保持中间电平,滤波后为 VCC/2,这是大多数音频电路的静态工作点。如果文件本身是 8 位无符号 WAV,则可以直接复制数据区,不需要这一步。转换后的数据建议按后续播放方式存成const uint8_t数组烧录进 Flash,或者通过文件系统从 SD 卡读取。
3. 搭建 PWM 播放链路:定时器、DMA 与启动顺序
3.1 先定两个频率:PWM 载波频率与采样率怎么选
PWM 播放音频需要两个时间基准:一是 PWM 本身的载波频率,也就是方波重复频率,它决定了滤波后的纹波大小;二是采样率,即多久更新一次占空比。载波频率越高,RC 滤波越容易把高频成分压下去,但载波频率受定时器时钟和分辨率共同限制;采样率则直接决定音调,错了就是变调播放。
对 STM32F103 而言,定时器挂在 APB1 或 APB2 总线,内部时钟一般可到 72MHz。常见的 8 位分辨率配置是ARR=255,PWM 频率 = 72MHz / (255+1) ≈ 281kHz,这个频率远高于音频上限 20kHz,一阶 RC 滤波后残余纹波已经很小,足够提示音场景使用。采样率建议选 22050Hz 或 8000Hz,前者音质接近 FM 广播,后者偏电话音质,但占用的 Flash 空间只有前者的三分之一。
3.2 配置 PWM 定时器:ARR 和 CCR 的关系决定了音质上限
ARR 是自动重载值,决定 PWM 周期;CCR 是比较值,决定占空比。占空比 = CCR / (ARR+1)。若 ARR=255,CCR 写入 0~255,全部有用位都落在 8 位映射内,分辨率刚好。若你把 ARR 调到 1023 去追求 10 位分辨率,PWM 频率会降到 72MHz/1024≈70kHz,滤波压力大增,实际听感未必更好。所以我一般固定 ARR=255,把 16 位 WAV 转成 8 位再播放。
// 定时器 4,输出 PWM 到 CH1,默认时钟 72MHz TIM_HandleTypeDef htim4 = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; htim4.Instance = TIM4; htim4.Init.Prescaler = 0; // 不分频,计数时钟 72MHz htim4.Init.CounterMode = TIM_COUNTERMODE_UP; htim4.Init.Period = 255; // ARR = 255,8 位分辨率 htim4.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim4); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 128; // 初始 50% 占空比,静音电平 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim4, &sConfigOC, TIM_CHANNEL_1);Prescaler=0意味着计数器每个时钟周期加 1,PWM 频率就是 72MHz / (ARR+1)。PWM 模式下高电平宽度由 CCR 决定,当 CCR 从 0 变到 255,占空比从 0% 平滑变到 100%。初始 CCR 设置为 128,是为了在还没开始播放时输出端保持中间电平,避免上电瞬间打到满幅引起功放“砰”的一声。
3.3 采样率定时器 + DMA:把采样值自动推给 CCR
占空比需要按采样率持续刷新。最省 CPU 的方式是让另一个定时器的更新事件去触发 DMA,DMA 每次搬运一个字节到 PWM 定时器的 CCR 寄存器。整个过程 CPU 不参与,适合长时间播放。
// 定时器 3 作为采样率发生器:72MHz / (0+1) / (3264+1) ≈ 22050Hz TIM_HandleTypeDef htim3 = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.Period = 3264 - 1; // 计到 3264 溢出一次 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim3); DMA_HandleTypeDef hdma_tim3 = {0}; hdma_tim3.Instance = DMA1_Channel4; // F103 上 TIM3_UP 的映射通道 hdma_tim3.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_tim3.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址固定,始终写 CCR1 hdma_tim3.Init.MemInc = DMA_MINC_ENABLE; // 源地址递增,逐字节取样本 hdma_tim3.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_tim3.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_tim3.Init.Mode = DMA_CIRCULAR; // 循环模式,播完自动重头开始 HAL_DMA_Init(&hdma_tim3); __HAL_LINKDMA(&htim3, hdma, hdma_tim3);这里DMA1_Channel4是 STM32F103 上定时器 3 更新事件对应的 DMA 通道,不同型号映射不同,换芯片时一定要查数据手册里的 DMA 请求映射表。DMA 搬运方向是“内存到外设”,内存地址按字节递增,外设地址固定为&htim4.Instance->CCR1。每次 TIM3 计数器溢出,DMA 就搬一个字节过去,采样率因此精确等于 TIM3 的更新频率。Mode=DMA_CIRCULAR会让播完一轮后重新从头开始,适合循环提示音;如果只想播一次,改成DMA_NORMAL,播完停止输出。
3.4 启动顺序与停播处理
把整套机制启动起来,顺序有讲究。先开 PWM 输出,再开 DMA 搬运,最后启动采样定时器。反过来操作,容易在 DMA 已经跑起来、PWM 还没输出时丢采样,或者 PWM 先输出满占空比造成瞬间冲击。
HAL_TIM_PWM_Start(&htim4, TIM_CHANNEL_1); // 1. 让 PWM 引脚输出确定电平 HAL_DMA_Start(&hdma_tim3, (uint32_t)pwm_buf, (uint32_t)&htim4.Instance->CCR1, audio_len); // 2. 准备好 DMA 搬运 __HAL_TIM_ENABLE_DMA_IT(&htim3, TIM_DMA_UPDATE); // 3. 允许更新事件触发 DMA HAL_TIM_Base_Start(&htim3); // 4. 启动采样时钟停播时反过来:先停止 TIM3,再停 DMA,最后停 PWM。如果直接在播放中把 DMA 停了,CCR 会停在最后一个采样值上,输出一个固定的直流电平,如果接到功放上就是“咔哒”一声。常见做法是停播后把 CCR 手动写回 128,再停 PWM,这样输出回到静音电平。
4. 模拟输出链路:RC 滤波截止频率与音量权衡
4.1 一阶 RC 截止频率计算:三个典型取值对应的听感
PWM 引脚输出的是 0~3.3V 方波,要变成音频模拟信号,标准做法是串一个电阻、对地接一个电容,构成一阶低通。截止频率的公式是fc = 1 / (2πRC),它决定两个东西:音频有效带宽内能保留多少高频;PWM 载波 281kHz 被压下去多少。截止频率越高,声音越亮,但残留的载波纹波越多;截止频率越低,声音越闷,但底噪越干净。
| R | C | fc | 281kHz 处衰减 | 听感 |
|---|---|---|---|---|
| 1kΩ | 100nF | 1.59kHz | 约 -45dB | 闷,人声厚,适合只报数字 |
| 1kΩ | 22nF | 7.23kHz | 约 -32dB | 人声清晰,高频略暗 |
| 1kΩ | 10nF | 15.9kHz | 约 -25dB | 偏亮,可能有轻微嘶嘶 |
很多人第一反应是选 100nF 把 PWM 滤干净,但算一下截止频率才 1.59kHz,人声的齿音、乐器的高频泛音全没了,听起来像隔着被子说话。做语音播报我一般先按 7kHz 左右取,也就是 1kΩ 配 22nF,听感均衡。想要更亮,再用 10nF,代价是底噪增加。
4.2 二阶滤波与缓冲级:什么时候值得多花两个元件
一阶滤波的衰减斜率是每十倍频程 -20dB,对 281kHz 的载波来说,能压住但不能压干净。如果播放过程中能明显听到“吱——”的高频底噪,问题大概率不在 PWM 频率,而在滤波阶数不够。方案是再多加一节 RC,组成二阶低通,衰减斜率提升到 -40dB/十倍频程,载波残余会小一个数量级。
二阶滤波不能直接把两个 RC 串在一起就完事,后一节会拉低前一节的等效阻抗,让截止频率偏移。规范做法是两个 RC 之间加一级运放缓冲,或者把第一节电阻减小、电容加大,让两节之间相互影响降到可忽略。成本敏感的产品里,更简单的做法是把 PWM 引脚先过一个 1kΩ+22nF 的粗滤波,再进功放——功放输入阻抗一般几十千欧,对 22nF 而言可以接受,实际效果比单纯一阶好不少。
4.3 输出阻抗、负载与音量:为什么接上耳机声音就变小
滤波后的信号从 RC 网络出来,驱动能力很弱。RC 的输出阻抗由电阻值决定,1kΩ 算是比较低的,但驱动 32Ω 耳机时,耳机和滤波电阻分压,大部分电压落在电阻上,音量会小到几乎听不见。这就是为什么 PWM 播放方案几乎都必须接功放或前级放大,而不是直接推耳机。
常见做法是滤波输出接一片低成本的音频功放芯片,增益设到 2~5 倍,输出推喇叭或耳机。如果只是用示波器验证,不接负载,也能在滤波后看到完整的正弦波形。这里最容易被忽视的是功放输入端的隔直电容:PWM 滤波后的静态电平是 1.65V,这个直流分量如果不隔掉,会被功放放大成很大噪声甚至烧喇叭。所以功放输入端一定串联一个 1~10μF 的电容,形成高通,和 RC 低通组合成带通,整条链路才安全。
5. PWM 播放 WAV 的 5 个高频坑:现象、原因与解决
5.1 现象:声音自带沙沙底噪,像在听收音机白噪音
原因有两类:一是 PWM 载波频率不够高,滤波压不干净;二是电源纹波直接串进了模拟通路。281kHz 的载波如果只配 1kΩ+100nF,理论上还能压住,但如果你把 ARR 调大去追求更高分辨率,载波掉到 70kHz,一阶滤波就明显露馅了。
解决:优先保住 PWM 频率,保持 ARR=255 的 8 位配置;然后在 MCU 电源引脚加 100nF+10μF 去耦,模拟部分和数字部分地线尽量单点接地。如果是电池供电,电池端再加一个 100μF 电解电容,声音会立刻干净一截。
5.2 现象:音调整体偏高或偏低,放出来像变声器
原因只有一个:采样率定时器算错了。比如 22050Hz 的 WAV,你用 32000Hz 去触发 DMA,播放速度就变成 1.45 倍速,音调整体上移。容易错的地方是 Prescaler 和 Period 的“减一”问题——定时器是从 0 计数到 Period,实际分频系数是 Prescaler+1 和 Period+1。
解决:先算好再写代码。目标频率 f,定时器时钟 clk,则Period = clk / (f * (Prescaler+1)) - 1。比如 72MHz、Prescaler=0、f=22050,则Period = 72000000 / 22050 - 1 = 3264 - 1。不确定时可以直接在一个 GPIO 上翻转输出测频率,验证定时器跑的是不是 22050Hz。
5.3 现象:播放到一半突然卡住或跳变,从头再来一遍
如果你用的是DMA_NORMAL模式,DMA 搬完一整轮就停了,后面的数据自然消失。有的代码没算对audio_len,DMA 只搬了半首歌就停止。还有一种典型问题:DMA 通道配置成MEMORY_TO_PERIPH,但外设地址忘了锁死,导致每次搬运后 CCR 被当成内存地址递增,写到未知寄存器里。
解决:检查PeriphInc = DMA_PINC_DISABLE;用MODE_CIRCULAR循环播放时,确认audio_len等于样本总字节数。可以先用一个已知长度的测试数组(比如 1000 字节正弦表)播放,用示波器数一下波形周期,验证 DMA 长度对不对。
5.4 现象:平时播放正常,一开串口或按键中断就破音
原因:其他中断把采样 DMA 的触发时序打乱了。PWM 播放对时间敏感,虽然 DMA 不占 CPU,但 DMA 请求本身也有优先级。如果某个高优先级中断频繁占用总线,DMA 搬运会被延后,CCR 更新瞬间滞后,听感就是咔咔的破音。
解决:把 DMA 通道优先级调高,同时降低不相关的串口中断频率。更彻底的做法是播放期间关掉无关中断,或者把播放任务放到一个专门的低负载阶段。我在项目中会把 DMA 通道的优先级设为DMA_PRIORITY_HIGH,并把采样定时器中断(如果开了)降为最低优先级。
5.5 现象:解析 WAV 后播放出来全是刺耳噪声,完全不像语音
原因:data 块偏移算错,把文件头或附加块内容当成采样值播放了;也可能是 16 位有符号样本没有做偏移转换,直接塞进 CCR,导致半周截断。
解决:用 2.2 节的遍历方法定位 data 块,别假设它固定从第 44 字节开始。同时在代码里加一个断言,检查wav_hdr.audio_format == 1(PCM),非 PCM 的 WAV 必须先转码。转换后的样本最好先输出到串口打印前几十个值,肉眼确认数据在 0~255 之间、静音段在 128 附近,再烧进 Flash。这一步能省掉大量排查时间。
6. 验证与调优:用示波器判断播放质量,再榨干音质
6.1 1kHz 正弦测试文件:全链路体检的标准样本
不要一开始就放流行音乐,那些复杂频谱掩盖了问题。先准备一个 1kHz、0dB、持续 3 秒的正弦 WAV,转成 8 位无符号样本后播放。1kHz 在示波器上非常容易辨认,播放是否卡顿、滤波是否到位、采样率是否正确,一眼就能判断。如果你听到的是 1kHz 附近干净的单音,说明链路基本通了。
6.2 三个测量点:PWM 引脚、滤波输出、DMA 请求
第一个测量点是 PWM 引脚本身。示波器应该看到 281kHz 方波,占空比在缓慢变化(周期约 1/22050 秒)。把时基调到 50μs,观察几个 PWM 周期的占空比是否在变,确认采样更新在跑。第二个测量点是滤波输出端。用 AC 耦合、20mV/格,应该看到 1kHz 正弦,峰峰值一般在几十到几百毫伏。第三个测量点是采样定时器的更新输出,用逻辑分析仪抓 TIM3 的更新事件频率,确认是 22050Hz。这三个点各有分工:PWM 看“有没有输出”,滤波看“还原得干不干净”,更新频率看“节奏对不对”。
6.3 音量归一化与采样率匹配
播放测试合格后想提升听感,先做音量归一化。很多 WAV 原始动态范围没录满,转成 8 位后只在 100~160 之间摆动,听觉上偏小。写个小工具遍历样本,找最大值max_val,然后把所有样本乘以127 / (max_val - 128),把摆动幅度拉满到接近满刻度。注意留约 3~5% 的头,防止削波失真。另外,素材采样率务必和播放定时器一致,44.1kHz 的音乐直接拿到 22050Hz 的播放链路上就是两倍速的“高音快歌”,要么先降采样,要么把定时器跳到 44100Hz。
这套 PWM 播放方案我从第一次调通到现在用了很长时间,最大的感受是:遇到杂音先查电源,遇到变调先查定时器,遇到噪声先查偏移,顺序不要反。最后养成一个习惯——每次上电先用 1kHz 正弦测试 30 秒,播放正常再换真人语音素材,能省掉大量“玄学”排查。如果你也照着这套链路走一遍,相信从 WAV 解析到 Pin 脚出声不会超过一个下午,希望帮到你。
本文还有配套的精品资源,点击获取