STM32无DAC怎么办?用PWM+RC滤波实现高精度模拟电压输出
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总线在多路输出场景下刷新率会越来越紧张。PWMRC方案的好处是几乎零成本每个定时器通道都是一路模拟输出单片机引脚够用就能无限扩展特别适合输出慢慢变化的控制电压。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 × DD的取值范围是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.9mV12位分辨率每一步对应0.8mV。这个0.8mV就是输出精度的下限因为占空比本身只能按整格跳。这里有一个绕不开的数学关系PWM频率和分辨率是互相抢资源的。STM32F103定时器时钟72MHzPWM频率的计算公式是f_PWM 72MHz / ((PSC1) × (ARR1))我用几组实际参数算给你看ARRPSCPWM频率分辨率3.3V下步进2551418.75kHz8位12.9mV255713.9kHz8位12.9mV999072kHz10位3.2mV4095017.58kHz12位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截止频率设到1kHz20kHz处的衰减大概只有26dB也就是输出信号幅度剩下5%左右。3.3V方波经过这样的滤波理论上还有160mV左右的波动实际测下来比这更大根本没法当高精度模拟输出用。如果把截止频率压低到100Hz呢20kHz处衰减提升到46dB纹波能降到20mV左右。但问题来了截止频率压得越低RC时间常数τ就越大输出建立时间越长。一个简单的经验公式是输出稳定到5%以内需要大约3τ稳定到1%需要5τ。RC1/(2π×100)≈1.6ms5τ建起来就要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) ≈ 338Hz20kHz处的理论总衰减大约75dB3.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/°C50ppm算是不错的。你可以简单换算一下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×R14.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 PeriodARR。我们目标PWM频率18.75kHz左右、8位分辨率256档的话直接填PSC14、ARR255即可。如果你需要12位分辨率和17.6kHz频率那就填PSC0、ARR4095。我一般这样记忆计数器从0数到ARR需要(PSC1)个时钟周期这个数就是单个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.330V0.328V-2mV1.000V0.997V-3mV1.650V1.648V-2mV2.500V2.501V1mV3.000V3.002V2mV这个偏差里包含了一部分万用表本身的误差但整体来看误差在±3mV以内和设计预期吻合。示波器上看到的残余纹波大约0.8mV左右12位分辨率下大概一个LSB肉眼已经很难从示波器上分辨出来。6.2 两点校准法提升输出精度如果你对绝对精度不满意可以用两点校准把系统误差消掉。系统误差主要来自电源电压不是精确的3.3V、电阻分压偏差、运放失调这些固定因素。校准方法很简单程序里先输出一个低电压V_low比如0.5V用万用表测到实际值V_meas_low再输出一个高电压V_high比如2.5V测到实际值V_meas_high。然后算出增益校正系数k和偏移bk (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Ω以上并且接受输出精度下降的事实。