前阵子我在调一块半桥驱动板,两个低压MOSFET轮换导通,用来驱动一台12V直流电机。一开始图省事,直接用两路普通GPIO在主循环里软切换,结果板子跑了几分钟,其中一个功率管温度异常升高,拆下来一量已经击穿。把两路波形抓出来一看,问题很清楚:上下桥在切换沿有一段同时导通的时间,这就是直通。解决这类问题的标准做法,就是让STM32输出两路PWM互补信号,并在一路切换到另一路之间插入死区。这篇文章把“基于STM32的两路PWM互补输出带死区”这件事从头到尾讲一遍,包括直通原理、硬件死区机制、CubeMX与HAL库的配置方法、仿真验证,以及上板实测中会踩的坑。适合刚开始调电机驱动、想做半桥或全桥的开发者,也适合已经能输出PWM但没认真处理死区的工程师参考。
1. 上下桥直通才是真问题:死区到底在防什么
1.1 一次烧管经历引出的直通分析
半桥驱动的原理是两个功率管交替导通:上管开通时下管关断,下管开通时上管关断。理想情况下这是两路完全反相的方波,但实际功率管不是理想开关。驱动芯片有传播延迟,MOSFET本身有开通延迟和关断延迟,而且这两类延迟往往不一样;更关键的是,两路驱动信号的边沿不可能做到严格意义上的“上管刚关,下管就开”。如果两路有效电平在几百纳秒内重叠,电源正极会通过上管和下管直接到地,形成一条短路通路,电流只受回路寄生电阻和电感限制,远大于正常工作电流。
这个电流哪怕持续几百纳秒,也足以让管子结温急剧上升;反复出现,就离烧管不远了。我当时就是只看了静态电平,觉得“程序里已经让一路先关、再开另一路了”,但物理世界里的延迟和程序顺序是两回事,最终只能靠波形说话。后来把两路栅极波形叠加在示波器上,清清楚楚看到两路高电平有一段重叠窗口,真相就在眼前。
1.2 死区的时间尺度:不能太小,也不能太大
死区就是在两路互补输出之间强行插入的一小段“两路都无效”的时间。以高电平有效为例:上管PWM的下降沿到来后,先让两个管子都保持关断一小段时间,这段时间就是死区;随后下管PWM再给出上升沿。同理,下管切回上管时也有一小段死区。
常见功率器件的死区参考值差别不小:低压MOSFET通常在几百纳秒到2微秒,IGBT一般要2到10微秒。如果死区太小,防不住直通;如果死区太大,占空比被白白吃掉,输出波形畸变,电机噪声和发热都会上来。所以死区不是“设一个意思意思”,而是要根据器件开关参数、驱动芯片传播延迟、开关频率一起算出来的。后面第6章我会给一个工程取值思路,这里先把概念对齐。
2. 高级定时器的死区发生器:把“防直通”做进硬件
2.1 为什么不能靠软件延时来插死区
很多人第一反应是:切换的时候两个通道都拉低,软件延时几微秒,再拉高另一路。这条路在低速、低可靠性要求的场合勉强能跑,但在电机驱动这种场景有几个根本问题。
第一,软件延时严重依赖CPU执行路径,一个中断进来,死区时长就跟着飘,无法保证每次都一样,而直通恰恰是“偶尔一次”就会烧管。第二,CPU被死区延时占用,主循环和中断响应都会变慢,占空比刷新不及时,还容易引入抖动。第三,如果程序因为看门狗复位或跑飞,GPIO状态可能恰好停在两路都导通的电平上,没有硬件保护,结果只能再烧一块板。
STM32高级定时器里的死区发生器,就是专门解决这件事的:它完全由硬件逻辑生成互补波形,CPU只负责在恰当的时候修改比较值,死区和互补关系不依赖代码循环。这才是真正可靠的实现方式。
2.2 从时基到互补输出的信号链
具体看一下STM32里的实现。以常用的F1系列为例,TIM1和TIM8属于高级控制定时器,它们才是带死区发生器和刹车输入的那一类;通用定时器通常不具备互补输出能力,配了也没有OCxN。常用的互补通道是CH1、CH2、CH3,CH4一般只做单路输出。
整个信号链大致是这样的:定时器计数器CNT持续计数,ARR寄存器决定周期,CCR寄存器决定翻转位置。在PWM模式1下,CNT小于CCR时输出有效,CNT大于等于CCR时输出无效。原来的有效信号经过输出比较电路之后分成了两路:一路直接作为OCx输出,另一路经死区发生器延迟后作为OCxN输出。
OCx和OCxN本来来自同一个比较事件,但死区发生器会在OCx的边沿和OCxN的边沿之间插入一段可配置的延迟,保证其中一路还没完全无效之前,另一路不会变成有效。从波形上看,两条曲线的相邻翻转沿之间就有了一个间隙,这就是我们要的死区。
2.3 DTG寄存器:死区时间到底怎么算
死区长度由TIMx_BDTR寄存器里的DTG[7:0]字段控制,但DTG值和实际时间不是简单的线性关系,要分段看公式。死区时间步长Tdts默认等于一个定时器时钟周期,也就是fDTS=fCK_INT;TIMx_CR1的CKD位可以把步长变成2倍或4倍,用于获得更粗的时间分辨率。
设DTG[7:0]的二进制高三位为DTG[7:5],分段公式如下:
- DTG[7:5]=0xx时:DT = DTG[7:0] × Tdts;
- DTG[7:5]=10x时:DT = (64 + DTG[4:0]) × Tdts × 2;
- DTG[7:5]=110时:DT = (32 + DTG[4:0]) × Tdts × 8;
- DTG[7:5]=111时:DT = (32 + DTG[4:0]) × Tdts × 16。
举个例子,定时器时钟72MHz,Tdts约13.89ns。想要约500ns死区,在0xx段需要DTG≈36,写0x24,实际死区500ns。如果想用2us死区,0xx段理论上是144,但144的二进制是0b10010000,高三位已经变成100,会自动落到10x段,实际算出来是(64+16)×27.8≈2.22us。
这说明填寄存器时不能只按十进制大小想当然,一定要先看落在哪个区间。实际开发中CubeMX会帮我们算,但看波形对不上时,回来手算一遍往往能发现问题。
3. 从CubeMX到HAL库:把互补PWM配出来
3.1 CubeMX里的关键配置项
如果你用STM32CubeMX生成工程,整个过程其实不难。下面是我在一颗F103C8T6上跑通的配置思路,其他型号的TIM1/TIM8逻辑类似。首先在Pinout视图中选TIM1,把Channel1的工作模式设为“PWM Generation CH1 CH1N”,这样CH1和CH1N会被同时启用,对应引脚自动变成复用输出。
然后在Parameters配置里做几件事:把Clock Source设成Internal Clock;把Prescaler设为71,这样72MHz输入被分频成1MHz计数时钟;Counter Period设为999,得到1kHz的PWM周期;Pulse初始值设500,占空比50%;在Dead Time一栏直接填你想要的死区时间,单位是纳秒,CubeMX会帮你翻译成DTG寄存器值;输出极性一般选High,后面再根据驱动芯片需求调整。
为了方便对照,我把这次实验的关键配置整理成了下面这张表。
| 配置项 | 本次实验取值 | 说明 |
|---|---|---|
| Clock Source | Internal Clock | 使用内部时钟,在F1上即APB2总线时钟 |
| Channel1 Mode | PWM Generation CH1 CH1N | 同时启用主输出与互补输出 |
| Prescaler | 71 | 72MHz/(71+1)=1MHz计数频率 |
| Counter Period | 999 | 1MHz/(999+1)=1kHz PWM |
| Pulse | 500 | 初始占空比50%,后续可动态改 |
| Dead Time | 500~1000ns | CubeMX中按ns填写,自动换算DTG |
| CKD | 00 | 保持默认时Tdts等于定时器时钟周期 |
3.2 生成代码后必须补的三行
CubeMX生成的MX_TIM1_Init会把上面的参数写成寄存器,但它默认不会启动PWM输出,尤其不会启动互补通道。实际使用中,至少要在初始化完成之后补上这样几行:
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); /* 运行中修改占空比 */ __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 300);第二行HAL_TIMEx_PWMN_Start是启动互补通道的,和第一行的HAL_TIM_PWM_Start是完全分开的函数。即便在CubeMX里已经勾选了CH1N,不调用它,PB13那个引脚就是没有波形。这是我见过的最高频问题,也是新手最容易卡半天的点。建议每次写完驱动代码后,先用示波器确认两个引脚都有信号,再往下接负载。
3.3 预装载与动态更新:为什么改了占空比没反应
运行中改PWM参数,最常用的是修改CCR和ARR。上面代码里用了__HAL_TIM_SET_COMPARE宏去改CCR,它改的是CCR的影子寄存器。如果CCR的预装载位使能了,这个新值不会立刻生效,而是等到定时器更新事件发生时才真正写入硬件比较器。
CubeMX默认生成的代码通常把预装载打开,所以改完Pulse后不会立刻看到波形变化,而是等到下一周期才生效。这本身是好事,能避免在PWM周期中途突然跳变带来的尖峰;但如果你是为了调试想立刻看到变化,就会觉得“改了没反应”。
需要立刻生效时,可以临时把预装载关掉,或者手动触发一次更新事件。我个人不太建议频繁手动改计数器,因为那样定时器的时基会乱。更好的做法是设计状态机:在特定的更新中断里改CCR和ARR,保证每次修改都发生在一个周期的边界。另外要小心把ARR改得比CCR还小,这会导致输出逻辑不稳定,写代码前先算好范围。
4. 仿真探索:先用软件把互补逻辑跑通
4.1 用Proteus搭一个PWM仿真工程
代码跑通之前,先用仿真把逻辑验证一遍,成本最低。我用的是常见的Proteus仿真环境,步骤很简单。先在CubeMX里按第3章的参数生成工程,编译出HEX文件。然后在Proteus里放一个STM32F103C8芯片,把PA8和PB13两个引脚用导线引出来,连接到虚拟示波器的两个输入通道。
加载HEX文件后运行,示波器上应该能看到两路方波。需要注意的是,虚拟示波器的时基要匹配PWM频率,1kHz的PWM建议把每格时间先设成500us,能看到几个完整周期;后面想看死区,再把时基放大到几十微秒甚至几微秒。
有个小细节:Proteus里芯片型号和CubeMX里最好保持一致,引脚封装不同会影响你拖出来的引脚位置。如果放了一个LQFP48封装,PA8和PB13的实际坐标可能和原理图库不一样,运行前先确认引脚的网络标签。
4.2 在仿真波形上识别死区
互补PWM带死区,在示波器上看起来并不复杂:两路波形基本反相,但在每个翻转沿附近,有一段两路都是低电平的间隙。测量方法是用示波器的标尺量一下PA8下降沿到PB13上升沿的时间差,这段间隔就是死区。如果配置了500ns,理论测量结果应该在500ns附近。
注意不要只测一边,另一边的边沿间隔也要看,因为在有些极性配置下,两个方向的实际死区可能略有差别。这里要提醒一句:Proteus对STM32外设的建模并不是把所有寄存器时序都百分百还原,不同版本对死区发生器的支持程度也不一样。如果仿真波形里两路看起来完全互补、没有任何间隙,先别急着怀疑代码,去查一下仿真模型版本和软件设置。
仿真最重要的价值是验证通道配对、引脚连接、PWM频率和占空比这些宏观逻辑,而不是验证纳秒级的时序细节。
4.3 仿真能发现和不能发现的问题
我用仿真实际验证下来,能发现的问题集中在几类:通道有没有选对、引脚是不是被其他外设占用、占空比修改后波形有没有随Pulse变化、两路是否真的互补。这些问题如果直接上板排查,往往要花不少时间。
仿真看不出来的则是真实世界的问题:MOSFET的开关延迟、栅极驱动芯片的传播延迟、PCB寄生电感引起的振铃、负载突变带来的电压尖峰。所以我的建议是:仿真跑通是第一步,但死区参数是否真的合适,最终必须以板级实测波形为准。
5. 上板实测:波形和死区值到底准不准
5.1 逻辑分析仪采样率的选择与接线
从仿真切到真实板子,我用的是通用USB逻辑分析仪加开源分析软件。接线非常简单:把通道A接PA8,通道B接PB13,地线夹住板子的GND,共地必须一致。
采样率要根据死区宽度来选,不能拍脑袋。比如目标死区是500ns,逻辑分析仪一个采样点如果是62.5ns(16MS/s),理论上还能看出大致趋势,但边沿定位误差会比较大;建议至少用50MS/s,这样死区测量误差能控制在几十纳秒量级。如果手头工具采样率上不去,就把死区临时调大到几微秒,先验证逻辑,再回到正常工作值。
还有接线方式的问题:如果你直接把逻辑分析仪夹在MOSFET栅极上,探头引线越长,引入的振铃越明显,测出来的边沿和死区可能被噪声干扰。我更建议先测驱动芯片输入端的两路PWM信号,确认定时器输出没问题后,再测栅极波形,看看驱动芯片有没有引入额外的延迟。
5.2 现象一:CH1N没有输出,把整个链路查一遍
这个现象出现频率最高,而且很容易一踩就是半天。我的排查顺序是:先看PA8有没有PWM。如果PA8也没有,基本可以断定定时器没启动或时钟配置有问题,回到CubeMX检查TIM1的Clock Source和引脚复用。如果PA8有、PB13没有,下一步查GPIO配置:PB13是不是被另一个初始化函数覆盖成了普通GPIO或者别的外设功能?很多开发板的PB13还连着板载LED或按键,代码里一旦初始化了GPIO,就会把复用功能冲掉。
如果GPIO没问题,再看启动函数。这里再强调一次,HAL_TIMEx_PWMN_Start和HAL_TIM_PWM_Start是两回事,漏掉任何一个,对应通道就是死的。最后一步检查CubeMX生成的引脚冲突表,看看有没有其他外设也在用PB13。按这个顺序查,基本都能定位。
5.3 现象二:两路高电平重叠,死区没有生效
看到两路高电平重叠时,第一反应不是“器件坏了”,而是先确认死区寄存器是不是真的写进去了。建议调试时直接在代码里读一遍TIM1->BDTR寄存器,看看DTG字段是不是你期望的值。
如果读出来是0,说明配置过程被某个函数覆盖了,比如初始化之后又调用了HAL_TIMEx_ConfigBreakDeadTime,而这个传入的DeadTime参数是0,那就等于把死区清掉了。还有一种隐蔽情况:CubeMX生成的初始化代码里,如果Break和DeadTime配置在某个条件编译块里,实际没执行,你看到的寄存器值自然不对。
检查完寄存器,再检查CKD位。有人把APB2配置成36MHz,忘记定时器时钟是2倍关系,以为fDTS=36MHz,实际是72MHz,算出来的DTG就会差一倍。这种时钟理解问题在死区排查里很容易被忽略。
5.4 现象三:死区值与预期偏差很大,用反推法定位
如果死区值不对,最快的定位方法是反推:把DTG设成0xFF,用逻辑分析仪测实际死区,反推出当前的Tdts。比如在72MHz、CKD=00时,0xFF落在111段,计算值是(32+31)×16×13.89≈14us。如果你实测出来接近28us,说明Tdts比预期大一倍,问题基本锁定在时钟配置或者CKD的选择上。定出Tdts之后,再按公式算回想要的死区对应的DTG,重新配置。
这个反推法在实际调试里特别有用,因为很少有人能一眼看出寄存器配置问题,但波形不会说谎。我第一次调的时候,就是靠这个办法发现APB2分频设置和预期不同的。
6. 死区参数的工程取值与调试心得
6.1 不同器件类型的死区参考范围
死区取值没有通用标准答案,但可以按器件类型给一个经验范围:低压MOSFET(比如40V以内的电机驱动)通常在0.2到1微秒;高压MOSFET(100V以上)因为开关时间更长,通常0.5到2微秒;IGBT一般需要2到10微秒,具体要看模块的开关损耗和驱动电阻。选择时至少要考虑三部分时间:功率管的关断延迟、驱动芯片的传播延迟、以及安全余量。总死区应大于上管完全关断到下管开始开通之间的实际间隔,否则直通保护就是假的。
我把常用参考整理在下面,供初选时用,但肯定要拿实际波形复核。
| 器件类型 | 常见死区范围 | 主要考虑因素 |
|---|---|---|
| 低压MOSFET | 0.2~1us | 开关时间短,死区可以小,注意寄生振荡 |
| 高压MOSFET | 0.5~2us | 米勒平台时间长,需要更大间隔 |
| IGBT | 2~10us | 拖尾电流明显,死区必须留够 |
6.2 死区过大带来的占空比损失
死区不是越大越安全。死区期间两路都关断,负载两端的有效电压会减少,等效为输出占空比被吃掉了一块。对于电机驱动,这意味着实际施加到电机的平均电压低于理论值,电流波形可能产生畸变,电机噪声和损耗都会增加。
如果系统里有电流闭环或速度闭环,死区还容易在过零附近引起电流波动。所以在满足防直通的前提下,死区应该尽量小。等到你开始做高性能电机控制时,还会碰到死区补偿的问题,但基础是先能把死区做对。
6.3 上板后我建议的验证流程
每次调完新板子,我固定走一遍这个流程:第一步,空载状态下测定时器输出端的两路PWM,确认频率、占空比、互补关系、死区时间都和配置一致。第二步,接上驱动芯片,测驱动芯片输出端波形,确认驱动芯片的传播延迟没有把死区缩小到危险水平。
第三步,接上功率级,先低压限流,测栅极波形,重复检查死区,同时观察负载有没有异常噪声。第四步,满载运行一段时间,用温度数据验证死区余量是否合理。这套流程看起来繁琐,但能省下很多返工时间。尤其是第一步,很多板子最终烧管,问题根本不在功率部分,而在定时器配置。
6.4 扩展方向:刹车输入与硬件故障保护
最后说一个和死区紧密相关的扩展点:高级定时器的刹车输入BKIN。它的作用是可以让一个外部信号直接触发定时器封锁PWM输出,把所有输出引脚拉到安全状态。这个过程由硬件完成,不需要CPU响应,比软件关输出快得多。
做电机驱动的时候,我强烈建议把过流保护信号接到刹车输入上。这次文章主题集中在互补PWM和死区,刹车功能以后有机会再单独展开,但原理上它是死区保护的天然搭档。
最后分享一点个人体会。刚接触互补PWM时,我也觉得死区只是个“为了稳妥而存在”的细节,结果第一次烧管就狠狠补了一课。现在我调这类板子固定三步:先按器件手册和驱动芯片传播延迟选一个初值;再上板用逻辑分析仪抓真实栅极波形,核实死区到底有多少;最后用满载温升来验证余量合不合理。仿真能帮你快速跑通逻辑,但真实死区值最终要以板级波形为准。每一次看到波形上清清楚楚地出现那段两路都低的时间,我心里才算踏实。建议你也把这个流程养成习惯,先把死区问题想透,再去谈效率和补偿,能少走很多弯路。