news 2026/10/3 9:58:00

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PWM从入门到工程实战:占空比、死区与DMA应用详解

我刚开始学单片机那会儿,觉得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到20kHz,20kHz以上基本听不见),又要保证足够的分辨率来精确控制电流。你在选型算参数的时候,一定要先想明白:这个应用里“频率下限”是多少,“分辨率下限”又是多少,然后反推定时器时钟够不够。

2. 手把手写一个51单片机上的软件PWM呼吸灯

2.1 为什么51单片机要“模拟”PWM

51单片机经典款像STC89C52、AT89S52这些,内部没有硬件PWM模块。如果你用的是STC15系列或者STC8系列,部分型号是有硬件PWM的,但传统8051内核的型号确实没有。那要输出PWM怎么办?用定时器中断,在中断服务函数里翻转IO状态,一点一点“拼”出PWM波形。

我第一次做的时候非常纠结,觉得软件模拟肯定不够准。后来实测下来,对于呼吸灯、小功率电机调速这类对时序要求不苛刻的应用,软件模拟完全够用。12MHz晶振下,定时器中断能做到微秒级的精确控制,只要中断服务函数写得够精简,抖动可以控制在很小的范围内。

2.2 可复现的完整源码与计算过程

先确定参数。呼吸灯要求PWM频率100Hz(亮灭周期大约1到2秒,配合缓慢变化的占空比),占空比从0%逐步到100%再逐步到0%。选12MHz晶振,定时器0工作于模式1(16位定时器)。

第一步,计算中断周期。我们需要一个基础节拍,把每个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高级定时器实战:带死区的电机驱动PWM

3.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频率为20kHz,ARR = 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位设置为1(1μs)。

第三步,配置频道模式为PWM Generation CH1 and CH1N。这样CH1输出高有效PWM,CH1N输出互补的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 主输出使能位没有置1,CH1和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,再向下减到0,PWM输出在高电平中间位置对齐。

这对电机控制里“电流采样时刻”的选择很重要。在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(周期20ms)1%~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的底层原理并不复杂,但它连接着数学计算、硬件电路、实时系统这三个层面,真正掌握好它,能帮你解决很多看起来毫不相关的问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 9:57:56

用XCO库批量生成CDS表函数与AMDP类的实战分享

上一周我一直在跟 CDS View 表函数较劲。对象本身不复杂&#xff1a;CDS 里一个 define function &#xff0c;AMDP 类里一个带 BY DATABASE FUNCTION 的实现方法&#xff0c;二者一对绑在一起&#xff0c;就能在 Open SQL 里调用 HANA SQLScript 的能力。复杂的是数量——…

作者头像 李华
网站建设 2026/10/3 9:57:30

数据湖与Spark启动调用:从Catalog配置到问题排查实战

摸索过一阵子数据湖的同学&#xff0c;八成都在第一次把 Spark 接上 Iceberg 或者 Hudi 的时候栽过跟头。表面上是“不就加个 catalog、多引几个包”&#xff0c;结果一启动就报Cannot find catalog plugin class&#xff0c;或者表建出来死活查不到数据。这个标题里提到的“数…

作者头像 李华
网站建设 2026/10/3 9:57:28

意义星图与信任生态:构建AI时代内容透明化的溯源体系

1. 项目概述&#xff1a;意义星图与信任生态到底在解决什么问题1.1 从一个内容创作者的视角看“思想生产”很多人听到“思想生产”这四个字会觉得有点玄&#xff0c;其实放到具体场景里很好理解。你做一篇行业分析、拍一条知识短视频、写一份产品说明&#xff0c;本质上都是在做…

作者头像 李华
网站建设 2026/10/3 9:56:38

Matlab实现遗传算法优化随机森林超参数的回归预测实战

简介&#xff1a;基于遗传算法优化随机森林回归预测的完整程序包&#xff0c;面向需要处理多特征输入、单输出变量的科研与工程人员。程序在MATLAB环境下实现&#xff0c;运行版本要求2018及以上&#xff1b;压缩包内共包含二十三个文件&#xff0c;其中以十七个MATLAB脚本文件…

作者头像 李华
网站建设 2026/10/3 9:55:44

GRFBrain:图结构校正流实现EEG动态源定位

1. 项目概述&#xff1a;当脑电图遇上图结构与校正流——GRFBrain到底在解决什么问题&#xff1f;我第一次看到“GRFBrain: Graph-Structured Rectified Flows for EEG Dynamic Modeling”这个标题时&#xff0c;手边正处理一组来自癫痫术前评估的256导联高密度EEG数据。信号本…

作者头像 李华
网站建设 2026/10/3 9:54:42

Microchip Studio 7.0 AVR开发实战指南:从安装到LED闪烁

1. 这不是IDE安装教程&#xff0c;而是一份AVR开发者“上手即用”的实战地图Microchip Studio 7.0 是当前AVR单片机开发的事实标准环境&#xff0c;它取代了老旧的AVR Studio 4和Atmel Studio&#xff0c;整合了编译、调试、仿真、器件编程与硬件交互的全链路能力。我带过十几届…

作者头像 李华