简介:STM32通过PWM播放WAV音频,是一项融合文件系统、音频解码与数字信号处理的典型嵌入式实战资源。项目以ARM Cortex-M内核的STM32为主控,讲解如何结合PWM接口、FatFS文件系统、SD卡存储及WAV格式解析,完成从硬件配置、SPI初始化、音频参数读取到PCM数据与PWM占空比转换、定时中断实时输出、播放控制及音质优化的完整流程,适合具备一定嵌入式基础、希望掌握音频播放实现思路的开发者。资源包共170个文件,压缩包大小1.05MB,主要包含45个h头文件、36个c源文件、工程链接/调试产物及汇编、列表、链接脚本等,其中核心代码与配置完整,可直接参照工程结构与模块划分进行学习。目前已有3891人浏览学习。借助该资源,读者可获取一套可运行的STM32音频播放工程参考,深入理解PWM输出与文件系统在音频场景下的协同调用,并迁移到更多需要低成本放音功能的嵌入式项目中。
1. STM32用PWM播放WAV:一张SD卡、一路定时器、三两个电阻
很多项目都会在最后一家卡在“要发声但MCU没有I2S”这种处境。其实没有I2S外设的STM32照样能播WAV音频,方案很简单:让定时器输出固定频率PWM,占空比跟着音频采样值变化,再用RC低通滤波把平均电压还原成模拟信号;WAV文件放在SD卡里,用FatFs逐块读出边读边播。我按这个思路做过一个小语音播报板,物料成本不到一块钱,做提示音、语音短句完全够用,高音细节确实少,但至少不用为加一颗音频Codec改板子。下面把参数换算、文件读取、滤波电路和播放状态机逐个拆开,新手照走能出声,老手可以直接跳到避坑和示波器验证那章。
2. WAV转PWM的关键换算:采样率、位深、占空比参数怎么定
先把话说清楚:PWM本身不是DAC,它输出的是一串方波,方波占空比随时间变化,经过低通滤波后,平均电压才形成模拟音频。所以第一步不是写播放代码,而是把WAV文件头解析出来,拿到采样率和位深,再去算定时器参数。这两者一旦错位,后面所有代码都是白调。
2.1 WAV文件头解析:44字节里藏着采样率与位深
WAV是RIFF封装格式,PCM数据按小端存储。标准头部一共44字节,常用字段分布如下:偏移0是“RIFF”,偏移8是“WAVE”,偏移12是“fmt ”子块,偏移20是音频格式(1代表PCM),偏移22是声道数,偏移24是采样率,偏移34是位深,偏移36是“data”,偏移40是数据长度。很多移植代码只读最后两个字段,其实采样率、声道数、位深都得从这里取,否则后面定时器分频和占空比映射全没有依据。
下面的函数用FatFs的FIL句柄读取这44字节,并填一个结构体:
#include "ff.h" #include <string.h> typedef struct { uint32_t sampleRate; uint16_t bitsPerSample; uint16_t channels; uint32_t dataSize; uint32_t dataOffset; } WavInfo; static uint16_t RD16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); } static uint32_t RD32(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); } int WAV_GetInfo(FIL *fp, WavInfo *info) { uint8_t hdr[44]; UINT br; if (f_lseek(fp, 0) != FR_OK) return -1; if (f_read(fp, hdr, 44, &br) != FR_OK || br != 44) return -1; if (memcmp(hdr, "RIFF", 4) != 0 || memcmp(hdr + 8, "WAVE", 4) != 0) return -2; if (memcmp(hdr + 12, "fmt ", 4) != 0) return -2; if (memcmp(hdr + 36, "data", 4) != 0) return -3; info->channels = RD16(hdr + 22); info->sampleRate = RD32(hdr + 24); info->bitsPerSample = RD16(hdr + 34); info->dataSize = RD32(hdr + 40); info->dataOffset = 44; return 0; }这个函数看起来简单,但有两个容易翻车的地方。第一,f_read不保证一次读满请求长度,所以必须检查br != 44,否则某些SD卡在跨扇区边界时会返回半截数据;第二,我假定data块紧跟在36字节之后,这对绝大多数用工具转换出来的标准PCM文件没问题,但录音笔或专业软件生成的文件可能会在fmt和data之间插入LIST或fact扩展块,此时dataOffset就不是44。
真正稳妥的做法是从偏移12开始扫描子块。每一个子块头部是8字节(4字节块类型加4字节小端长度),随后跟着块内容,长度如果是奇数,还需要补一个对齐字节。下面这个函数可以拿来找data块:
uint32_t WAV_FindData(FIL *fp, uint32_t *dataSize) { uint8_t hdr[8]; UINT br; uint32_t off = 12; uint32_t len; while (off < 65536) { if (f_lseek(fp, off) != FR_OK) break; if (f_read(fp, hdr, 8, &br) != FR_OK || br != 8) break; len = RD32(hdr + 4); if (memcmp(hdr, "data", 4) == 0) { *dataSize = len; return off + 8; } off += 8 + len + (len & 1); } return 0; }这里有个参数细节:子块长度是数据体长度,不包含8字节头部,所以下一个子块起始位置是off + 8 + len。len & 1是为了处理奇数长度时的字对齐,RIFF规定块大小按字对齐,少了的字节要跳过。
2.2 PWM频率选择:为什么我建议载波取采样率的整数倍
拿到采样率后,接着要定PWM的载波频率。这里最容易混的一个概念是“PWM频率”和“采样率”不是一回事。采样率决定每秒从WAV文件里取多少个样本来更新占空比,比如8kHz采样就是每125微秒更新一次;PWM载波频率是方波本身的重复频率,这个频率要远高于采样率,低通滤波器才能把它滤干净。
工程上我一般取“PWM载波 = 采样率 × N”,N是每个采样周期内完整脉冲的数量,推荐4到16。为什么不直接让PWM频率等于采样率?因为那样每个采样周期内只有一次跳变,占空比的变化看起来像不规则脉冲,低通滤波后会有明显的高频噪声和时序抖动。如果取整数倍,每次占空比更新的时间点都对齐在脉冲序列的固定相位上,噪声更匀称,听起来更干净。
定时器参数按下面的关系算。假设定时器时钟是72MHz,设预分频PSC=0,那么:
ARR = 72000000 / PWM频率 - 1占空比分辨率受ARR限制。如果希望8位WAV的每个数字值都能映射到不同占空比,ARR至少要有255,即PWM频率不能超过72MHz / 256 ≈ 281kHz。下面是一组常用配置参考:
| 采样率 Fs | N | PWM载波 | ARR(72MHz) | 可用占空比位宽 |
|---|---|---|---|---|
| 8000 Hz | 16 | 128 kHz | 561 | 约9.1 bit |
| 16000 Hz | 8 | 128 kHz | 561 | 约9.1 bit |
| 22050 Hz | 8 | 176.4 kHz | 407 | 约8.7 bit |
| 44100 Hz | 4 | 176.4 kHz | 407 | 约8.7 bit |
注意ARR大于255不代表浪费,它反而给了占空比量化更多的中间台阶,播放8位WAV时误差更小。顺便说一个反直觉的结论:并不是N越大越好。N太大会把PWM载波抬到几百kHz,定时器中断频繁触发,MCU忙于更新CCR,留给SD卡读取的时间变少;同时RC低通滤波需要更高的阶数才能把载波抑制住。所以44.1kHz采样时我一般选N=4,8kHz采样时选N=16甚至32,折中效果都很好。
定时器初始化代码大致如下,务必将ARR算好再写进寄存器:
TIM_HandleTypeDef htim1; htim1.Instance = TIM1; htim1.Init.Prescaler = 0; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 72000000UL / 128000UL - 1UL; /* ARR=561 */ htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim1);这里为什么Prescaler设0?因为PSC一旦不为0,定时器的实际频率会受分频值影响,不如直接用72MHz往下整除来得精确。如果主频不是72MHz,把第一行的时钟源值换成实际值即可,其他逻辑不变。
2.3 占空比映射:8位数据如何变成寄存器值
PWM输出通道的比较寄存器是CCR,它决定一个PWM周期内高电平持续多少计数。对8位无符号WAV数据,映射关系很简单:
void WAV_FeedSample(uint8_t sample) { uint16_t arr = __HAL_TIM_GET_AUTORELOAD(&htim1); uint16_t ccr = (uint32_t)sample * (arr + 1) / 256; /* 0 到 arr */ __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr); }这段代码把0到255的采样值线性映射到0到ARR之间,sample=128时占空比约50%,输出平均电压在约一半VCC的位置。ARR+1乘进去再除以256,是为了避免直接用sample * arr / 255时出现舍入不均的问题,而且加括号防止单片机做16位乘法溢出,这里特意用了uint32_t。
参数说明:arr不是每次都要读寄存器,也可以缓存,但画寄存器方式能保证代码在定时器参数变化后依然正确。如果WAV是16位数据,同样的函数原型应该接收uint16_t,映射分母改成65536。
这里必须说一个直流偏置的问题。8位PCM的中点值128对应50%占空比,意味着扬声器上持续有一个近似VCC/2的直流分量。如果直接接喇叭,不仅会产生“吱”的开启声,还可能让喇叭音圈长期偏置。解决办法是在滤波输出和负载之间串一个较大的电解电容,把直流隔掉。很多第一次做的人在这里翻车,后面避坑章会再展开。
3. SD卡读取与播放:FatFs挂载、双缓冲与播放状态机
文件解析和定时器参数定好之后,真正影响播放体验的是数据供给。SD卡不是实时设备,f_read有时候快有时候慢,如果代码一边读卡一边播放,稍不留神就断流。下面这套结构我在几个小项目里反复用,思路是把“读卡”和“喂样本”分离成两个角色,主循环负责填缓冲,定时器中断负责消费缓冲。
3.1 挂载SD卡并打开WAV文件
FatFs建议在系统上电时挂载一次,不需要每次播放都重新挂载。打开文件时需要保存FIL句柄,同时把WavInfo解析好,播放前把文件指针定位到dataOffset。
static FATFS fs; static FIL file; static WavInfo wav; int SdAudio_Open(const char *path) { FRESULT fr = f_mount(&fs, "", 1); if (fr != FR_OK) return -1; fr = f_open(&file, path, FA_READ); if (fr != FR_OK) return -2; if (WAV_GetInfo(&file, &wav) != 0) { f_close(&file); return -3; } return 0; }f_mount(&fs, "", 1)的第三个参数1表示立即挂载,如果文件系统不存在会直接返回错误。若返回FR_DISK_ERR,先检查SD卡底层的SPI初始化速率,很多卡在低速下能识别,高速下不稳定;若返回FR_NO_FILESYSTEM,说明卡不是FAT16/FAT32格式。FatFs挂载成功后再调f_open,在这之前不要做任何前提假设。
播放前还要补一句:
f_lseek(&file, wav.dataOffset);如果不做这步,文件指针可能在解析头部时停在44字节处,也可能在扫描子块时停在某个扩展块里,直接播放会从头开始读RIFF描述符,声音自然不对。
3.2 双缓冲:让“读卡慢”不再打断“播放快”
SD卡在SPI模式下读一个512字节扇区通常要1到5毫秒,但跨簇读取时FAT表的额外寻道可能拖到几十毫秒。8kHz采样率下,512字节够播放64毫秒,听起来“够”了,但一次突发延迟就能把缓冲耗尽,结果是音频卡顿一下然后继续。双缓冲的作用是给读卡留出至少一个缓冲块的预算。
我用两个长度1024字节的数组,一个正在被中断消费,另一个由主循环从SD卡填充。当前缓冲被消费完时,两个角色交换角色。
#define AUDIO_BUF_LEN 1024 static uint8_t buf[2][AUDIO_BUF_LEN]; static uint8_t cur = 0; static volatile uint8_t ready = 0; static uint16_t pos = 0; static volatile uint8_t playing = 0;定时器更新中断里只做“消费”这一件事:
void Audio_ISR_Update(void) { if (!playing) return; if (pos >= AUDIO_BUF_LEN) { if (!ready) { /* 缓冲耗尽,暂时停在静音中点 */ __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, htim1.Init.Period / 2); return; } cur ^= 1; pos = 0; ready = 0; } WAV_FeedSample(buf[cur][pos++]); }注意cur ^= 1是在当前缓冲消费完毕后才切换,切完ready置0,表示另一缓冲已经被占用,主循环不能再往里写。主循环的任务是发现ready==0时立刻读卡:
void Audio_MainLoop_Task(void) { UINT br; uint8_t nxt; if (!playing || ready) return; nxt = cur ^ 1; if (f_read(&file, buf[nxt], AUDIO_BUF_LEN, &br) != FR_OK) { Audio_Stop(); return; } if (br == 0) { Audio_Stop(); /* 数据读完,停止播放 */ return; } if (br < AUDIO_BUF_LEN) { /* 不够一块时补静音,避免最后一段突变 */ memset(buf[nxt] + br, 0x80, AUDIO_BUF_LEN - br); } ready = 1; }这里的memset补的是0x80,也就是8位PCM的静音中点,不是0。如果直接补0,相当于把音量突然拉到最低,扬声器会“啪”地弹一下。16位WAV应该补0x0000,8位的0x80在不同编译器下等价于signed char 0转换后的中点,这点最容易看走眼。
3.3 播放状态机:暂停、停止与恢复别搞出爆音
播放状态至少要有空闲、播放、暂停三种。暂停时我不停PWM,而是把CCR钉死在中点,让输出保持一个稳定的静音电平:
typedef enum { AUDIO_IDLE = 0, AUDIO_PLAY, AUDIO_PAUSE } AudioState; static AudioState state = AUDIO_IDLE; void Audio_Pause(void) { if (state != AUDIO_PLAY) return; state = AUDIO_PAUSE; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, htim1.Init.Period / 2); } void Audio_Resume(void) { if (state != AUDIO_PAUSE) return; pos = 0; state = AUDIO_PLAY; } void Audio_Stop(void) { playing = 0; state = AUDIO_IDLE; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, htim1.Init.Period / 2); f_lseek(&file, wav.dataOffset); }为什么暂停时不直接HAL_TIM_PWM_Stop?因为停止PWM通道后,引脚电平可能被释放为普通GPIO,如果被拉到0,滤波电容上的电荷会瞬间反向,产生爆音;即使不释放电平,从50%占空比直接切到0V,同样是一个阶跃。先把占空比设定为中点,让电容电压保持在静态工作点,再慢慢处理状态切换,声音基本是无感的。这个细节算是我被扎过几次之后总结出来的。
4. PWM音频滤波电路:RC参数、运放选型和四个避坑
代码层面把样本喂进去还不够,PWM引脚出来的是方波,必须经过模拟滤波才能听。这里的坑往往比代码还多,因为模拟电路的输出波形不能用调试器看,只能靠耳朵、万用表和示波器去猜。
4.1 一阶RC滤波截止频率计算:从方波还原模拟波形
一阶RC低通是最省事的方案,一个电阻一个电容就能让方波变成近似模拟波形。截止频率公式是:
fc = 1 / (2 * pi * R * C)截止频率选在哪里很关键。选太低,音频中的高频段会被削掉,声音闷;选太高,PWM载波纹波滤不干净,播放时背景有“沙沙”声。以8kHz采样率、128kHz PWM载波为例,我会把截止频率放在3.4kHz左右,对应R=4.7kΩ、C=10nF:
fc = 1 / (2 * 3.14 * 4700 * 10e-9) ≈ 3388 Hz一阶滤波在128kHz处的衰减大约按每十倍频20dB往下掉,128kHz离4kHz差不多两个十倍频程,能压掉40多dB,剩余纹波对语音播放影响不大。如果是16kHz采样率的提示音,截止频率可以放到5kHz以上,R=3.3kΩ、C=10nF就合适。
给PWM定时器写参数时,建议把频率和占空比初始化拆开,便于后面做测试音:
static void PWM_Config(uint32_t timer_clock, uint32_t pwm_hz) { htim1.Instance = TIM1; htim1.Init.Prescaler = 0; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = timer_clock / pwm_hz - 1; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim1); }参数说明:timer_clock换成72MHz,pwm_hz是之前算出来的载波频率。Period内部会自动减1,但如果你用STM32CubeMX生成代码,需要留意Prescaler和Period不能同时为0,否则定时器时钟会变得异常快。
4.2 输出带载:从滤波后接运放还是射极跟随器
RC滤波输出的内阻主要由R决定,4.7kΩ的输出内阻直接推耳机完全不行。耳机阻抗32Ω,信号一加载就被分压到几乎听不见。常见做法是滤波后接一个运放电压跟随器,再串一个100μF电解电容到负载。
我常用单电源轨到轨运放,比如LM358这类通用便宜运放,电路结构很简单:滤波输出接运放同相输入端,输出端直接反馈到反相输入端,构成电压跟随。运放把阻抗从4.7kΩ降到几十欧,驱动32Ω耳机没有问题。没有运放时也可以用NPN三极管射极跟随器,但静态工作点要设计好,容易在其他噪声进来后产生偏置漂移,不如运放省心。
有一点要记住:运放输出端同样需要交流耦合电容。因为整个模拟链路的直流电平大约在VCC/2附近,不隔直会让耳机一直通着直流电,音量一开就能听到明显的“滋滋”底噪,长时间还可能损坏音圈。
4.3 四个避坑:现象、原因、解决
做PWM播放WAV,几乎没有一次就成功的,常见问题基本集中在下面几个方向上。每一条我都按“现象、原因、解决”来写,方便你排查时直接对照。
| 现象 | 原因 | 解决 |
|---|---|---|
| 播放刚开始或暂停时猛地“啪”一声 | 占空比从0直接跳到音频中点,RC电容被瞬间充放电 | 打开/暂停前先把CCR设为中点,等几十毫秒再开始喂样本 |
| 声音发闷,人声像隔着被子 | RC截止频率选太低,比如低于1.8kHz | 把截止频率提到3.4kHz以上,语音播报建议4kHz到6kHz |
| 背景有明显“沙沙”声或高频噪声 | PWM载波没有被充分滤除,或者载波频率和采样率不是整数倍关系 | 确认PWM实际频率,令F_pwm = Fs * N,N取4到16 |
| 播放中周期性“咔嗒”卡顿 | SD卡f_read耗时超过当前缓冲余量,中断取不到样本 | 双缓冲长度加到2048或4096,降低采样率,读卡放到低优先级任务 |
| 音调明显偏高或偏低 | ARR或PSC换算错,定时器实际频率和WAV头里采样率不匹配 | 用示波器测PWM脚频率,反推实际采样节拍,校准后再听 |
关于第5条多说一句,这类问题最容易“觉得是代码写错了”,结果往往是定时器分频少减了1。比如72MHz下想要128kHz,ARR=72000000/128000-1等于561,如果直接写成720,实际频率就变成119.6kHz,歌曲整体音调会低半个多音;反过来多减1会偏高。遇到这种情况,先量频率,再改代码,不要靠耳朵猜。
5. 进阶:音量控制与示波器验证——给PWM音频加一层确定性
5.1 音量缩放:不要直接乘占空比
把音量变小最容易想到的写法是ccr = sample * volume / 255,这在小音量时会发现低音量感丢失严重。原因是音量乘完后,原本8位数据中的低4位信息被压缩成台阶,量化噪声变大。更好的做法是把8位采样先转成有符号数,再按音量缩放并叠加到中点,同时做上下限保护。
void WAV_FeedSample_Volume(uint8_t sample, uint8_t volume) { uint16_t period = htim1.Init.Period; int16_t pcm = (int16_t)sample - 128; /* 转成有符号 */ int32_t v = (int32_t)pcm * volume * period / 256 + period / 2; if (v < 0) v = 0; if (v > period) v = period; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (uint16_t)v); }这里的volume取值范围建议0到128,0是静音,128是原音量。公式里的period / 2是静音中点,音量缩放只作用于有符号部分,不会把直流偏置也一起缩放,这样音量变小时不会出现整体电平下坠的感觉。需要说明的是:这种做法依然损失位深,但比直接缩CCR要平滑,而且不会在低音量时让声音“断成台阶”。
5.2 用示波器验证:从“应该能响”到“确定能响”
我发现很多人在PWM音频上花的调试时间,最终都浪费在没有一个标准测量步骤。其实只要三步就能把问题范围缩小到某个具体环节。
第一,先播放一段1kHz标准正弦波WAV,把示波器探头夹在TIM引脚,不接RC滤波。这时候能看到一串固定频率的方波,频率应该在采样率 × N的预期值上;然后暂停播放,占空比应该回到50%的中点。
第二,把探头移到RC滤波输出端,时间轴切到500μs每格,应该能看到一个平滑的正弦波形。如果看到明显的尖峰或阶梯,说明滤波阶数不够或占空比更新节拍不均匀。
第三,确认波形幅度不超过单片机的供电范围,如果正弦波顶部被削平,就是音量过载,把音量参数调小再测。
这个方法比听感可靠得多。有一次我把ARR算小了一百多,歌曲整体变高,耳朵听了好一会儿都没判断出是定时器问题,后来接上示波器量PWM频率才发现是分频换算时少减了1。从那以后我每次改完参数都先测一屏波形再开始听,这套流程成了习惯,希望帮到你。
本文还有配套的精品资源,点击获取