尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
不用DAC也能播音频:STM32用PWM输出WAV的完整工程方案
简介这是一个面向STM32开发者的完整音频播放工程演示借助PWM接口播放SD卡中的WAV文件不依赖外部DAC通过定时器与GPIO即可输出音频。工程覆盖FatFS文件系统挂载、WAV头解析、PCM数据到PWM占空比转换、定时器中断实时更新等关键环节并给出排错与音质优化思路适合想深入理解嵌入式音频输出与文件系统配合的进阶学习者。压缩包为rar格式大小1.05MB共170个文件以c和h源码为主另含o、crf等编译中间文件及uv2、sct等Keil工程配置可直接用MDK打开查看或二次开发。已有3894人学习资源具备较高参考热度。从内容预览看工程基于STM32F10x标准外设库包括Systick、GPIO、TIM、SPI等模块spi_sd_driver负责SD卡底层驱动可帮助读者完整梳理SPI读写SD卡、FatFS移植与PWM音频输出的实现链路也可作为课程设计或产品原型的参考。1. 不用 DAC 也能播音频PWM 出 WAV 的玩法与边界用没有 DAC 外设的 STM32 播放 WAV是很多开发者在做提示音、开机音和语音播报时绕不开的一步。PWM 方案的核心思路是用定时器产生高频可变占空比方波再经过一级低通滤波把平均电压还原成模拟信号从而省掉音频解码芯片和额外 DAC只靠一个定时器通道加几颗电阻电容就能把 SD 卡里的 WAV 文件放出来。它适合 8kHz 到 22.05kHz 采样率的语音片段不适合拿来听歌更不要指望达到 44.1kHz 十六位无损的保真度。真正动手之后你会发现采样率换算、DMA 触发、WAV 头解析和最后的爆音处理每一步都有坑不是把引脚电平翻一翻就能出声的。2. 先做好定时器与分频计算WAV 能不能流畅播全看这里2.1 拆解 WAV 头先搞清楚真实采样参数WAV 文件本质是 RIFF 容器绝大多数 PCM WAV 的前 44 字节是文件头里面包含了采样率、位深、通道数和数据块偏移。很多人想当然地认为音频数据从第 44 字节开始实际上不少录音软件会在 fmt 块和 data 块之间插入 LIST、fact 等附加块直接跳过 44 字节去读数据轻则播放错位重则把文件头当音频播声音全是爆音。我一般会在打开文件后先读一个结构体校验关键字段而不是直接硬编码偏移typedef struct { char riff[4]; // 固定为 RIFF uint32_t fileSize; // 文件总长度减 8 char wave[4]; // 固定为 WAVE char fmt[4]; // 固定为 fmt uint32_t fmtSize; // fmt 块长度通常为 16 uint16_t audioFormat; // 1 表示线性 PCM uint16_t channels; // 1单声道2双声道 uint32_t sampleRate; // 采样率单位 Hz uint32_t byteRate; // 每秒字节数 uint16_t blockAlign; // 单个采样帧字节数 uint16_t bitsPerSample; // 位深常见 8/16 } WAVHeader;读取头之后一定要检查audioFormat是否为 1。如果读到 0x0003 之类的值说明是浮点编码或者 ADPCMPWM 方案没办法直接播。bitsPerSample决定后续映射方式16 位有符号 PCM 和 8 位无符号 PCM 的转换逻辑完全不同这个字段不能猜。接着用一个循环找到 data 块偏移uint32_t dataOffset 44; while (1) { char chunkId[4]; uint32_t chunkSize 0; f_lseek(file, dataOffset); f_read(file, chunkId, 4, br); f_read(file, chunkSize, 4, br); if (memcmp(chunkId, data, 4) 0) { break; } // 块长度是奇数字节时要补一个对齐字节 dataOffset 8 chunkSize (chunkSize 1); } f_lseek(file, dataOffset 8);这个跳转逻辑对大多数非标准 WAV 都有效。chunkSize是当前块的有效数据长度chunkSize 1是 RIFF 规范里要求的偶数字节对齐不少解析代码漏了这一步导致读出来的音频文件头错位、播放卡顿。2.2 定时器分频计算把采样率换算成 PWM 周期PWM 播放的本质是每个采样周期更新一次比较寄存器所以定时器的更新频率必须等于 WAV 的采样率。以最常见的 STM32F103 为例APB1 定时器时钟通常是 72MHz但也有配置成 36MHz 的情况第一步先确认RCC_GetClocksFreq读出来的定时器时钟别拿内核主频直接套公式。定时器更新频率公式是f_update TIM_CLK / ((PSC 1) * (ARR 1))。如果采用 8 位 PWMARR设为 255那么只剩PSC需要反推。比如目标采样率 22050HzPSC 1 72e6 / (22050 * 256) ≈ 12.75取整为 13实际采样率变成72e6 / (13 * 256) ≈ 21634Hz偏差不到 2%人耳基本无感。下面是一组常用配置前提是定时器时钟 72MHzARR255目标采样率PSC实际采样率偏差8000 Hz348035 Hz0.4%16000 Hz1715625 Hz-2.3%22050 Hz1221634 Hz-1.9%采样率的微小偏差只会让声音整体变快或变慢几个百分点语音内容仍然可懂但不建议偏差超过 3%否则听感上会明显觉得音调不对。设置完分频后最好实际用示波器量一下定时器更新引脚或者直接量 PWM 输出频率不要只信计算器。2.3 样本到占空比的映射别把负值变爆音16 位 WAV 的每个样本是有符号数范围是 -32768 到 32767而定时器比较寄存器只能接受无符号值。如果直接把int16样本赋值给 CCR负样本会变成 65535 附近的大值PWM 输出在高低电平之间剧烈跳变听感就是连续爆音。正确的做法是先把有符号样本偏移成无符号值再截取到 PWM 分辨率。8 位 PWM 的映射函数如下uint8_t pcm16_to_pwm8(int16_t sample) { int32_t v sample; // 右移 8 位得到 -128~127再加 128 偏置到 0~255 v (v 8) 128; if (v 0) v 0; if (v 255) v 255; return (uint8_t)v; }代码里的关键点是128。它把静音点样本值 0映射到占空比 50% 的位置这样滤波后的电压恰好是电源电压的一半扬声器没有直流偏置。如果漏掉这一步输出变成半波整流声音不仅失真严重还会让喇叭一直带着直流电流时间长了有烧线圈的风险。如果 WAV 本身就是 8 位无符号 PCM样本范围天然是 0 到 255静音点在 128可以直接把样本值作为 CCR不需要再做偏移。判断依据就是 WAV 头里的bitsPerSample。3. 从 SD 卡读文件到 DMA 自动搬运播放通道这样搭3.1 用 FATFS 定位并读取 WAV 数据块SD 卡里取文件绕不开 FATFS这套文件系统函数库几乎是 STM32 播放音频项目的标配。先把 SD 卡初始化程序跑通再用f_mount挂载文件系统之后的操作和 PC 端读写文件就非常接近了。读取流程一般分三步。第一步用f_open打开 WAV 文件第二步按上章的偏移定位逻辑找到 data 块起始位置第三步把 data 块的数据切成固定大小的缓冲块循环读入内存。代码骨架如下FIL file; UINT br; FRESULT fr; fr f_open(file, voice.wav, FA_READ); if (fr ! FR_OK) { // 文件不存在或 SD 卡未挂载走错误处理 } // 解析 WAV 头并计算 data 偏移详见上一节 parse_wav_header(file, wav_info); // 读取第一块音频数据到缓冲 f_lseek(file, wav_info.dataOffset); f_read(file, pcm_buf[0], BUFFER_BYTES, br);这里要注意BUFFER_BYTES最好做成扇区大小的整数倍SD 卡底层读取是按扇区进行的512 字节对齐可以减少碎片化读写的开销。如果缓冲块是 1024 字节一次f_read正好读两个扇区效率比按 1000 字节读高很多。文件读取不能全部塞进一个循环否则播放过程中任何一次磁盘繁忙都会造成声音断续。实际工程里我会把读取动作放在主循环或低优先级任务里后台缓冲填满后置一个标志由 DMA 播放线程消费。3.2 定时器触发 DMA每次更新搬一个样本定时器一旦开始运行会按采样率产生更新事件。利用这个事件触发 DMA把内存缓冲里的样本自动搬运到定时器比较寄存器CPU 就可以从逐样本搬运中解放出来只负责偶尔填充缓冲。DMA 的配置方向是内存到外设外设地址固定为TIMx-CCR1内存地址按样本递增。以 HAL 库为例关键参数是这几个DMA_HandleTypeDef hdma; hdma.Instance DMA1_Channel3; hdma.Init.Direction DMA_MEMORY_TO_PERIPH; hdma.Init.PeriphInc DMA_PINC_DISABLE; hdma.Init.MemInc DMA_MINC_ENABLE; hdma.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma.Init.Mode DMA_CIRCULAR; HAL_DMA_Init(hdma); __HAL_LINKDMA(htim, hdma[TIM_DMA_ID_CC1], hdma);PeriphInc必须关闭因为目标永远是同一个 CCR 寄存器MemInc必须打开否则每次搬运都读同一个样本声音会变成一个固定音调。数据宽度用半字是因为 16 位 WAV 样本占两个字节。如果强行配置成字节宽度DMA 每次只搬低位字节播放出来的波形会被截断音色明显发闷。启动播放的调用也比较直观HAL_TIM_PWM_Start(htim, TIM_CHANNEL_1); HAL_TIM_PWM_Start_DMA(htim, TIM_CHANNEL_1, (uint32_t *)pcm_buf, BUF_SAMPLES);HAL_TIM_PWM_Start_DMA内部会自动把 DMA 请求绑定到定时器更新事件照这个思路做第一版播放程序通常很快就能跑通。3.3 双缓冲与循环模式把 SD 卡耗时藏起来循环模式适合数据全部常驻内存的短音频但对于 SD 卡里的文件还必须考虑读取耗时。如果把 DMA 缓冲设成整个文件大小内存不够所以要用双缓冲方案两块缓冲交替使用DMA 播放 A 缓冲时主循环把 B 缓冲的数据从 SD 卡读进来播放到 B 时再回头填 A。DMA 回调是切换缓冲的天然时机volatile uint8_t active_buf 0; void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIMx) { active_buf 1 - active_buf; // 让 DMA 指向新缓冲同时触发下一轮文件读取 HAL_TIM_PWM_Start_DMA(htim, TIM_CHANNEL_1, (uint32_t *)pcm_buf[active_buf], BUF_SAMPLES); file_need_refill 1; } }回调里只做缓冲切换和置标志绝不做f_read和解析。文件读取放到主循环里判断file_need_refill这样 SD 卡耗时再大也不会阻塞 DMA 播放。真正限制播放音质的是两件事一是 SD 卡读取速度是否跟得上采样率二是缓冲切换时会不会出现半个缓冲的错位。前者靠双缓冲解决后者靠 DMA 循环模式一直固定在整块缓冲边界。如果使用循环模式HAL_TIM_PWM_Start_DMA只需要启动一次DMA 会在缓冲边界自动回绕不需要每次回调都重新启动。但双缓冲切换时我还是倾向于重新调用启动函数让 DMA 指针明确落在新缓冲首地址避免奇偶缓冲切换的边界错位。4. 播放链路常见问题排查静音、爆音、卡顿一次说清4.1 完全静音引脚复用和时钟没配对现象程序正常执行定时器也启动了但喇叭没有任何声音用示波器量 PWM 引脚甚至有波形。原因多数是 GPIO 复用功能没有配对。STM32 的定时器通道不是固定出现在任意引脚上比如 TIM2_CH1 可能映射到 PA0也可能重映射到 PA15如果只初始化 GPIO 推挽输出而不设置复用功能引脚输出的就是普通数字电平定时器波形根本引不到外面。另一种常见原因是 APB1 外设时钟没开定时器寄存器写不进去看起来函数执行了实际全废。解决先查数据手册确认定时器通道对应引脚再用GPIO_Mode_AF_PP配置复用推挽输出。调试时用示波器量定时器通道引脚不要只量滤波之后的输出这样能快速定位是 PWM 没出来还是后级电路问题。4.2 播放出爆音样本转换把符号位弄丢了现象声音能出来但整体尖锐刺耳低音量时尤其明显像有一层电流声底噪。原因最常见的做法是把 16 位有符号样本直接塞进 CCR 比较寄存器。样本为负时无符号寄存器读到的值是巨大的正数PWM 占空比在 0% 和 100% 之间来回弹跳形成剧烈杂音。这是 PWN 播 WAV 最容易踩的一个坑。解决统一走有符号转无符号的映射函数先右移再做偏置参考第二章节的pcm16_to_pwm8。如果播放 8 位无符号 WAV则要反过来检查是否多加了 128 偏置重复偏移会让波形上下颠倒声音一样发闷。4.3 播放卡顿缓冲太小或者中断里干了重活现象声音每隔几百毫秒停顿一下停顿没有规律短音频结束时还有拖尾。原因DMA 缓冲太小SD 卡还没读完下一块上一块已经播完播放链路出现空窗。另一种情况是把f_read写进了 DMA 回调导致回调阻塞时间过长DMA 传输跟不上。解决把 DMA 缓冲加大到至少 512 个样本双缓冲时每块 1024 字节以上给 SD 卡留出足够的读取窗口。回调里只切换缓冲指针和置标志所有文件操作移到主循环。如果用的是低速 SPI 模式读写 SD 卡优先把 SPI 时钟调到 18MHz 以上基本就能消除卡顿。4.4 声音速度快定时器分频和系统时钟对不上现象播出来的语音像开了倍速男声变成女声或者反过来变得非常低沉。原因系统时钟实际运行在 72MHz但代码里按 36MHz 算的 PSC或者 APB1 预分频系数导致定时器时钟不是预期值。使用内部 RC 振荡器时频率本身就有几个百分点的误差计算值也会偏移。解决先把SystemCoreClock打印出来确认内核主频再按第 2.2 节的公式反向验证定时器时钟。如果允许直接测量 PWM 输出频率用实测值反推采样率偏差比口算可靠得多。5. 音质与硬件取舍低通滤波、PWM 分辨率和功放5.1 一级 RC 滤波截止频率该怎么定PWM 输出本质是方波直接接喇叭会听到高频“滋滋”声。必须用低通滤波器把 PWM 载波滤掉只剩音频包络。最省成本的就是一节 RC 低通截止频率f_c 1 / (2πRC)。RC 取值不是越小越好。截止频率太低会把语音高频也砍掉声音发闷太高又滤不干净 PWM 噪声。一组常用参考RC截止频率适用场景1kΩ33nF约 4.8kHz8kHz 语音播报1kΩ10nF约 15.9kHz22.05kHz 采样470Ω33nF约 10.2kHz16kHz 采样实际听感测试时可以把截止频率定在采样率的四分之一到三分之一之间。采样率 22050Hz 时截止频率 5kHz 到 7kHz 是安全区间既能保留语音清晰度又能压制 PWM 噪声。一级 RC 的衰减斜率只有 20dB/decade如果觉得噪声还是明显就串两级 RC每级之间用电压跟随器隔离否则阻抗相互影响会让截止频率大幅偏移。5.2 分辨率与 PWM 载波频率的取舍PWM 方案的音质上限由两个参数决定一是占空比分辨率二是 PWM 载波频率。ARR 设成 255 时分辨率是 8 位PWM 载波频率恰好等于定时器更新频率也就是采样率本身。问题在于如果采样率 44100Hz、定时器时钟还是 72MHz保持 ARR255 会得到约 0.65MHz 的计数频率能算通但偏差极大。想要更高采样率只能缩小 ARR分辨率随之下降采样率定时器时钟 72MHzARR实际 PWM 分辨率8000 Hz72MHz2558 位22050 Hz72MHz2558 位44100 Hz72MHz101约 6.7 位6.7 位分辨率的声音会有可闻量化噪声尤其是静音段底噪特别明显。这也是 PWM 播 WAV 方案大多止步于 22.05kHz 的原因。如果项目对音质要求更高要么换带 DAC 的型号要么把 PWM 当成 1 位 sigma-delta 流来推但那种玩法对定时器中断实时性要求极高不适合入门项目。5.3 输出级不要直接拿 PWM 去推喇叭PWM 滤波后的信号是毫伏级的电压信号直接接喇叭声音会非常小而且 MCU 引脚最大输出电流有限长时间重载还可能导致芯片发热或复位。实际项目中要加一级驱动或小功率功放。最简单的方案是单管放大用 NPN 三极管做射极跟随器把信号电压抬到能驱动耳机或小喇叭的程度。如果做语音播报产品直接买现成的 386 类小功放模块最省事输入接滤波输出输出接喇叭增益引脚配 10μF 电容设置放大倍数。接功放时还要注意电源共地问题。MCU、SD 卡、功放如果共用一个电源播放时喇叭电流波动会让 MCU 电源跌落轻则声音失真重则程序跑飞。我会在功放电源入口加 100μF 电解电容和 0.1μF 陶瓷电容并联并把功放地线单独引回到电源地不要经过 SD 卡和 MCU 的走线。6. 进阶技巧把播放变成后台状态机顺手加上音量调节播放链路跑通只是第一步实际项目里很少只播一个固定文件。我习惯把整个播放过程拆成一个状态机放在主循环里调度每个状态只做一件小事这样既不会卡死 DMA也方便插入暂停、停止、音量调节这些功能。状态大致分成空闲、打开文件、解析头部、读取缓冲、等待播放、缓冲耗尽后继续读取、播完收尾。主循环每轮只调用一个状态函数DMA 回调只负责把active_buf切过去并置一个“缓冲已空”的对应标志。音量调节不要在 DMA 回调里做而是放在填充缓冲的时候。因为每次回调都做乘法运算会拖长中断时间而且音量突变还会产生“咔哒”爆音。更稳妥的做法是给样本加一个音量系数音量 0 到 128 对应静音到全音量uint8_t pcm16_to_pwm8_vol(int16_t sample, uint8_t volume) { int32_t v sample; // volume 范围 0~128128 相当于 100% 音量 v (v * volume) 7; // 偏置到无符号占空比范围 v (v 8) 128; if (v 0) v 0; if (v 255) v 255; return (uint8_t)v; }这里用移位代替浮点乘法速度快且不引入浮点库。volume 7是等效缩放音量 128 时完全不衰减音量 64 时输出减半。实际听感上人耳对音量不是线性感知所以音量步进要用查表法把 0 到 32 映射成指数曲线比如volume_lut[i] 128 * i * i / 1024这样小音量段调节更细腻。音量调好的同时还要处理静音和停止两个边界。暂停时不要只停止 DMA否则定时器通道会停在当前的占空比上喇叭一直通着直流电。正确做法是把 CCR 强制写回中间值 128让输出电压回到中点再关停 PWM。停止播放时记得f_close文件否则下次打开同一文件会读到错误的文件偏移。第一次做这个项目时我把样本转换和音量计算全塞进了 DMA 回调结果播放断断续续还偶尔出爆音。后来把所有重活都移出回调换成主循环状态机播放链路才真正稳定下来。从那以后我每次设计播放类功能都强制先画一遍状态转移再确认哪些操作能进中断、哪些必须留在主循环凡是拿不准的都先放主循环。希望这套 PWM 播 WAV 的工程思路能帮到你少走一圈我之前绕过的弯路。本文还有配套的精品资源点击获取
RELATED

相关推荐

JavaEE二手图书交易平台实战拆解:从JSP+Servlet+JDBC到部署避坑

JavaEE二手图书交易平台实战拆解:从JSP+Servlet+JDBC到部署避坑

简介:基于JavaEE的二手图书交易平台设计源码与配套高分报告,是一套面向Java Web课程设计、期末大作业的完整可运行项目。平台覆盖二手图书发布、浏览检索、下单购买、订单管理、后台数据维护等典型业务,界面采用Bootstrap响应式布局&#xff…

📅 2026/10/18 4:22:12
硬盘分区合并全攻略:C盘扩容与无损合并方法详解

硬盘分区合并全攻略:C盘扩容与无损合并方法详解

1. 先说清楚:合并分区到底是怎么回事作为一个折腾电脑十几年的人,我隔三差五就会碰到有人来问:“我C盘又红了,能不能把D盘的空间匀给它?”或者“当时装系统分区没规划好,现在盘符又多又乱,能不能…

📅 2026/10/18 4:22:12
Django+Vue构建茶叶电商系统:从商品建模到订单全栈实战

Django+Vue构建茶叶电商系统:从商品建模到订单全栈实战

接到一个茶叶销售系统的开发需求时,我的第一反应是:这不就是个电商系统嘛。可等到把需求文档一条条过完,才发现茶叶这类目背后藏着一堆商品逻辑——品类分级、产地、工艺、年份、批次口感,甚至同一款茶换个山头味道就完全不一样。…

📅 2026/10/18 4:22:12
MORE NEWS

更多资讯

📰

CSP期末预测之最佳阈值:从暴力枚举到排序递推的O(n log n)优化

1. 从"暴力枚举"到"排序递推":这道题到底在考什么如果你刷过CCF-CSP的历年真题,会发现一个很有意思的规律:第一题通常是"送分题",第二题开始上强度,而有些第二题看似简单,实…

📰

从TiDB 5.0看分布式数据库演进:HTAP、云原生与智能化运维

数据库领域正在发生巨变,从 TiDB 5.0 发布会看未来的数据库发展趋势。说实话,这几年分布式数据库已经从"要不要上"变成了"怎么上、上了之后怎么用好"的阶段。我自己是从 MySQL 分库分表一路折腾过来的,中间踩过不少坑&am…

📰

基于FOMIAUKF与分数阶模型的锂电池SOC估计研究与Matlab实现

做电池SOC估计的人,基本都绕不开一个问题:模型建得不够准,滤波算法再花哨也白搭。我早几年用整数阶等效电路模型配合扩展卡尔曼滤波(EKF)的时候,经常被低温和动态工况下的误差搞到头大——明明OCV曲线拟合得…

📰

直播CDN分发架构设计(进阶篇)最佳实践与踩坑记录

本文深入探讨直播CDN分发架构设计(进阶篇),涵盖背景分析、原理剖析、实战步骤、配置示例、优化建议和避坑指南。随着业务规模增长,直播CDN分发架构设计(进阶篇)的重要性日益凸显。无论你是刚入门还是资深工…

📰

Docker部署SRS直播集群:从单点到源站边缘架构实战

做直播服务的同学应该都听过SRS,这套开源流媒体服务器在RTMP、WebRTC、SRT这些协议上表现一直很稳。但不少朋友有个误区:觉得Docker里跑一个SRS容器,能推流能拉流就算完事了。真到了线上,单节点扛不住并发,推流源一挂整…

📰

异常的实现与代价:零成本异常、栈展开与 -fno-exceptions 什么时候用

C 异常常被贴上「零成本」的标签,可真正在生产里用它的人又总在担心性能。矛盾其实很好解释:「零成本」指的是不抛异常时的正常路径(happy path)不额外付代价,而一旦真的抛出,代价是巨大的:编译…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬