
前阵子做一个模拟调光台灯的项目被一个问题卡了很久手头一块几十元的8位MCU片上PWM只有8位分辨率做呼吸灯时亮度台阶非常明显最低亮度下肉眼能看出“咯噔、咯噔”的跳动。想把调光做到4096级换高分辨率PWM芯片贵了两块多还要改PCB外接专用调光驱动芯片更不划算。后来同事提了个思路说先别急着换芯片把占空比寄存器“小数部分”拿出来分配到连续16个PWM周期里等效精度直接多出4位——这就是所谓的“4位扩展法”。这篇文章就把这个方法的原理、落地代码和工程坑一次讲清楚。它适合手里只有低成本MCU、却想实现更高PWM分辨率的开发者尤其适合LED调光、直流电机调速、加热控制这类对瞬时波形不敏感、只在乎平均效果的场景。理解了它你还能举一反三从4位扩展到更多位。1. 8位PWM精度不够用想换芯片之前先算一笔账1.1 台阶感、电机异响与“差那一点”的量化遗憾8位PWM意味着一个周期内的高电平时间只能取0~255这256个档位。对LED来说256级看起来很够但实际调光时人眼在低亮度区域的对比度感知非常敏感亮度从1/256跳到2/256人眼能明显察觉。更麻烦的是如果你想把PWM频率做高一些比如20kHz避免频闪同时又要保持宽调光范围8位就显得很局促。电机调速也一样。8位分辨率在某些转速区间会造成明显的扭矩波动特别是在低速段电机可能会发出“嗡嗡”或者“咔哒咔哒”的异响。原因很简单占空比的量化误差落到一定范围内负载的惯性不足以完全平滑掉这个误差机械系统就把这种“阶梯”表现出来了。我当时的需求是PWM输出频率固定在20kHz占空比控制范围要求0~4095即0.1%级别的步进。如果用普通8位定时器做最多只能表达256级也就是说目标4096级里有3840级其实是重复的精度根本不够。1.2 高分辨率PWM的成本图景换方案之前我认真做过对比市面上通往高分辨率PWM的路大致有四条换更高端的MCU片上自带16位定时器或高分辨率PWM外设。成本会上去而且程序移植、PCB走线、库存管理都要跟着动。外接专用PWM/调光芯片比如一些恒流LED驱动芯片内置高精度调光能把外部控制做到12~16位。但多一颗芯片就多一份BOM成本小批量项目还能接受大批量就肉痛。用外部DAC 模拟电路把占空比变成模拟电压再配合比较器生成PWM。精度可以很高但电路复杂、温漂问题多调试时间成倍增加。软件扩展分辨率不增加任何硬件只在固件层面把低分辨率PWM的“量化误差”平摊到多个周期里用平均效果换等效精度。第四种方案的成本几乎为零只占用一点CPU时间或DMA通道。这也是“4位扩展法”能站住脚的根本原因它不增加硬件成本只增加一点点软件复杂度。1.3 一张低分辨率照片怎么拼出高分辨率效果理解4位扩展法可以先想一个生活场景假如体重秤的最小刻度是0.5kg那么要称出某个物体的更精确重量你可以把它连续称16次记录每次的读数和“是否多出0.5kg”的趋势再取平均。虽然单次测量还是0.5kg的精度但多次平均的结果往往能逼近更高的精度。PWM的4位扩展法也是一样的逻辑单看某一个PWM周期占空比依然只有256种可能但把16个连续周期当作一个“宏周期”来看平均占空比的可表达档位就变成了256×164096种也就是从8位扩展到了12位。人眼、电机转子的惯性、加热器的热容本质上都是“低通滤波器”它们关心的是几毫秒甚至几十毫秒内的平均占空比而不是每个PWM周期瞬间的占空比。既然负载本身就具备这种积分特性我们就可以放心地把精度“存”到时间维度上。2. 4位扩展法原理把17块的找零散成16次支付2.1 基础PWM分辨率的数学边界假设定时器工作在向上计数模式PWM周期为256个时钟周期比较寄存器CCR的取值为0~255。那么占空比就是D CCR / 256每一步的占空比变化量是1/256约等于0.39%。如果你想输出占空比D_target 0.5873最接近的CCR是CCR round(0.5873 × 256) 150此时实际占空比是150/256 0.5859和目标的误差约0.14个百分点。这个误差在某些应用里微不足道但在精密调光、高分辨率运动控制里就很明显。那能不能让“平均占空比”刚好等于0.5873当然可以。关键在于我们并不需要每一个PWM周期都精确只需要在若干周期的累计时间内精确。2.2 宏周期、整数部分与4位余数把目标占空比放大到4096的尺度Target 0.5873 × 4096 ≈ 2405于是可以拆成Target C_hi × 16 rem其中C_hi 2405 / 16 150整除rem 2405 % 16 5。这里C_hi就是基础PWM的比较值它决定了每个周期的“基础高电平时间”rem是0~15之间的余数也就是“低4位”。接下来我们看16个连续的PWM周期。假设其中有rem个周期的CCR设为C_hi 1其余(16 - rem)个周期的CCR设为C_hi那么这16个周期的总高电平时间就是(16 - rem) × C_hi rem × (C_hi 1) 16 × C_hi rem平均到每个周期等效比较值就是C_hi rem / 16 (16 × C_hi rem) / 16 Target / 16再除以基础周期256平均占空比刚好就是Target / 4096。也就是说只要让16个周期中恰好有rem个周期“多出一个时钟的高电平”这16个周期的平均占空比就能精确到1/4096的档位。2.3 从8位到12位平均占空比的推导为了更直观我用一张表展示几个例子。假设基础PWM是8位扩展4位后目标值Target从0到4095目标值TargetC_hi Target/16rem Target%1616周期中CCRC_hi1的周期数平均等效CCR0000010110.062515015150.9375161001171111.062515049400941505941194.0625注意这里“平均等效CCR”可以是小数正是这个小数部分让分辨率突破了8位的限制。如果把16个周期的平均占空比看作一个整体我们实际上是在用一个“16周期宏PWM”表达更高精度的占空比。这就是为什么叫“4位扩展法”余数部分占了4个bit最多15再加上基础8位总共12位有效分辨率。2.4 余数分配的关键均匀才好不要扎堆有余数之后下一个问题就是这rem个“1周期”到底放在哪里最笨的办法是把它们全放在宏周期的开头。比如rem15时前15个周期CCRC_hi1最后一个周期CCRC_hi。这样会产生一个周期性的低频波动前15/16的时间占空比偏高最后1/16时间占空比偏低。如果负载的滤波能力不够这个低频波动就会变成可感知的亮度闪烁、转速抖动。正确的做法是把这rem个“1周期”尽量均匀地分散在整个宏周期里。这里的核心思路是“误差累积法”每次PWM周期中断里把余数rem累加到一个变量acc里一旦acc超过16就说明这个周期应该“多出一个时钟”同时acc减掉16。简单来说就是让误差一点点累积、又一点点释放。用生活类比如果16次购物每次都有5毛钱的找零你不会把16次找零全部攒到最后一笔才处理而是每次结账后直接把零钱放进零钱罐平时就能花。误差累积法做的就是这个事。3. 在普通MCU上的最小实现中断版与DMA版3.1 资源清单只多占用一次中断或一路DMA实现4位扩展法需要的基础资源非常少一个普通的PWM定时器支持更新中断或者DMA请求一个8位或16位变量用于保存目标值一个变量用于误差累积acc如果是DMA版还需要一小块RAM存放16个比较值的循环表。不需要额外的定时器硬件不需要比较器不需要外部电路。这也是它适合低成本MCU的原因。在动手写代码前先确认定时器的比较寄存器是“立即生效”还是“预装载生效”。绝大多数定时器可以在更新事件overflow时刻把预装载值同步到实际比较寄存器这样在中断里更新CCR就不会造成边沿毛刺。如果MCU不支持预装载同步那就必须在比较匹配输出已经完成之后再改值通常也能接受但需要在边沿时刻上谨慎处理。3.2 误差累积算法中断版代码与关键变量下面这段是我在一颗8位MCU上实际用过的逻辑主频8MHzPWM 20kHz更新中断频率20kHz。代码用C语言风格写可以很方便地移植到其他平台volatile uint16_t target_4096; // 0~4095 目标占空比 volatile uint8_t c_hi; // 高8位比较值 volatile uint8_t rem; // 低4位余数 volatile uint8_t acc; // 误差累积器 volatile uint8_t compare_val; // 最终写入CCR的值 // PWM定时器更新中断 void pwm_timer_isr(void) { // 先继承当前基础比较值 compare_val c_hi; // 误差累积每周期把余数加进acc acc rem; if (acc 16) { acc - 16; compare_val; // 这个周期多给一个时钟的高电平 } // 写入PWM比较寄存器 set_ccr(compare_val); } // 主循环/上位机接口里更新目标 void set_pwm_target(uint16_t new_target) { // 限制范围并拆成高8位 低4位 target_4096 new_target 0x0FFF; c_hi (uint8_t)(target_4096 4); rem (uint8_t)(target_4096 0x0F); // 清零累积误差让新目标从干净的边界开始 acc 0; }这里acc 16的判断很关键。acc相当于一个小数部分的累加器随着周期推进余数的误差被不断累积每当累计到16个“时钟单位”就释放一次额外高电平。这样1周期不会扎堆自动均匀分布在整个宏周期里。我在实测中遇到过一个问题如果更新目标时不清零acc从一个目标切换到另一个目标时可能会在切换点出现半个宏周期的占空比跳变。对LED还好对某些精密运动控制就可能产生可察觉的抖动。所以上面代码里把acc清零放在入口处让每个新目标值都从宏周期边界开始走这是我个人建议保留的操作。3.3 DMA循环搬运版把16个比较值一次配齐20kHz的中断对8MHz主频的8位MCU来说还扛得住但如果你想跑60kHz、100kHz的PWMCPU开销就会变得很难看。这时可以用DMA方式把“每个周期的CCR值”预先算好让DMA不断循环搬运到比较寄存器。具体做法在RAM中开辟一个16字节的数组table[16]每个元素是这一周期要写入的CCR值目标值为Target时c_hi Target 4rem Target 0x0F用误差累积法算出16个周期中哪些周期为c_hi1哪些为c_hi填入table配置DMA为循环模式把table的首地址指向比较寄存器地址触发源设为定时器更新事件目标值变化时只需要重新填充tableDMA自动在下一个循环周期读到新数据。uint8_t table[16]; void build_table(uint8_t c_hi, uint8_t rem) { uint8_t i; uint8_t acc 0; for (i 0; i 16; i) { table[i] c_hi; acc rem; if (acc 16) { acc - 16; table[i]; } } }DMA搬运的好处是CPU只在目标值变化时才跑一次build_tablePWM周期内完全不用进中断。缺点是table必须放在RAM里因为很多MCU的DMA不支持直接读Flash或者读Flash会导致周期延长、无法做到每个更新事件精确搬运。我第一次用DMA版的时候就踩过这个坑把表放在const区域结果DMA读出来的数据全是乱的查了半天才发现是Flash访问延时问题。3.4 代码落脚点更新目标值时的原子性问题在多线程或主循环中断的结构里更新目标值时要防止数据被撕成“半新半旧”。比如主循环先写了c_hi还没来得及写remPWM中断发生读到的是新的c_hi和旧的rem输出就乱了一拍。最简单的方法是先关中断再更新void set_pwm_target(uint16_t new_target) { // 关中断保护避免中断里读到中间态 DISABLE_PWM_ISR(); target_4096 new_target 0x0FFF; c_hi (uint8_t)(target_4096 4); rem (uint8_t)(target_4096 0x0F); acc 0; ENABLE_PWM_ISR(); }有些MCU可以用“影子寄存器”实现原子更新但通用性最强的还是临界区保护。只要中断服务函数足够短关中断几十微秒完全没问题。4. 工程实践里的细节中断预算、纹波与实测4.1 中断开销预算20kHz下CPU占了多少很多开发者担心20kHz的中断频率会把便宜的MCU拖垮但实际上开销没那么恐怖。以一颗1T的8位MCU为例主频8MHz每周期一条指令PWM中断服务函数大约执行30~50条指令加上中断进入和返回的固定开销总共按80条指令算单次中断耗时80 / 8MHz 10微秒每秒中断次数20000次每秒总耗时20000 × 10微秒 0.2秒换句话说CPU占用率只有20%左右对大多数调光、调速应用来说完全可以接受。如果你的业务逻辑不重主循环还能跑得很轻松。如果PWM频率到100kHz这个比例就变成接近100%肯定不行。这时要么用DMA方案要么降低PWM频率要么把扩展位数换成2位减少宏周期轮转开销——但中断次数取决于PWM频率本身和扩展位数没有直接关系因为每个PWM周期都要更新比较值这是逃不掉的。不过我后来优化过一版把中断里只做“查表索引”不做乘除法。误差累积法中只有加法和比较非常适合低成本MCU。这是我在老平台上坚持用中断版的原因之一。4.2 实测波形与感知看什么、怎么看别被单个脉冲骗了在示波器上看4位扩展法输出的PWM波形你会觉得“怎么占空比一直在抖”——不用担心这是正常现象。单个PWM周期看脉搏宽度只有两种C_hi和C_hi1。当rem0时所有16个周期完全一样波形非常整齐当rem7时16个周期中有7个宽一个时钟、9个窄一个时钟看起来像是“边缘轻微的抖动”。正确的验证方法是看平均值。用示波器的“平均”模式或者加一级RC低通滤波器观察滤波后的电压是否精确落在目标值上。比如目标是1504/4096 ≈ 0.3672滤波后电压应约为3.3V × 0.3672 ≈ 1.21V。如果误差小于一个12位档位约0.8mV3.3V说明实现正确。我在一次LED呼吸灯实测中发现8位PWM在亮度较低时台阶感明显切到12位等效后呼吸曲线肉眼看起来完全平滑用手机慢动作拍摄也看不到任何低频闪烁。4.3 宏周期边界与滤波器件选择误差累积法已经把1周期打得很散但毕竟16个周期里还是有高有低。假设PWM频率20kHz宏周期频率是20kHz/161.25kHz这个频率的纹波分量并不高因为1周期是错开分布的能量的主要频率集中在PWM频率附近。所以一级RC滤波器的截止频率可以设置在PWM频率的1/10左右比如2kHz就能得到很干净的直流电平。如果贪心把扩展位数加到8位也就是256个周期为一个宏周期宏周期频率会降到78Hz低于人眼闪烁阈值就可能出现可感知的闪烁。所以扩展位数不是越大越好4~6位在多数场景里比较平衡。这一点我在第5章还会详细说。4.4 什么时候该放弃多周期平均改用定时器级联多周期平均法的限制在于它牺牲了“单周期瞬时精度”换取了“平均精度”。如果负载本身的惯性不够或者应用要求每个PWM周期的占空比都严格精确那这个方法就不适用。典型例子是音频D类放大器、某些精密逆变电源它们要求输出波形的每一个脉冲的宽度都准确不能允许这周期宽一点、下周期窄一点。这时可以考虑“定时器级联”方案用一个主定时器产生基础PWM再用另一个辅助定时器在比较事件触发后产生一个可编程的4位延迟把每个边沿都精确细分到主时钟的1/16。这种硬件级联法能真正做到每个周期的占空比都有N4位精度但需要额外的定时器资源和更复杂的初始化逻辑而且不是所有低成本MCU都具备“从定时器触发另一路输出”的能力。我个人的选择标准是LED照明、通用电机调速、加热控制一律用多周期平均法音频、通信、精密电源老老实实去换带高分辨率定时器的MCU别用软件硬扛。5. 扩展位数的边界从4位到更多位别贪杯5.1 M位扩展的通用公式与开销学会了4位扩展推广到M位扩展就很直白。假设基础PWM位数为N需要扩展M位那么宏周期长度是K 2^M目标值Target是一个NM位的数拆成C_hi Target M rem Target (K - 1)然后在K个连续周期里让rem个周期CCR C_hi 1其余K-rem个周期CCR C_hi就用K个低分辨率周期换来了NM位的平均精度。表格能更清楚地看开销扩展位数M宏周期长度K额外变量大小典型PWM频率下的宏周期频率(20kHz PWM)121 bit10kHz242 bit5kHz4164 bit1.25kHz6646 bit312Hz82568 bit78Hz从表中能看到扩展位数越大宏周期频率越低低频纹波越大。所以实际项目中不要盲目追求“扩展越多越好”。很多时候到12位已经足够因为模拟域噪声、电源纹波本身就把底噪盖住了再加位数只是自我安慰。5.2 一阶噪声整形的本质与二阶可能性误差累积法本质上就是一阶sigma-delta调制器它在每个周期把量化误差rem累加并在累计超过阈值时输出一个修正脉冲。这个过程的“噪声整形”效果是把量化噪声的能量推向高频而低频部分保持很高的精度。这就是为什么负载的低通特性可以轻松滤掉这些高频噪声。如果把误差累积从一阶改成二阶比如再对误差的误差做一次积分宏周期里的1周期分布会变得更“智能”低频噪声进一步降低但算法复杂度和变量数量都会增加。对于大多数8位MCU应用一阶已经足够。我曾在某32位低端平台上试过二阶整形效果确实更好但中断服务函数的开销翻了一倍最终因为项目不敏感又改回一阶。5.3 三个典型场景中的取舍不同负载对扩展位数的要求差异很大我按自己的项目经验整理一下LED调光优先看低频闪烁和色彩漂移。4位扩展12位总精度基本够用如果做影视级调光6~8位可能更好但一定要同步提高PWM频率宏周期频率不能低于约200Hz。直流电机调速电机电磁惯性本身能滤掉大多数纹波4位扩展足够而且由于机械惯性即使宏周期频率偏低也不会有明显噪音。但如果电机带编码器做闭环控制要注意占空比抖动量会不会干扰速度环采样这时建议加一点软滤波。加热控制热惯量极大对分辨率要求其实没那么高但往往PWM周期就是几秒量级这时候“宏周期”的概念已经不重要反而是定时器溢出周期太长需要用软件算平均占空比来模拟高分辨率。5.4 最后一个实用技巧目标值变化时如何平滑过渡直接改写c_hi和rem会导致占空比突变。如果你要做的是呼吸灯、渐亮渐暗效果建议在主循环里做插值static uint16_t current_target 0; void ramped_target(uint16_t final_target, uint16_t steps) { uint16_t start_temp current_target; for (int i 1; i steps; i) { uint16_t next start_temp (final_target - start_temp) * i / steps; set_pwm_target(next); delay_ms(2); } current_target final_target; }这样一个16步的软件斜坡就能避免亮度突变。实际效果比直接跳变柔和得多而且没有额外硬件成本。我在几个项目里用4位扩展法做了LED调光、直流风扇调速和恒温加热控制没有一台出现前后对比的素质下降。真正需要注意的是在目标值变化瞬间把acc清零不然连续两个不同目标的宏周期之间会出现过渡毛刺。这个小细节当初调了很久才发现分享出来供各位少走弯路。如果你手头正好有“PWM精度不够”的硬件平台不妨先写几十行代码试试这个方法成本几乎为零收益可能超出预期。