首页 / 资讯中心 / 文章详情

单片机PWM实战从原理到源代码:STM32与51案例避坑指南

单片机PWM实战从原理到源代码:STM32与51案例避坑指南 ★ FEATURED ARTICLE
做单片机开发PWM是躲不开的。我见过太多朋友上来就查“PWM的源码”下载了一堆例程结果编译通过但板子毫无反应或者电机一转就有奇怪啸叫又或者舵机抖动得像在跳舞。这个标题里提到的“案例相关项目源码”其实藏着一个很实用的信号我们需要的不是一个能闪灯的demo而是一套能真正放进项目里的PWM工程思路。今天的文章我打算用不同平台STM32、51单片机的几个真实案例把 PWM 从原理讲到源码组织再带你避掉我在实际调试中踩过的坑。1. 拆解需求PWM到底在单片机项目里解决什么问题1.1 从“调电压”到“调时间”——PWM工作原理一句话很多初学者觉得PWM神秘其实一句话就能讲完PWM就是通过快速切换高低电平改变高电平在一段固定周期里所占的比例从而让负载获得一个介于0和电源电压之间的“等效平均电压”。这个比例就是占空比单位是百分比比如50%占空比的方波经过滤波或负载的自然惯性后等效出来的电压基本就是电源电压的一半。这背后有一个常用的生活化类比你在厨房用燃气灶烧水如果想控制火力最笨的办法是拧小火但拧小后火苗不稳、效率也低更聪明的办法是“开一会儿大火、关一会儿”只要开关频率足够快锅里的水感受到的就是一个比较稳定的加热功率。PWM干的就是这件事——用“时间比例”换“输出强弱”。对于LED人眼有视觉暂留所以几百赫兹以上的PWM调光几乎看不出闪烁对于电机转子有转动惯量几千赫兹的PWM调速比线性调压效率高出不少对于舵机PWM更像是携带角度信息的数字信号脉冲宽度就是角度命令。理解了“等效平均”和“脉宽携带信息”这两种视角后面的所有代码和电路就都好懂了。1.2 为什么用单片机而不是555/模拟电路以及选型时的三个关键指标网上经常搜到“555 pwm电路”“ao3400a pwm电路”说明很多人知道可以用555定时器加MOS管搭一个纯硬件PWM。这确实便宜100Hz以下做调速、调光没问题但它有三个痛点频率和占空比由R、C电阻电容决定温漂明显参数修改要换电阻电容很难远程或软件动态控制无法和反馈系统融合。而单片机方案本质上是用“定时器寄存器”替代了“电阻电容”修改占空比只需要改一个寄存器的值线上通信、闭环控制都能做。选型或者评估一个PWM方案时我通常只看三个关键指标分辨率占空比最小能调多少。如果定时器计数范围是0~255分辨率就是1/256≈0.39%计数范围0~65535分辨率就是1/65536≈0.0015%。分辨率不够LED调光会出现肉眼可见的阶梯感。频率上限一定范围内可以做到多高的PWM频率。STM32高级定时器配合合适的时钟几百千赫兹甚至几兆赫兹都能做普通51单片机用IO翻转模拟可能几十千赫兹就是极限。CPU占用硬件PWM几乎不占CPU软件PWM每一个周期都要靠中断或延时来翻转引脚这会直接影响其他任务的调度。所以在开启源码阅读或移植之前先想清楚你这套PWM是给LED用、电机用还是舵机用。用途不同频率和分辨率的要求完全不同后面我会用具体案例展开。2. 硬件PWM与软件PWM两条路线怎么选2.1 硬件PWM定时器才是主角很多新手误以为PWM只是“GPIO输出高电平或低电平”但实际上在带硬件PWM的单片机里核心模块是定时器。以STM32为例一个定时器包含预分频器PSC、自动重装载寄存器ARR、比较寄存器CCR和输出控制逻辑。时钟进入定时器后先经过PSC分频再驱动计数器从0计到ARR一路到达后重新归零计数过程中计数器的值一旦达到CCR输出引脚就翻转一次。这样一来ARR决定周期频率CCR决定翻转时刻占空比整个翻转过程完全不依赖CPU干预。关键原因在于硬件PWM是事件驱动的你只需要启动一次比如调用HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1)之后定时器会自己持续输出脉冲。从代码角度看这就是它比软件PWM优雅的核心优势也是MCU可以休眠而PWM依然输出的原因。2.2 软件PWM51单片机救急方案如果你用的是普通51单片机没有硬件PWM外设那么最常见的方案就是“定时器中断翻转IO”。思路也很直接选一个定时器比如定时器0每10us进入一次中断在中断里维护一个计数器变量和一个占空比阈值计数器小于阈值输出高大于阈值输出低。这里有个注意点中断频率越高PWM频率就能越高但CPU负担也越大。比如你要产生20kHz的PWM周期是50us如果占空比精度做到1%那你每0.5us就得进一次中断这简直就是灾难。实际中51单片机软PWM更适合低频场景比如50Hz舵机或1kHz以下调光CPU还能喘口气。有些项目会用两个定时器一个定时器控制周期另一个定时器控制脉宽虽然可以降低一部分中断频率但也增加了代码复杂度。所以如果项目对PWM频率和分辨率要求高要么选择带硬件PWM的芯片要么给51配一个专用PWM模块芯片。2.3 源码结构层面把“控制策略”和“底层驱动”拆开不管用硬件还是软件PWM代码组织上都建议做一层抽象。我见过很多人写PWM项目直接在main函数里疯狂操作寄存器一旦换个PWM引脚所有代码都要改。更好的做法是把源码分成三层底层驱动层只负责初始化定时器、启动输出、设置比较值对外提供类似pwm_set_duty(channel, percent)这样的接口。控制策略层负责计算你要输出多少占空比比如根据ADC采样的温度来调风扇转速这块不要直接跟寄存器打交道。应用层负责调度、通信比如通过Modbus串口帧接收上位机下发的目标转速再调用策略层去执行。这样做的好处是项目源码的可移植性特别强。今天在STM32上跑明天换GD32、AT32只需要改底层驱动51单片机上跑软PWM也只需要实现同样的几个接口上层代码一行不用动。这一点对做课程设计、竞赛和找工作时的项目答辩都很重要。3. 三个实战案例的源码级拆解3.1 案例一直流电机调速STM32 定时器PWM——电机飞车问题排查电机调速是PWM最经典的场景。我拿STM32F103的定时器1TIM1高级定时器来举例时钟来自APB2通常是72MHz。如果我们要生成20kHz的PWM公式是[ PWM频率\frac{定时器时钟}{(PSC1) \times (ARR1)} ]令定时器时钟72MHz取PSC0那么ARR3599时频率72MHz/360020kHz。占空比通过CCR来设若ARR3599那么CCR1800就对应50%占空比。C代码HAL库可以这样写/* 初始化部分TIM1PSC0ARR3599 */ TIM_HandleTypeDef htim1; htim1.Instance TIM1; htim1.Init.Prescaler 0; htim1.Init.Period 3599; // 20kHz 72MHz htim1.Init.CounterMode TIM_COUNTERMODE_UP; htim1.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(htim1); /* 配置通道1输出比较 */ TIM_OC_InitTypeDef sConfigOC; sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 1800; // 50%占空比 sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(htim1, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); /* 运行中动态调速 */ void motor_set_speed(uint16_t speed_percent) { uint32_t cc (uint32_t)speed_percent * 36; // 3600/100 if (cc 3599) cc 3599; __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, cc); }实际调试中最容易遇到的“怪病”是电机飞车。现象是占空比明明在变小电机转速却不降反升甚至直接失控。这个问题的成因通常不是PWM寄存器配置错误而是驱动这块出了问题。比如电机驱动板上方向控制引脚悬空或抖动又或者驱动芯片内部因过流进入半关断状态而你又没有及时拉低PWM电机就会在惯性下“自由飞车”。解决办法有三板斧驱动板上方向脚加上下拉电阻软件里对速度做斜坡变化禁止跳变PWM意外关断时要立刻把方向脚设为刹车状态。3.2 案例二舵机PWM控制与输入捕获回读20ms周期/0.5-2.5ms脉宽舵机是另一个高频PWM场景网上一直能看到“接收机pwm信号”“舵机pwm控制”这类热词。标准模拟舵机的控制周期是20ms也就是50Hz脉宽通常在0.5ms到2.5ms之间对应-90°到90°而1.5ms对应中位0°。用STM32产生这个信号非常简单把ARR设为19999PSC设为71输入时钟按72MHz算这样定时器时钟被分频为1MHz计数器每tick就是1us那么输入比较值CCR1500就表示1.5ms脉宽。/* 20ms周期1MHz计数即ARR19999PSC71 */ htim2.Init.Prescaler 71; htim2.Init.Period 19999; HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1); void servo_set_angle(int16_t angle) { /* angle范围-90~90 */ uint32_t pulse_us 1500 (uint32_t)(angle * 1000 / 90); __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, pulse_us); }这里的细节在于舵机的脉冲宽度不能一次跳变太快否则舵机会“冲过头”。很多接收机模块回传信号也是PWM格式你要读回外部输入的PWM脉宽不是用输出比较而是用输入捕获配置定时器检测到第一个上升沿时记录计数值检测到下降沿时再记录一次两者差值就是脉宽。同样的定时器资源既能输出又能捕获很多项目里的“PWM轮速协议”也是这个原理只不过捕获的对象换成了轮速传感器的脉冲。3.3 案例三51单片机驱动WS2811灯带——软件时序一次点亮热词里有一条“51单片机 pwm 驱动 ws2811”问的人特别多。WS2811/WS2812这类灯珠的信号协议其实不是传统PWM而是一种特殊的时序编码每一位数据通过“高电平持续时间长/短”来区分0和1典型码元周期是1.25us800kHz。所以很多人误以为需要用PWM外设但更准确的思路是用“快速IO翻转精确延时”来模拟出来。51单片机的优势是IO翻转速度可以很快但前提是你得用汇编或带精确NOP计数的C代码。假设你用12MHz晶振一条NOP大概1us这个速度偏慢驱动WS2811会很吃力最好用24MHz以上时钟或STC那种1T单片机。我用STC15系列写过一个点亮第一颗灯珠的裸机例程代码逻辑大致如下sbit DATA P1^0; void ws2811_send_byte(unsigned char dat) { unsigned char mask; for (mask 0x80; mask ! 0; mask 1) { if (dat mask) { DATA 1; _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); DATA 0; _nop_(); _nop_(); } else { DATA 1; _nop_(); DATA 0; _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); } } }这套代码的难点不是逻辑而是延时精确性。每条C语句编译后到底生成几条指令和编译器优化级别、单片机型号直接相关所以发出这张图的朋友一定要拿逻辑分析仪或示波器验证波形光靠眼睛“看灯亮不亮”去盲调效率太低。如果项目里灯珠数量大我强烈建议换带SPI/DMA的MCU或者专用驱动芯片不要用51硬干否则后续刷新率会非常吃力。4. PWM周边硬核细节死区、中心对齐、故障保护、ADC同步4.1 死区为何不是“可有可无”如果你只是调个灯可能永远不需要关心死区但一旦进入直流无刷电机、H桥、逆变器这些场景“pwm的死区”就是必须处理的硬核问题。H桥上下两个MOS管如果同时导通电源直接短路轻则发烫重则炸管。所以同一桥臂两路PWM在切换时必须插入一段两管都关断的时间这段“同臂导通间隔”就是死区。STM32高级定时器TIM1/TIM8在输出比较配置里有一个sConfigOC.OCIdleState和sConfigOC.OCNIdleState同时还要设置刹车和死区寄存器BDTR。死区时间的长短取决于功率管的关断延时时序差异太快则炸管太慢则波形失真。英飞凌TC3xx系列的CCU6模块里也有DTG寄存器专门配死区原理完全一致死区时间是数字量由时钟和分频系数算出。我推荐大家学习时找一个H桥驱动电路亲手把死区从0慢慢往上加观察不同死区时间下的波形占空比损失这比背寄存器要有用得多。4.2 中心对齐模式与ADC采样点设置热词里“stm32 高级定时器 pwm 中心对齐模式和 adc 采样时刻点设置”是个很专业的搜索词。普通PWM是边沿对齐计数器从0加到ARR再立即归零中心对齐模式则是计数器先从0加到ARR再从ARR减到0这样PWM脉冲的中心是对齐的。它的好处是谐波能量更集中电流纹波更小很多电源和三相电机控制都爱用。但这带来一个新的问题PWM开关瞬间会产生大量噪声如果你在开关沿附近启动ADC采样采到的电压往往是带毛刺的。正确做法是把ADC采样点设置到PWM周期内噪声最小的时刻比如脉冲中心或下一秒地电平区间。STM32的定时器可以复用为ADC触发源配合重复计数寄存器可以做到“每隔几个PWM周期采样一次”。我在实际项目中习惯把ADC触发点放在PWM信号稳定后的中点然后读多组数据做软件平均。这个位置不是拍脑袋定的而是看示波器波形选出来的。4.3 故障保护输入是怎么工作的很多PWM项目源码里都有“pwm故障保护”这个模块但真正认真用的不多。以STM32为例高级定时器带有刹车输入BKIN和故障保护功能只要这个引脚被拉起PWM输出会立即进入预设的安全状态不管软件跑到哪里。这在电机失控、过流、短路时是保命的机制。实际使用中我把驱动芯片的FAULT引脚直接接到BKIN上并且同时把过流比较器的输出也并到这里。一旦发生异常硬件毫秒级切断PWM软件根本来不及反应。这可能有点反直觉很多人觉得保护逻辑应该写在中断里但中断有延迟、有优先级抢占问题硬件刹车才是最可靠的第一道防线。软件里的保护只能作为备份负责把故障标志记录下来并向上位机发送告警。4.4 跨平台参考从CCU6到RK3588风扇调试除了MCU一些带操作系统的板子也会碰到PWM风扇调试问题比如“rk3588 pwm fan 调试”。这类调试的通用步骤是先确认PWM控制器在设备树里打开了节点名类似pwm-fan再确认频率设置常见风扇是25kHz太低了会啸叫最后确认热阈值策略连接的是哪个温度传感器。这里面的调试思路和单片机完全一致——先看有没有波形再看占空比变化是否符合预期。5. 参数计算、调试记录与排查速查表5.1 频率、分辨率、占空比怎么算才不糊我把PWM计算整理成一张速查表工程上直接套用即可频率[ f_{PWM} \frac{f_{clk}}{(PSC1) \times (ARR1)} ]分辨率[ resolution \frac{1}{ARR1} \times 100% ]分辨率比特数 ≈ log2(ARR1)占空比[ duty \frac{CCR}{ARR1} \times 100% ]选择参数的通用原则是LED调光优先保分辨率频率覆盖几百Hz到几kHz即可电机调速优先保频率避免人耳可闻的啸叫一般取15kHz以上舵机严格50Hz周期20ms脉宽范围由舵机型号决定。5.2 示波器/逻辑分析仪看PWM时容易被忽略的点测量点不要只在单片机引脚上量波形还要在电机或MOSFET栅极上量二者差异非常大。单片机引脚输出5V和3.3V经过驱动芯片后到栅极的波形往往有振铃振铃太大说明走线寄生电感或者驱动电阻不合适需要串个10Ω~100Ω的电阻。探头接地线如果探头的地线夹得过长比如超过5cm在高频PWM下会引入明显噪声波形上会看到高频毛刺很多人误以为是由软件问题造成的。占空比突变程序初始化时如果先启动PWM再设置CCR那么第一个脉冲往往是满占空比可能造成LED闪爆或电机猛冲。解决办法是先设好CCR再启动PWM顺序非常关键。5.3 常见问题速查表与维修心得现象可能原因排查方法完全没有PWM波形定时器时钟没开、通道映射错、引脚被复用功能占用看RCC和GPIO复用配置用逻辑分析仪量引脚灯亮但无法调光占空比设置范围超出ARRCCR被截断为0或ARR打印CCR实际值检查数据类型电机啸叫明显频率落在人耳可听区1k~5kHz或和机械谐振点重合提高PWM频率到18k~25kHz并避开共振点舵机抖动周期不稳定软件PWM被其他中断打断、脉宽过“窄”改硬件PWM输出或保证中断不长时间嵌套H桥发烫死区时间不够或没设置示波器看两路输出是否存在同时导通的交叉区域ADC采集数值乱跳采样时刻恰好落在PWM开关沿附近把ADC触发点移到周期中点并加软件滤波5.4 Modbus等通信协议与PWM联动的源码组织最后再聊一个工程上非常常见的需求通过上位机或触摸屏远程控制PWM输出比如用Modbus单片机帧接收数据程序来改变调光档位或电机转速。很多下载的“项目源码”只是分开展示UART和PWM两块但真正能落地的是把它们接起来。我建议的模块划分是这样的串口中断只负责抓完整帧帧接收标志置位主循环里解析Modbus协议校验CRC后从寄存器表里取出目标占空比把目标值传给控制策略层做斜坡变化最后才调用底层pwm_set_duty()。这里有一个易错点不要直接在串口中断里调用pwm_set_duty()因为串口中断频率高加上PWM更新可能在抢占中出问题最好只置标志、不执行重操作。最后的个人体会用单片机开发PWM最难的部分其实不在代码本身而在于你是否理解“占空比只是一个结果而定时器到底如何工作才是原因”。我见过太多人拿了一堆源码复制粘贴最后连 CCR 和 ARR 都分不清反而是一个人拿着示波器慢慢调死区、看波形很快就能把高级定时器玩明白。如果你正在做课程设计或者准备大赛答辩我特别建议你在源码旁边写一个README把自己如何计算频率、如何选择占空比、调试中遇到什么现象都记录下来这对理解和表达都比那几行源代码更有价值。至于我的习惯始终是“先设比较值再启动PWM”如果你还没有这个习惯下一次调PWM的时候可以试试看应该能让你的项目少一个隐藏的启动毛刺。
阅读完成 · 觉得有帮助?
咨询建站