1. 项目缘起与整体设计思路
1.1 为什么偏偏要选互补PWM,而不是单路PWM加死区芯片
很多刚接触电机驱动的朋友,第一反应是拿STM32的一个普通定时器通道输出一路PWM,再外挂一个IR2104或者EG2133这类半桥驱动芯片,靠芯片自带的死区逻辑去推半桥。这个方案能用,但做到双极性调制或者需要精确控制上下管切换时序的时候,你就会发现外挂死区芯片的延迟一致性很难保证,尤其是批量生产时不同批次的驱动芯片死区时间能差出几十纳秒,反映到电机上就是低速抖动和MOS管温升不一致。
STM32的高级定时器(TIM1、TIM8)自带互补输出通道和可编程死区发生器,死区时间可以从0到几百纳秒精细调节,而且上下管的切换由硬件保证,不受中断延迟和软件抖动影响。我实测过用TIM1的CH1和CH1N推半桥,死区设成200ns,MOS管栅极波形干净得很,完全没有交叉导通的风险。这就是为什么这个项目值得单独拿出来讲——它解决的是电机驱动里最核心的“安全换相”问题。
这个内容适合谁看?如果你已经会用STM32CubeMX点灯、会配置基本定时器,但一到电机驱动就心里没底,不知道死区怎么算、互补输出怎么接、刹车功能怎么用,那这篇就是写给你的。我会从CubeMX的配置界面一路讲到示波器实测波形,把每个参数背后的“为什么”都掰开揉碎。
1.2 整体方案框图和信号流向
先把这个项目的硬件链路理清楚,不然后面配置的时候容易迷。整个系统分四层:
- 控制层:STM32F103C8T6或者STM32G431,跑CubeMX生成的HAL库代码,负责产生互补PWM和接收调速指令。
- 驱动层:半桥栅极驱动芯片,比如IR2104S、EG2133或者UCC27211,把STM32的3.3V逻辑电平抬升到MOS管需要的10-15V驱动电压。
- 功率层:N沟道MOS管组成的半桥或全桥,比如IRF3205、IRF540N或者更现代的CSD18540,负责真正流过电机电流。
- 执行层:有刷直流电机、无刷电机的其中一相,或者步进电机的某一相绕组。
信号流向是这样的:STM32的TIM1_CH1输出高侧PWM,TIM1_CH1N输出低侧互补PWM,两路信号进驱动芯片的HIN和LIN引脚,驱动芯片的HO和LO分别推高侧和低侧MOS管的栅极。电机接在半桥中点和高低侧母线之间。如果是全桥驱动,那就需要两对互补通道,比如CH1/CH1N推左半桥,CH2/CH2N推右半桥。
注意:IR2104这类芯片的HIN和LIN不能同时为高,否则它会同时输出两路高电平导致直通。STM32的互补输出模式天然保证CH1和CH1N不会同时有效,但前提是你在CubeMX里正确设置了死区时间,并且没有在代码里手动去翻转这两个引脚的电平。
1.3 关键参数选型背后的计算逻辑
PWM频率和死区时间是两个最核心的参数,不能拍脑袋定。
PWM频率:有刷直流电机一般用10kHz到20kHz,低于10kHz人耳能听到啸叫,高于20kHz开关损耗又会明显上升。我通常取16kHz,刚好在听觉范围上限附近,MOS管用IRF3205的话开关损耗还能接受。无刷电机FOC控制常用20kHz到40kHz,因为电流环带宽要求更高。这个项目以有刷电机为例,取16kHz。
死区时间:死区时间必须大于MOS管的关断延迟时间,否则上下管会瞬间直通。IRF3205的关断延迟典型值约30ns,加上驱动芯片的传输延迟约100ns,再留一倍余量,死区时间取200ns到300ns比较稳妥。如果MOS管是IRF540N,关断延迟约40ns,死区可以取250ns。
定时器时钟和预分频:STM32F103的TIM1挂在APB2总线上,主频72MHz。要得到16kHz的PWM频率,计数周期 = 72MHz / 16kHz = 4500。所以ARR寄存器设为4499(从0开始计数),预分频器PSC设为0。如果你用的是STM32G431,主频170MHz,ARR = 170MHz / 16kHz = 10625,取整后ARR = 10624。
死区时间的计算:死区时间 = DTG[7:0] × T_dts,其中T_dts是定时器时钟周期。72MHz下T_dts = 13.89ns。要得到200ns死区,DTG值 = 200 / 13.89 ≈ 14.4,取14,实际死区 = 14 × 13.89 ≈ 194ns。CubeMX里直接填200ns它会自动算,但你要知道它算出来的实际值是多少。
2. STM32CubeMX配置全流程拆解
2.1 时钟树配置与定时器时钟源确认
打开CubeMX新建工程,选好芯片型号后第一件事是配时钟树。以STM32F103C8T6为例,外部晶振选8MHz,PLL倍频到72MHz,APB2预分频器设为1,这样TIM1的时钟就是72MHz。如果你用的是STM32G431,外部晶振选24MHz,PLL配置到170MHz,APB2不分频,TIM1时钟也是170MHz。
这里有个坑:很多人配完时钟树直接去配定时器,结果发现PWM频率怎么算都不对。原因在于APB2预分频器如果设成了2,定时器时钟会自动倍频,实际时钟是APB2时钟的2倍。CubeMX的时钟树界面会实时显示各定时器的时钟频率,配完一定要看一眼“TIM1 Clock”那一栏显示的是不是72MHz。
提示:STM32F1系列如果APB2预分频系数为1,定时器时钟等于APB2时钟;如果预分频系数大于1,定时器时钟等于APB2时钟的2倍。这个规则在F4、G4、H7系列上也类似,但具体倍频关系要看参考手册的时钟树章节。
2.2 TIM1模式配置:PWM Generation CH1 CH1N
在CubeMX的Pinout视图里找到TIM1,把Clock Source设为Internal Clock,Channel1设为“PWM Generation CH1 CH1N”。这个选项一选,PA8和PB13两个引脚会自动被分配为TIM1_CH1和TIM1_CH1N。PA8是高侧输出,PB13是低侧互补输出。
接着去Configuration标签页点开TIM1的参数设置:
- Prescaler:0
- Counter Mode:Up
- Counter Period (ARR):4499
- Internal Clock Division:No Division
- auto-reload preload:Enable
- PWM Mode:PWM mode 1
- Pulse (CCR1):2250(初始占空比50%)
- CH Polarity:High
- CHN Polarity:High
- Dead Time:200(单位是ns,CubeMX会自动换算成DTG寄存器值)
- Break State:根据你的硬件刹车电路决定,没有刹车电路就Disable
这里重点说三个参数:
Counter Period决定了PWM频率,前面算过了,72MHz / 4500 = 16kHz。
Pulse是占空比,2250 / 4500 = 50%。注意这个值是CCR1寄存器的值,不是百分比,CubeMX里填的是原始计数值。
Dead Time填200,CubeMX会在生成的代码里自动计算DTG值。你可以在生成的tim.c文件里看到htim1.Init.DeadTime = 14;这样的赋值,14就是DTG寄存器的值。
2.3 刹车功能和死区时间的联动设置
Break功能是高级定时器的一个安全特性,当刹车引脚检测到有效电平时,定时器会立即强制输出通道进入预设的安全状态(通常是高阻或低电平),同时可以触发中断。这个功能在电机驱动里非常有用,比如过流保护电路检测到电流超标,拉低刹车引脚,PWM立刻关断,比软件中断响应快得多。
CubeMX里Break State选Enable的话,需要指定刹车引脚(通常是PB12或PA6),还要设置刹车极性和自动恢复模式。如果你没有过流保护硬件,建议先Disable,等硬件调通了再开。
死区时间和刹车功能在寄存器层面是联动的,因为BDTR寄存器同时管这两件事。CubeMX生成的代码里,HAL_TIMEx_ConfigBreakDeadTime()这个函数会一次性配置好死区和刹车参数。你如果手动改代码,注意不要只改死区而把刹车配置覆盖掉了。
2.4 生成代码前的最后检查清单
在点“GENERATE CODE”之前,对照这个清单过一遍:
- 时钟树里TIM1 Clock显示72MHz(或170MHz)
- TIM1的Channel1选了“PWM Generation CH1 CH1N”
- Prescaler = 0,Counter Period = 4499
- Pulse = 2250,PWM Mode = PWM mode 1
- CH Polarity = High,CHN Polarity = High
- Dead Time = 200
- Break State = Disable(暂时)
- Toolchain/IDE选MDK-ARM或STM32CubeIDE
生成代码后,打开tim.c,找到MX_TIM1_Init()函数,确认里面有以下几行:
htim1.Instance = TIM1; htim1.Init.Prescaler = 0; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 4499; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;以及后面的:
sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 2250; 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;还有死区配置:
sBreakDeadTimeConfig.OffStateRunMode = TIM_OSSR_DISABLE; sBreakDeadTimeConfig.OffStateIDLEMode = TIM_OSSI_DISABLE; sBreakDeadTimeConfig.LockLevel = TIM_LOCKLEVEL_OFF; sBreakDeadTimeConfig.DeadTime = 14; sBreakDeadTimeConfig.BreakState = TIM_BREAK_DISABLE; sBreakDeadTimeConfig.BreakPolarity = TIM_BREAKPOLARITY_HIGH; sBreakDeadTimeConfig.AutomaticOutput = TIM_AUTOMATICOUTPUT_DISABLE;这些都对上了,硬件层就稳了。
3. 代码实现与电机调速实操
3.1 启动PWM输出的正确姿势
CubeMX生成的代码里,MX_TIM1_Init()只完成了定时器的初始化,PWM输出还没有真正启动。你需要在主函数里手动调用两个函数:
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1);第一行启动CH1的高侧PWM,第二行启动CH1N的互补PWM。注意第二行用的是HAL_TIMEx_PWMN_Start,不是HAL_TIM_PWM_Start,这个函数在stm32f1xx_hal_tim_ex.c里,别搞混了。
如果你只调用了第一行,CH1N不会有输出,低侧MOS管一直关断,电机只能靠高侧MOS管的体二极管续流,效率极低而且发热严重。我见过有人调了半天发现电机转是转了,但MOS管烫得能煎鸡蛋,就是漏了第二行。
注意:调用
HAL_TIMEx_PWMN_Start之前,确保BDTR寄存器的MOE位被置位。HAL库的HAL_TIMEx_PWMN_Start内部会自动置位MOE,但如果你用的是寄存器操作,记得手动TIM1->BDTR |= TIM_BDTR_MOE;。
3.2 动态调速:改CCR寄存器的两种方式
调速的本质就是改CCR1寄存器的值。有两种方式:
方式一:直接改寄存器
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 3000);这行代码把CCR1改成3000,占空比变成3000/4500 = 66.7%。这种方式最快,适合在中断里调用。
方式二:用HAL库函数
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 3000);其实HAL库也是宏定义直接操作寄存器,和方式一本质一样。真正用函数调用的是HAL_TIM_PWM_Start这类,改占空比没有专门的函数,都是用宏。
调速范围要注意:CCR值不能等于或超过ARR值,否则占空比100%就没有开关动作了。也不能太小,比如小于死区时间对应的计数值,否则实际输出会被死区吃掉。72MHz下200ns死区对应约14个计数,所以CCR最小建议不小于50,对应占空比约1.1%。
3.3 用串口指令实时调速的完整代码
光会改CCR还不够,实际项目里通常要通过上位机或者遥控器实时调速。这里给一个串口调速的完整实现:
#include "main.h" #include "usart.h" #include "tim.h" extern TIM_HandleTypeDef htim1; extern UART_HandleTypeDef huart1; uint8_t rx_buffer[1]; uint16_t duty_cycle = 2250; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_buffer[0] == 'U' || rx_buffer[0] == 'u') { if (duty_cycle < 4400) duty_cycle += 100; } else if (rx_buffer[0] == 'D' || rx_buffer[0] == 'd') { if (duty_cycle > 100) duty_cycle -= 100; } else if (rx_buffer[0] == 'S' || rx_buffer[0] == 's') { duty_cycle = 0; } __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, duty_cycle); HAL_UART_Receive_IT(&huart1, rx_buffer, 1); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM1_Init(); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); HAL_UART_Receive_IT(&huart1, rx_buffer, 1); while (1) { } }这段代码里,串口收到'U'就加速,'D'就减速,'S'就停机。每次改完CCR立即生效,因为ARR预装载和CCR预装载都使能了,新值会在下一个计数周期开始时生效,不会产生毛刺。
3.4 软启动:避免电机飞车的保护逻辑
直接给电机上50%占空比,电流冲击会很大,尤其是大功率电机。我习惯加一个软启动逻辑,让占空比在500ms内从0线性升到目标值:
void soft_start(uint16_t target_duty, uint16_t step_delay_ms) { uint16_t current = 0; while (current < target_duty) { current += 50; if (current > target_duty) current = target_duty; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, current); HAL_Delay(step_delay_ms); } }调用soft_start(2250, 10)就是每10ms增加50个计数,从0升到2250大约需要450ms。这个逻辑放在主循环里,上电后先软启动再进入正常调速循环。
提示:软启动期间不要用HAL_Delay阻塞太久,否则串口接收会丢数据。更好的做法是用定时器中断做状态机,每10ms更新一次CCR。但作为入门项目,HAL_Delay够用了。
4. 实测波形分析与常见问题排查
4.1 示波器抓互补波形:死区到底长什么样
配置完成后,用示波器双通道抓PA8和PB13的波形。正确的情况下,你应该看到两路频率相同、相位相反的PWM,但在高低电平切换的瞬间,两路都是低电平,中间有一段空白时间,这就是死区。
实测数据:72MHz主频,ARR=4499,CCR=2250,死区设为200ns。示波器上高侧PWM的上升沿到低侧PWM的上升沿之间大约有194ns的间隔(因为DTG=14,14×13.89≈194ns)。这个间隔就是死区时间,它保证了高侧MOS管完全关断之后,低侧MOS管才开通。
如果你把死区设成0,示波器上两路波形会紧挨着切换,看起来“完美互补”,但实际电路中上下管会有几十纳秒的同时导通,瞬间电流可能达到几十安培,MOS管很快就烧了。所以死区宁大勿小,200ns到500ns都是安全范围。
4.2 MOS管发热严重?先查这三个地方
电机能转但MOS管烫手,按这个顺序排查:
第一,死区时间够不够。把示波器调到上升沿触发,放大看高低侧切换的细节。如果两路波形有重叠,说明死区太小。加大到500ns试试,如果发热明显改善,就是死区问题。
第二,驱动芯片的供电和自举电容。IR2104的高侧驱动靠自举电容供电,如果自举电容容量不够或者漏电,高侧MOS管会工作在放大区而不是开关区,功耗急剧上升。自举电容一般取100nF到1μF,耐压要高于母线电压。我遇到过用10nF自举电容导致高侧MOS管发热的案例,换成1μF就好了。
第三,PWM频率是不是太高。16kHz下IRF3205的开关损耗约0.5W,如果频率提到40kHz,开关损耗会翻倍以上。用手摸散热片如果只是温热,问题不大;如果烫得不能碰,要么降频,要么换低Qg的MOS管。
4.3 电机不转或者只嗡嗡响的排查流程
按这个表逐项检查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 电机完全不转 | MOE位没置位 | 检查是否调用了HAL_TIMEx_PWMN_Start |
| 电机嗡嗡响不转 | 占空比太低 | 把CCR调到2250以上试试 |
| 电机抖动 | 死区太大 | 减小死区到100ns试试 |
| 电机转但没力 | 驱动电压不足 | 测MOS管栅极电压,应该10V以上 |
| 上电就烧MOS管 | 上下管直通 | 示波器抓波形看有没有重叠 |
| 调速时电机飞车 | CCR值超过ARR | 检查调速代码有没有限幅 |
注意:调试时一定要用限流电源,把电流限制在电机额定电流的1.5倍以内。一旦发现电流异常,立即断电检查,不要抱着“再试一次”的心态,烧MOS管是小事,烧电机或者引发火灾就麻烦了。
4.4 互补输出与普通PWM的切换技巧
有时候你需要从互补模式切回普通单路PWM,比如驱动一个只需要单路PWM的舵机。这时候不能直接改CubeMX重新生成,因为会覆盖你写的应用代码。正确做法是在代码里动态切换:
// 切换到单路PWM HAL_TIMEx_PWMN_Stop(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // 切换回互补PWM HAL_TIM_PWM_Stop(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);注意切换回互补模式时,要先启动CH1N再启动CH1,顺序反了会有短暂的直通风险。这个细节在参考手册里没有明说,是我在实际项目中踩坑总结出来的。
5. 进阶扩展与个人实操体会
5.1 从半桥到全桥:双互补通道的配置要点
驱动直流有刷电机做正反转,需要全桥。全桥需要两对互补通道:CH1/CH1N推左半桥,CH2/CH2N推右半桥。CubeMX里把Channel2也设成“PWM Generation CH2 CH2N”,对应引脚是PA9和PB14。
全桥驱动有个特殊要求:左半桥和右半桥不能同时输出高电平,否则母线直接短路。所以CH1和CH2的占空比不能同时大于0。正转时CH1给PWM,CH2给0;反转时CH2给PWM,CH1给0。这个逻辑要在应用代码里保证,硬件层面没有保护。
我通常写一个set_motor(int16_t speed)函数,speed范围-1000到1000,正值正转,负值反转:
void set_motor(int16_t speed) { if (speed > 0) { __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, speed * 4); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, 0); } else if (speed < 0) { __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 0); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, -speed * 4); } else { __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 0); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, 0); } }5.2 用刹车功能做过流保护的实战配置
前面Break State一直Disable,现在说说怎么用它做过流保护。硬件上需要一个采样电阻加比较器,比较器输出接到STM32的刹车引脚(TIM1_BKIN,通常是PB12)。当电流超过阈值,比较器翻转,刹车引脚收到有效电平,TIM1硬件自动关断所有输出通道,响应时间只有几十纳秒,比软件中断快两个数量级。
CubeMX里Break State选Enable,Break Polarity根据比较器输出极性选High或Low,Automatic Output选Disable(这样刹车后不会自动恢复,需要软件手动清除)。生成的代码里会多出刹车中断的回调函数:
void HAL_TIMEx_BreakCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { // 刹车触发,关闭PWM输出 HAL_TIM_PWM_Stop(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Stop(&htim1, TIM_CHANNEL_1); // 点亮故障指示灯 HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); } }这个回调函数在刹车触发时自动调用,你可以在里面做故障记录、报警、停机等操作。恢复时需要手动清除刹车标志位并重新启动PWM。
5.3 个人实操中踩过的三个坑
第一个坑:忘了使能ARR预装载。有一次调速时电机突然抽搐,示波器抓波形发现PWM周期偶尔会变长。查了半天发现是ARR预装载没使能,改CCR的时候ARR寄存器被意外修改了。CubeMX里Auto-reload preload一定要选Enable,这个选项默认是Disable,很多人不注意。
第二个坑:死区时间单位搞错。CubeMX里Dead Time填的是纳秒,但生成的代码里DeadTime是DTG寄存器的原始值。我一开始以为填200就是200ns,结果生成的代码里DeadTime=200,实际死区是200×13.89≈2.8μs,电机低速时抖动得厉害。后来才搞明白CubeMX会自动换算,但如果你手动改代码,一定要按DTG的计算公式来。
第三个坑:互补输出引脚被其他外设占用。STM32F103的PA8和PB13除了TIM1_CH1和CH1N,还可以复用为其他功能。有一次我在CubeMX里同时开了SPI1和TIM1,结果PA8被SPI1的NSS占用了,TIM1_CH1死活没有输出。解决办法是在Pinout视图里检查引脚冲突,把冲突的外设重新分配引脚。
5.4 这个方案还能怎么扩展
互补PWM驱动电机的框架搭好之后,可以往几个方向扩展:
- 加入电流环:用ADC采样母线电流,在定时器中断里做PI调节,实现恒流驱动。STM32的ADC可以和TIM1同步触发,采样时刻精确对准PWM中心点,避开开关噪声。
- 换成无刷电机:三对互补通道推三相全桥,加上霍尔传感器或者无感反电动势检测,就是一套完整的BLDC驱动器。
- 加入CAN通信:用CAN总线接收上位机的速度和方向指令,适合多电机协同的场景,比如小车底盘或者机械臂关节。
- 做参数自整定:通过串口上位机实时修改PWM频率、死区时间、PID参数,不用重新烧录固件就能调参,效率提升明显。
我个人在实际操作中的体会是,STM32CubeMX把互补PWM的配置门槛降得很低,但死区时间、刹车功能、预装载这些细节才是决定项目成败的关键。工具帮你省了查寄存器的时间,但省不了理解原理的时间。每次调新电机,我都会先用示波器把互补波形和死区时间确认一遍,再上功率电,这个习惯帮我省下了至少五块烧掉的MOS管。