news 2026/10/15 2:51:55

STM32用PWM播放WAV:低成本语音播报方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32用PWM播放WAV:低成本语音播报方案

简介: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。下面是一组常用配置参考:

采样率 FsNPWM载波ARR(72MHz)可用占空比位宽
8000 Hz16128 kHz561约9.1 bit
16000 Hz8128 kHz561约9.1 bit
22050 Hz8176.4 kHz407约8.7 bit
44100 Hz4176.4 kHz407约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。从那以后我每次改完参数都先测一屏波形再开始听,这套流程成了习惯,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 2:51:30

SpringBoot+Vue移动台账管理系统:从业务设计到全栈落地

做台账管理系统已经是两三年前的活儿了&#xff0c;但这类项目到今天依然很典型——尤其是用 “SpringBoot Vue” 这套组合来做。前阵子整理硬盘翻出一套完整的“基于SpringBootVue的移动台账管理系统”的交付材料&#xff08;源码 文档 PPT&#xff09;&#xff0c;恰好有人…

作者头像 李华
网站建设 2026/10/15 2:49:47

visual studio配置libtorch

1、libtorch找到与自己cuda相匹配的版本&#xff1b;2、配置visual studio&#xff1a;1&#xff09;配置包含目录2&#xff09;配置库目录&#xff1a;3&#xff09;配置链接器&#xff1a;也可以输入自己所需的lib文件名&#xff1a;asmjit.lib c10.lib c10_cuda.lib caffe2_…

作者头像 李华
网站建设 2026/10/15 2:49:11

ExpiringMap详解:Java本地缓存过期管理与对比实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 2:48:39

UE5.0.1原生不支持H.264?三步配置FFmpeg实现稳定播放

简介&#xff1a;本资源是面向虚幻引擎&#xff08;UE5&#xff09;开发者的技术增强补丁&#xff0c;聚焦解决UE5.0.1版本原生不支持H.264视频解码导致的网页内嵌视频播放失败问题&#xff0c;适用于需在游戏UI中集成MP4流媒体、剧情过场、交互式教程等场景的中高级引擎开发人…

作者头像 李华