资讯动态

PWM从入门到工程实战:占空比、死区与DMA应用详解

发布时间:2026/10/3 9:58:04 来源:尧图企业网站定制
我刚开始学单片机那会儿觉得PWM是个特别玄乎的东西——一会儿说它能调灯光亮度一会儿说它能控制电机转速好像什么都能干。后来自己在示波器上看了波形手写了几段寄存器代码又折腾坏了两块驱动板才算真正把PWM这件事吃透。这篇博文就用几个实际项目案例把PWM从原理到源码、从基础到进阶完整梳理一遍包括51单片机上的软件模拟PWM、STM32高级定时器的电机驱动配置、以及死区、故障保护、DMA搬运这些工程上绕不开的细节。1. 从波形到物理世界PWM到底在解决什么问题1.1 占空比、频率、分辨率三者的真实含义PWM的全称是脉宽调制本质就是让引脚交替输出高电平和低电平通过改变高电平在一个周期内所占的时间比例来改变输出的“等效电压”或“等效功率”。先说占空比。占空比就是高电平时间占整个周期的百分比。50%占空比意味着输出波形一半时间是高、一半时间是低。用直流电压表去测量你会看到约等于电源电压一半的读数。这个等效电压的计算很简单等效电压 高电平电压 × 占空比高电平为VCC时比如5V系统、70%占空比等效电压就是3.5V。这就是PWM能“调压”的本质——不是说它真的把电压变了而是用开关的方式让负载感受到一个平均电压。再说频率。频率决定了你“开关”的速度。同样是50%占空比1Hz和10kHz的效果天差地别。1Hz时灯会肉眼可见地闪烁10kHz时人眼完全分辨不出来反而觉得是恒定亮度。电机也是类似道理频率太低电机会一顿一顿地转会有明显的噪声和振动频率合适了运行才平稳。最后是分辨率。分辨率取决于你的定时器计数器位宽。8位定时器可以分256级16位可以分65536级。级数越多占空比调节越细腻比如需要从0%缓慢渐变到100%256级可能在渐变过程中有肉眼可见的跳变65536级就非常平滑。1.2 频率与分辨率的“跷跷板”效应这里有个很多新手忽略的数学关系。定时器的工作时钟通常是固定的假设定时器时钟是 f_timer那么PWM频率 f_timer / (预分频系数1) / (自动重装载值1)而分辨率就是自动重装载值 ARR 加1。在同一个定时器时钟下想要更高的PWM频率就需要减小ARR分辨率就会跟着下降。想要更高分辨率就得加大ARR频率就要降低。二者不可兼得。我做过一个 LED 调光项目用 72MHz 的 STM32 定时器。想要 20kHz 的 PWM 频率如果 ARR 取 3599分辨率就是 3600 级完全够用。但如果想要 200kHz那 ARR 只能取 359分辨率一下子降到 360 级以下在LCD背光调光时可能就会出现轻微的亮度阶梯感。这就是为什么工业伺服驱动器里PWM频率往往取 8kHz 到 20kHz 之间——既要避开人耳可听范围20Hz到20kHz20kHz以上基本听不见又要保证足够的分辨率来精确控制电流。你在选型算参数的时候一定要先想明白这个应用里“频率下限”是多少“分辨率下限”又是多少然后反推定时器时钟够不够。2. 手把手写一个51单片机上的软件PWM呼吸灯2.1 为什么51单片机要“模拟”PWM51单片机经典款像STC89C52、AT89S52这些内部没有硬件PWM模块。如果你用的是STC15系列或者STC8系列部分型号是有硬件PWM的但传统8051内核的型号确实没有。那要输出PWM怎么办用定时器中断在中断服务函数里翻转IO状态一点一点“拼”出PWM波形。我第一次做的时候非常纠结觉得软件模拟肯定不够准。后来实测下来对于呼吸灯、小功率电机调速这类对时序要求不苛刻的应用软件模拟完全够用。12MHz晶振下定时器中断能做到微秒级的精确控制只要中断服务函数写得够精简抖动可以控制在很小的范围内。2.2 可复现的完整源码与计算过程先确定参数。呼吸灯要求PWM频率100Hz亮灭周期大约1到2秒配合缓慢变化的占空比占空比从0%逐步到100%再逐步到0%。选12MHz晶振定时器0工作于模式116位定时器。第一步计算中断周期。我们需要一个基础节拍把每个PWM周期分成100份。PWM频率100Hz每份就是 1/(100×100) 0.1ms 100μs。第二步计算定时器初值。12MHz晶振传统51的机器周期是12个时钟周期所以定时器时钟是 12MHz/12 1MHz也就是每个计数脉冲1μs。要定时100μs需要计数100次。16位定时器从0开始计数到65535溢出所以初值 65536 - 100 65436 0xFF9C。代码这样写#include reg52.h sbit LED P1^0; unsigned char pwm_duty 0; // 当前占空比 0~100 unsigned char pwm_counter 0; // 当前处于周期的哪一段 bit direction 1; // 呼吸方向 void Timer0_Init(void) { TMOD 0xF0; // 定时器0工作在模式1 TMOD | 0x01; TH0 0xFF; // 初值高8位 TL0 0x9C; // 初值低8位 ET0 1; EA 1; TR0 1; } void Timer0_ISR(void) interrupt 1 { TH0 0xFF; TL0 0x9C; pwm_counter; if(pwm_counter 100) { pwm_counter 0; } if(pwm_counter pwm_duty) { LED 1; // 高电平 } else { LED 0; // 低电平 } } void main(void) { Timer0_Init(); while(1) { // 每10ms调整一次占空比实现缓慢呼吸 delay_ms(10); if(direction) { pwm_duty; if(pwm_duty 100) { direction 0; } } else { pwm_duty--; if(pwm_duty 0) { direction 1; } } } }这个代码的思路是每100μs进入一次中断维护一个从0到99的计数器 pwm_counter。只要 pwm_counter 小于 pwm_duty就让LED输出高电平否则输出低电平。pwm_duty 从0到100循环变化灯光就会呈现呼吸效果。2.3 这段代码背后的几个工程教训第一中断服务函数里绝对不要做耗时的事情。我最早写的版本为了测试方便在中断里塞了一个串口发送字符的函数结果波形一塌糊涂——中断还没处理完下一个定时器中断又到了出现“中断嵌套丢失”PWM周期忽长忽短。第二软件PWM的占空比更新要选择安全时机。如果在 pwm_counter 已经跨过 pwm_duty 之后才修改它这一周期的输出就会多出一个毛刺。上面代码里 pwm_duty 在主循环里改中断里读存在一定的竞争风险。对于呼吸灯这种慢速变化场景问题不大但做电机控制时就需要注意。更稳妥的做法是先关闭定时器中断、修改占空比、再打开中断或者用双缓冲方式。第三输出频率和占空比分辨率要平衡。100份的分辨率呼吸灯够用了。但如果你要控制一个对精度要求高的舵机通常建议把 PWM 频率做到50Hz分辨率做到200级甚至更高这时候要么降低PWM频率要么用更高主频的单片机。3. STM32高级定时器实战带死区的电机驱动PWM3.1 为什么电机控制要选高级定时器从51过渡到STM32最明显的区别就是有了硬件PWM模块。而STM32的高级定时器TIM1和TIM8比通用定时器TIM2到TIM5多出了几个关键能力这些能力在电机驱动中是刚需。第一是互补输出。电机驱动常用H桥电路上下桥臂的MOS管需要互补的PWM信号——上管导通时下管必须关断下管导通时上管必须关断。高级定时器可以让CH1和CH1N自动输出互补波形硬件上自动处理时序。第二是死区插入。这是H桥驱动里绝对不能省的功能。MOS管从导通切换到关断需要时间如果上管还没完全关断下管就开始导通会造成上下直通短路瞬间烧毁功率管。死区就是在互补信号切换时插入一段两边都关断的时间。第三是刹车功能。高级定时器提供BRK输入引脚外部电路检测到过流、过压可以硬件上直接封锁PWM输出不需要CPU介入响应速度在微秒级。3.2 基于HAL库的完整配置步骤我用的是STM32F103系列TIM1输出两路互补PWM控制一个直流电机的H桥。频率设为20kHz因为MOS管开关损耗在这个频率下和噪声控制之间比较平衡。第一步确定定时器时钟。F103的TIM1挂在APB2总线上时钟72MHz。使用 CubeMX 配置时PSC设为71得到1MHz的计数时钟。要让PWM频率为20kHzARR 1MHz / 20kHz - 1 49。第二步配置死区时间。STM32的死区时间寄存器DTG是8位具体数值跟定时器时钟有关。在1MHz计数时钟下每个计数单位是1μs这个粗粒度不适合做500ns这种死区。实际项目里通常把死区时间设在0.5μs到2μs之间取决于MOS管的关断延迟。我在CubeMX里把死区设为1μs对应DTG寄存器区域选择DTS/1模式也就是BDTR寄存器里的DTG高两位为0b10低6位设置为11μs。第三步配置频道模式为PWM Generation CH1 and CH1N。这样CH1输出高有效PWMCH1N输出互补的PWM两者之间自动插入死区。CubeMX生成后的核心代码逻辑如下// 定时器PWM初始化 TIM_OC_InitTypeDef sConfigOC {0}; sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 25; // 初始占空比50% sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCNPolarity TIM_OCNPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; sConfigOC.OCIdleState TIM_OCIDLESTATE_RESET; sConfigOC.OCNIdleState TIM_OCNIDLESTATE_RESET; HAL_TIM_PWM_ConfigChannel(htim1, sConfigOC, TIM_CHANNEL_1); // 配置死区 TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig {0}; sBreakDeadTimeConfig.OffStateRunMode TIM_OSSR_DISABLE; sBreakDeadTimeConfig.OffStateIDLEMode TIM_OSSI_DISABLE; sBreakDeadTimeConfig.LockLevel TIM_LOCKLEVEL_OFF; sBreakDeadTimeConfig.DeadTime 1; // 对应1微秒死区 sBreakDeadTimeConfig.BreakState TIM_BREAK_DISABLE; sBreakDeadTimeConfig.BreakPolarity TIM_BREAKPOLARITY_HIGH; sBreakDeadTimeConfig.BreakFilter 0; sBreakDeadTimeConfig.AutomaticOutput TIM_AUTOMATICOUTPUT_DISABLE; HAL_TIMEx_ConfigBreakDeadTime(htim1, sBreakDeadTimeConfig); // 启动PWM输出 HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(htim1, TIM_CHANNEL_1);有个特别坑的地方必须提醒高级定时器跟通用定时器不一样配置完成后不是直接调用 HAL_TIM_PWM_Start 就能输出还得显式启动互补通道 HAL_TIMEx_PWMN_Start。更关键的是如果使能了刹车功能且 MOE 主输出使能位没有置1CH1和CH1N是什么都没有的。我第一次调试时折腾了两个晚上波形出不来最后发现是忘了调用 HAL_TIMEx_PWMN_Start。3.3 电机调速到底该调频率还是调占空比用PWM控制电机不少新人会踩一个误区觉得调频率能调速。实际完全不是这样占空比才是决定电机输出电压平均值的关键频率只要保持在一个合适的区间就行。直流电机的等效模型是电阻串电感加反电动势。PWM频率选择要看电机绕组的电气时间常数。频率太高电流还没来得及上升到目标值就进入关断阶段电流纹波虽然小但开关损耗会大增频率太低电流波动剧烈电机会产生明显的“嗡嗡”噪声甚至出现转速抖动。我常用的经验法则是PWM频率选电机电气时间常数对应频率的5到10倍以上。对于常见的小型直流减速电机1kHz到20kHz之间都可以工作但要把噪声控制在人耳不敏感的区域10kHz以上比较合适。这里还得提醒一个“PWM死区影响占空比”的问题。当PWM频率提高后一个周期的时间变短死区时间在周期中占的比例上升。比如20kHz的PWM周期是50μs一个1μs的死区占了2%。这意味着你设置占空比50%实际有效的导通时间并不是一半因为死区期间上下管都是关断的。精准计算有效占空比时要把死区时间扣除。4. 从单一输送到系统方案PWM应用中的进阶工程细节4.1 PWM故障保护宁可封锁输出也不等CPU反应在电机驱动、电源变换这类功率应用中故障保护优先级远高于功能逻辑。很多新手设计时会把过流保护做成“ADC采样到电流后在中断里关断PWM”——看着合理实际响应太慢了。ADC采样需要转换时间中断响应需要压栈时间CPU处理逻辑又需要时间整个链路下来少说几十微秒。而功率管在过流状态下几百纳秒就能烧毁。所以真正可靠的方案是用硬件比较器和刹车引脚。我给一个客户做直流电机驱动器时用了INA240这种带比较器输出的电流检测芯片。芯片检测到电流超过设定阈值输出一个高电平信号直接接到TIM1的BRK引脚。高级定时器硬件检测到BRK信号后自动把所有PWM输出锁定为安全电平整个过程不经过CPU。实测从过流发生到PWM封锁大约几百纳秒。CubeMX中配置刹车功能时可以把BreakState设为ENABLE再把AutomaticOutput设为DISABLE。这样刹车事件发生后即使外部信号消失PWM也不会自动恢复输出必须由软件调用 HAL_TIMEx_MOE_Disable 并重新初始化才能再次使能。这种“锁存式”设计防止了故障原因未排除时PWM自动恢复导致二次损坏。4.2 PWM加DMA让多个通道数据“零拷贝”搬运热词里有一个“pwm加dma”这个组合在灯带控制和多通道伺服控制中非常实用。典型的场景是控制WS2811这类单线协议灯带或者同时控制多路舵机。WS2811这类芯片用的是单线归零码通信需要在极短的时间内精确控制高低电平的宽度。直接用主循环去翻转引脚频率一高CPU就被占满了。用DMA配合PWM的思路是PWM产生一个固定的高频基准时钟DMA在每次更新事件时从内存搬运一个预计算好的数组值到定时器的比较寄存器从而改变每个周期的占空比。具体做法是先用数组存放一帧所有灯珠的颜色数据每个灯珠的24位数据预先编码成对应占空比的数值。配置定时器的更新事件触发DMA请求DMA从内存数组连续搬运数据到TIMx-CCR寄存器。这样CPU只需要准备好数组然后启动一次DMA传输剩下的事情由硬件自动完成。这里有个关键点DMA搬运速度和PWM频率必须匹配。如果PWM频率是800kHz周期1.25μs每次更新搬一个数据那么DMA传输时间必须小于1.25μs。F103的DMA时钟72MHz每次传输大约需要几个时钟周期能够满足这个速度。4.3 中心对齐模式与ADC采样的时序配合高级定时器的计数模式除了向上计数还有中心对齐模式。所谓中心对齐就是计数器先从0向上数到ARR再向下减到0PWM输出在高电平中间位置对齐。这对电机控制里“电流采样时刻”的选择很重要。在H桥PWM驱动中电流波形在每个开关周期的开始和结束阶段会有较大的开关噪声。如果要采样电流做闭环控制最好避开开关瞬间的噪声选择在一个周期中最稳定的时候采样。中心对齐模式下定时器向下计数到达0或向上到达ARR时发生更新事件。对于向上-向下-再向上这样的计数方式当计数到0时正好对应PWM周期中间点此刻上下桥臂的开关动作最少电流采样最稳定。所以做电机矢量控制时很多人专门选用中心对齐模式并在定时器更新中断里触发ADC采样。我一度以为只要把TIM配置成中心对齐ADC采样就自动精准了后来才发现还需要检查ADC触发源是否真的是从定时器的更新事件来。F103的ADC外部触发源里TIM1_CC1事件可以做触发源。需要把ADC触发模式设为外部触发并选择TIM1的更新事件。这一步必须在CubeMX里配好否则ADC采样点会乱飘。不同应用场景的PWM配置参考我整理了一个表格应用场景推荐PWM频率分辨率需求注意点LED呼吸灯100Hz~1kHz8~10位足够频率太低可见闪烁直流电机调速10kHz~20kHz10位以上更平滑需考虑死区对占空比的影响舵机控制50Hz周期20ms1%~12%占空比对应角度注意中位值校准无刷电机FOC16kHz~20kHz12位以上电流采样点与PWM同步WS2811灯带400Hz~800Hz只需对应0/1编码DMA搬运效率是关键4.4 硬件电路上容易翻车的几个细节软件配死了但波形不对很多时候是硬件电路的问题。第一个坑是栅极驱动供电不足。STM32的IO口输出能力有限直接驱动MOS管栅极充放电速度不够开关波形拖出长长的斜边。这种情况下PWM频率越高损耗越大甚至MOS管工作在放大区直接过热。给栅极加专用的栅极驱动芯片是标准做法。第二个坑是地线回流。电机是大电流负载PWM开关时电流变化率很高如果功率地的走线经过控制板的地线回路会在单片机的地电位上叠加尖峰噪声导致ADC采样异常甚至单片机复位。我调试时遇到过单片机每隔几秒就自动复位最后发现是功率地和控制地没有单点隔离。第三个坑是PWM输出引脚的电平空闲状态。STM32高级定时器在复位后OCx和OCxN的默认输出逻辑取决于CCER寄存器的CCxP和CCxNP位。如果没有配置好空闲电平电机在程序启动瞬间可能会有“一哆嗦”的动作。工程上稳妥的做法是让单片机复位后默认把所有PWM输出引脚拉为无效电平等初始化完成、死区配置完毕后再使能主输出。5. 我的调试工具链与源码复用建议调试PWM项目示波器是绝对刚需。双通道是最低要求一个通道看PWM波形另一个通道看互补信号或电流信号。用示波器观测死区时间时用“时间游标”功能直接测量下降沿到上升沿的间隔比靠眼睛估准得多。占空比的验证有一个小技巧如果手头暂时没有示波器可以用万用表的直流电压档测滤波后的电压。占空比50%、5V系统时万用表读数应该在2.5V左右前提是负载足够轻不影响分压这个方法可以快速验证PWM通路是否正常工作连接线尽量短避免采样到开关噪声。源码复用方面我自己的习惯是维护一个PWM驱动模块把初始化参数抽出来做成结构体包括定时器时钟频率PWM频率初始占空比死区时间刹车极性输出极性这样换项目时只需要填一个结构体实例不需要反复改HAL库生成的初始化函数。下面是一个简化版的结构typedef struct { TIM_HandleTypeDef *htim; uint32_t channel; // 主通道 uint32_t complement_ch; // 互补通道无则为0 uint32_t psc; // 预分频 uint32_t arr; // 自动重装载 uint8_t dead_time; // 死区值 uint16_t init_pulse; // 初始比较值 } Pwm_Config_t; int Pwm_Init(Pwm_Config_t *cfg); int Pwm_SetDuty(Pwm_Config_t *cfg, uint16_t duty_percent); int Pwm_StopSafe(Pwm_Config_t *cfg);实际项目中PWM初始化涉及死区、刹车、互补输出等一堆寄存器抽成函数后不仅可读性好排查问题时也能快速定位是哪一层出了问题。对于还在学习阶段的朋友建议按这个顺序练手先做呼吸灯搞清楚原理再做简单的直流电机调速感受频率与占空比的影响然后用带死区的互补PWM驱动H桥体会到死区的重要性最后尝试加入刹车保护和ADC闭环。这四步走下来你从代码到电路再到排障的整个闭环基本就通了。PWM的底层原理并不复杂但它连接着数学计算、硬件电路、实时系统这三个层面真正掌握好它能帮你解决很多看起来毫不相关的问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑