8位PWM不够用?4位扩展法实现12位分辨率
1. 从一个尴尬的工程需求说起前阵子帮朋友调一个温控项目主控用的是某款八位单片机资源紧得可怜。项目要求输出一路可调占空比的PWM去驱动加热丝占空比调节精度要能到千分之一级别也就是大约10位分辨率。问题来了这颗片子的硬件PWM模块只有8位理论分辨率256级占空比步进接近0.4%在低温段控温时波动肉眼可见加热丝电流抖动明显温度曲线毛刺大。换芯片成本直接翻倍而且板子已经打样了。加专用PWM芯片又是一笔BOM开销还要多占两个IO口。当时我盯着这颗八位机发呆突然想到一个老掉牙但极其好用的技巧——用两个IO口做加权叠加把8位硬件PWM扩展成12位甚至更高。这就是标题里说的“4位扩展法”本质上是用低位PWM去调制高位PWM的“时间平均”靠两个不同权重的PWM通道叠加把分辨率硬生生拉高4个比特。这套方法在低成本嵌入式圈子里流传很久了但网上讲得都比较零散要么只给个公式要么代码写得云里雾里。我把它整理成一篇完整的工程实践记录从原理推导、参数计算、代码实现到实测波形和踩坑经验全部摊开讲。如果你手头也有那种“硬件PWM位数不够、又不想加钱换芯片”的场景这篇内容应该能直接抄作业。2. 4位扩展法到底在扩展什么2.1 硬件PWM的分辨率天花板在哪先把这个问题的本质说清楚。一个N位的硬件PWM它的占空比只能取 0/2^N、1/2^N、2/2^N …… 一直到 2^N/2^N 这些离散值。8位就是256个档位最小步进是 1/256 ≈ 0.39%。你想输出一个 0.1% 的占空比硬件根本表达不出来它只能给你0或者0.39%中间那段是空的。那为什么不能直接把定时器的自动重装载值调大因为硬件PWM的位数是由定时器计数器位宽决定的8位定时器最大计数值就是255你没法让它数到4095。除非换16位定时器但很多低端MCU的PWM就是8位的这是硬件限制改不了。所以思路必须转向“软件层面做文章”。既然单个通道的分辨率不够那就用两个通道叠加让它们各自承担一部分精度。2.2 加权叠加的核心思想4位扩展法的核心可以用一句话概括用一个高4位的粗调PWM加上一个低4位的微调PWM两者按权重相加等效出一个12位的PWM。具体怎么理解假设我们要输出一个12位的占空比目标值 D范围0到4095。把它拆成两部分高8位部分D_high D 4范围0到255直接喂给硬件PWM通道A低4位部分D_low D 0x0F范围0到15喂给另一个通道B但这两个通道不能简单相加因为它们的权重不同。通道A每变化1代表总占空比变化 16/4096通道B每变化1代表总占空比变化 1/4096。也就是说通道B的一个步进只相当于通道A一个步进的1/16。那怎么让通道B的“小步进”生效答案是时间平均。让通道B在一个较长的时间窗口内以一定的密度输出高电平这个密度就代表了那低4位的值。比如低4位是8就让通道B在16个周期里有8个周期输出高电平平均下来就贡献了 8/16 个通道A步进的权重。这里有个关键点两个通道的输出必须经过某种形式的叠加。常见做法有两种一种是用两个电阻做加权求和模拟叠加另一种是纯数字方式——两个IO口分别驱动靠外部RC滤波或者负载本身的惯性做平均数字叠加。前者精度高但需要额外元件后者零成本但对负载特性有要求。2.3 为什么偏偏是“4位”你可能会问为什么扩展4位而不是2位、8位这其实是个工程折中。扩展位数越多低位的PWM频率就越低。因为低位通道需要在 2^4 16 个高位周期内完成一次完整的密度调制如果高位PWM频率是1kHz那低位调制频率就只有 1000/16 62.5Hz。这个频率如果太低经过RC滤波后纹波会很大或者驱动电机时会有可听噪声。扩展4位是个比较甜的点分辨率提升16倍8位变12位低位频率还有高位频率的1/16大多数场景下都能接受。如果扩展到8位低位频率只有高位的1/2561kHz进去出来就剩4Hz滤波电容得大到离谱响应速度也没法看了。所以4位扩展法本质上是在分辨率、频率、成本三者之间找的一个平衡点。这也是它在低成本方案里特别受欢迎的原因。3. 原理拆解从数学到电路3.1 占空比合成的数学推导我们把整个过程用数学语言描述一遍这样后面写代码的时候心里有数。设目标12位占空比值为 DD ∈ [0, 4095]。硬件PWM周期为 T对应8位分辨率。我们把D分解为D D_high × 16 D_low其中 D_high D 4 ∈ [0, 255]D_low D 0x0F ∈ [0, 15]。通道A粗调的占空比设为 D_high / 256它在每个周期T内输出高电平时间为t_A T × D_high / 256通道B微调不能每个周期都输出 D_low/16 的占空比因为硬件只有8位表达不了这么细。所以我们采用脉冲密度调制的思路在连续的16个周期T内让通道B有 D_low 个周期输出满高电平其余 16 - D_low 个周期输出低电平。这样在16T的时间窗口内通道B贡献的高电平总时间为t_B_total D_low × T平均到每个周期通道B贡献的有效高电平时间为t_B_avg D_low × T / 16两个通道叠加后的等效高电平时间t_total t_A t_B_avg T × (D_high / 256 D_low / 4096)而理想的12位占空比应该是t_ideal T × D / 4096 T × (D_high × 16 D_low) / 4096 T × (D_high / 256 D_low / 4096)两者完全相等。数学上严丝合缝这就是4位扩展法的理论依据。3.2 两种叠加方式的电路实现数学上成立不代表电路上能直接实现关键在“怎么把两个通道加起来”。这里有两种主流方案各有适用场景。方案一电阻加权求和模拟叠加两个IO口各串一个电阻然后接到同一个节点。通道A的电阻设为R通道B的电阻设为16R。这样通道B的高电平对节点的贡献只有通道A的1/16天然实现了权重分配。节点后面接RC低通滤波把PWM变成模拟电压。这个方案的优点是叠加是“瞬时”的不依赖时间窗口响应快纹波小。缺点是电阻精度要求高16R那个电阻如果误差1%整个低4位就偏了。而且两个IO口的驱动能力要匹配否则高电平电压不一致也会引入误差。方案二数字时间平均软件叠加两个IO口分别独立输出不直接电气连接。通道A驱动主负载通道B通过一个较大的RC滤波后叠加到反馈节点或者直接并联驱动。更常见的做法是两个IO口接同一个负载但通道B的驱动能力被硬件限制比如串一个大电阻靠负载和线路的惯性做平均。这个方案零额外成本但要求负载有低通特性。驱动加热丝、LED、电机这类惯性负载很合适驱动需要快速响应的信号就不行。我这次温控项目用的是方案二因为加热丝本身就是个大惯性负载热时间常数几十秒62.5Hz的调制频率对它来说完全是“直流”。如果是驱动音频或者通信信号就必须上方案一。3.3 低位调制的频率与纹波权衡低位调制频率 f_low f_high / 16这个频率决定了叠加后信号的纹波大小。以RC滤波为例一阶RC的纹波峰峰值大约为V_ripple ≈ V_cc × D × (1 - D) / (f_low × R × C)假设 V_cc 3.3VD 0.5f_low 62.5HzR 10kΩC 10μF算下来纹波约 0.66mV完全可以接受。但如果 f_low 降到 4Hz扩展8位的情况同样参数下纹波就变成 10mV 级别对于12位精度3.3V/4096 ≈ 0.8mV来说就太大了。所以选4位扩展不是拍脑袋是算出来的。你可以根据自己项目的纹波容忍度和滤波成本反推能接受的最低 f_low再决定扩展位数。4. 代码实现从寄存器到可复用模块4.1 硬件层配置要点先说要配置哪些硬件资源。以常见的八位单片机为例需要一个8位定时器工作在PWM模式输出到通道A的IO口一个通用定时器或者同一个定时器的另一个通道用于产生通道B的周期中断两个IO口分别对应通道A和通道B通道A的PWM频率设为 f_high比如1kHz。通道B不需要硬件PWM它只需要在每次通道A周期结束时根据一个16状态的计数器决定下一个周期输出高还是低。所以通道B可以用普通的GPIO配合定时器中断来翻转。这里有个细节通道B的翻转必须和通道A的周期同步。如果不同步低位调制的16个周期窗口会和高位PWM周期错位导致叠加结果出现周期性误差。做法是在通道A的每个周期结束中断里更新通道B的状态。4.2 核心代码结构下面是我实际用的代码框架用C语言写的移植性比较好。核心是一个结构体加两个函数。typedef struct { uint16_t target; // 12位目标占空比 0-4095 uint8_t phase; // 低位调制相位 0-15 uint8_t ch_a_pin; // 通道A引脚 uint8_t ch_b_pin; // 通道B引脚 } pwm12_t; // 每个高位PWM周期调用一次放在周期中断里 void pwm12_update(pwm12_t *pwm) { uint8_t d_high pwm-target 4; // 高8位 uint8_t d_low pwm-target 0x0F; // 低4位 // 更新通道A的硬件PWM比较值 set_pwm_compare(d_high); // 通道B在16个周期内前d_low个周期输出高其余输出低 if (pwm-phase d_low) { set_gpio_high(pwm-ch_b_pin); } else { set_gpio_low(pwm-ch_b_pin); } // 相位推进16周期循环 pwm-phase (pwm-phase 1) 0x0F; } // 设置目标占空比 void pwm12_set(pwm12_t *pwm, uint16_t value) { if (value 4095) value 4095; pwm-target value; }这段代码的关键在pwm12_update里的相位逻辑。phase从0到15循环当phase d_low时通道B输出高。这样在16个周期里通道B恰好有d_low个周期是高电平实现了脉冲密度调制。4.3 参数计算与初始化初始化的时候要算几个参数。假设系统时钟是16MHz想要通道A的PWM频率为1kHz8位分辨率那么定时器周期寄存器值period F_cpu / (F_pwm × prescaler) - 1取 prescaler 64则 period 16000000 / (1000 × 64) - 1 249。正好在8位范围内。通道B的调制频率就是 1000 / 16 62.5Hz这个不需要配置硬件由软件相位计数器自然产生。初始化代码void pwm12_init(pwm12_t *pwm, uint8_t pin_a, uint8_t pin_b) { pwm-target 0; pwm-phase 0; pwm-ch_a_pin pin_a; pwm-ch_b_pin pin_b; // 配置通道A为硬件PWM输出 config_pwm_output(pin_a, 249, 64); // 配置通道B为普通推挽输出 config_gpio_output(pin_b); // 使能PWM周期中断 enable_pwm_period_interrupt(); }注意通道B的IO口一定要配置成推挽输出如果是开漏输出高电平需要外部上拉上升沿会变缓影响低位调制的精度。4.4 中断服务函数的写法中断服务函数是整个实现里最容易出问题的地方。它必须足够快不能有浮点运算不能有函数调用开销大的操作。void pwm_period_isr(void) { pwm12_update(g_pwm); clear_pwm_interrupt_flag(); }就这两行。pwm12_update里面全是位运算和GPIO操作执行时间在几十个时钟周期以内对1kHz的中断频率来说毫无压力。如果你用的是那种中断响应很慢的单片机可以把pwm12_update的逻辑直接展开写在ISR里省掉函数调用的开销。我实测过展开后ISR执行时间从约80个周期降到约50个周期对于高频PWM场景还是有意义的。5. 实测波形与精度验证5.1 测试平台搭建验证这套方法是否真的有效我用了一个简单的测试平台单片机输出两路PWM经过RC滤波后接到示波器上同时用高精度万用表测量滤波后的直流电压。测试参数系统时钟16MHz通道A PWM频率1kHz8位通道B调制频率62.5HzRC滤波R 10kΩC 10μF截止频率约1.6Hz供电3.300V用基准源校准过5.2 关键测试点数据我选了四个有代表性的目标值来测试覆盖低位边界和高位边界目标值(12位)理论占空比理论电压(mV)实测电压(mV)误差(mV)10.0244%0.8050.90.095160.3906%12.8912.8-0.09204850.000%1650.01649.2-0.8409599.976%3299.23298.5-0.7从数据看误差在1mV以内对应12位精度0.8mV/LSB来说基本做到了±1LSB的精度。这个结果比我预期的好主要归功于RC滤波的参数选得比较合适纹波被压得很低。5.3 波形细节观察用示波器看滤波前的波形能看到通道A是标准的1kHz方波通道B则是在16个周期里有一段连续的高电平。两个波形叠加后高电平的“密度”在16周期窗口内是变化的这正是低位调制的直观体现。滤波后的波形非常干净纹波峰峰值实测约0.5mV和理论计算基本吻合。响应时间方面从设置新值到电压稳定大约需要5个RC时间常数也就是50ms左右。对于温控这种慢过程完全够用但如果要做快速动态调节就得减小RC或者提高PWM频率。实操心得测试的时候一定要用稳压源给单片机供电不要用USB口。USB供电的纹波和电压波动会直接反映在输出上让你误以为是PWM精度不够。我一开始就踩了这个坑折腾了半天才发现是电源的问题。6. 常见问题与排查实录6.1 低位调制完全不起作用这是最常见的问题。现象是设置低4位变化时输出毫无反应只有高8位在起作用。排查思路按顺序来检查通道B的IO口是否真的在翻转。用示波器直接看通道B引脚如果一直是低电平说明GPIO配置有问题或者中断根本没进。检查中断是否使能。有些单片机的PWM周期中断需要单独使能光配置PWM不够。检查相位计数器是否在推进。如果phase一直不变通道B的状态就不会更新。可以在ISR里翻转一个调试引脚用示波器看频率对不对。检查两个通道的叠加电路。如果是电阻求和方案量一下两个电阻的阻值是不是16:1的关系。如果是数字叠加确认两个IO口确实接到了同一个负载。我遇到过一次原因是通道B的IO口被复用成了其他功能配置寄存器写错了导致GPIO根本不输出。这种问题看代码看不出来必须用示波器实测。6.2 输出有周期性纹波现象是滤波后的电压上叠加了一个低频波动频率正好是 f_high/16。原因通常是RC滤波的截止频率太高没能把62.5Hz的调制分量滤干净。解决办法有两个要么增大RC降低截止频率要么提高通道A的PWM频率让低位调制频率也水涨船高。但增大RC会拖慢响应速度提高PWM频率会增加开关损耗。需要根据具体应用权衡。我的经验是对于12位精度RC截止频率至少要比 f_low 低一个数量级也就是6Hz以下才能把纹波压到1LSB以内。6.3 低位精度非线性有时候你会发现低4位从0变到15的过程中输出不是均匀变化的某些值跳变特别大。这通常是通道B的驱动能力不足导致的。如果通道B串的电阻太大或者IO口驱动能力弱高电平建立时间会变长在1kHz周期内可能还没达到稳定高电平就被切断了导致实际贡献的时间小于理论值。解决办法是减小通道B的串联电阻或者换驱动能力更强的IO口。如果用的是数字叠加方案确保通道B的IO口配置为推挽输出不要用开漏。6.4 常见问题速查表现象可能原因排查方法解决措施低位无反应中断未使能/GPIO配置错误示波器看通道B引脚检查中断使能和GPIO模式低频纹波大RC截止频率过高测量纹波频率增大RC或提高PWM频率低位非线性通道B驱动不足看通道B上升沿减小串联电阻/换推挽输出整体精度差电源纹波大换稳压源测试使用LDO供电响应速度慢RC时间常数大测阶跃响应减小RC或提高PWM频率7. 工程实践中的经验与扩展7.1 什么时候该用、什么时候不该用4位扩展法不是万能的。它最适合的场景是负载有低通特性、对成本敏感、对响应速度要求不高。比如加热控制、LED调光、电机调速这些。不适合的场景也很明确需要快速动态响应的闭环控制、驱动纯阻性负载且要求低纹波、PWM频率本身就很低比如50Hz以下的场合。这些情况下要么换带16位PWM的芯片要么用专用DAC硬上4位扩展法只会给自己找麻烦。7.2 扩展到更高位数可行吗理论上可以继续扩展。比如用三个通道分别承担高8位、中4位、低4位就能做到16位分辨率。但每扩展一级最低位的调制频率就再除以1616位的话最低调制频率只有 f_high/2561kHz进去出来就剩4Hz滤波电容得大到板子放不下。所以实际工程中4位扩展是性价比最高的选择。再往上成本和技术难度都急剧上升不如直接换方案。7.3 一个实用的调试技巧调试这类PWM扩展方案时我习惯先写一个“扫描测试”模式让目标值从0慢慢增加到4095每个值停留几百毫秒同时用万用表记录输出电压。把数据导出来画成曲线一眼就能看出线性度好不好、有没有跳变点。这个测试能暴露很多静态观察发现不了的问题比如某些低位值的非线性、温度漂移、电源耦合等。我每次做PWM相关项目都会跑一遍这个扫描花不了多少时间但能省掉后面大量的排查工作。7.4 关于成本的真实账最后算一笔账。用4位扩展法额外成本就是两个电阻如果是模拟叠加或者零成本数字叠加加上一点软件开销。换一颗带12位PWM的芯片成本可能增加几块钱对于大批量产品来说就是几十万的差价。但软件开销也不是免费的。ISR里多了几十个周期的执行时间对于主频只有几MHz的低端芯片来说这几十个周期可能占到CPU资源的百分之几。如果系统本身就很忙这个开销也需要考虑进去。我的判断标准是如果CPU占用率在70%以下优先用软件扩展如果已经接近满载那就老老实实换硬件。毕竟稳定性比省几毛钱重要得多。这套方法我在好几个项目里都用过从温控到LED调光实测下来很稳。关键是把原理吃透参数算清楚剩下的就是代码实现和调试了。如果你也在为PWM位数不够发愁不妨试试这个思路说不定能省下一颗芯片的钱。