我用一个小场景开头吧。去年我调一块无刷驱动板,三路互补PWM死活出不来波形,示波器一挂,六路信号里有两路是齐的,另外四路互相乱抖。查了一整天,最后发现是死区寄存器没配,上下桥臂直通,电流直接把你板子上的一路采样电阻烧冒烟。从那天起我就意识到,PWM波这件事,会写两行代码配置定时器只是入门,真正决定波形能不能用、电路会不会炸的,是那些藏在高级定时器里没人给你画重点的寄存器位和硬件特性。
这篇学习笔记就是把PWM波的生成从底层到实战完整梳理一遍,涵盖占空比计算公式、不同芯片平台(51、STM32、PY32、Mega2560、RK3588)的生成方式、死区与中心对齐模式、PWM DMA搬运、故障保护,以及AO3400A这类MOS管驱动电路里容易被忽略的细节。适合刚接触PWM的新手建立体系,也适合已经写过PWM但一直“知其然而不知其所以然”的开发者查漏补缺。
1. 先从波形本身说起:占空比、频率与等效电压
1.1 占空比决定的是负载的“平均感受”
PWM波从外观上看就是一个方波:高电平、低电平、再高电平,循环往复。但真正驱动负载的时候,负载并不关心那一瞬间是多少伏,它感受的是整个周期内的平均效果。比如一个5V的方波,高电平占四分之一时间、低电平占四分之三时间,等效平均电压就是1.25V。LED会按这个平均电压呈现亮度,电机会按这个平均电压决定转速,加热棒按它决定功率。
这个“平均”对不同类型的负载有不同的物理含义。对LED这类阻性负载来说,电流和电压线性相关,人眼又有视觉暂留效应,所以1kHz左右的PWM就能实现无级调光。对电机这类感性负载来说,电感会产生续流效应,电流不会立即归零,而是被电感平滑成带有一定纹波的直流电流,因此PWM调压的效果比想象中更接近模拟电压。理解了这一点,很多后续问题——比如为什么同一个PWM信号驱动LED不闪但驱动舵机乱抖——就都解释得通了。
核心计算公式写在这里:
- 占空比:D = T_on / (T_on + T_off) × 100%
- 等效电压:V_avg = V_high × D
- 频率:f = 1 / (T_on + T_off)
1.2 频率选择不是越大越好
频率代表的是“一秒开关多少次”。选低了,LED会闪、电机会一顿一顿;选高了,开关损耗上升、驱动电路可能跟不上。具体怎么选,取决于负载属性和系统设计目标。这里给出几个我实际用过的参数区间:
| 负载类型 | 推荐频率 | 选择理由 |
|---|---|---|
| LED调光 | 1kHz ~ 5kHz | 人眼在300Hz以上基本无闪烁感,过高意义不大 |
| 标准舵机 | 50Hz | 接收机/舵机协议固定,周期20ms,脉宽0.5ms~2.5ms |
| 普通直流电机 | 10kHz ~ 30kHz | 避开人耳可听范围,同时纹波可被电感有效滤除 |
| TEC帕尔贴 | 100Hz ~ 1kHz | 过高会导致制冷效率降低,过低会出现温度波动和可闻噪声 |
| BLDC/开关电源 | 20kHz ~ 100kHz+ | 需要配合电机电感/变压器参数做开关纹波设计 |
这里有一个反直觉的点:PWM频率不是越高越好。频率提高确实能减小电流纹波,但开关损耗和驱动电路损耗会同步上升。尤其是在用MOS管做功率开关时,一个开关周期内的开通损耗和关断损耗都是实实在在的发热来源,频率翻倍发热几乎翻倍。所以工程上选频率的原则是“够用就好”,先看负载对纹波和噪声的要求,再反推开关频率。
1.3 定时器里的占空比是怎么算出来的
写代码之前,得先理解单片机的定时器是如何“翻译”占空比的。以最常见的通用定时器为例,里面有三个关键寄存器:
- PSC(预分频器):对定时器时钟进行分频
- ARR(自动重装载值):计数器从0数到ARR,数满就是一个PWM周期
- CCR(捕获/比较寄存器):计数器计到CCR时,输出电平翻转
所以:
- PWM频率:f = 定时器时钟 / ((PSC+1) × (ARR+1))
- 高电平时间:T_on = CCR / 定时器时钟 × (PSC+1)
- 占空比:D = CCR / (ARR+1) × 100%
我举个实际算过的例子。某STM32芯片的定时器时钟是84MHz,我想生成一个20kHz的PWM。选PSC=41、ARR=99,频率就是84,000,000 / (42×100) = 20kHz。占空比50%时,CCR=50。这里要注意的是,ARR和PSC是两回事:PSC决定的是“计数一次要多长时间”,ARR决定的是“一共数多少次算一个周期”。两者共同决定频率,但CCR只与ARR共同决定占空比。很多人刚开始会误以为调节PSC也能改变占空比,其实PSC只影响频率。
2. 生成PWM的三条路线:软件翻IO、定时器中断和硬件输出
2.1 纯软件延时翻转:只适合理解概念
最直觉的写法就是拉高IO,延时,拉低IO,延时,再拉高,循环。核心代码大概长这样:
while (1) { GPIO_SetPin(HIGH); delay_us(50); // 高电平持续时间 GPIO_SetPin(LOW); delay_us(150); // 低电平持续时间 }这种方式的优点只有一条:不依赖任何外设,只要会GPIO就能写。我第一次做呼吸灯用的就是它,LED确实能亮能灭,但做出来的效果很粗糙。问题出在三个方面:
- CPU被完全占死。延时期间什么都做不了,没法同时处理按键、显示、通信。
- 占空比不准。只要来了一个中断,ISR执行期间电平状态就被拉长了,波形直接失真。
- 频率做不高。延时函数的精度有限,想生成几十kHz的PWM基本不可能。
所以我的建议是:纯软件翻转只适合学概念的时候跑一下,确认“哦,原来PWM就是这个意思”,然后就没有然后了,正经项目千万别这么干。
2.2 定时器中断翻转:解决了占用,但精度依然有限
把延时改成“在定时器中断里翻转IO”,可以解决CPU被占死的问题。做法是让定时器按固定周期触发中断,每次中断里翻一次IO,再用一个变量记录当前处于高电平还是低电平状态。
void TIM_IRQHandler(void) { count++; if (count <= CCR) { GPIO_SetPin(HIGH); } else { GPIO_SetPin(LOW); } if (count >= ARR) { count = 0; } }这种方式比纯延时好,CPU可以在主循环里干别的。但依然有隐患:中断响应本身有延迟,从“比较条件成立”到“真正执行GPIO翻转”之间隔着中断入栈、变量读取、IO操作的一条龙时间。频率越高,这个延迟占周期的比例越大,波形就越不规范。所以我用它做过呼吸灯、简单的舵机控制,但一旦涉及电机驱动或者对波形时序有要求的场合,还是得回到定时器硬件的轨道上。
2.3 硬件PWM输出:工程上的正解
大多数MCU的定时器都有“输出比较”或“PWM模式”。它的原理简单说就是:计数器的计数值是硬件自动累加的,无需软件参与;当计数值等于CCR时,硬件自动翻转输出引脚;当计数满ARR溢出时,硬件自动复位。整个过程中CPU只需要在初始化时写一次寄存器,之后就可以去睡觉了。
硬件PWM和软件PWM最大的区别在于确定性。电平翻转的时机由数字比较器硬件完成,不依赖指令执行时间、不依赖中断响应时间,所以波形的频率和占空比是严格确定的。打开“PWM模式”和“自动重装载预装载”之后,运行中修改CCR还能让占空比在下个周期平滑生效,这也就是呼吸灯、云台舵机、电机调速这类动态调占空比场景的标准做法。
后面的内容基本都围绕硬件PWM展开,因为这才是工程上真正能用、用了不慌的生成方式。
3. 不同平台的PWM生成实操:从51到RK3588
3.1 51单片机:定时器模拟PWM,以及驱动WS2811的硬骨头
51单片机的大部分型号不带硬件PWM,或者只带一个极简功能的PCA模块。所以51上做PWM的标准姿势是:用定时器产生一个固定时间片中断(比如10us),在里面维护一个计数器,把计数器和目标比较值做比较,决定IO高低电平。
void timer0_isr() interrupt 1 { counter++; if (counter < compare_value) { P1_0 = 1; } else { P1_0 = 0; } if (counter >= period_value) { counter = 0; } }这套逻辑能跑出占空比可调的波形,做LED呼吸灯绰绰有余。但如果你拿去驱动WS2811/WS2812灯带,就会撞到南墙。WS2811的数据协议是纳秒级的:0码要求高电平约350ns、低电平约800ns,1码要求高电平约700ns、低电平约600ns。51的定时器中断进一次至少要几微秒,根本来不及逐bit输出。所以网上那些“51单片机驱动WS2811”的方案,要么用汇编做精细延时,要么用硬件SPI模块按位搬运,比如把8bit的亮度数据映射到16bit SPI数据,用2MHz左右的SPI时钟模拟时序。这种做法能跑,但相当折腾,而且主频不够的51很容易翻车。
3.2 STM32/PY32:通用定时器做PWM的寄存器级写法
到了STM32这个级别的MCU,PWM就变成了正经的外设功能。以STM32F103的TIM2为例,用标准库或HAL之外,我更推荐先看一眼寄存器级写法,能帮助理解硬件到底在干什么:
// 1. 开启定时器时钟,这里以APB1上的TIM2为例 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 2. 配置PA0为复用推挽输出,对应TIM2_CH1 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~(0xF << (0 * 4)); GPIOA->CRL |= (0xB << (0 * 4)); // 复用推挽输出50MHz // 3. 配置PSC和ARR,假设时钟72MHz,目标频率20kHz TIM2->PSC = 71; // 72MHz / (71+1) = 1MHz TIM2->ARR = 49; // 1MHz / (49+1) = 20kHz // 4. 配置通道1为PWM1模式,设置占空比 TIM2->CCMR1 |= (TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1); // 110: PWM模式1 TIM2->CCMR1 |= TIM_CCMR1_OC1PE; // 输出比较预装载使能 TIM2->CCR1 = 25; // 占空比 = 25/50 = 50% // 5. 使能输出和定时器 TIM2->CCER |= TIM_CCER_CC1E; TIM2->CR1 |= TIM_CR1_CEN;这段代码跑通了,PWM就在PA0上出来了。PY32F003这类国产小MCU思路完全一致,只是库函数不同。我之前用PY32F003做过一个呼吸灯demo:PWM输出接LED,主循环里每隔几毫秒把CCR从0递增到ARR再递减回来,LED的亮度就流畅地“呼——吸——”。整个过程几乎不占CPU时间,效果比软件PWM强太多。
3.3 Mega2560和RK3588:从Arduino到嵌入式Linux的PWM
Mega2560用的是AVR的Timer,如果不想折腾寄存器,Arduino的analogWrite()可以直接输出固定频率的PWM。但这里有个坑:analogWrite()的频率是固定的,大约490Hz(取决于Timer分频),想要调节频率得直接操作寄存器。比如说要配置Timer1输出10kHz的PWM,就必须自己改WGM、COM和OCR。Arduino封装了易用性,也把灵活性藏起来了。
RK3588这类带操作系统的SoC,PWM生成的思路又不一样。Linux下PWM通常由片上PWM控制器驱动,通过设备树配置好节点后,操作/sys/class/pwm/下的文件即可。我调RK3588风扇PWM时,流程是这样的:
# 在设备树里使能pwm-fan节点,配置好pwm通道和频率范围 # 系统启动后,导出PWM通道 echo 0 > /sys/class/pwm/pwmchip0/export # 设置周期(单位ns),比如40kHz echo 25000 > /sys/class/pwm/pwmchip0/pwm0/period # 设置占空比,50%就是12500ns echo 12500 > /sys/class/pwm/pwmchip0/pwm0/duty_cycle # 使能输出 echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable从51到STM32再到Linux,平台变了,但底层逻辑完全一致:频率由周期寄存器决定、占空比由比较寄存器决定、最终输出由硬件自动翻转。把这套抽象模型吃透,换什么平台都只是换个寄存器名字的问题。
3.4 常见负载的PWM参数参考
我把几个典型场景的参数整理成一张表,方便直接抄作业:
| 应用对象 | 频率 | 占空比范围 | 说明 |
|---|---|---|---|
| LED呼吸灯 | 1kHz | 0% ~ 100% | 动态改CCR即可 |
| 标准舵机 | 50Hz | 2.5%~12.5%(脉宽0.5~2.5ms) | 1.5ms脉宽对应中位 |
| 直流电机调速 | 20kHz | 0%~100% | 超过20kHz人耳听不到啸叫 |
| TEC温控 | 100Hz~1kHz | 0%~100% | 需要兼顾制冷效率 |
| 三相BLDC | 20kHz~50kHz | 由FOC算法计算 | 需要6路互补PWM+死区 |
4. 高级定时器才有的功能:死区、中心对齐、DMA和故障保护
4.1 死区:为什么H桥/BLDC控制绕不开它
H桥或三相逆变桥驱动电机时,上下桥臂的两个开关管(MOS管或IGBT)是串联在电源两端的。如果上下两个管子同时导通,电源直接短路,电流瞬间飙升,板子必炸。理想情况下PWM切换瞬间是“上面关断、下面开通”的完美接续,但实际器件不是瞬间完成开关的——关断延迟比开通延迟长。
解决办法是在上下桥切换之间插入一段“都不导通”的时间,这就是死区。死区时间里上下桥臂全部关断,电流靠MOS管体二极管或外部反并联二极管续流。STM32的高级定时器TIM1/TIM8、英飞凌TC3xx的CCU6这类外设,都支持硬件死区插入:只要配置了互补PWM和死区时间,硬件会在每一条互补边沿自动插入延迟,不需要软件干预。
死区时间不是越大越好。死区太长,波形失真、电机电流谐波增加、输出效率下降;太短,关断延迟覆盖不掉,直通风险仍在。常见做法是:先查开关管的关断延迟和栅极驱动电路的传播延迟,再留出30%~50%的余量。我之前用TC3xx CCU6输出三相六路PWM控制BLDC,死区设置在1us左右,使用示波器观察上下桥的导通波形,确认没有交叠区间后才敢真正上强电。
4.2 中心对齐模式与ADC采样时刻的选择
常规PWM叫边沿对齐模式:计数器从0数到ARR,然后立刻回0,输出波形的高电平集中在周期一侧。中心对齐模式不一样,计数器从0数到ARR再从ARR数回0,输出波形的高电平被对称地放在周期中间。
中心对齐的优点是输出波形对称性好,谐波含量低,电机相电流波形更平滑,所以FOC矢量控制里普遍用中心对齐模式。但代价是:同样的计数器时钟和ARR,中心对齐的PWM频率降为边沿对齐的一半;或者说要保持同频率,需要的计数频率翻倍。
这个模式下有一个非常实际的坑:ADC采样的时刻。电机控制电流采样要避开功率管开关瞬间产生的噪声尖峰,最佳采样时刻是计数器的顶端或底端——也就是PWM周期的正中心,此时上下桥臂状态刚稳定,噪声最小。我当时第一次调STM32高级定时器中心对齐模式加ADC同步采样,总采到毛刺很大的电流波形,后来把触发源配置为定时器更新事件再偏移一点采样点,波形才干净。这件事说明:PWM的波形质量不仅在于生成端,还在于和ADC采样的时序配合。
4.3 PWM+DMA:用内存搬运替代CPU干预
光看“PWM输出”本身,CPU只需要配置一次。但很多场景需要动态改变占空比,比如WS2812整条灯带串行数据、多路舵机的连续角度轨迹、音乐频谱灯。一波数据几百上千个点,如果每个点都在中断里改一次CCR,CPU忙死且时序不稳。
PWM+DMA的思路很简单:把占空比序列预放在一个数组里,利用定时器的更新事件作为DMA请求源,DMA自动把数组中的值逐个搬运到CCR寄存器。CPU只管准备数组和启动DMA,中间过程不参与。以STM32 HAL库为例:
// 假设已经初始化好PWM输出 uint32_t duty_buffer[100]; // 占空比序列,提前计算好 HAL_TIM_PWM_Start_DMA(&htim, TIM_CHANNEL_1, duty_buffer, 100);数据搬完可以触发DMA半传输/传输完成中断,在中断里填充下一段数据。这样就实现了“数据生成交给CPU、时序执行交给硬件”的流水线。我做WS2812灯带驱动的时候就是用的这个方案,一路PWM模拟数据位的脉宽,DMA把一个灯珠的GRB数据按bit展开成序列,几百个灯珠的刷新全程不卡顿。这也是网上“PWM DMA HAL”那类搜索词背后最典型的应用。
4.4 PWM故障保护(Break/刹车)机制
高级定时器通常都带故障保护引脚,比如STM32 TIM1/TIM8的BKIN引脚、TC3xx CCU6的故障输入。这个功能的设计初衷是:当外部电路检测到过流、过压、过温时,硬件在微秒或纳秒级内把PWM输出强制置于安全状态(比如全部输出低电平),不需要软件参与判断。
故障保护为什么重要?因为PWM控制电机或功率变换器时,软件响应速度根本赶不上故障的破坏速度。你可能在1us内就能烧掉一个MOS,但CPU从检测到中断到执行关断指令至少需要几百ns到几微秒,中间这段空白足以让功率器件冒烟。硬件故障保护直接锁死输出,是最后一道安全防线。
配置故障保护时要确认几个参数:故障输入极性(高有效还是低有效)、故障响应电平(输出高还是低)、故障后是否需要软件解锁。我在调试中遇到过一种情况:设置了BKIN后,PWM一有负载就立即保护,查了半天发现是外部比较器的参考电压设低了,正常工作时噪声触发了故障误判。这类问题排查起来确实头疼,但总比真炸管好。
5. PWM波生成之外的硬件细节:驱动级和电路设计的坑
5.1 单片机IO直驱还是驱动芯片?电流能力说了算
单片机的GPIO输出驱动能力一般在几毫安到二十毫安之间,带个LED没问题,带继电器、电机、灯带这类负载就力不从心了。PWM信号本身只是“控制信号”,真正驱动负载的是功率器件。
工程上最常见的接法:PWM从MCU引脚输出,经过一个限流电阻接到三极管或MOS管的栅极/基极,功率管再开关负载回路。这样负载的大电流走电源回路,不经过MCU引脚,MCU只负责提供几毫安的控制电流。如果负载电流更大或者开关频率更高,就用专门的栅极驱动芯片,比如半桥驱动、MOS驱动,它们内置电平转换、死区控制甚至故障反馈。
5.2 AO3400A这类N-MOS在PWM电路里的角色
AO3400A是一颗非常常见的低压N沟道MOS管,SOT-23封装,Vgs阈值电压大约在0.65V到1.4V之间,也就是说以3.3V或5V逻辑电平可以直接驱动,不需要额外电平转换。它的导通电阻Rds(on)在Vgs=4.5V时大概在几十毫欧级别,作为低压侧的PWM电子开关非常合适。
很多人直接把单片机PWM引脚接到MOS管栅极就算完事,但有几个细节值得注意。第一个是栅极电阻:栅极串联一个几欧到几十欧的电阻,用来抑制开关瞬间的振铃。电阻太小,栅极回路寄生电感会和栅极电容形成LC振荡,波形上出现明显过冲;电阻太大,开关边沿变缓,开关损耗增大。第二个是栅极下拉电阻:10k到100k左右的栅源下拉,防止MCU复位期间引脚悬空导致MOS误开通。这一点对“PWM上电瞬间乱跳”这类问题特别关键。
5.3 H桥PWM电路的数学原理
H桥是直流电机正反转控制的基础拓扑,四个开关管组成一个“H”形——负载接在中间横杠上,四个开关交替组合,让电流可以双向流过负载。PWM调速时,通常是固定方向,用PWM控制有效占空比。假设电源电压是Vbus,那么负载两端的平均电压就是:
- V_motor = D × Vbus(前提是方向组合固定且上下桥未直通)
这也是直流电机PWM调速的基本公式。实际波形并不平滑,电机两端等效一个电阻加电感,电感对PWM电压起滤波作用,电流纹波大小和PWM频率、电感量、母线电压都有关系。频率越高、电感越大、母线电压越低,电流纹波越小。但频率高到一定值后,开关损耗成为主要矛盾,所以要综合权衡。
6. 我在实际调试PWM时踩过的几类坑
6.1 上电瞬间的意外PWM导致误动作
MCU上电复位期间,GPIO默认状态是浮空输入或弱上拉,PWM外设还没初始化。如果这个GPIO直接接到MOS管栅极,浮空状态下栅极电压可能超过阈值,负载在上电瞬间会“抖”一下或者误动作。我遇到过一上电风扇就猛转一下才停的问题,排查了很久,最后在栅极加了一颗100k下拉电阻,同时把初始化顺序改成“先配置GPIO为低电平输出,再初始化PWM复用”,问题彻底消除。
6.2 示波器探头带来的假波形
PWM频率上到几十kHz之后,示波器测量本身的寄生参数就开始影响测量结果。最典型的是那条带鳄鱼夹的接地线,线长十厘米左右就等效于一个分布电感,会和探头电容形成谐振,波形上出现看起来像信号本身在“振铃”的假像。我一度以为是驱动电阻没调好,折腾了几个小时,最后换成一个短接地弹簧,振铃立刻消失。用示波器看高频PWM时,接地线尽量短,测量点尽量靠近引脚。
6.3 电机啸叫和PWM频率的恩怨
直流电机在几百赫兹到几千赫兹的PWM下会发出刺耳的“吱吱”声,这是PWM电流纹波中的音频分量通过电机壳体辐射出来的。解决办法很直接:把开关频率提高到20kHz以上,超出人耳听觉范围。但提高频率后要留意两个问题:一是开关损耗变大,MOS管温度会升高;二是如果你的驱动芯片本身开关速度不够,高频下波形会变差。所以提高PWM频率前,先确认驱动方案能扛住这个开关速度。
6.4 舵机和WS2811这类“挑剔”负载
舵机的PWM虽然占空比只有几个百分点到十几个百分点,但脉宽的绝对值是关键。标准舵机要求周期20ms(50Hz),脉宽0.5ms到2.5ms对应0度到180度。如果你的定时器频率设计得不合适,CCR的步进精度不够,舵机转动时就会一跳一跳,不平滑。WS2811则是另一个极端,它的数据协议对高低电平的脉宽精度要求是纳秒级,用普通定时器中断模拟基本没戏,老老实实用硬件SPI或PWM+DMA方案。
最后说说我自己做笔记的习惯。每次调完一个PWM相关的问题,我都会把三样东西记下来:第一是波形截图,标出频率、占空比和异常点;第二是寄存器配置代码,注明关键参数怎么算出来的;第三是踩坑过程,记录现象、排查链路和最终原因。这些笔记积累起来之后,再遇到类似问题基本能凭记忆快速定位,不用每次都从头查数据手册。建议你也试试这个办法,比收藏一堆教程有用得多。这篇PWM波生成的笔记就先整理到这里,下一步我打算把PWM+DMA驱动WS2812灯带的完整工程和波形实测数据整理出来,到时候再继续聊。