简介:基于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去模拟一个DAC
1.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模拟DAC
3.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。设置预分频PSC=71,则计数器时钟为1MHz。自动重载值ARR设成999,则PWM频率为:
f_PWM = 1MHz / (999 + 1) = 1kHz
占空比分辨率是1/1000,也就是大约相当于10位DAC。如果觉得1000分之一不够用,可以把ARR调到3999(PWM频率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.29V
- RC电阻有误差,电容漏电流也会影响最终电压
- 负载阻抗带来的分压效应
所以实际项目里我一般会做两点标定。方法:设置占空比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设置等于ARR+1或者某些特殊值时,输出不一定保持高电平,而可能表现为窄脉冲甚至反相,这取决于定时器的计数器模式和输出极性配置。某些芯片在计数器溢出瞬间,如果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占空比的经典应用。舵机需要的是周期20ms(50Hz)、脉冲宽度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芯片方案。
本文还有配套的精品资源,点击获取