news 2026/9/28 15:52:09

STM32低成本音频输出实战:PWM模拟DAC与滤波电路设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32低成本音频输出实战:PWM模拟DAC与滤波电路设计

STM32做音频输出,很多人第一反应是不是得外挂一个DAC芯片,或者至少用上芯片内部的自带DAC。但实际上,一个最普通的定时器PWM引脚,配合几颗电阻电容,就能把音频信号“造”出来。这个方案在成本敏感型产品里非常常见,像一些智能小家电的提示音、玩具的语音播放、工控设备的蜂鸣报警升级版,基本都是这么干的。这篇文章就带你完整走一遍“STM32 + PWM + 滤波电路”的音频输出方案,从原理到滤波设计,再到CubeMX配置和DMA播放代码,最后附上一份实际问题排查清单。适合想低成本给产品加声音功能的人,或者刚入门STM32想搞点有意思实战项目的朋友。

本质上说,这个方案就是用PWM去模拟一个DAC,再用模拟滤波器把PWM的高频载波滤掉,留下平滑的音频波形。思路不复杂,但里面的坑不少:滤波截止频率怎么定、PWM频率和采样率怎么配合、音频数据怎么送到定时器里、为什么播放出来声音发闷。下面一条条说清楚。

1. 为什么用PWM来“冒充”DAC:原理与选型思路

1.1 PWM变成一个DAC,靠的是低通滤波的平均效应

先想想PWM输出的是什么波形:一个频率固定、占空比可变的方波。这个方波经过低通滤波器之后,高频分量被衰减,输出就变成了一个相对平滑的直流电平。这个直流电平的大小,正好等于占空比乘以PWM的高电平电压。比如STM32的引脚输出高电平是3.3V,占空比50%,那滤波后的平均电压就是1.65V;占空比100%就是3.3V,0%就是0V。

这个特性其实就是PWM DAC的核心思想:占空比就是“数字量”,滤波后的平均电压就是“模拟量”。你给PWM设置256级占空比,就能得到256级电压输出,相当于一个8位的DAC。如果把定时器的自动重载值ARR设为255,那么比较寄存器CCR的值从0到255变化时,占空比就从0%到100%线性变化,对应输出电压从0V到3.3V,每级约12.9mV。

如果你播放一段音频,采样点是8位的,每个采样点对应一个占空比,输出波形经过滤波后,就还原出了原始音频的包络。这就是“PWM DAC播放音频”的最简模型。要注意的是,这里每个采样点至少占一个完整的PWM周期,所以PWM频率决定了你能输出的采样率上限。

1.2 STM32自带DAC不好用?聊聊PWM方案的优势

很多STM32型号其实自带了DAC外设,比如STM32F103系列就有2个12位DAC通道。那为什么还要折腾PWM?有几个很现实的原因。

第一,引脚限制。自带DAC的引脚通常是固定的,比如F103的PA4和PA5。这在PCB布局上很被动,你可能为了把一个DAC引脚引到音频输出位置,绕了一大圈走线。而且如果这几个引脚被其他功能占用了,DAC就没法用。PWM方案就没这问题,几乎所有定时器通道都能输出PWM,哪个引脚方便就用哪个。

第二,输出驱动能力。DAC的输出是电压信号,带载能力很弱,直接接耳机或者喇叭基本带不动,后面照样得加运放或者功放。而PWM引脚好歹是推挽输出的GPIO,灌电流拉电流能力更强,驱动一个简单的RC负载没问题。

第三,成本角度。不是所有项目都用F103这种带DAC的型号,很多便宜的MCU根本没有DAC外设。用PWM方案意味着哪怕你换一颗1块钱的芯片,只要它有定时器,这套代码和电路就能复用。这对做产品的人来说,是一个非常有吸引力的可迁移方案。

还有一点,PWM的位数可以通过定时器自由扩展。16位定时器可以把ARR设到65535,得到16位的PWM分辨率,远超大多数内部DAC的12位。当然,代价是PWM频率会跟着降下来,这里就涉及一个“分辨率”和“频率”的取舍,后面参数计算部分再细说。

1.3 什么场景适合用PWM DAC,什么场景别硬上

PWM DAC不是万能的,它更适合以下场景:

  • 语音提示、人声播报,采样率8kHz到16kHz,这种电话音质级别的要求完全没有问题;
  • 简单的音效,比如按键音、开机音、报警提示音;
  • 需要输出一个可变的模拟电压,比如控制某些模拟电路的偏置电压;
  • 低成本产品,不想为音频功能增加一颗DAC芯片或codec芯片。

但如果你要做HiFi音乐播放、需要对音频信号进行高保真还原,那PWM DAC方案就不合适了。它最大的短板是音质上限由PWM分辨率和采样率共同决定,即使你把滤波做得很好,量化噪声的底子也摆在那,跟专业的音频DAC还是差几个数量级。另外,如果你要求输出真正意义上的双极性音频信号(正负半周都有),那就得在滤波输出后面加隔直电容或者偏置电路,这部分我会在滤波设计里专门讲。

2. 滤波电路设计:RC、LC还是有源?

滤波电路是整个PWM DAC方案里最关键的部分,也是很多人忽略的地方。PWM信号经过滤波后,如果滤波不干净,你听到的就是“嘶嘶”的高频噪声叠加在音频上;如果滤波太狠,音频的高频细节又会被滤掉,声音变得闷闷的。所以这个“度”必须把握好。

2.1 一阶RC滤波:最省料的起手式

最简单的滤波电路就是一颗电阻加一颗电容,构成一阶低通滤波器。截止频率公式是:

fc = 1 / (2π × R × C)

实际设计滤波器时,首先要确定PWM的载波频率。比如你的PWM频率是100kHz,那么滤波器的截止频率至少要远低于100kHz,才能把载波衰减到足够小。常见做法是把截止频率设置在载波频率的1/10到1/20。

举个例子:PWM频率100kHz,截止频率设10kHz,取R=1kΩ,那么C = 1/(2π × 1000 × 10000) ≈ 15.9nF,取标称值15nF或10nF都可以。如果PWM频率只有20kHz,截止频率还想设5kHz,那么C = 1/(2π × 1000 × 5000) ≈ 31.8nF,取33nF也能凑合。

但一阶RC的衰减斜率只有20dB/dec,这意味着截止频率之后频率每升高10倍,信号衰减20dB。如果PWM频率是截止频率的10倍,那载波只衰减了20dB,大概相当于信号幅度缩减到原来的1/10,听起来还是会有明显的“齿音”或者“嘶嘶声”。所以一阶RC适合要求不高的场景,想效果好一点,就得用二阶或更高阶的滤波器。

2.2 二阶RC与LC滤波:把载波压得更干净

二阶RC低通滤波器有两种接法。一种是无源的两级RC直接串联,但要注意,两级RC直接级联时,后一级的输入阻抗会成为前一级的负载,实际截止频率会偏离理论值,计算时不能简单地把两级公式相乘。如果两级之间加一个运放电压跟随器做隔离,那每一级的截止频率就可以独立计算。这种方法在成本允许的情况下是首选。

另一种更常用的思路是用π型RC滤波,也就是在输入端串一颗电阻,然后分成两个并联支路,每个支路各有一颗对地的电容。这种结构对高频的衰减比一阶好,而且元件数量也不多。实际搭电路的时候,我比较推荐用“R=1kΩ,C1=C2=10nF”这种组合,在STM32的PWM输出上表现很不错。

LC滤波在PWM输出电路里也有应用,特别是在D类功放的输出端,用来滤除高频开关频率。它比RC滤波的衰减特性更陡峭,但电感有两个问题:一是成本高、体积大,二是有可能产生谐振峰,如果没有加阻尼电阻,在某些频率点反而会放大信号。对PWM DAC这种小信号场景,除非你对体积和成本不敏感,否则RC已经足够,不推荐一开始就去动LC。

2.3 有源滤波和电平偏置:想要正常音质的必经之路

如果你直接用RC滤波后的信号去驱动耳机或者喇叭,大概率会失望。一方面RC滤波输出的是高阻信号,带不动低阻抗负载,音质会明显变差;另一方面,STM32的PWM输出电压范围是0V到3.3V,经过滤波后得到的是一个纯正的单极性信号,也就是只有正半周,没有负半周。真正的音频信号是交流的,有正有负,你直接把单极性信号怼到耳机里,声音会严重失真,而且如果信号平均值不为零,还会导致耳机振膜一直偏向一侧,长时间工作还可能损坏耳机。

解决这个问题有几个办法。

第一个办法是串联一个隔直电容。在滤波电路输出端接一个电容,比如10μF到100μF的电解电容,再串联一个电阻到耳机。这个电容把直流分量隔断,只让交流音频信号通过。这个办法最简单,但音质也就那样,低频信号会被电容衰减,特别是电容容量太小的时候低音基本没了。

第二个办法是加法偏置电路。用两个电阻把信号偏置到1.65V,然后用运放做电压跟随,再做隔直输出。STM32很多芯片内部有VREFINT或者你可以直接用3.3V电源分压,得到一个1.65V的参考电压。这样音频信号的中间点就在1.65V附近,交流信号可以在0V到3.3V之间摆动,再串隔直电容输出,就能还原出比较完整的双极性音频。

第三个办法更专业一点,用运放做电平移位和放大,典型电路是用反相放大器把0V到3.3V的单极性信号转换成以0V为中点的双极性信号。这个方案需要正负双电源或者用轨到轨运放配合虚拟地,电路复杂度上去了,但音质上限也更高。

我个人在产品里最常用的方案是:PWM引脚 → π型RC滤波(R=1k,C=10nF×2)→ 运放电压跟随器 → 隔直电容(10μF)→ 耳机或功放。这个组合成本低、性能稳定,而且对新手友好。

3. STM32软件实现:定时器PWM + DMA播放音频

硬件方案定下来以后,软件部分分两大块:音频数据怎么来,以及怎么把数据以正确的节奏送到PWM输出上。

3.1 整体流程与音频数据准备

软件的整体流程可以总结为:准备音频数据 → 将数据映射为占空比数值 → 定时器PWM生成载波 → DMA把占空比数值逐个搬运到定时器的比较寄存器 → 占空比实时变化 → 输出端得到音频波形。

音频数据有几种常见来源。

第一种是直接生成“简易音调”,比如正弦波查表。如果你只是想播放一个“叮咚”的提示音,根本不需要外部音频文件,在代码里生成一个正弦波表,用DMA循环播放同一个表,再配合定时器的频率,就能得到持续的正弦波音调。这个方法做报警器、门铃、键盘提示音非常合适。

第二种是播放WAV文件。把音频文件转成C语言数组,常见工具是“WAV to C array”或者Python脚本。转换时需要注意音频格式:单声道、8位PCM是比较方便直接播放的,因为每个采样点正好一个字节,可以直接作为PWM的占空比数据。如果原来的音频是16位PCM,可以取高8位作为输出数据,不过这样相当于做了取整,会引入一定的量化噪声,实际听感会稍微有点“粗糙”。想减少这种粗糙感,可以加简单的噪声整形,但新手阶段不建议一上来就搞这么复杂。

第三种是使用语音合成芯片,比如SYN6288这类,它输出的本来就是文本转语音的音频信号,可以接到STM32的ADC里再重采样播放。但这个偏题了,这里不展开。

3.2 关键参数计算:采样率、PWM频率和ARR

这一节直接关系到你能不能正确播放,先说结论:采样率是多少,PWM频率就必须是采样率的整数倍,最直接的做法是让PWM频率等于采样率。

为什么?因为每个采样点需要保持一个完整的PWM周期,这样在每个PWM周期内,占空比不变,滤波后电压稳定。如果PWM频率是采样率的2倍,那么一个采样点要占2个PWM周期,但这2个PWM周期的占空比应该相同,这就得靠DMA或中断来精确控制更新时间,实现起来更复杂。所以新手入门,PWM频率 = 采样率是最好理解也最容易实现的。

举例:你想以8kHz采样率播放一段8位PCM音频,那么定时器的更新频率要设成8kHz。STM32F103的时钟是72MHz,定时器预分频PSC设为0,那么自动重载值ARR = 72000000 / 8000 - 1 = 8999。因为PWM占空比寄存器是16位的,而音频数据是8位的,所以我们要把8位音频数据左移或者映射到16位范围,让占空比和PWM分辨率匹配。

这里有个细节:ARR设成8999,意味着PWM的分辨率是9000级,但你用8位数据去驱动它,实际只用了256级。这没问题,等价于8位PWM。但如果你的采样率是44.1kHz,ARR = 72000000 / 44100 - 1 ≈ 1631,分辨率约1632级,大于256,仍然满足8位音频的需要。

反过来,如果你希望提高PWM分辨率到16位,ARR = 65535,那PWM频率就只能是72000000 / 65536 ≈ 1098Hz,这个频率做音频载波是远远不够的。所以“高分辨率”和“高载波频率”在同一个定时器配置里是矛盾的,这就是前面说的取舍。对音频应用来说,PWM频率尽可能高一点比较好,这样滤波比较容易,所以实际工程中常常用8位或10位PWM,而不是16位。

3.3 基于CubeMX的配置步骤与核心代码

下面是STM32CubeMX配置PWM输出的关键步骤,我用的是STM32F103C8T6,工程用Keil MDK编译。

  1. 打开CubeMX,选择芯片STM32F103C8T6,配置系统时钟为72MHz(PLL 9倍频)。
  2. 在左侧“Timers”中选择TIM2,勾选“PWM Generation CH1”,设置Prescaler为0,Counter Period为8999(对应8kHz采样率)。
  3. 在TIM2的“Parameter Settings”里,把Pulse设为0,模式选PWM Mode 1,Pulse极性选High。
  4. 切换到“DMA Settings”,添加DMA请求,方向选“Memory To Peripheral”,外设地址选TIM2的CCR1寄存器,模式选Circular,数据宽度按实际音频数据大小设置。如果音频数据是8位的,外设数据宽度最好也要匹配,但注意TIM2的CCR1是32位寄存器,DMA搬运到CCR1时,如果两边宽度不匹配,会出问题。稳妥的做法是把音频数组定义成uint32_t类型,每一个元素的高8位或低8位存放占空比,然后DMA宽度都设为Word。

初始化完成后,生成代码。核心代码就几行:

#define SAMPLING_RATE 8000 #define AUDIO_LENGTH 256 const uint32_t sine_wave[AUDIO_LENGTH] = { /* 预先生成的256点正弦波数据 */ }; // 在main函数中启动播放 HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)sine_wave, AUDIO_LENGTH);

DMA启动后,TIM2每次溢出更新,就会自动从sine_wave数组里取下一个值写入CCR1,占空比随之变化。因为设置了Circular循环模式,数组播完以后会循环回到开头继续播,这样就实现了一个持续的正弦波输出。

使用DMA的好处是CPU基本不用管波形播放,音频数据按固定节奏搬运,波形稳定不卡顿。如果不用DMA,你得在定时器中断里手动更新CCR,中断频繁进入会很占CPU资源,还容易出现时序抖动,导致音调不稳定。

3.4 播放完整WAV音频和实时音调生成

播放完整WAV音频的原理和正弦波一模一样,只是把sine_wave数组换成你转换好的音频数组。但有一个问题:DMA是循环播放的,如果音频数组播完以后又回到开头,就会形成循环播放,这在很多场景下不是我们想要的。想实现只播一次,DMA模式改成Normal,播完自动停止。

实际项目里,我建议的做法是把WAV文件转成16进制数据放到外部Flash里,用FATFS文件系统读取到SRAM,再做DMA播放。这样代码灵活,可以随时换音频文件,不用每次重新编译下载。不过这是进阶方案,对新手来说,先在一个C文件里放一段几百毫秒的人声样本,跑通了再说。

如果你只是想播放某个频率的提示音,甚至可以不用查表,直接在主循环里改变CCR的值,实现斜坡变化,从而产生“哔哔”或者“滴——”的声音。比如一个500Hz报警音,设置PWM频率为500Hz,CCR设为ARR的一半,就是50%占空比,声音持续响。想让声音鸣响一下再停,用定时器控制开关就行。

4. 常见问题与排查技巧实录

做这个项目的时候,很多问题不是代码跑没跑起来,而是输出声音不对。下面几个是我自己实际调试过程中踩过的坑,整理成速查表,方便你遇到问题直接对照。

4.1 问题速查表

现象可能原因解决思路
播放时有明显的“嘶嘶”高频噪声PWM载波泄漏,滤波不足增加滤波阶数,或把滤波器截止频率再调低,让载波衰减更多
声音发闷,高音完全听不见滤波器截止频率太低,把有效音频频段滤掉了提高截止频率,或提高采样率让有效频段和高频载波分开
播出来的声音速度不对,感觉变慢定时器溢出频率和音频采样率不匹配重新计算ARR,确保定时器更新频率等于音频采样率
声音很小,几乎听不到滤波后信号直接驱动了低阻负载,信号被拉低了增加运放缓冲,或增加一级放大电路
有“滋滋”的周期性杂音DMA和PWM更新不同步,更新点上出现毛刺检查DMA配置是否Circular、数据宽度是否匹配
播放过程中声音偶尔卡顿或重复DMA循环模式导致数组播完又从头播需要播放一次就改成Normal模式,或用标志位控制
播放时单片机程序卡死DMA配置出错,访问非法地址检查DMA外设地址是否指向CCR1,数组首地址是否对齐

4.2 典型问题:滤波到底怎么调才合适

滤波是新手最容易困惑的地方,这里多说一句。滤波的目标不是在“完全滤掉载波”和“保留全部音频”之间找个完美点,而是尽量拉开PWM载波频率和音频有效频段之间的距离,让滤波变得容易。

比如你要播放的电话语音采样率是8kHz,有效音频带宽大概是3.4kHz,PWM载波频率如果是8kHz,那滤波截止频率设在3.4kHz,载波只比截止频率高不到2.4倍,一阶RC根本压不住。这种情况下,要么提高PWM频率到32kHz(采样率变成32kHz,但音频数据得重新采样),要么把滤波器做到二阶、三阶。

另一种思路是PWM频率不跟着采样率走,而是固定在一个高频率,比如100kHz,然后每隔若干个PWM周期更新一次占空比,这样相当于把采样和载波解耦了。但这个做法需要额外的定时器来控制占空比更新时间,代码复杂度高一些。对刚开始玩的朋友,我建议先用“PWM频率 = 采样率 + 二阶滤波”这个组合,能解决80%的新手问题。

4.3 典型问题:播放音频时地线和电源引起的底噪

如果你把电路搭在面包板上,用USB给STM32供电,然后用耳机线直接勾到滤波输出端,很容易听到持续的“嗡嗡”底噪。这个底噪不是PWM滤波惹的祸,而是电源和地线的问题。

USB供电本身噪声很大,特别是有电机、继电器这类感性负载在同一个电源上工作时,噪声会直接跑进音频回路。解决办法是:音频部分的地要单点接地,不要形成环路;电源上并联一个100μF的大电容和100nF的小电容,分别滤低频和高频干扰;如果条件允许,音频部分用独立的LDO单独供电。

还有一个容易忽略的点:耳机线的地线如果接在STM32的地上,STM32的数字地噪声会干扰音频信号地。最好的做法是模拟地和数字地分开走线,在一个点汇合。这个细节在PCB设计时要特别注意,面包板调试阶段很难完全避免,但至少要保证共地可靠。

5. 一点扩展:从提示音到语音播报

聊到这里,基础方案已经完整了:PWM模拟DAC、RC滤波、运放输出、DMA播放。最后分享几个我觉得很有用的扩展思路。

如果你在做一个带人声语音播报的产品,比如“温度正常”“请刷卡”,方案是先在PC端用工具把语音压缩成8kHz 8bit WAV,再转成C数组,用DMA循环或单次播放。这种音质水平在提示音场景完全够用,而且资源占用极小,一段2秒的语音占用16KB Flash,在STM32F103的64KB Flash里可以存好几段。

如果觉得PWM DAC音质不够好,但又不舍得换成真正的音频DAC芯片,可以看看STM32系列里带真正DAC的型号,比如F103已经带了,只是引脚和代码需要重新适配。也可以把目光放到G0系列或者L4系列,它们的DAC外设更完善,还支持硬件触发DMA。但核心思路依然是:模拟信号出来之后,滤波和放大电路的设计才是决定音质的关键。

还有一个比较好玩的方向是PWM输出配合快速傅里叶变换做音频频谱显示。你已经在播放音频了,同时把同一个音频数组送进FFT计算,驱动一个小屏或者LED点阵显示频谱。这个项目作为进阶训练非常推荐,因为音频采集、播放、处理都在一颗MCU里闭环了,对理解整个数字信号链路也有帮助。

我在实际调试过程中最大的体会是:不要一上来就追求高保真音质,先把“能出声、声音不破、播放速度正确”这个目标达成,再逐步优化滤波和放大。最怕的就是滤波电路堆了一堆元件,结果连波形都没调出来,出了问题都不知道该查模拟部分还是数字部分。先用示波器看PWM引脚,再看滤波输出,一步一级往上查,问题基本都能快速定位。

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

60个工具下Agent挑花眼?工具路由与动态检索三招解决

六十个工具堆在 Agent 面前的时候,问题不是它“不知道选哪个”,而是它开始乱选、反复横跳、甚至干脆不干活。这段时间我在折腾一个内部办公助手,把各类接口从 PDF 处理、表格解析、定时任务、图片压缩到会议纪要全挂上去,前前后后…

作者头像 李华
网站建设 2026/9/28 15:51:41

AI编码助手实战:融合代码问答与任务执行的Agent设计

做 AI 編碼助手,最常見的誤區是把它做成一個「會說話的搜索引擎」。用戶問「這個報錯什麼意思」,它答得頭頭是道;用戶問「那你幫我改一下、跑一下、把任務排上」,它就啞火了。羲和(XiheAgent)這個項目的出發…

作者头像 李华
网站建设 2026/9/28 15:50:51

从零构建Servlet+JDBC点餐系统:MVC分层、事务与连接池实战

简介:这份压缩包是一套基于MVC架构的JavaWeb点餐系统完整项目,适合用作毕业设计、课程设计或Servlet与JDBC入门实战练习。项目从前台点餐到后台管理,覆盖用户注册登录、菜品分类展示、购物车与订单提交、订单管理等功能模块,通过M…

作者头像 李华
网站建设 2026/9/28 15:50:44

Jev模型源码解析:不生成文字的轻量级单token预测器

前两天我在 Hacker News 上刷到一个节奏感很强的项目:发布 3 天,直接登顶首页第一,标题写着“不生成一个字的模型”。我本来以为又是那种噱头拉满的 AI 玩具,点进 GitHub 之后反而越看越上头。Jev 这个项目和我想象的不太一样&…

作者头像 李华
网站建设 2026/9/28 15:50:33

容器冷启动优化:Agent服务快照恢复实战,从35秒到1秒

我最近被一个 Agent 服务的冷启动坑得够呛。团队把一个大模型 Agent 框架打包进容器,加上 Python 依赖、几个本地 embedding 模型文件,镜像轻松超过 1.5GB。每次弹性扩容或发布新版本,新容器要经历拉镜像、解压、初始化框架、加载模型这一整套…

作者头像 李华
网站建设 2026/9/28 15:50:05

RK628F MIPI转HDMI黑屏排查实战:从I2C到固件到4K时序

最近在调一块RK3588方案的板卡,外接的显示输出就是一颗RK628F桥接芯片,作用是把SoC的MIPI DSI输出转成HDMI,接到4K显示器上。从拿到样板到屏幕真正点亮,中间黑屏了将近一周。这类方案在初期出现黑屏太正常了——RK628F不是你焊上去…

作者头像 李华