1. 为什么放着片上DAC不用,非要拿PWM硬凑?
1.1 先看你手里的芯片有没有DAC外设
做项目最怕的是画完原理图才发现芯片选错了。我有一阵子手头全是STM32F103C8T6的小板子,焊了一堆东西上去,最后想给一个压控放大器输出一路可调模拟电压,翻了数据手册才发现:这颗芯片根本没有DAC外设,只有12位ADC。不是"不好用",是没有。很多小封装、低成本的STM32型号都没有DAC,哪怕是F103系列里的某些中容量型号,DAC通道也是按封装和产品线砍掉的。
这时候摆在你面前的有三条路:换一颗带DAC的芯片、外挂一颗I2C/SPI接口的DAC芯片、或者用定时器输出PWM再过RC滤波。换芯片意味着PCB和代码都可能推倒重来,外挂DAC比如MCP4725单价几块钱,也能接受,但如果要输出4路、8路模拟量,成本直接翻倍,而且I2C总线在多路输出场景下刷新率会越来越紧张。PWM+RC方案的好处是几乎零成本,每个定时器通道都是一路模拟输出,单片机引脚够用就能无限扩展,特别适合输出慢慢变化的控制电压。
1.2 哪些场景适合PWM转模拟电压
我做过的几个项目里,PWM转模拟量最稳的场景是:调LED亮度或色温、控制压控振荡器的频率、给LCD提供对比度电压、模拟温度传感器信号、输出缓慢变化的设定值给变频器或伺服驱动器。这类场景的共同点是电压变化速率要求不高,几十毫秒建立起来都能接受,但精度和纹波要求并不低。
相反的,如果要用它出音频波形,或者做一个20kHz以上的高速信号源,那还是老老实实加DAC芯片或者用I2S接口的音频DAC。RC滤波对高频分量的抑制是渐进的,不是一刀切,音频信号本身的频带和PWM载波如果靠得太近,滤波会连有用信号一起削掉,声音发闷不说,还会残留明显的载波噪声。另外一个不适合的场景是电源类应用,PWM转出来的模拟电压只能驱动高阻负载,直接给电机供电或者当稳压源用,电流能力完全不够。
1.3 这个方案的精度天花板
说句实在话,PWM做DAC的天花板大概在12位左右。为什么?占空比的量化分辨率可以靠拉大定时器计数周期做得非常高,比如ARR设到65535,理论上就是16位分辨率。但实际输出精度会被四个方面卡住:第一,电源电压VCC本身不是绝对稳定的3.3V,它会直接乘到输出电压上;第二,RC滤波的残余纹波做不到绝对为零,会叠加在直流电平上;第三,电阻电容的精度和温漂会让电路的实际截止频率和分压比偏离设计值;第四,输出带负载以后,负载阻抗会和滤波电容并联,改变滤波特性。
工程上我一般把预期控制在10到12位有效精度,也就是3.3V满量程下误差几个毫伏以内。再往上不是做不到,而是投入产出比太低,不如直接上外部DAC。明白了这个边界,后面所有的电路参数设计和元件选型就都有了判断依据。
2. 从方波到平直电压:平均值提取与RC滤波的数学本质
2.1 PWM输出的"平均值"就是模拟量
PWM输出的本质是一串幅值固定、占空比可变的方波。如果高电平是3.3V,低电平是0V,占空比是D,那这一串方波的平均电压就是:
V_avg = 3.3V × D
D的取值范围是0到1,所以平均电压可以在0到3.3V之间连续变化。你可能会问,这不是已经是模拟量了吗?问题在于平均值是拿方波"算"出来的,不是实际输出的一条平直线。示波器看PWM引脚,看到的是一跳一跳的方波,而不是稳定的直流。我们要做的事情,就是把方波的高频分量滤掉,把平均值"提取"出来。
2.2 分辨率的上限:ARR位宽与PWM频率的根本矛盾
STM32定时器输出PWM时,占空比的精度由比较寄存器CRR和自动重装载寄存器ARR决定。ARR越大,占空比的调节步进就越细。假设ARR是255,那么占空比有256个档位,对应8位分辨率;ARR是4095,就有4096个档位,对应12位分辨率。在3.3V系统里,8位分辨率每一步对应12.9mV,12位分辨率每一步对应0.8mV。这个0.8mV就是输出精度的下限,因为占空比本身只能按整格跳。
这里有一个绕不开的数学关系:PWM频率和分辨率是互相抢资源的。STM32F103定时器时钟72MHz,PWM频率的计算公式是:
f_PWM = 72MHz / ((PSC+1) × (ARR+1))
我用几组实际参数算给你看:
| ARR | PSC | PWM频率 | 分辨率 | 3.3V下步进 |
|---|---|---|---|---|
| 255 | 14 | 18.75kHz | 8位 | 12.9mV |
| 255 | 71 | 3.9kHz | 8位 | 12.9mV |
| 999 | 0 | 72kHz | 10位 | 3.2mV |
| 4095 | 0 | 17.58kHz | 12位 | 0.8mV |
如果既要12位分辨率,又要20kHz以上的PWM频率,在72MHz主频下会差一口气。所以实际项目里都是先定PWM频率的底线,再倒推分辨率能开到多少。比如开关频率20kHz是为了避开音频噪声,那ARR最多2000出头,分辨率就是11位,步进1.6mV,大多数场合够用了。
2.3 RC低通滤波为什么会留下直流分量
RC低通滤波的原理可以用一个生活化的类比:方波信号就像一个人快速地开关水龙头,水流一会儿大一会儿小,RC电路里的电容就像一个水箱,电阻是连接水管的水阻。水龙头开关太快的时候,水箱里的水位不会跟着剧烈波动,只会稳定在一个平均水位附近。开关越慢,水位波动越明显;水箱越大、水阻越大,水面越平稳。
数学上,一阶RC滤波器的截止频率是:
f_c = 1 / (2πRC)
截止频率的含义是:高于这个频率的信号会被衰减,低于这个频率的信号基本无损通过。PWM方波有一个基波频率(就是PWM频率)和无穷多个奇次谐波,这些高频成分落在截止频率以上的部分会被大幅衰减,剩下的直流分量就留下来了。但衰减是渐进的,不是直接从平直变没,所以实际输出总会有残余纹波,这就是为什么一阶RC往往不够用。
3. 给出一套能直接落地的滤波电路:一阶到二阶的演进
3.1 一阶RC的理想与骨感现实
很多人第一次做PWM DAC,搜到最简单的电路就是一个电阻串联一个电容对地,然后直接接出去用。能不能用?能,但精度很差。一阶RC的衰减速率是每十倍频程20dB,也就是频率提高10倍,信号被衰减10倍。假设PWM频率20kHz,一阶RC截止频率设到1kHz,20kHz处的衰减大概只有26dB,也就是输出信号幅度剩下5%左右。3.3V方波经过这样的滤波,理论上还有160mV左右的波动,实际测下来比这更大,根本没法当"高精度模拟输出"用。
如果把截止频率压低到100Hz呢?20kHz处衰减提升到46dB,纹波能降到20mV左右。但问题来了:截止频率压得越低,RC时间常数τ就越大,输出建立时间越长。一个简单的经验公式是输出稳定到5%以内需要大约3τ,稳定到1%需要5τ。RC=1/(2π×100)≈1.6ms,5τ建起来就要8ms,动态响应会明显变慢。一阶RC的困境就是:要么纹波大,要么响应慢,很难两全。
3.2 二阶RC的正确打开方式:缓冲隔离
解决思路是加滤波级数。二级RC的衰减斜率变成每十倍频程40dB,同样在20kHz处,如果每级截止频率都是1kHz,总衰减大约52dB,理论纹波不到8mV;如果每级截止频率在300Hz左右,20kHz处总衰减能到70dB以上,纹波轻松压到1mV以内,而响应速度和单级100Hz截止频率差不多。这个性价比明显高很多。
但二级RC有一个容易踩的坑:如果两个RC节直接串联,后面的RC会作为前面RC的负载,等效于拉低了第一级的截止频率,而且频率越高,效应越明显。整个电路的滤波曲线会变得很奇怪,不是理想的两级相乘。正确的做法是在两级RC之间加一个电压跟随器,把前后级隔离开。跟随器输出电压等于输入电压,不放大也不衰减,但它有很高的输入阻抗和很低的输出阻抗,正好解决阻抗匹配问题。
3.3 完整参考电路与参数计算
我目前常用的电路是这个结构:
STM32 PWM引脚 → R1 → C1对地 → 运放跟随器A → R2 → C2对地 → 运放跟随器B → 输出端
两个跟随器可以并到同一个运放芯片里,很多运放是双通道封装,比如LM358、TL072或者更精密一点的OPA2333。第二级跟随器是可选但强烈建议加的,因为如果没有它,负载阻抗会直接并联到C2上。比如负载是10kΩ,C2在1kHz时的阻抗约1.6kΩ,10kΩ并联1.6kΩ会明显改变滤波行为,输出电压还会被负载分压拉低。
我给一个具体参数,按20kHz PWM和12位分辨率设计:
- R1 = R2 = 4.7kΩ
- C1 = C2 = 100nF
- 单级截止频率:f_c = 1/(2π×4700×100×10^-9) ≈ 338Hz
20kHz处的理论总衰减大约75dB,3.3V方波的基波约2.1V,衰减75dB后约0.37mV。加上高次谐波的贡献,输出纹波大约1mV以内,对应12位分辨率的1.2个LSB,是可以接受的水平。这个电路的建立时间大约在5到10ms,适合做控制电压输出,不适合做快速动态信号。
4. 元件选型指南:从阻值到介质类型再到运放指标
4.1 电阻:别小看1%与50ppm这两个数字
滤波电阻的选择直接决定截止频率的准确性。在RC滤波里,截止频率与R和C的乘积成反比,电阻偏差5%,截止频率就偏差大约5%。如果两级电阻偏差方向不一致,整个滤波特性会偏离设计值。我建议至少选1%精度的金属膜电阻,这是市面上最常见、成本几乎可以忽略的选项。
更关键的是温漂。普通碳膜电阻的温漂可能到几百ppm/°C,温度从25°C变到85°C,阻值能漂好几个百分点,输出电压也跟着飘。金属膜电阻的温漂一般在50ppm/°C到100ppm/°C,50ppm算是不错的。你可以简单换算一下:50ppm/°C意味着温度每变化10°C,阻值变化0.05%,满量程3.3V下对应1.65mV的变化,这对于10位以上的精度来说必须纳入考量。
阻值范围同样需要权衡。电阻太大,比如100kΩ级别,后面即使接跟随器,跟随器的输入偏置电流也会在电阻上产生压降,造成输出偏移。电阻太小,比如100Ω级别,流过PWM引脚的电流会很大,虽然STM32推挽输出能抗住,但会增加引脚功耗和地弹噪声。4.7kΩ到10kΩ是我试下来比较舒服的范围。
4.2 电容:X7R还是C0G,区别比想象中大
滤波电容看起来不起眼,实际上对精度的影响比电阻还大。陶瓷电容有多个介质类型,最常见的X5R和X7R有一个隐藏缺陷:它们的电容量会随施加的直流偏置电压下降。给一个额定100nF的X7R电容加3V直流电压,实际容量可能只剩70%到80%。这意味着电容接到电路里以后,真实截止频率比设计值偏高不少,而且输出电压越高,容量掉得越多,直接影响输出精度和线性度。
滤波电路里优先选C0G/NP0介质的陶瓷电容,这种介质几乎没有偏置效应,容量随温度和电压都极其稳定。缺点是容量做不大,一般100nF以下没问题,再大的容量就要考虑薄膜电容了。薄膜电容,尤其是聚丙烯电容,容量稳定性和损耗特性都很好,唯一缺点是体积偏大。在PWM滤波这种低频场景里,C0G是目前性价比最高的选择。
电容精度也很重要。常见陶瓷电容用±5%甚至±10%精度,两级滤波的容量误差如果叠加,会让截止频率偏差10%以上。好在截止频率偏差不会对输出直流平均值造成直接影响,只是纹波抑制能力会有变化。如果你对纹波极其敏感,可以挑选容值偏差在±2%以内的电容,实际项目中这种情况比较少见。
4.3 运放:轨到轨、失调电压与偏置电流
运放选型有三个关键指标:输出是否轨到轨、输入失调电压Vos、输入偏置电流Ib。
STM32的PWM输出是0到3.3V,如果你想输出满量程范围,运放必须支持轨到轨输出。普通运放,比如LM358,线性输出范围大约比电源轨高出1V多,3.3V供电下最多输出到2V左右,满量程直接砍掉三分之一,根本不达标。至少要选输出轨到轨的运放,输入轨到轨是加分项。
失调电压会被电路直接叠到输出上。OPA333的失调电压只有10µV级别,MCP6001大概是±1mV。理论上全温范围内几百微伏的失调都不会太伤害输出精度,但如果选型不当用了5mV以上失调的运放,满量程3.3V的0.15%误差就直接没了。偏置电流则影响第一级滤波器的准确性,因为偏置电流会流过R1,在C1上产生额外的电压。对于跟随器结构,偏置电流造成的误差约等于Ib×R1,4.7kΩ的R1配合10pA级别的偏置电流,误差几乎可以忽略,但如果是普通双极性运放的几百nA偏置电流,就可能出现几十微伏的偏移。
4.4 布线层面的三个小经验
第一,滤波电容的接地端要尽量靠近单片机的地引脚,用独立过孔直接打到底层地平面,不要让滤波电流和分析电流走同一段地线。第二,PWM信号线不要从滤波电容旁边平行走过,尤其不要让数字信号线跨过模拟滤波区域,否则寄生耦合会引入额外噪声。第三,如果板子上同时有ADC和PWM DAC,把模拟输出部分和数字部分分区布线,中间用地线隔离带隔开。这些细节在低频下可能不明显,但当你把纹波要求压到1mV级别时,每一项都能在示波器上看到差别。
5. STM32固件侧配置:CubeMX参数设计与占空比无缝更新
5.1 CubeMX初始化PWM的完整参数换算
固件部分我从CubeMX的配置讲起。芯片选STM32F103C8T6为例,系统时钟先配到72MHz,然后打开你要用的定时器。通用定时器TIM2、TIM3、TIM4挂在APB1总线上,CubeMX配置时会自动把定时器时钟倍频到72MHz,不需要手动处理。
在定时器配置页面里,关键参数就两个:Prescaler(预分频PSC)和Counter Period(ARR)。我们目标PWM频率18.75kHz左右、8位分辨率(256档)的话,直接填PSC=14、ARR=255即可。如果你需要12位分辨率和17.6kHz频率,那就填PSC=0、ARR=4095。我一般这样记忆:计数器从0数到ARR需要(PSC+1)个时钟周期,这个数就是单个PWM周期的耗时,周期取倒数就是频率。
PWM模式选Mode 1。模式1的定义是:计数器值小于比较值时输出有效电平,大于比较值时输出无效电平。有效电平由Polarity参数决定,选High就是高电平有效。Pulse初始值可以随便填,反正启动代码里会改。引脚会自动映射到对应的复用功能,比如TIM3_CH1对应PA6,这个页面里能看到,确认没有和别的外设冲突就行。
5.2 输出任意电压值的代码实现
初始化完成后,改变占空比只需要一行HAL函数:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty);duty的取值范围是0到ARR,对应占空比0%到100%。把电压值换算成duty的代码我一般封装成一个小函数:
#define PWM_ARR 255 #define VREF 3.3f void pwm_dac_set_voltage(float volt) { uint32_t duty; if (volt <= 0.0f) { duty = 0; } else if (volt >= VREF) { duty = PWM_ARR; } else { duty = (uint32_t)(volt / VREF * (PWM_ARR + 1)); } __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty); }这个函数的核心思想是线性映射:电压值除以参考电压得到占空比比例,再乘上总的计数档位数。调用的时候直接传目标电压,比如pwm_dac_set_voltage(1.65f),输出的就是接近1.65V的模拟量。
5.3 更新占空比的注意点:影子寄存器与更新时序
STM32的定时器比较寄存器有影子寄存器机制,写入比较寄存器的新值不会立即生效,而是在下一次定时器计数周期更新事件到来时统一加载。这个设计的好处是占空比切换不会在PWM波形中间产生毛刺,坏处是你得知道更新发生的时机。
如果你的应用需要在固定时刻改变输出电压,可以在定时器更新中断里修改比较值。更新中断的频率就等于PWM频率,18.75kHz的中断密度对M3内核来说压力不大,但如果在中断里做了太多事情,会影响其他任务的实时性。更省心的做法是:在普通任务代码里直接调用__HAL_TIM_SET_COMPARE,因为影子寄存器机制已经保证了更新不产生毛刺,你唯一需要注意的就是更新值会延迟最多一个PWM周期才生效。对于RC滤波后的模拟输出,这个延迟完全无感。
6. 实测复盘与校准:纹波、线性度和动态响应
6.1 测试仪器使用技巧与实测数据
电路焊好以后,第一个坑往往出现在测试方法上。万用表的直流档测的是平均值,它测出来的电压看着很干净,但完全反映不了纹波。纹波必须用示波器看,而且要切到AC耦合挡,把直流量隔掉,再把垂直档位调到10mV或者5mV每格才能看清。如果你用DC耦合看1V左右的直流电平,纹波1mV在屏上会被压缩成一条粗线,根本看不出细节。
我拿前面给的电路参数实测了一组数据,PWM频率18.75kHz,两级RC加双跟随器,12位分辨率配置:
| 目标电压 | 实测电压 | 偏差 |
|---|---|---|
| 0.330V | 0.328V | -2mV |
| 1.000V | 0.997V | -3mV |
| 1.650V | 1.648V | -2mV |
| 2.500V | 2.501V | +1mV |
| 3.000V | 3.002V | +2mV |
这个偏差里包含了一部分万用表本身的误差,但整体来看,误差在±3mV以内,和设计预期吻合。示波器上看到的残余纹波大约0.8mV左右,12位分辨率下大概一个LSB,肉眼已经很难从示波器上分辨出来。
6.2 两点校准法提升输出精度
如果你对绝对精度不满意,可以用两点校准把系统误差消掉。系统误差主要来自电源电压不是精确的3.3V、电阻分压偏差、运放失调这些固定因素。校准方法很简单:程序里先输出一个低电压V_low(比如0.5V),用万用表测到实际值V_meas_low;再输出一个高电压V_high(比如2.5V),测到实际值V_meas_high。
然后算出增益校正系数k和偏移b:
k = (V_meas_high - V_meas_low) / (V_high - V_low) b = V_meas_low - k × V_low
实际输出时,把目标电压先按反函数修正:
V_corrected = (V_target - b) / k
再把V_corrected送进占空比换算函数。经过两点校准以后,我实测全量程误差可以压到±1mV以内,这个精度对大多数模拟控制场景已经完全够用了。
6.3 建立时间到底有多大影响
RC滤波的代价是动态响应慢。测试建立时间的方法是程序让输出电压从0.3V跳到3.0V,用示波器看输出从10%上升到90%需要多久。我实测这个电路从0.3V到3.0V的上升时间大概是4ms左右,稳定到最终值的1%以内需要大约8ms。
这个延迟对LED调光、阀门控制、压控振荡器调频这些场景来说几乎可以忽略,但如果你在做一个需要快速上下扫频的信号源,就要考虑PWM DAC的响应是否能跟上扫描速率。我的建议是:输出电压变化幅度大且要求快速稳定时,可以把截止频率往高调,牺牲一点纹波换响应速度;变化缓慢时,优先保证低纹波。
6.4 我遇到的三个坑及排查思路
第一个坑是输出一直为某个固定电压不随占空比变化。查了半个小时发现定时器时钟没打开,CubeMX里配置了PWM通道但没勾选中断或没启动定时器。HAL_TIM_PWM_Start必须显式调用,很多人包括我自己在初始化里容易漏掉这一行。
第二个坑是实测纹波远大于理论计算值。用示波器看才发现电源本身有几十毫伏的开关噪声,直接从VCC耦合进了电路。解决办法是在靠近运放电源引脚的地方加一个100nF的陶瓷去耦电容,同时把PWM输出到RC电路的走线缩短。这个坑让我明白了一个道理:电路里所有"看起来没问题"的环节都可能成为噪声入口。
第三个坑是带负载以后输出电压明显下降。给一个2kΩ的负载接上去,输出下降了几十毫伏。原因是我的第二级没有加缓冲跟随器,负载直接并联到了C2上,等于给滤波电容增加了一个放电回路。后来补上加一级跟随器就好了。如果你确定要省掉第二级跟随器,至少要把负载阻抗控制在100kΩ以上,并且接受输出精度下降的事实。