简介:STM32 PWM播放WAV音频完整工程包,面向嵌入式开发与音频应用学习者,解决在资源有限的微控制器上利用脉宽调制输出还原存储于SD卡中WAV格式音频的实战问题。项目完整覆盖硬件接口配置、FatFS文件系统挂载、SD卡SPI通信、WAV文件头解析、PCM音频数据量化转换,以及通过定时器中断实时更新占空比的关键流程;解析文件头时需提取采样率、位深度与通道数,直接影响PWM定时器重装值和输出音质,同时实现播放、暂停、停止等控制功能,并讨论了占空比更新速率、滤波等音质优化手段,形成从底层驱动到应用层的闭环参考。压缩包共170个文件,以36个C源码、45个头文件为核心,包含Keil工程配置文件、编译生成的axf/map/o目标文件、调试用的crf/d/lst清单,以及readme.txt说明文档,整体大小1.05MB,目录层次清晰,便于按模块查阅。该工程已有3894人学习下载,适合想要动手实践STM32音频输出、理解PWM发声原理或移植FatFS文件系统的开发者。
1. 为什么我断言PWM播放WAV是被低估的STM32省钱方案
做一个小设备,需要播几段语音提示,结果一查方案:外挂DAC要加芯片,I2S音频解码器更贵,连功放都得重新选型。而STM32芯片里本来就有定时器,定时器本来就能输出PWM——只要让占空比跟着WAV采样值走,外面串一个RC低通滤波器,就完成了从数字波形到模拟声音的还原。这个项目就是把这条链路完整打通:SD卡存WAV文件,上电后读取文件、解析WAV头、DMA自动把采样数据搬进定时器比较寄存器、PWM引脚输出方波、滤波后驱动扬声器。适合两种人:一是手里有STM32开发板、想省硬件成本、需要语音播报功能的开发者;二是不满足于跑通点灯例程、想弄明白PWM到底怎么还原一段真实音频的人。整个方案只需要一个定时器通道、一根引脚和两颗电阻电容,成本几乎为零。
2. 方案设计与硬件选型:PWM+DMA+定时器的三角结构
2.1 整体架构:从SD卡到扬声器的数据链路
先看全局,数据流是单向的:SD卡存的是标准WAV文件,通过FATFS文件系统读取;读出来的字节先经过WAV头解析,拿到采样率、位深、数据区偏移量;然后原始PCM采样数据被放进内存缓冲区;DMA在定时器更新事件触发下,自动把一个16bit采样值送到定时器的比较捕获寄存器CCR;定时器按照新的CCR值输出一个周期的PWM方波;方波经过RC低通滤波器后,平均电压就还原出了采样点的幅度;最后经过功放或直接驱动小喇叭发声。
这条链路里CPU的角色被刻意弱化。PWM每个周期都要刷新一次CCR,如果靠中断逐样本搬运,8kHz采样率意味着每秒8000次中断,虽然CPU勉强扛得住,但留给其他任务的余量几乎没有。所以DMA是必须的,它用循环模式把缓冲区的数据逐个搬进CCR,搬完自动从头开始,CPU只在缓冲区播完一半和播完整个时各进一次中断,负责从SD卡补充下一段数据。
提示:经典实现里,定时器的更新频率直接等于WAV采样率。比如8kHz采样的WAV,PWM频率也配成8kHz。这意味着PWM载波落在音频频段边缘,低通滤波会比较尴尬,这个取舍我会在避坑章节专门展开。
2.2 PWM如何还原一个采样点
PWM输出是一串频率固定的方波,占空比决定平均电压。STM32定时器里,ARR决定周期长度,CCR决定高电平持续时间,占空比就是CCR除以ARR。当CCR随着WAV的采样序列变化,方波的平均电压就逐步描出了原始波形。采样率越高,单位时间内描的点越多,还原度越好。
STM32F103的主频是72MHz,定时器更新频率的公式是:f = 72MHz / (PSC+1) / (ARR+1)。PSC设为0,那么ARR = 72MHz / 采样率 - 1。几个常用采样率对应的配置值如下:
| WAV采样率 | ARR(PSC=0) | 实际PWM频率 |
|---|---|---|
| 8 kHz | 8999 | 8.000 kHz |
| 16 kHz | 4499 | 16.000 kHz |
| 22.05 kHz | 3264 | 22.052 kHz |
| 44.1 kHz | 1632 | 44.079 kHz |
22.05kHz和44.1kHz不是72MHz的整除数,算出来的ARR会有取整误差,导致实际频率偏差千分之几,反映到听感上几乎察觉不到。更需要注意的是16bit采样值怎么映射到CCR:ARR等于8999时,CCR最大有效范围约13bit(2的13次方等于8192),所以WAV的16bit采样值右移3位,得到0到8191的范围,直接写进CCR即可。占空比始终有效,不会出现CCR大于ARR的越界情况。
2.3 硬件接线与低通滤波参数
硬件连接非常简单:定时器通道引脚(比如TIM3_CH3对应PB0)输出PWM,经过一个RC低通滤波器,再接一个小喇叭。RC的取值直接决定音质走向——R取1kΩ、C取47nF时,截止频率约3.4kHz,对8kHz采样的语音正好覆盖人声频段,还能留出一点高频余量;如果想声音更亮一些,把C降到10nF,截止频率推到15.9kHz,但8kHz的PWM载波也会漏过去,听感是"嘶嘶"的底噪。所以这个项目里滤波不是在滤高端噪声,而是在载波和信号之间找平衡,属于典型的取舍问题。
喇叭的接法有个血泪经验:不能把PWM引脚直接怼到喇叭上。PWM输出的平均电平不是零,直接驱动会让音圈一直通着直流电,轻则发出滋滋声,重则发热发烫。正确做法是在喇叭前串联一个100µF到220µF的电解电容隔直,或者经过带输入耦合的小功放模块。调试时我会用一个耳机座加普通耳塞,省功放还听得清楚。
3. 核心代码拆解:WAV解析、FATFS读取与PWM输出的完整链路
3.1 WAV文件头解析:44字节里藏着什么
WAV文件头固定44字节,包含RIFF标识、WAVE标识、fmt块和data块。直接定义一个结构体,用FATFS的f_read一次读进来:
typedef struct { uint32_t chunkID; // "RIFF" 的小端读法,判断文件是否RIFF格式 uint32_t chunkSize; uint32_t format; // "WAVE" uint32_t subchunk1ID; // "fmt " uint32_t subchunk1Size; uint16_t audioFormat; // 1 = PCM,其他编码先不支持 uint16_t numChannels; // 1 = 单声道,2 = 双声道 uint32_t sampleRate; // 采样率,用来配置定时器 uint32_t byteRate; uint16_t blockAlign; uint16_t bitsPerSample; // 位深,本方案只处理16bit uint32_t subchunk2ID; // "data" uint32_t dataSize; // 数据区字节数 } WAVHeader;解析函数要做的校验很简单,但每一行都对应一个容易翻车的点:
FRESULT parseWavHeader(FIL *fp, WAVHeader *hdr) { UINT br; if (f_read(fp, hdr, sizeof(WAVHeader), &br) != FR_OK) return FR_INT_ERR; if (hdr->chunkID != 0x46464952) // "RIFF" return FR_INT_ERR; if (hdr->format != 0x45564157) // "WAVE" return FR_INT_ERR; if (hdr->audioFormat != 1) // 仅PCM return FR_INT_ERR; if (hdr->bitsPerSample != 16) // 仅16bit return FR_INT_ERR; return FR_OK; }这里有个小细节:读出来的chunkID是一个32位整数,但WAV文件是小端存储,"RIFF"四个字节按顺序读进uint32_t后,数值等于0x46464952,而不是字符字面量的'RIFF'。写比较时不能写if (hdr->chunkID == 'RIFF'),编译器会按大端思路处理,直接跳过合法文件。另外,bytesPerSample和blockAlign这两个辅助字段一般不参与校验,但如果后面要做双声道混音,blockAlign就必须拿来算帧长度。
3.2 FATFS读卡与数据区定位
引入FATFS之后,SD卡的读写接口统一成f_open、f_read、f_lseek这套标准API。初始化代码很短,难的是搞清楚三个状态量:
static FATFS g_fs; static FIL g_file; void SD_Init(void) { f_mount(&g_fs, "", 1); // 挂载根目录,第三个参数1表示立即挂载 } FRESULT WAV_Open(const char *path, WAVHeader *hdr) { FRESULT res = f_open(&g_file, path, FA_READ); if (res != FR_OK) return res; res = parseWavHeader(&g_file, hdr); if (res != FR_OK) return res; // 跳过非标准位置可能存在的LIST块,定位到真正的音频数据起点 return locateDataBlock(&g_file, hdr); }locateDataBlock的作用我先卖个关子,避坑章节会专门讲。这里要明确的是:打开文件后,文件指针停在44字节处,如果WAV头后面紧跟着就是data块,那好办;但很多工具生成的WAV在头部和data之间塞了LIST、fact这类元数据块,直接把第44字节当音频数据读,出来的就是一片噪音。
音频数据区的大小等于hdr->dataSize字节,除以2就是16bit采样点的个数。这个数值后续要用于判断播放是否接近文件末尾,好决定是继续读还是循环回起点。
3.3 定时器PWM与DMA初始化:全套参数拆解
这是整个项目最核心的初始化函数。它做的事情可以拆成四步:配置时基、配置PWM输出、开启更新事件触发DMA、配置DMA循环搬运。
void AudioTimer_Init(uint32_t sampleRate, uint16_t *buf, uint32_t bufSize) { TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; DMA_InitTypeDef dma; // 1. 时基:更新频率 = WAV采样率 tb.TIM_Prescaler = 0; // 不分频,直接用72MHz计数 tb.TIM_Period = (uint16_t)(SystemCoreClock / sampleRate - 1); tb.TIM_ClockDivision = 0; tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &tb); // 2. PWM模式1,初始占空比50%,避免上电瞬间输出异常电平 oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_Pulse = tb.TIM_Period / 2; oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC3Init(TIM3, &oc); // 用TIM3的第3通道,对应PB0 TIM_OC3PreloadConfig(TIM3, TIM_OCPreload_Enable); // 3. 定时器更新事件触发DMA请求 TIM_DMAConfig(TIM3, TIM_DMA_Update, ENABLE); TIM_DMACmd(TIM3, TIM_DMA_Update, ENABLE); // 4. DMA:内存→外设,半字(16bit),循环模式 dma.DMA_PeripheralBaseAddr = (uint32_t)&TIM3->CCR3; dma.DMA_MemoryBaseAddr = (uint32_t)buf; dma.DMA_DIR = DMA_DIR_PeripheralDST; // 方向:内存到外设 dma.DMA_BufferSize = bufSize; // 搬运的采样点数 dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // CCR地址固定 dma.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; // 循环,播完自动回起点 DMA_Init(DMA1_Channel6, &dma); DMA_ITConfig(DMA1_IT_TC | DMA1_IT_HT, ENABLE); // 开完成+半传输中断 TIM_Cmd(TIM3, ENABLE); }几个参数单独说。TIM_Period的计算用的是SystemCoreClock而不是硬编码72MHz,这样换板子、换主频时不会因为时钟配置不同而播放变速。DMA_BufferSize的单位是"次搬运",不是字节,这里搬运的是16bit半字,所以缓冲区大小是字节数除以2。DMA_Mode用循环模式是本方案的关键——它让DMA自己"跑圈",播完整个缓冲区自动回到起点继续播,CPU完全不需要参与逐样本搬运。
还有个容易忽略的点:DMA通道不要随便挑。STM32F103里DMA1的通道和定时器请求有映射关系,TIM3的更新请求映射到DMA1_Channel6,对应中断向量是DMA1_Channel6_IRQn。配错通道的话,中断永远不会触发。
3.4 播放调度与双缓冲中断节奏
启动播放的流程清晰简短:
void WAV_Play(const char *path) { WAVHeader hdr; if (WAV_Open(path, &hdr) != FR_OK) return; // 根据采样率重新配置定时器 AudioTimer_Init(hdr.sampleRate, buf[0], SAMPLE_BUF_SIZE); // 文件指针已定位到音频数据起点,先读满第一个缓冲区 readChunk(buf[0], SAMPLE_BUF_SIZE); DMA_Config(buf[0], SAMPLE_BUF_SIZE); // 启动定时器,DMA开始自动搬运 TIM_Cmd(TIM3, ENABLE); }播放启动之后,CPU的工作全部在中断里完成。双缓冲的节奏是:DMA当前在播buf[0],播到一半触发半传输中断,批量读取下一段数据到buf[1];播到整个缓冲区结束触发传输完成中断,下一次再读数据时落到buf[0],这样的乒乓结构保证DMA随时有一块完整的新数据可播。
void DMA1_Channel6_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT6)) { DMA_ClearITPendingBit(DMA1_IT_HT6); readChunk(buf[1], SAMPLE_BUF_SIZE); // 前半段播完,预读下一块 } if (DMA_GetITStatus(DMA1_IT_TC6)) { DMA_ClearITPendingBit(DMA1_IT_TC6); readChunk(buf[0], SAMPLE_BUF_SIZE); // 后半段播完,预读下一块 } }readChunk内部就是一次f_read加一次文件指针判断,读到文件末尾时根据播放策略决定是停止还是回到开头。缓冲区大小取512个采样点(即1KB)左右比较合适:太小的话SD卡读取次数太频繁,中断里容易来不及;太大则双缓冲占内存过多,F103C8T6的RAM只有20KB,留给业务逻辑的空间会被挤掉。
4. 常见问题避坑:PWM播放音频的几个经典翻车现场
4.1 播到一半声音变成持续杂音
现象:文件开头几秒正常,随后声音变成刺耳噪声,或者干脆卡死不动。这是PWM播放项目里最容易遇到的现象级问题。
原因有两种:第一种是DMA中断标志没有及时清除。标准外设库的中断服务函数里,如果不调用DMA_ClearITPendingBit清标志,NVIC会认为该中断源没有处理完毕,后续中断不再触发,缓冲区得不到补充,DMA只能反复搬运已经播过的旧数据。第二种原因是SD卡读取速度跟不上播放速度:缓冲区太小而SD卡单个块读取时间太长,DMA已经把一半数据播完,中断里还在等f_read返回。
解决:中断服务函数第一行清标志,第二行再处理业务逻辑。缓冲区大小按下式计算——SD卡读取1KB数据的时间要小于DMA播放1KB数据的时间。8kHz采样、16bit单声道时,1KB数据播放时长为512/8000等于64ms,而SPI模式SD卡读1KB通常只要几毫秒,留足余量。另外,f_read返回FR_OK不代表数据有效,还要检查读到的字节数是否等于请求值,读到0字节说明文件指针已经越过数据区结尾。
4.2 声音发闷或者有嘶嘶载波底噪
现象:人声含糊不清,像隔着被子说话;或者反过来,底噪明显,像电台没调准频率。
原因:低通滤波器截止频率选错了方向。前面说过,这种方案里PWM载波频率等于采样率,8kHz的载波就在人声频段上方一点点。RC截止频率取得太低,比如1kHz,有用的人声高频成分也被滤掉,声音自然发闷;取得太高,15.9kHz,则8kHz载波漏过去,变成持续的"嘶嘶"声。
解决:先按R等于1kΩ、C等于47nF的起点试,截止频率约3.4kHz,对人声从300Hz到3.4kHz的主频段覆盖得比较好。耐心一点,用示波器看滤波后波形,对比原始PWM波形,确认载波确实被削掉大部分。如果声音还是闷,说明问题出在WAV文件本身的高频缺失,而不是滤波。
4.3 标准WAV文件解析失败或播放出来是噪音
现象:在电脑上随便找的WAV文件,有的能播,有的解析直接报错,更有甚者播放出一段刺耳的乱响。
原因:不是所有WAV的音频数据都在44字节偏移处。很多录音软件会在fmt块和data块之间插入LIST块、fact块、bext块,这些块的长度不定。如果代码直接按固定偏移44读取数据,读到的就是元数据,不是PCM采样值。
解决:用搜索的方式定位data块,而不是用固定偏移。
uint32_t offset = 12; // 跳过RIFF和WAVE标识 uint32_t size; uint32_t id; while (offset < 0x100000) { // 防止异常文件死循环 f_lseek(&g_file, offset); f_read(&g_file, &id, 4, &br); f_read(&g_file, &size, 4, &br); if (id == 0x61746164) { // "data" 小端读法 dataOffset = offset + 8; dataSize = size; return FR_OK; } offset += 8 + size + (size & 1); // 块尾按2字节对齐 }0x61746164这个数字看起来很魔幻,其实就是"data"四个字符按WAV的小端字节序读进uint32_t的值。注意块对齐:每个子块的size字段是数据长度,但块整体要从偶地址开始,所以size为奇数时要加1补对齐。这个问题排查过一次之后,我再也不敢用固定偏移量打开WAV文件了。
4.4 播放速度不对,声音变成花栗鼠
现象:同一首歌放出来明显偏快或偏慢,音调也随之变化,但波形本身没有失真。
原因:定时器实际频率和WAV采样率对不上。常见情况有两种:一是代码里写死72MHz,但板子的外部晶振是8MHz,内部PLL倍频后实际主频可能只有64MHz或者48MHz;二是ARR计算时忘记减1,导致定时器更新频率整体偏低。
解决:把硬编码的72MHz改成SystemCoreClock,然后在初始化完成后实测一下PWM频率。快速验证的字段是:
uint32_t realFreq = SystemCoreClock / (TIM3->PSC + 1) / (TIM3->ARR + 1);这条计算线上任何一处和70MHz偏差,播放速度就会跟着偏差。我在调试时习惯把realFreq直接通过串口打印出来,和WAV头里的sampleRate字段逐位对比,确认一致之后再去听声音。
4.5 喇叭一直有滋滋声,播放久了发烫
现象:静音状态下喇叭也有轻微电流声,连续播放十几分钟后喇叭外壳发热明显。
原因:PWM引脚输出的平均电压不为零,直连喇叭时构成了直流回路,音圈长期工作在偏置状态。
解决:喇叭前串联100µF到220µF的电解电容隔直,注意电容极性,正极接PWM输出侧,负极接喇叭。用带输入耦合的小功放模块时,模块输入端通常已经有隔直电容,不需要重复串。这个坑在我第一次搭电路时就踩过,当时还以为是代码问题,查了半天才发现是模拟电路的事。
5. 从出声到工程可用:音量控制、SD卡轮播与代码分层
5.1 音量控制:整块预处理,避免播放中抖动
PWM音频的音量本质是采样幅度的缩放。最简单的方式是在把缓冲数据交给DMA之前,对整个缓冲区预处理一遍:
void applyVolume(uint16_t *buf, uint32_t count, uint8_t volume) { for (uint32_t i = 0; i < count; i++) { // 缩放后右移3位,把16bit采样映射到CCR的13bit范围 buf[i] = (uint16_t)((uint32_t)buf[i] * volume / 100); } }关键决策是预处理放哪。如果放在主循环或中断里逐样本计算,8kHz采样下每秒要额外做8000次乘法和除法,占用CPU不说,还会导致声音抖动。放在缓冲层整块处理,音量变化只会在缓冲区边界处生效,听感上更干净。音量突变时会有"咔哒"爆音,常见做法是让音量按步进渐变:每次只调整当前音量到目标音量的四分之一,播几个缓冲块之后自然过渡到位。
5.2 SD卡目录扫描与多文件轮播
做语音播报设备时,不可能每次换提示音都改代码烧录。利用FATFS的目录遍历接口,可以上电时自动扫描根目录下所有WAV文件,生成播放列表:
static char playlist[16][64]; static uint8_t songCount = 0; void scanWavFiles(void) { DIR dir; FILINFO fno; f_opendir(&dir, "/"); while (f_readdir(&dir, &fno) == FR_OK && fno.fname[0]) { if (strstr(fno.fname, ".wav") || strstr(fno.fname, ".WAV")) { strcpy(playlist[songCount++], fno.fname); } } f_closedir(&dir); }两个细节。第一,fno.fname是短文件名,只支持8.3格式,如果文件名超过这个长度或含中文,必须在FATFS配置里打开_USE_LFN选项,同时给FILINFO分配一块lfname缓冲,否则中文文件名读出来是乱码。第二,播放到文件末尾的判断,不要依赖DMA中断,因为循环模式下DMA永远不会"结束"。我在readChunk里检查f_read返回的字节数,读到0说明文件读完了,这时根据轮播策略决定播放列表下一首还是回到第一首。
5.3 软件分层:把WAV解析和硬件驱动彻底拆开
代码量超过一千行之后,分层就变得必要。这个项目的分层可以很轻量:
app/ player_app.c 播放状态机、播放列表轮播、按键逻辑 music_list.c 目录扫描、文件列表维护 driver/ pwm_audio.c 定时器+DMA封装,只暴露Audio_Play/Audio_Stop wav_parser.c WAV头解析和data块搜索,只做解析不做硬件操作 sdcard_lib.c SD卡初始化、底层读写 bsp/ bsp_clock.c 时钟树配置,SystemCoreClock的最终来源每个模块的依赖方向要控制住。wav_parser只依赖标准库和FATFS的文件指针类型,不包含任何stm32外设寄存器操作;pwm_audio内部可以随意访问TIM3和DMA1,但对外接口只需要"开始播放这个缓冲区"和"停止"两个函数。这样做的收益很明显:以后把SPI模式的SD卡换成SDIO高速模式,只需要动sdcard_lib.c;做ADPCM解码支持时,替换wav_parser.c的数据读取部分,pwm_audio.c一行不用改。
| 模块 | 职责 | 对外接口 |
|---|---|---|
| pwm_audio.c | 定时器PWM、DMA中断、缓冲调度 | Audio_Play / Audio_Stop |
| wav_parser.c | 头解析、子块搜索、参数提取 | WAV_Open / WAV_GetInfo |
| sdcard_lib.c | SD卡物理层读写、FATFS适配 | SD_Init / SD_ReadSector |
| player_app.c | 播放状态、文件切换、按键响应 | Player_Task |
6. 二次开发思路:双定时器级联与从语音到音效
这个项目最值得改的方向,就是把PWM载波从"采样率等于载波"的困局里解放出来。为什么要改?因为8kHz载波离人声频段太近,低通滤波器怎么调都有别扭感。双定时器级联的思路是:主定时器TIM4的更新频率等于采样率,专门负责触发DMA搬运数据;从定时器TIM3的PWM载波固定跑在200kHz以上,只负责输出高频方波。这样PWM载波和采样率解耦,RC低通滤波器可以把截止频率安心放在4kHz左右,高频载波被干净滤除,音质提升非常明显。
实现时要注意CCR的装载时机。标准PWM模式下,CCR寄存器有预装载功能,新的占空比要到下一个更新事件才真正生效。既然更新事件来自TIM4,那么TIM4每次更新的瞬间,TIM3的CCR被写入新值,这个值会在TIM3的下一个PWM周期边界被装载,逻辑上是完全正确的。配置上需要做两件事:一是让TIM4的更新事件触发DMA请求,DMA把采样值写到TIM3的CCR;二是TIM3只开PWM输出,不开自己的更新中断。TIM3的ARR固定为359,对应72MHz除以360等于200kHz的载波频率,CCR的缩放比例也要重新算。
如果不想动双定时器,还有几条更低成本的改造路线:把按键接到PA0等引脚,中断里切换播放列表的index,就能做一个多语音提示器;ADC读取电位器电压,把0到4095映射到音量百分比,替代固定音量参数;想要更长的语音内容,可以先在PC端把WAV转成IMA ADPCM格式,压缩率约4比1,SD卡容量压力小一半以上,代价是解码逻辑要自己写。无论走哪条路,我都会保持同一个验证习惯——每次改动后先拿示波器戳PWM引脚,确认定时器频率精确等于目标值,再戴耳机听音质。频率这个变量一旦漂移,后续所有调参都是白费。希望这个从WAV解析到PWM输出的完整链路,能帮你少走几趟弯路。
本文还有配套的精品资源,点击获取