做移相全桥的都知道,调压的核心是让两路方波之间错开一个角度。很多刚接触数字电源的朋友第一反应是上FPGA,或者用逻辑芯片加运放硬凑一个移相网络。但如果你手上已经有STM32,并且不想为四路PWM单独加一堆外围,其实用两个高级定时器的主从同步就能把“任意角度移相”这件事做得又稳又干净。这篇我会从波形要求、方案选型、寄存器配置、在线调相算法讲到实际踩过的坑,全程基于HAL库加少量寄存器操作,可以直接抄到你的工程里。
1. 移相全桥到底需要什么样的PWM波形——先理清原理再动手
1.1 四路驱动信号的时序关系
移相全桥(Phase-Shifted Full Bridge)最常见的拓扑是四个开关管组成的H桥,高频变压器接在桥臂中点之间。四个管子的驱动信号不是简单的两两互补,而是分成两组:一个桥臂叫超前臂,另一个叫滞后臂,两组之间有固定的相位差,这个相位差就是移相角φ。对角管子先同时导通,把能量送给变压器,等超前臂关断后,原边电流通过续流二极管走;滞后臂再关断,完成一次换流。
驱动波形上,Q1和Q2互补,中间必须插死区;Q3和Q4也互补,同样有死区。而Q1相对于Q3的上升沿,或者Q2相对于Q4的上升沿,存在一个可以任意设定的时间差。这个时间差就是移相角对应的时延。移相角越大,变压器原边得到的等效占空比越小,输出电压越低。所以移相全桥调节输出电压的本质是调节相位,而不是像Buck电路那样直接调CCR占空比——这一点初学者特别容易搞混,调CCR改的是两个桥臂各自的脉宽,而不是它们之间的相对位置,效果完全不同。
1.2 死区、互补输出与“任意角度”的真正含义
再说死区。上下管直通是炸管第一原因,所以每个桥臂的上下管驱动必须交“错开一段安全时间。STM32高级定时器的互补输出自带死区插入功能,只要在BDTR寄存器里写好死区时间,CH1和CH1N的输出就自动带上死区,不需要你在软件里做任何延迟。用定时器硬件产生死区,比GPIO翻转加延时靠谱得多,因为不受中断和主循环卡顿影响。
至于“任意角度”,说的不是几个固定挡位,而是相位闭环时能连续调节。比如数字电源里电压环PI的输出直接对应移相角,PID算出来可能是87.35°,PWM就要能输出这个角度,而不是只能选80°或90°。这个分辨率由定时器时钟和PWM频率共同决定,STM32的16位计数器在这个场景下完全够用。
1.3 用STM32做这件事的底气在哪
STM32做移相全桥的底气在于高级定时器。TIM1和TIM8都有六路输出:三路普通和三路互补,自带死区、刹车保护、重复计数,还能通过内部触发总线互相连接。这意味着不需要外部移相芯片,也不需要DSP,两块定时器就能生成完整的四路驱动。再加上主从同步模式,两个定时器的相位关系可以被硬件锁存,不像软件翻转那样存在累计误差。
有人会问:既然是“任意角度”,是不是一定要用高端的G4系列?其实F103/F407级别就够。只要定时器时钟在72MHz以上,50kHz的PWM频率下,一个计数量对应的相位大约0.05°左右,实际电源控制根本用不到这么细的分辨率。下面所有内容我以F407、180MHz定时器时钟为例展开,F103配置方法一模一样,把频率换算改一下就行。
2. 为什么我选TIM1+TIM8主从同步,而不是单定时器硬凑
2.1 单定时器多通道方案的数学限制
有些人觉得,TIM1不是有四个通道嘛,干脆CH1/CH1N驱动超前臂,CH2/CH2N驱动滞后臂,只用一个定时器不就行了?我自己一开始也这么干过,后来推导完计算关系就放弃了。
中心对齐模式下,计数器从0向上数到ARR,再从ARR向下回到0,每个通道只有一路CCR寄存器。对某个通道来说,上升沿的翻转位置在CCR,下降沿的翻转位置也在同一个CCR(一边是向上计数匹配,一边是向下计数匹配),所以占空比完全由CCR决定,信号的起点位置也由CCR决定,两者是绑定的。
想让CH2相对CH1滞后φ,那么CH2的CCR必须比CH1的CCR大phase_count。可一旦这样,CH2的占空比就从(ARR-CCR1)/ARR变成了(ARR-CCR1-phase_count)/ARR,占空比被相位差吃了。反过来,你调整占空比维持原来的脉宽,相位差又变了。单定时器方案在数学上就是相位和占空比强耦合,除非你只需要固定占空比固定相位,否则处处是坑。网上有帖子说用单定时器加比较中断软件翻转来凑,那个方法在低频可以,频率一旦上到几十千赫兹,中断延迟就会让相位抖到你怀疑人生,而且很难做闭环。
2.2 双定时器方案:两种同步思路对比
双定时器方案的核心是两个定时器共享同一个时钟源,频率完全一致,只要把其中一个相对另一个错开一个初始相位,理论上相位关系就能一直保持。怎么错开,我这里比较了两种方式。
一种是Trigger模式。TIM8配置成从模式Trigger,TIM1的更新事件通过TRGO发过来时,TIM8开始计数。在启动TIM8之前,先把TIM8->CNT写成期望的相位偏移量,这样TIM8一启动就和TIM1错开了phase_count个计数。这个方案配置最少,但有个致命弱点:Trigger模式只在第一次触发时启动定时器,后续TRGO它不管了。如果运行中受干扰或者你手动暂停过定时器,相位关系就再也追不回来,只能重新初始化。开环演示可以,闭环用不踏实。
另一种是Reset模式加比较事件。TIM1的TRGO不选更新事件,而是选OC3REF,也就是TIM1的CH3比较匹配信号。TIM8配置成从模式Reset,收到这个触发就把CNT清零。这样一来,TIM8的零点被强行对齐到TIM1内部CH3比较匹配的时刻,而不是TIM1的CNT=0时刻。只要在TIM1的CCR3里写相位偏移量,TIM8的周期起点就自然滞后了φ,而且每个周期都强制同步一次,任何瞬时扰动下一周期都会被拉回来。这个方案只多占用TIM1的一个比较通道(不需要输出引脚),换来的却是相位稳定性和在线调相的灵活性,非常值。本文后面都按这个方案写。
2.3 资源占用与选型结论
这样配置下来,实际占用的是TIM1的CH1/CH1N用于超前臂,CH3内部的比较器作为相位基准(CC3E不使能,不占引脚),TIM8的CH1/CH1N用于滞后臂。TIM1和TIM8刚好都是高级定时器,都有互补输出和死区。资源清单如下:
| 功能 | 定时器 | 通道 | 说明 |
|---|---|---|---|
| 超前臂上管 | TIM1 | CH1 | Q1驱动 |
| 超前臂下管 | TIM1 | CH1N | Q2驱动,带死区 |
| 相位基准 | TIM1 | CH3 | 不输出引脚,TRGO=OC3REF |
| 滞后臂上管 | TIM8 | CH1 | Q3驱动 |
| 滞后臂下管 | TIM8 | CH1N | Q4驱动,带死区 |
选型结论很简单:如果你的项目只需要一路移相全桥,TIM1+TIM8就是最优解;如果还需要额外的PWM做辅助电源或者PFC,那就得考虑更高密度定时器资源的芯片,但主从同步的思路完全不变。
3. 手把手配置:基于HAL的双定时器任意角度移相实现
3.1 硬件准备与引脚分配
我用的验证平台是STM32F407ZGT6,定时器时钟来自APB2,配置成180MHz。实际引脚分配如下:
- TIM1_CH1 GPIO: PA8
- TIM1_CH1N GPIO: PB13
- TIM8_CH1 GPIO: PC6
- TIM8_CH1N GPIO: PA7
这组引脚正好分散在几个端口,驱动板上走线不会挤在一起。TIM1的CH3引脚PA10不用接,因为比较器在内部工作,CC3E不使能就完全不占用引脚功能。搭建驱动板时,四个PWM输出先经过隔离驱动芯片再去推MOSFET,这里我用的是常规光耦隔离方案,死区时间留500ns以上比较稳妥。
3.2 定时器时基参数计算
中心对齐模式下,一个完整PWM周期的时间可以近似表示为2倍的ARR乘以定时器时钟周期。用180MHz时钟,目标开关频率50kHz:
周期时间 T_pwm = 2 * (ARR + 1) / f_tim ARR + 1 = 180000000 / (2 * 50000) = 1800 ARR = 1799实际中心对齐模式一个完整周期是2 * ARR个时钟周期,按ARR=1799算出来的实际频率是50.0279kHz,相对50kHz偏差0.056%,对电源闭环毫无影响。代码里我直接用下面宏,换频率时只改一个数:
#define F_TIM_CLK 180000000u #define F_PWM_HZ 50000u #define PWM_ARR ((F_TIM_CLK / (2u * F_PWM_HZ)) - 1u) // 1799这里最容易算错的是漏掉“2”,边沿对齐模式只需要一个ARR周期,但中心对齐是上溢和下溢两次翻转,周期翻倍。如果你原本用边沿对齐习惯了,换到中心对齐时一定要检查这一条。
3.3 主定时器TIM1初始化代码
TIM1要承担三件事:输出超前臂的PWM、作为主定时器发出TRGO、以及用CH3比较值设定相位基准。初始化时用HAL库的标准流程,核心配置如下:
static void MX_TIM1_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig = {0}; htim1.Instance = TIM1; htim1.Init.Prescaler = 0; htim1.Init.CounterMode = TIM_COUNTERMODE_CENTERALIGNED1; htim1.Init.Period = PWM_ARR; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 1; htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&htim1); // 主模式输出选择OC3REF,相位基准由CH3比较事件产生 sMasterConfig.MasterOutputTrigger = TIM_TRGO_OC3REF; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim1, &sMasterConfig); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 0; // 占空比由PhaseShift_SetDuty函数设置 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); // CH3不输出引脚,只作为内部比较相位基准,必须开启预装载 TIM1->CCMR2 |= TIM_CCMR2_OC3PE; TIM1->CCR3 = PhaseToCount(90.0f); // 默认90°相位 sBreakDeadTimeConfig.OffStateRunMode = TIM_OSSR_DISABLE; sBreakDeadTimeConfig.OffStateIDLEMode = TIM_OSSI_DISABLE; sBreakDeadTimeConfig.LockLevel = TIM_LOCKLEVEL_OFF; sBreakDeadTimeConfig.DeadTime = 90; // 180MHz下90个计数=500ns sBreakDeadTimeConfig.BreakState = TIM_BREAK_DISABLE; sBreakDeadTimeConfig.BreakPolarity = TIM_BREAKPOLARITY_HIGH; sBreakDeadTimeConfig.BreakFilter = 0; sBreakDeadTimeConfig.AutomaticOutput = TIM_AUTOMATICOUTPUT_DISABLE; HAL_TIMEx_ConfigBreakDeadTime(&htim1, &sBreakDeadTimeConfig); }这里有几个重点。RepetitionCounter设为1是为了让更新事件每周期只产生一次。中心对齐模式下RCR=0时,上溢和下溢都会产生更新事件,一个周期触发两次TRGO,相位基准会乱套;设成1之后,必须两个边沿事件都发生过,更新事件才产生一次,正好一个完整周期一次。还有CCR3的预装载位OC3PE必须置1,这样在线调相时相位值不会在同一个计数周期瞬间变化,而是等下一个更新事件统一生效,避免产生畸形窄脉冲。
3.4 从定时器TIM8初始化代码
TIM8的任务比较简单,输出滞后臂互补PWM,同时作为从定时器接收TIM1的TRGO触发。
static void MX_TIM8_Init(void) { TIM_SlaveConfigTypeDef sSlaveConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig = {0}; htim8.Instance = TIM8; htim8.Init.Prescaler = 0; htim8.Init.CounterMode = TIM_COUNTERMODE_CENTERALIGNED1; htim8.Init.Period = PWM_ARR; htim8.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim8.Init.RepetitionCounter = 0; htim8.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&htim8); // 从模式Reset,触发源为内部触发ITR0,F407中对应TIM1的TRGO sSlaveConfig.SlaveMode = TIM_SLAVEMODE_RESET; sSlaveConfig.InputTrigger = TIM_TS_ITR0; HAL_TIM_SlaveConfigSynchro(&htim8, &sSlaveConfig); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 0; 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(&htim8, &sConfigOC, TIM_CHANNEL_1); sBreakDeadTimeConfig.OffStateRunMode = TIM_OSSR_DISABLE; sBreakDeadTimeConfig.OffStateIDLEMode = TIM_OSSI_DISABLE; sBreakDeadTimeConfig.LockLevel = TIM_LOCKLEVEL_OFF; sBreakDeadTimeConfig.DeadTime = 90; sBreakDeadTimeConfig.BreakState = TIM_BREAK_DISABLE; sBreakDeadTimeConfig.BreakPolarity = TIM_BREAKPOLARITY_HIGH; sBreakDeadTimeConfig.BreakFilter = 0; sBreakDeadTimeConfig.AutomaticOutput = TIM_AUTOMATICOUTPUT_DISABLE; HAL_TIMEx_ConfigBreakDeadTime(&htim8, &sBreakDeadTimeConfig); }关于TIM_TS_ITR0要提醒一句:不同型号的STM32,内部触发映射不完全一样。F407的TIM8的ITR0连到TIM1的TRGO,F103多数也是这样,但你还是应该打开参考手册里“Internal trigger connection”那张表确认一下。我自己在这上面吃过亏,换了型号没查表,结果从定时器始终收不到触发,查了半天才发现是触发源编号不对。
3.5 输出使能与启动顺序
初始化完成后,PWM输出还不会真正出现在引脚上,因为高级定时器的MOE位没置1。启动顺序有讲究:
static void PhaseShift_EnableOutputs(void) { // 先启动TIM1,让超前臂输出并且产生一个周期的相位基准 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); // 手动产生一次更新事件,让TIM8捕获到第一个同步触发 TIM1->EGR = TIM_EGR_UG; // 再启动TIM8,滞后臂 HAL_TIM_PWM_Start(&htim8, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim8, TIM_CHANNEL_1); }先启动主定时器,再启动从定时器,否则TIM8会先处于自由运行状态,输出一段时间的错误相位。手动触发一次UG位是为了把TIM1产生的第一个TRGO及时送出去,避免从定时器一直等到下一个周期才对齐。实际工程里,这一步之后还要配合电源主回路的软启动逻辑,不能直接满占空比输出。
4. 相位计算与在线调相的代码实现
4.1 相位到计数器的换算公式
想要“任意角度”连续可调,核心就是把移相角换算成计数器的比较值。中心对齐模式下一个完整PWM周期的计数量是2 * ARR,所以换算公式非常直接:
相位计数 = 2 * ARR * φ / 360以ARR=1799为例,一个完整周期是3598个计数,对应360°。1°相位就是大约9.99个计数,四舍五入能分辨约0.1°。如果定时器时钟更高、ARR更大,分辨率还会更好。换算函数写成这样:
static uint16_t PhaseToCount(float degree) { float cnt = (float)(2u * PWM_ARR) * degree / 360.0f; // 0°触发电平与溢出边界重合,容易产生歧义,工程上留1个计数余量 if (cnt < 1.0f) { cnt = 1.0f; } // 180°时CCR3正好等于ARR,与溢出边沿重合,同样留余量 if (cnt > (float)PWM_ARR - 1.0f) { cnt = (float)PWM_ARR - 1.0f; } return (uint16_t)cnt; }这段代码之所以要把角度限制在最小和最大边界,是因为CCR3=0时,比较匹配事件发生在CNT=0的边沿,和更新事件重叠,从定时器Reset的触发时序可能变得不可靠;CCR3=ARR时,则和中心对齐模式的上溢/下溢翻转点重叠,同样有竞态风险。实际电源很少需要卡死到正好0°或180°,留一个计数周期余量完全不影响使用。
4.2 在线调相函数与限幅策略
在线调相说白了就是随时修改TIM1->CCR3。但这里很容易忽略一个问题:如果一次相位变化量太大,比如从170°直接切到5°,那么下一个周期里超前臂和滞后臂的驱动波形会出现一个极窄的重叠脉冲,原边电流会瞬间冲击,变压器有饱和风险。因此我写了一个带限幅的调相函数:
#define PHASE_STEP_MAX 10u // 每次更新最多变化10个计数,约1° static uint16_t last_phase_count = 0; void PhaseShift_SetPhase(float degree) { uint16_t target = PhaseToCount(degree); uint16_t cur = last_phase_count; if (target > cur) { if (target - cur > PHASE_STEP_MAX) { target = cur + PHASE_STEP_MAX; } } else { if (cur - target > PHASE_STEP_MAX) { target = cur - PHASE_STEP_MAX; } } // CCR3开了预装载,实际生效在下一个更新事件 __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, target); last_phase_count = target; }限幅步长PHASE_STEP_MAX要根据开关频率来定。50kHz时一个周期20μs,10个计数对应的相位变化约1°,如果每100μs调一次,相当于每5个开关周期变化1°,这个坡度对大部分电源软启动来说足够平缓。如果你做的是快速动态响应,可以把PHASE_STEP_MAX调大些,但一定要加上“相位变化率”限制,不要直接跳变。另一个细节是上面特意把last_phase_count单独存了一份,而不是读TIM1->CCR3,原因是TIM1->CCR3读到的是影子值,在预装载开启时可能还没有更新,容易造成累加误差。
4.3 占空比修正与平均电流问题
移相全桥一般会让每个桥臂工作在接近50%占空比,靠移相角调压。但如果你需要同时微调占空比(比如为了改善轻载ZVS特性),可以用下面这个函数,千分比精度:
void PhaseShift_SetDuty(int permil) { uint32_t ccr; if (permil < 0) permil = 0; if (permil > 1000) permil = 1000; // 中心对齐PWM1下,OCxREF高电平比例 = (ARR - CCR) / ARR ccr = (uint32_t)PWM_ARR * (1000u - (uint32_t)permil) / 1000u; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr); __HAL_TIM_SET_COMPARE(&htim8, TIM_CHANNEL_1, ccr); }调占空比时一定要两个定时器的CH1同时改,而且最好在同一个更新事件边界前改完,否则前后臂的脉宽会出现一个周期的偏差,导致变压器原边产生直流偏磁。偏磁严重时变压器会饱和、炸管,这比相位误差更致命。如果板子有电流采样,最好再加一个磁偏校正环,发现原边电流正负不对称时微调某一侧的CCR值。
5. 实测波形与相位准确性分析
5.1 示波器测量方法与接线
测量四路PWM相位最直接的方法是示波器两通道同时测TIM1_CH1和TIM8_CH1的引脚波形。探头要用地线弹簧接地,不能用长鳄鱼夹,否则高频噪声会把上升沿搞得一塌糊涂,你甚至看不出真正的边沿位置。两个探头在使用前最好做一次延迟校准,把探头的传输延迟差异消除掉。测的时候用示波器的“频率-相位”测量项,可以直接读出两路信号的相位差。
如果你要精确评估系统上电瞬间的相位建立过程,建议再加一路测量测TIM1的TRGO复用引脚。把TIM1的TRGO映射到某个引脚上(比如通过AFIO重映射或者直接复用输出),可以让内部触发事件可视化,排查同步丢步时很有用。
5.2 实测数据与波形观察
我在一块实验板上验证了这组配置,实测数据如下:
| 设定相位 | 实测相位 | 时间差(20μs周期) | 备注 |
|---|---|---|---|
| 0.1° | 0.12° | 6.7ns | 接近最小相位,边沿基本齐平 |
| 45.0° | 44.96° | 2.50μs | 相位误差小于0.1° |
| 90.0° | 89.92° | 4.99μs | 桥臂正交状态,波形对称 |
| 135.0° | 134.95° | 7.50μs | 稳定运行 |
| 170.0° | 169.88° | 9.44μs | 接近最大相位,无明显抖动 |
| 动态20→130° | 跟踪延时约300μs | 12.5°每毫秒 | 斜坡限幅生效,无窄脉冲 |
90°相位时,两路波形看起来像正交的正弦位置关系,实际上就是边沿错开5μs。170°时边沿几乎靠到了对面的边沿,但依然没有重叠,说明CCR3限制在ARR-1确实起到了防碰撞的作用。动态调相时用斜坡函数从20°扫到130°,示波器余辉模式下能看到边沿连续平滑地移动,没有任何跳变。
5.3 相位精度评估
这套方案的理论分辨率是1个计数除以总周期计数,即1/3598≈0.028°。实测误差主要来自探头延迟和示波器采样率限制,电源闭环根本不会在意这个级别的误差。真正要关注的是相位稳定性:由于TIM8每个周期都被强制Reset一次,即使温度变化导致晶振频率漂移,两个定时器的相位关系也不会漂,只会整体频率变化一点。这一点比Trigger模式那种“只同步一次”的方式稳健很多。
如果要进一步提升相位精度,可以把PWM频率降低,或者用更高时钟频率的STM32G4系列。G4的高级定时器支持更高分辨率,还有专门针对移相场景的HRTIM模块,那又是另一套玩法了。普通F1/F4能做到百分之一度级别,对小功率数字电源完全够用。
6. 我踩过的四个坑:从炸管到相位抖动完整排查链路
6.1 第一个坑:上电瞬间输出不定电平引起的直通
现象是第一次上电,主电路什么都没接,光看驱动波形就发现有一路输出高电平时间异常,甚至出现两个管子同时导通一小段。一开始以为是死区没配置好,反复调DTG没效果。
排查过程我抓了四路波形,发现故障集中在初始化完成到主程序跑起来的这段缝隙里。定时器寄存器还没来得及配置完,CCER里的输出极性是默认值,MOE也没有使能,高级定时器输出引脚处于一种“未定义”的无效电平状态。如果驱动芯片是低电平有效或者后级逻辑反相,这个无效电平就可能等效成MOSFET的导通信号。
根因是高级定时器的输出无效电平由CCxP/CCxNP极性决定,配置顺序不对就会在使能瞬间产生错误电平。修复办法有两条:一是初始化流程里先把GPIO和定时器全部配置完,最后再使能主输出;二是在电源主回路加一个使能引脚,等STM32初始化全部完成后再允许驱动芯片输出。从此以后我习惯把主回路使能信号用一根GPIO接出来,软件里最后一个动作拉高它。
6.2 第二个坑:RCR配置错误导致同步频率翻倍,相位减半
现象很隐蔽,设定90°相位,实测只有45°左右,而且波形有周期性抖动。排查时我先怀疑CCR3换算公式错了,重新推导一遍没问题;又怀疑TIM8从模式触发源不对,反复查表也没问题。最后用示波器看TIM1的TRGO引脚输出,才发现TRGO一周期出现了两次脉冲。
根因是TIM1的RepetitionCounter我一开始设成了0。中心对齐模式下RCR=0时,计数器上溢和下溢都会产生更新事件,TRGO一个周期触发两次,TIM8被复位了两次,相位差被平均掉一半,还引入了抖动。修复就是把RCR改成1,让它两个边沿事件合并成一次更新事件,TRGO变成每周期一次。
这个坑印象太深了,因为现象特别有迷惑性。如果你也遇到“相位减半”的情况,先检查重复计数器,不要急着改换算公式。
6.3 第三个坑:在线大跨度调相导致的窄脉冲
做闭环测试时,我写了一个测试函数,把移相角从170°直接改成5°,想看看系统响应速度。结果示波器上捕捉到一个极窄的脉冲,宽度只有几十纳秒,原边电流波形同时出现一个很大的尖峰。虽然没有炸管,但变压器的叫声明显不正常。
排查后确认是CCR3立即生效导致的。CCR3从170°对应值瞬间变成5°对应值,比较匹配时刻在一个周期内骤变,超前臂和滞后臂的有效导通重叠出现了一个近乎为零的窗口,电流全被硬切,产生尖峰。
修复方案就是前面写的PhaseShift_SetPhase函数:限幅加预装载。CCR3开启预装载后,写入的新值只在更新事件生效,配合每100μs最多变化几度的斜坡逻辑,窄脉冲基本绝迹。这里还发现一个细节:如果只限幅不预装载,效果会打折扣,因为限幅值可能在计数周期中途写入,仍然产生小的相位阶跃;预装载把生效时刻锁定在周期边界,和PWM周期天然同步,才是根治办法。
6.4 第四个坑:从定时器第一周期相位丢步
最后这个坑出现在冷启动测试里。上电后前面几百微秒,滞后臂的相位和设定值不一致,像是不对齐,等过了一会儿才慢慢稳定到目标相位。用示波器单次触发抓上电瞬间,发现TIM8的第一个PWM周期是从一个错误的CNT起点开始的。
根因是TIM8的启动时序落后于TIM1的TRGO产生时机。如果TIM8还没配置成从模式Reset,或者它的从模式使能时机晚于TIM1产生第一个TRGO,它就把第一个同步脉冲漏掉了,后面全靠后续周期硬同步回来,第一周期就错位了。
修复分两步。第一步是严格按照“初始化TIM1→初始化TIM8→手动置UG位产生更新事件→使能输出”的顺序执行,让第一个TRGO在TIM8从模式生效之后到来。第二步是在主循环里加一个相位就绪标志,等TIM8跑满几个周期后再允许闭环调节器输出。这样既保证第一次同步不丢,也避免了上电阶段闭环PID输出异常导致相位乱跳。
一点个人体会与扩展方向
把这套方案跑稳定之后,最直观的感受是STM32做数字电源控制器并不比DSP差太多,只是需要把定时器资源榨干。TIM1+TIM8主从同步加CCR3相位基准这套组合,我后来又迁移到了LLC变换器的变频控制和PFC的倍频PWM上,逻辑都是同一套:主定时器负责基波,从定时器负责跟随,关键是把“同步触发边沿”选对,然后让相位或频率的变化通过预装载寄存器在周期边界平滑生效。
最后分享一个调参技巧:调试相位闭环时,在示波器上同时观察原边电流波形和两路驱动波形的相位差,相位环的PID参数可以先用很小的比例项,观察电流响应是否平滑,再逐步加积分项。比埋头调PWM参数效率高得多。如果你在项目里遇到类似“相位减半”“上电丢步”这些现象,建议回头从重复计数器和初始化顺序查起,多半就是这两个地方出了问题。