如果你调过逆变器、电机驱动器或者任何带功率半桥的电路,大概率遇到过这类需求:同一组上下桥臂需要两路PWM驱动,波形反相、相位差严格180°,占空比还得跟着控制指令随时变。直接拿两路独立的定时器输出,逻辑上能凑出反相,但只要负载一变化、频率一提高,你就会发现相位对不齐、波形抖动,严重的时候上管和下管会短暂同时导通,几微秒内直接烧管子。我这次要分享的,就是用STM32高级定时器的硬件互补输出,生成两路相位相差180°、占空比可调的PWM,把从原理、配置、代码到实测的完整过程整理出来。
这篇文章适合正在做电源、电机驱动、H桥/全桥拓扑的开发者,也适合刚接触STM32高级定时器、想搞清楚TIM1/TIM8互补通道怎么用的人。我会先给结论:在STM32上做两路反相PWM,最优解是高级定时器自带互补输出的硬件机制,而不是拿两个定时器软件翻转。接下来把为什么这么选、底层寄存器如何配合、代码怎么写、实测要抓哪些波形、以及实际项目中容易踩的坑,一次讲透。
1. 反相PWM到底解决什么问题:从半桥驱动到占空比调节
1.1 半桥为什么会烧管子:直通的代价
很多做控制板的朋友第一次接触反相PWM,是从半桥驱动开始的。半桥电路里,两个功率管串联在电源正极和地之间,负载接在两个管子的中点。要让负载两端电压为正,就开通上管、关断下管;要让负载两端电压为负,就开通下管、关断上管。
问题来了:如果控制信号出现一瞬间“上下同时给高”,电流就会从电源正极直接穿过两个管子回到地,中间几乎不带负载,回路阻抗极低,瞬间电流可以达到几十安甚至上百安。功率管的导通压降再小,也扛不住这种短路电流持续几个微秒。这就是所谓“直通”,也是半桥电路最容易烧管子的原因。
所以两路驱动信号必须保证:任何时刻,最多只有一个管子处于开通状态。这是“反相PWM”这个需求最原始、最底层的动机——不是为了让波形好看,是为了让功率级安全。
1.2 “相位差180°”与“互补+死区”的真实差距
这里有个概念上的坑值得一提。很多工程师把“反相”“相位差180°”和“互补输出”混为一谈,其实它们是三个层次的需求:
- 反相:两路波形逻辑电平相反。比如第一路高电平时第二路低电平,反之亦然。
- 相位差180°:两路周期相同,但导通区间在时间轴上错开半个周期,互不重叠。
- 互补+死区:两路在逻辑上互斥,并且在任意一路关断到另一路开通的切换瞬间,主动插入一段“两路都关闭”的时间窗口。
纯软件做反相很容易,引脚翻转就行。但纯软件做不了“主动插入死区窗口”这件事——因为你无法预知外部功率管要多久才能完全关断。如果只做逻辑反相,上下管的导通区间虽然错开了,但切换瞬间仍然可能因为管子关断延迟、驱动芯片延迟、布线寄生参数,出现短暂重叠导通。这个重叠非常致命。
STM32高级定时器的互补输出,就是为了解决这个场景设计的。硬件上主输出和互补输出天然反相,死区时间由定时器硬件自动插入,CPU只负责给占空比,不需要在中断里实时维护相位关系。
1.3 频率、占空比、死区参数怎么定
要给出一个可复现的设计,参数必须先定调。我以某电源项目里实际用的参数为例:
- PWM频率:20kHz,这是电机驱动和逆变器比较常用的频率,兼顾开关损耗和噪声。
- 占空比范围:5%~95%,软件做边界钳位,不在极限值附近工作。
- 死区时间:1微秒。开关管的关断延迟大约0.2~0.4微秒,加上驱动芯片的传输延迟,死区给到1微秒是比较稳妥的起步值。
- 相位关系:主输出和互补输出相差180°,占空比跟随关系为“互补”:主输出占空比为D,互补输出占空比就是1-D。
这些参数定下来,后面定时器的时钟分频、自动重装载值、死区寄存器值就都有依据了。如果直接跳进代码乱配PSC和ARR,波形出不来或者频率不对,返工成本更高。
2. 四种实现相位差180°的方式:硬件互补输出、双定时器同步、双通道镜像与IO翻转
2.1 方案A:高级定时器OCx/OCxN硬件互补输出
这是本文最终采用的方案,也是工程上的首选。STM32的TIM1和TIM8属于高级定时器,每个输出通道都带一对互补引脚,比如TIM1_CH1对应PA8,互补输出TIM1_CH1N对应PA7。硬件逻辑上,CH1和CH1N由同一个比较寄存器CCR控制,占空比由CCR和ARR共同决定,两路输出天然反相,中间再插入可配置的死区时间。
整个过程中,CPU只需要往CCR寄存器写一个值,剩下的“反相”和“插入死区”全部由定时器硬件完成。这意味着死区时间精确、抖动小、不受中断影响,也比软件翻转省出大量CPU时间。我在实际项目里测过,20kHz的PWM,开互补输出后CPU占用基本可以忽略。
2.2 方案B:主从定时器同步
有人手里芯片没有高级定时器,只有通用定时器TIM2~TIM5,该怎么办?最接近的方案是让两个通用定时器做主从同步:主定时器输出PWM,从定时器通过内部触发输入(ITR)与主定时器同步,从定时器的计数器在每个周期被主定时器复位一次,从而保证两路PWM周期一致、相位对齐。
这个方案原理上可行,但要做到“相位差严格180°”并不容易。同步只能解决“周期对齐”,无法直接制造“半个周期偏移”。要在从定时器上实现半个周期偏移,通常得借助比较翻转模式或者镜像CNT值,代码复杂度明显上升。再加上通用定时器没有死区硬件,你还需要在外部用RC延时电路或者驱动芯片补死区,可靠性反而不如高级定时器。
2.3 方案C:同一个定时器双通道镜像
思路是:用一个定时器的两个通道,通道1配置成PWM1模式,通道2配置成PWM2模式,再让CCR2 = (ARR + 1) - CCR1,这样两个通道的输出波形就形成了时间上的镜像关系,占空比也是互补的。
这个方案比双定时器同步简单,因为它天然共享同一个计数器CNT,不需要外部同步。但有两个明显问题:一是依旧没有硬件死区,上下管切换瞬间的保护完全靠外部电路;二是两个通道的比较事件在同一个计数周期内,更新CCR时要格外小心时序,否则容易导致边沿抖动。如果项目里实在没有高级定时器,用这个方案勉强能跑,但我不推荐在功率较大的场合使用。
2.4 方案D:GPIO翻转或DMA刷IO
还有一类思路是完全绕开定时器比较输出,直接用GPIO翻转,或者用DMA把波形数据写到GPIO数据寄存器。
这类方案最明显的短板是没有确定性。GPIO翻转需要进中断或依赖定时器更新事件,中断响应延迟、DMA总线占用都会造成相位抖动。20kHz下周期只有50微秒,哪怕抖动2微秒都会让占空比偏差4%,这在电机控制里已经非常明显了。它的适用场景更多是低频率、低功率的演示项目,不适合作为功率级驱动。
2.5 选型结论:一张表看清楚差距
| 方案 | 相位精度 | 硬件死区 | 动态调占空比 | CPU开销 | 适用场景 |
|---|---|---|---|---|---|
| A:高级定时器互补输出 | 高,硬件保证 | 支持,寄存器配置 | 方便,写CCR即可 | 极低 | 半桥/全桥/逆变器,首选 |
| B:通用定时器主从同步 | 中等,依赖同步链路 | 不支持 | 较麻烦 | 中等 | 无高级定时器的场合 |
| C:单定时器双通道镜像 | 较高,同一CNT | 不支持 | 一般 | 低 | 低功率演示 |
| D:GPIO/DMA翻转 | 低,抖动明显 | 不支持 | 繁琐 | 高 | 演示、低速PWM |
我做这个项目时直接选了方案A,事实证明后续调试省了一大半时间。只要板子上有TIM1或TIM8,优先考虑互补输出,不要在通用定时器上死磕。
3. 用TIM1的CH1和CH1N配置20kHz互补PWM:寄存器级初始化解说
3.1 先算时钟:PSC、ARR与DTG的数学关系
以STM32F1系列为例,TIM1挂在APB2总线上,频率我按系统主频84MHz来说明。要让PWM频率等于20kHz,先确定计数时钟。
选择预分频PSC = 3,计数时钟就是 84MHz / (3 + 1) = 21MHz。每个PWM周期需要的计数个数是 21MHz / 20kHz = 1050。因为计数器从0计数到ARR,所以ARR = 1050 - 1 = 1049。这样占空比 50% 时,比较寄存器 CCR = 1050 / 2 = 525。
死区1微秒对应的计数个数是 21MHz × 1微秒 = 21个计数周期。死区控制寄存器DTG的分段公式里,当DTG[7:5] = 000时,死区时间 = DTG × Tdts,其中Tdts = 1 / 21MHz ≈ 47.6纳秒。所以DTG = 21 / 47.6 ≈ 21,写成十进制就是0x15。
这个计算逻辑建议你自己手推一遍,比直接抄代码可靠得多。
3.2 GPIO复用配置:PA8和PA7的初始化为数不多的高级定时器专用引脚
TIM1_CH1默认复用输出在PA8,TIM1_CH1N在PA7。如果你用的是更大封装,别忘记还有重映射可以选择,比如把CH1映射到PE9、CH1N映射到PE8。代码里先用复用推挽模式初始化这两个引脚,注意不是开漏,PWM输出要用推挽才能提供足够的驱动能力。
GPIO这块有个细节:F1系列的AFIO时钟也需要使能,不然复用功能可能不生效。虽然很多示例代码没开也跑起来了,但确实存在个别封装上复用映射异常的情况。我习惯把RCC_APB2Periph_AFIO一起开了,顺手的事,不给自己留隐患。
3.3 TIM1初始化:时基、输出比较、死区寄存器逐项配置
先看时基结构体:
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Prescaler = 3; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 1049; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure);时钟分频系数TIM_ClockDivision在PWM模式下一般用DIV1,它主要影响输入捕获的数字滤波器采样频率,对输出PWM没有影响。
然后是输出比较结构体,这里是最容易配错的地方:
TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_OutputNState = TIM_OutputNState_Enable; TIM_OCInitStructure.TIM_Pulse = 525; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OCNPolarity = TIM_OCNPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure);PWM1模式下,CNT小于CCR时输出有效电平,大于等于CCR时输出无效电平。把OCPolarity和OCNPolarity都配成High,意思是在无效状态下输出低电平、有效状态下输出高电平,这样CH1和CH1N才能在逻辑上形成反相。如果极性配置反了,两路会同时输出高电平,后果就是半桥直通。
紧接着开预装载功能,保证运行中修改CCR值时不会造成当前周期波形畸变:
TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE);最后配置死区寄存器BDTR,这是高级定时器独有的一环:
TIM_BDTRInitTypeDef TIM_BDTRInitStructure; TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Enable; TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Enable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime = 21; TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, &TIM_BDTRInitStructure);TIM_OSSRState和TIM_OSSIState控制的是运行态和空闲态下输出状态寄存器是否使能,一般按库示例都打开。TIM_AutomaticOutput必须使能,它对应BDTR寄存器里的AOE位,决定刹车事件发生后主输出能否自动恢复。TIM_Break我们不使用,所以Disable,极性随便设置不影响。
初始化完成后,还有两个步骤顺序不能错:先使能主输出,再启动定时器。
TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE);TIM_CtrlPWMOutputs设置的是BDTR寄存器里的MOE位,也就是主输出使能位。这个位不清除,高级定时器的PWM引脚上是不会有波形的。我第一次调的时候就是忘了加这一句,PA8和PA7量了半天都是低电平,还以为是引脚复用配错了。
3.4 动态调占空比函数:CCR值的计算与边界保护
两路互补输出共用同一个CCR1,修改占空比只需要改这一个寄存器。写一个通用函数:
void SetDuty(uint16_t duty_percent) { uint16_t ccr; if (duty_percent < 5) duty_percent = 5; else if (duty_percent > 95) duty_percent = 95; ccr = (uint16_t)((uint32_t)1050 * duty_percent / 100); TIM_SetCompare1(TIM1, ccr); }为什么把边界钳位在5%~95%?因为靠近0%或100%时,死区时间会相对挤压,输出有效脉冲宽度可能小于死区宽度,波形会出现异常窄脉冲,对功率管驱动不友好。实际项目里,我一般会在初始化后立即调用一次SetDuty(50),让输出先落到一个安全占空比,再启动主电路。
4. 示波器实测:死区窗口、相位偏移量与动态调制的验证方法
4.1 第一帧波形应该长什么样
程序下载进去之后,第一步不用看太多参数,先把CH1接到PA8,CH2接到PA7,公共地接好。正常情况下应该看到两路频率相同、幅值相同、相位相反的方波。
把时基放到25微秒/格左右,20kHz的周期是50微秒,一屏刚好显示两个周期。如果占空比设成50%,两路高电平时间都应该是接近25微秒,但上升沿之间会有一个很小的错位,这就是死区造成的偏移。
注意一个容易误判的点:严格意义上的“相位差180°”在没有死区时,两路上升沿应该恰好相隔半个周期。加上死区后,互补输出的上升沿会比“理想反相点”晚一个死区时间。这是正常现象,不是配置错误。我的实测数据是:主输出上升沿到互补输出上升沿的时间间隔约26微秒,其中25微秒是半周期,1微秒是死区。
4.2 死区窗口到底怎么“看出来”和测准
死区插入在每一个切换边沿,表现为一段两路均为低电平的窗口。用示波器测死区时有几个注意事项:
把时间轴放大到500纳秒/格,观察主输出的下降沿和互补输出的上升沿之间,是否有一段两路都是低电平的区间,这段宽度就是死区时间。我测到的稳定值在1.02微秒左右,符合DTG=21的计算预期。
顺便提一个更隐蔽的检查点:开启余晖模式,停留几秒,观察死区窗口是否始终保持在同一位置。如果窗口位置在逐周期漂移,说明相位抖动偏大,需要检查时钟源和初始化配置。
不要只测一路波形就判断死区有效,必须两路同时观察。只测量主输出,你只能看到占空比,看不到互补通道的切换关系。
4.3 动态调节占空比时的验证方法
在调试串口里接一个简单的指令解析,输入数字就能改变占空比。每次修改后观察输出状态:波形应该在一个完整周期后稳定到新占空比,中间不出现窄脉冲或者多余边沿。
如果使用了预装载功能,CCR的更新会在下一个更新事件生效,所以从修改到波形变化最多延迟一个周期,这是正常的。真正需要警惕的是:没有任何延迟就出现的波形变化,大概率是因为预装载没使能,寄存器直写导致当前周期被截断。
我在实测中还习惯把占空比阶梯从5%调到95%再调回来,全程观察是否有毛刺。用示波器的脉冲宽度触发功能,设置一个略小于正常脉宽的触发条件,可以自动捕捉异常窄脉冲,比肉眼盯屏幕高效得多。
5. 实际项目里踩过的五个坑:从启动毛刺到占空比边界
5.1 坑一:忘记使能主输出,波形静默
这个坑在前面已经提到,但还是值得单独记一笔。TIM1/TIM8的引脚输出受BDTR寄存器MOE位控制,只要MOE位为0,哪怕定时器在跑、CCR和ARR配置全对,引脚上也不会有信号。
很多初次接触高级定时器的人,在GPIO、时基、输出比较上都检查了一遍,唯独漏掉TIM_CtrlPWMOutputs(TIM1, ENABLE)。排查方法很简单:调试器里查看BDTR寄存器,确认MOE位是不是1。如果不是,先把主输出使能函数加上。
5.2 坑二:极性配置错位,两路同时导通
把TIM_OCPolarity配成Low是另一个高频错误。这样配置之后,PWM的有效电平变成低电平,看起来波形反相了,但死区逻辑可能错位,严重情况下两路高电平区间重叠,直接构成直通风险。
正确理解是:极性位决定的是“有效电平”是高还是低,而不是输出是否取反。标准用法是OCPolarity和OCNPolarity都配成High,让有效电平均为高电平,然后依靠比较逻辑实现反相。配完之后,最好用示波器确认一个关键点:任意时刻两路不能同时为高。
5.3 坑三:DTG不会算,死区不是想要的值
DTG寄存器不是简单的“死区时间直接写进去”,它分成四段分段公式。很多新手想配置1微秒死区,直接写DTG=1,结果死区时间是几十纳秒,管子关断还没完成对管就开通了。
正确做法是先算Tdts,再确定落在哪一段DTG范围。前面已经算过,21MHz计数时钟下1微秒对应21个计数周期,落在DTG[7:5]=000区间,所以直接写21即可。如果死区更大,可能需要进入高段位公式:
- DTG[7:5] = 0xx:死区 = DTG × Tdts
- DTG[7:5] = 10x:死区 = (64 + DTG[5:0]) × Tdts × 2
- DTG[7:5] = 110:死区 = (32 + DTG[4:0]) × Tdts × 8
- DTG[7:5] = 111:死区 = (32 + DTG[4:0]) × Tdts × 16
每次改完死区都建议实测一下,不要只看公式。
5.4 坑四:运行中改ARR/CCR出现畸形波形
运行中直接修改ARR或CCR,如果没有预装载保护,可能在当前计数周期内就产生边沿跳变,导致一个周期的占空比异常。这个异常周期如果发生在大功率设备上,轻则电流波动,重则触发保护。
解决办法就是前面代码里那两行:TIM_OC1PreloadConfig和TIM_ARRPreloadConfig都使能。这样每次修改只在更新事件到来时统一生效,保证当前周期波形完整。
5.5 坑五:0%和100%边界的脉冲“吞噬”
把占空比调到0%或100%,表面上输出恒定低或恒定高,但对带死区的互补结构,占空比接近极限时会出现一个容易被忽略的现象:有效脉冲宽度可能小于死区时间,死区窗口会占掉整个脉冲,导致互补输出端出现极窄的毛刺或缺失边沿。
我最后采用的方案是软件钳位,把占空比限制在5%~95%,从源头上避开极端情况。如果你的系统确实需要极限占空比,建议另外设计欠压保护和对管互锁逻辑,不要完全依赖PWM硬件。
6. 从固定反相扩展到动态移相:CCR镜像算法与SPWM/DMA总线
6.1 用CCR镜像实现任意相位角
“相位差180°”其实是移相PWM的一个特例。有些项目需要做动态移相,比如两路PWM之间的相位差从180°调成120°,这时候可以把互补输出思路升级成“双通道镜像法”。
核心思想是:主通道的CCR1保持占空比控制,第二通道的CCR2根据目标移相角动态计算。如果目标相位差是180°,固定反相时,CCR2 = (ARR + 1) - CCR1即可,与PWM2模式配合能实现互斥输出。如果目标相位差是任意角度,先算出移相计数:
SHIFT = (uint32_t)((ARR + 1) * theta_deg / 360)
再根据方向计算CCR2:
CCR2 = (ARR + 1 + CCR1 - SHIFT) % (ARR + 1)
整个过程在控制代码里执行,需要把通道2也配置成PWM模式并开启预装载,然后每个控制周期更新CCR2。这个方法的精度受定时器时钟分辨率限制,但20kHz载波下每个计数周期47.6纳秒,实际已经足够细腻。需要提醒的是,这个方案没有硬件死区,用在功率级时一定要外接带死区的栅极驱动芯片。
6.2 从PWM到SPWM:DMA批量喂CCR
如果进一步把PWM用于逆变器,就不只是“固定两路互补”这么简单了。50Hz正弦波调制到20kHz载波上,每个周期需要按正弦表更新几百次CCR值。用CPU在中断里逐个更新,虽然能跑,但会占用大量中断时间。
更高效的做法是用DMA定时搬运。预先算好一张正弦表,按照载波频率查表得到对应的CCR序列,再把DMA配置成由定时器更新事件触发,每次更新事件自动把下一个CCR值写入TIM1的比较寄存器。这样CPU只需要在正弦表切换时介入,其余时间完全交给硬件。
高级定时器的互补输出在这个架构里依然扮演核心角色:它提供死区、反相和可靠的功率级驱动逻辑,DMA只是把“占空比数据流”搬运到CCR寄存器。我之前在某逆变器样机上跑过这个结构,20kHz调制频率下,输出波形稳定,动态响应也够用。
最后说句实在话。做这类PWM输出,我最大的体会是:能用硬件解决的事,就不要用软件硬撑。STM32高级定时器把反相和死区都做成硬件机制,本意就是让你把精力放在控制算法和系统调参上,而不是在中断里去矫正一个本来就该硬件保证的边沿关系。如果你正在做半桥或全桥项目,建议从高级定时器互补输出起步,把极性、死区、预装载这几个基础点吃透,再往动态移相和SPWM扩展时,你会发现自己已经站在一个非常稳的地基上了。