
简介基于STM32的多路PWM模拟DAC输出电压工程包聚焦PWM脉宽调制技术在高精度多通道电压输出场景的应用。工程基于STM32与Keil5环境开发利用12路PWM通道通过调节占空比生成最高3.2V的可变电压可用于LED调光、电机调速、模拟量输出等嵌入式开发实践。资源共530个文件压缩包约7.95MB包含134个h头文件与115个c源文件等完整工程代码以及uvprojx工程文件、hex/axf编译下载文件、s启动汇编与lst列表文件可直接在Keil5下打开、编译并烧录验证readme与pdf文档补充了使用说明和原理介绍。目前已有612人学习浏览。配套源码结构清晰可快速理解多路PWM通道的初始化配置、占空比调整与模拟电压输出逻辑对正在学习STM32定时器/PWM外设、想掌握软件模拟DAC方法的开发者具有直接参考价值。 多路PWM模拟DAC输出电压这事儿我在不少项目里都遇到过。MCU自带的DAC通道通常就那么一两个碰上需要输出好几路可调电压的场景比如程控增益、偏置调节、多路基准源要么外挂DAC芯片要么就得想办法用现有的PWM资源硬凑。这篇文章我就把这套“多路PWM模拟DAC”的方案完整拆开讲从原理、滤波计算到STM32上的配置代码、标定流程再到我实际踩过的坑一次性说清楚。适合手头有MCU开发板、想在不出钱买DAC芯片的情况下解决多路模拟输出的朋友参考。1. 为什么用多路PWM去模拟一个DAC1.1 什么场景适合这种方案先捋一下需求。很多嵌入式项目里“输出电压”不一定要求高精度、高带宽只要是能调的直流电平就行。比如给运放提供偏置电压实现零点调节控制VCO的调谐电压改变输出频率做可调比较器阈值多路LED亮度控制虽然这个一般用PWM直驱但某些需要模拟调光信号给驱动芯片的场景就得转成直流这类需求电压范围可能就是0~3.3V或者0~5V精度要求1%左右更新速度几毫秒一次就够。这时候用PWM加RC低通滤波变成直流电平成本几乎为零纯粹靠MCU的定时器资源就能实现。我见过有人一上来就纠结“PWM模拟DAC不干净不如买DAC芯片”但在实际产品里能用一颗几毛钱的MCU解决的事情硬要加一颗几块钱的DAC芯片成本和PCB面积都被浪费了。方案选型永远是看需求边界而不是看参数表谁好看。1.2 和真DAC对比能省什么、要付出什么代价直接列个对比大家心里有数维度多路PWM模拟DAC外置真DAC芯片硬件成本几乎为零几个电阻电容芯片本身几块到几十块不等通道数扩展取决于定时器/引脚资源容易扩展每路都要一颗芯片或选多通道型号输出建立时间慢RC时间常数决定ms级常见快us级甚至更快纹波/噪声RC滤波后仍有残余纹波需权衡由芯片指标决定通常很干净精度受限于PWM分辨率、RC稳定性和温漂由DAC位数和基准决定软件复杂度需要维护占空比映射和补偿表写寄存器就行一句话总结PWM模拟DAC是用时间换硬件。如果系统本身对输出精度要求不是特别苛刻更新速度也不快这招非常香。反过来如果要做音频波形输出或者高精度可调电源老老实实上真DAC别折腾PWM方案。2. PWM转DAC的核心原理占空比、滤波和纹波2.1 PWM信号怎么看PWM输出本质上是数字方波只有高电平和低电平两个状态。它的平均值等于V_avg V_H × D其中D是占空比取0到1。如果高电平是3.3V占空比50%平均值就是1.65V。这个公式是整个方案的基础。但平均值不等于物理输出。直接把PWM引脚接负载你测到的是方波不是直流。要拿到接近直流的电压必须经过低通滤波器把方波里高频分量滤掉。滤波器截止频率要远低于PWM频率同时要保证负载不影响滤波特性后面会详细算。2.2 低通滤波器和RC参数计算最常用的一阶RC低通便宜、简单。关键参数就俩R和C。截止频率f_c 1 / (2π × R × C)设计原则有两个截止频率要远低于PWM频率。一般取PWM频率的1/10到1/100。低于这个范围纹波更小但响应变慢。输出端负载阻抗要高。RC滤波后的输出节点如果有负载电阻会跟C形成分压导致电压误差。举个例子。PWM频率20kHz想做到纹波尽可能小同时建立时间接受取f_c 200Hz。若R 10kΩ则C 1 / (2π × 10kΩ × 200Hz) ≈ 79.6nF取标称值100nF的话实际f_c ≈ 159Hz。此时10kΩ电阻和100nF电容的时间常数τ 1ms。从占空比变化到输出稳定到63.2%需要1ms稳定到99%需要约4.6ms也就是约4.6ms。这个响应速度对前面说的偏置调节场景完全够用。如果想要响应更快同时保持低纹波可以加一级有源二阶滤波或者用多个RC级联。不过对于大多数应用一阶滤波就够用了。2.3 关于“多路”的取舍独立通道还是加权合并标题里写的是“多路PWM模拟DAC”这个词有两种理解多个PWM通道每一路都各自滤波输出一个独立可调的模拟电压多路PWM信号通过电阻网络加权合并成一路更高分辨率的DAC第二种方案业内也叫PWM加权DAC用多位PWM叠加出高分辨率输出。比如3个PWM通道分别对应1/2、1/4、1/8的权重电阻比例做到2:1就能叠加出三位的效果。但实际操作中非常痛苦因为电阻精度、温漂、PWM相位不一致都会引入误差精调起来相当费劲。我自己的建议是老老实实走第一种一路PWM对应一路独立模拟输出。多路的意义在于扩展通道数量而不是强行提高单路分辨率。单路分辨率想要提高用MCU定时器的更高位计数器配合低频PWM就能解决没必要把多路的精度搞到一起。3. 实操设计以STM32为例搭建多路PWM模拟DAC3.1 硬件连接与滤波电路我以STM32F103ZET6为例这个芯片定时器资源多TIM1到TIM8加基本定时器随便挤出来4路PWM很轻松。硬件连接很简单PWM引脚 —— 10kΩ电阻 —— 节点A —— 100nF电容 —— GND节点A就是模拟输出。如果后面接的负载阻抗不够高比如小于100kΩ建议在节点A后面加一个电压跟随器用运放做阻抗变换。这里踩过的一个坑是RC滤波电阻不敢选太大。曾经我为了减小电流把R选到100kΩ结果输出阻抗特别高后续接了一个100kΩ对地的测量电阻电压直接掉了将近一半。后来才反应过来输出阻抗必须远小于负载阻抗否则你看到的电压就是分压结果而不是PWM平均值。3.2 TIM配置与占空比计算以TIM3为例配置四路PWM输出。PWM频率选择要综合分辨率。STM32F103的TIM3挂在APB1上时钟72MHz。设置预分频PSC71则计数器时钟为1MHz。自动重载值ARR设成999则PWM频率为f_PWM 1MHz / (999 1) 1kHz占空比分辨率是1/1000也就是大约相当于10位DAC。如果觉得1000分之一不够用可以把ARR调到3999PWM频率250Hz分辨率提升到4000分之一约12位。代价是滤波截止频率要相应降低否则纹波明显。代码示例基于标准外设库的写法用寄存器也能达到同样效果// 初始化GPIO将PA6、PA7、PB0、PB1配置为复用推挽输出 GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_TIM3, DISABLE); // 默认映射即可不重映射 GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // PA6对应TIM3_CH1, PA7对应TIM3_CH2, PB0对应TIM3_CH3, PB1对应TIM3_CH4 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_Init(GPIOB, GPIO_InitStructure); // 配置TIM3 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Prescaler 71; // 72MHz / 72 1MHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 999; // PWM频率1kHz分辨率1000 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 配置四个通道为PWM1模式 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; // 初值占空比0% TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_OC2Init(TIM3, TIM_OCInitStructure); TIM_OC3Init(TIM3, TIM_OCInitStructure); TIM_OC4Init(TIM3, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC4PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE);然后改变输出电压只需要设置比较寄存器TIM_SetCompare1(TIM3, 500); // 占空比50%理论输出1.65V TIM_SetCompare2(TIM3, 250); // 占空比25%理论输出0.825V值得注意的是TIM_SetCompare函数的参数是0到ARR之间的值。如果你用的是自动重载值和PSC的配置占空比就是CCR除以ARR。算好输出目标电压对应的CCR值往下写就行。3.3 输出标定和精度提升理论上公式很简单V_out (CCR / ARR) × V_ref。但实际测量总会偏离一点原因是引脚输出高电平并不是精确的3.3V而是略低一点大概3.25V到3.29VRC电阻有误差电容漏电流也会影响最终电压负载阻抗带来的分压效应所以实际项目里我一般会做两点标定。方法设置占空比0%和99%两个点用万用表读取实际输出然后做线性映射。代码里把目标电压换算成实际需要写入的CCR值。比如实测0%输出是2mV不完全到0因为引脚内部有上拉/下拉干扰99%输出是3.26V那实际斜率就是(3.26V - 0.002V) / 0.99 3.29V左右。你可以把这些校准数据存到Flash里上电自动加载。如果对精度要求更高还可以做多点标定比如10%、50%、90%三个点用分段线性插值修正非线性。对于RC滤波PWM的方案非线性主要来自RC时间常数变化做三点标定基本就够了。4. 多路扩展中的常见坑4.1 100%占空比异常这一点我必须单独列出来。STM32定时器输出PWM时当CCR设置等于ARR1或者某些特殊值时输出不一定保持高电平而可能表现为窄脉冲甚至反相这取决于定时器的计数器模式和输出极性配置。某些芯片在计数器溢出瞬间如果CCR刚好等于ARR占空比输出会异常。这个在STM32F103上反而是小概率但在其他系列上常见。解决方法是设置占空比时不要直接让CCR等于ARR或0而是用ARR-1表示最高占空比用1表示最低占空比。或者使用比较模式时确保CCR的范围在1到ARR-1之间。极端情况下如果确实需要0%或100%输出直接控制GPIO电平切换不要依赖PWM外设。这个逻辑我在多个项目中反复检验90%的“占空比不对”问题都可以用这个办法绕开。4.2 PWM故障保护与停振热词里反复出现“PWM故障保护”。其实这是定时器刹车Break功能在作怪。STM32高级定时器TIM1和TIM8有刹车输入功能一旦刹车信号触发PWM输出会被强制拉到安全电平。如果你初始化了高级定时器但没有正确配置刹车功能哪怕刹车引脚悬空也可能因为噪声误触发导致输出异常。对于多路模拟DAC建议直接用通用定时器TIM2、TIM3、TIM4这些没有刹车功能的定时器能省掉很多麻烦。如果非要用TIM1/TIM8必须显式配置TIM_BDTRInitTypeDef TIM_BDTRInitStructure; TIM_BDTRInitStructure.TIM_OSSRState TIM_OSSRState_Enable; TIM_BDTRInitStructure.TIM_OSSIState TIM_OSSIState_Enable; TIM_BDTRInitStructure.TIM_LOCKLevel TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime 0; TIM_BDTRInitStructure.TIM_Break TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, TIM_BDTRInitStructure); TIM_CtrlPWMOutputs(TIM1, ENABLE);很多新人卡在高级定时器上输出PWM没有波形就是因为漏了最后那一行TIM_CtrlPWMOutputs(TIM1, ENABLE)这是高级定时器特有的主输出使能不打开的话引脚一直是高阻或无效状态。4.3 死区配置的注意点热词里提到28335配置PWM死区DBFED和DBRED这是TI DSP里C2000系列的死区配置。虽然DSP和STM32是不同平台但死区的原理是相通的死区是为了防止上下桥臂同时导通而插入的一段延迟。我在做电机驱动时配置过F28335的PWM死区这里提醒一点——如果你只是想模拟DAC输出千万不要开死区功能。死区会让PWM的有效占空比变小而且不是简单的线性偏移会导致输出电压与理论值不符。就算你是做电机控制也要先搞清楚DBFED死区上升沿延迟和DBRED死区下降沿延迟各自的作用。如果设置不当可能导致输出波形明显畸变甚至引起桥臂直通。我当时调28335死区本来以为只是延迟几微秒的事实际上从配置寄存器到示波器验证花了整整一个下午。核心教训是先确认极性选择再配时间值最后看波形确认。4.4 波形查看与滤波前后对比调试PWM模拟DAC示波器是必备工具。我常用的检查流程是直接看PWM引脚波形确认频率和占空比正确在RC滤波节点看直流电平同时观察纹波幅度切换占空比观察输出建立时间和是否有过冲如果你用Keil5调试STM32可以用逻辑分析仪窗口或者MDK的波形仿真功能看PWM输出但实际效果不如真示波器。我早期偷懒用仿真看波形结果相位和时序全对一上实物就出问题追问原因才发现仿真里的寄存器初值跟实际硬件有差异。滤波前后波形对比很重要。单纯PWM引脚上的方波再漂亮也不代表模拟输出就干净。我用示波器在RC节点看到的纹波一般在10mV到50mV之间视滤波截止频率而定如果纹波超出预期优先检查滤波电容是否靠近输出节点、地线是否走好。4.5 开关量PWM恒温控制的思路热词里有“开关量PWM恒温控制”。这是个有意思的应用用PWM控制加热器通过调节占空比来改变平均加热功率本质上和模拟DAC是一个思路——用时间换模拟量。区别在于被控对象是功率而不是电压。这种场景下PWM频率不能太高也不能太低。太高了加热器开关损耗增大太低了温度波动明显。我一般用0.5Hz到10Hz之间的频率配合温度传感器做PID闭环控制输出的就是不断调整的PWM占空比相当于一个动态变化的“模拟DAC”。如果你已经理解了前面讲的PWM平均值原理这个应用就很自然了。5. 变通应用与心得5.1 从呼吸灯、舵机到RC电路搜索热词里提到“用STM32F103ZET6的TIM3制作呼吸灯从引脚重映射到PWM占空比调节”这个我实测过。呼吸灯无非就是让LED亮度呈正弦变化也就是PWM占空比按正弦曲线变化。用一个定时器更新占空比每次更新后延迟一小段时间1000个等级从0变到999再反向变回来就形成了渐变效果。引脚重映射这个点要注意如果TIM3的默认引脚PA6、PA7被其他功能占用可以用GPIO_PinRemapConfig(GPIO_PartialRemap_TIM3, ENABLE)把引脚映射到PB4、PB5之类的位置。舵机控制也是PWM占空比的经典应用。舵机需要的是周期20ms50Hz、脉冲宽度1ms到2ms的PWM信号。注意这里我们关心的是脉冲宽度而不是占空比百分比虽然本质一样但算起来更直观。拿STM32来说设置PSC和ARR让PWM周期精确为20ms然后CCR的值在1000到2000之间变化对应0度到180度。RC电路就更常用了。PWM信号直接驱动RC积分电路本质就是低通滤波器跟我们的模拟DAC原理完全一致。在做一些简单的RC延时或波形整形时PWM频率、电阻电容三者要匹配否则RC充放电还没完成PWM就已经切到下一个周期输出会一直在某个中间值附近波动。5.2 我踩过的几个坑和心得最后分享几个实操中容易翻车的点。第一多路PWM的相位同步问题。如果你把两路PWM引脚的输出同时滤波并且要求两者之间电压差恒定那么两路PWM最好由同一个定时器产生如TIM3的四个通道因为它们共用同一个计数器相位严格同步。如果用两个定时器相位差可能会导致毛刺和误差。第二电源去耦不能省。PWM输出瞬间会有电流变化如果MCU电源引脚上的去耦电容不足PWM切换瞬间会在模拟输出上产生毛刺严重影响纹波指标。我一般在每个PWM输出引脚附近放一个100nF电容模拟输出节点再接一个10uF电解电容。第三批量一致性。RC元件的精度直接决定输出电压的一致性。如果电阻电容精度只有5%输出会偏差比较大。要想一致性更好电阻选0.1%精度电容选C0G材质的成本也就多几毛钱值得。这个方案后期可以扩展的方向也很多。比如把RC滤波换成运放构成的二阶低通滤波器纹波可以压到很低或者把多路PWM输出配合模拟开关实现可切换的DAC通道矩阵。我自己最近的板子上就是把6路PWM模拟DAC用在了电源模块的多个偏置引脚上配合一个简单校准程序精度做到了千分之五以内已经完全替代了原来的DAC芯片方案。本文还有配套的精品资源点击获取