简介:面向STM32嵌入式开发者的PWM输出实验工程包,基于标准外设库完成定时器TIM3与TIM4通道一的PWM输出,并重点演示高级定时器TIM8与TIM1的互补PWM配置,支持频率与占空比实时调整,适用于电机控制、电源管理等需要精确信号控制的场景。压缩包共134个文件,容量约400KB,代码部分包含33个C源文件与34个H头文件,另有Keil工程配置文件以及编译生成的HEX、AXF等文件,整体是可直接打开、编译并烧录验证的完整工程。资源已有1542人学习下载。实验代码覆盖定时器工作模式设置、预分频与自动重载计算、比较寄存器调节占空比、高级定时器互补输出、程序初始化流程等关键环节,并涉及边沿对齐PWM模式、互补通道死区控制与频率动态调整等知识点,可帮助读者深入理解STM32定时器PWM的设计与实现方法,快速迁移到电机驱动、开关电源等实际项目中。对于需要在现有工程中快速加入PWM功能或调试互补波形的开发者,该工程提供了可直接参考的参数配置与主程序框架,可显著减少底层驱动编写时间。 做电机控制和电源类的工程,PWM 输出迟早要迈过“互补”这道坎。普通定时器发个呼吸灯、驱动个舵机很容易,但一旦接上 H 桥或者三相逆变器,你会发现只靠软件去错开上下管的导通时间根本不现实——中断一抖动、代码一跑偏,上下管同时导通,轻则波形畸形,重则直接炸管。我这段时间在做 TIM8 和 TIM1 的双定时器互补 PWM 输出实验,频率可调、占空比可调,还要带死区,把整个配置链路和踩坑过程整理成文。这篇文章适合正在学 STM32 高级定时器、准备做电机驱动或数字电源的开发者,尤其是卡在“为什么我配了 PWM 却没有输出”和“死区到底怎么设置”这两个问题上的人。
1. 为什么互补PWM必须交给TIM1/TIM8
1.1 普通定时器到底缺了什么
很多初学者习惯用 TIM2、TIM3、TIM4 来产生 PWM,这些通用定时器在 F103 上数量多、用起来顺手。但如果你把需求换成“同时输出一对互补信号,中间还要留死区”,通用定时器就力不从心了。通用定时器可以用 PWM 模式产生单路波形,但要让 CH1 和 CH2 输出精确互补、可编程死区、可硬件刹车,它根本没有对应的硬件逻辑。
高级定时器和通用定时器最本质的差别在于,它内部多了三样东西:
- 死区生成器:可以在互补输出之间自动插入一段延迟时间,硬件完成,不需要软件干预。
- 刹车输入(Break):外部故障信号可以直接把输出关断,比如过流、过压信号接入后,几微秒内就能封锁 PWM。
- 主输出开关(MOE):也就是 BDTR 寄存器里的主输出使能位,它单独控制高级定时器所有 PWM 输出是否真正送到引脚上。
如果把普通 PWM 当作一个“能定时翻转的电平发生器”,那么高级定时器的互补输出就是一个“自带安全机制的功率驱动核心”。这不只是多几个寄存器那么简单,而是硬件层面的驱动保护设计。电机驱动、逆变器、开关电源这类应用,恰恰最吃这点。
1.2 TIM1/TIM8的资源与引脚布局
STM32F103ZET6 这颗芯片上,TIM1 和 TIM8 都挂在 APB2 总线上,最高时钟 72MHz。每个定时器都有 4 个独立通道,其中前 3 个通道带互补输出,第 4 通道是单端输出,另外还有专门的刹车输入引脚。我这次实验用的是 CH1 和 CH1N,但实际上三对互补通道的配置方法完全一样,只是引脚不同。
下面把两者的引脚对照整理出来,方便查表:
| 定时器 | 通道 | 主输出引脚 | 互补输出引脚 | 备注 |
|---|---|---|---|---|
| TIM1 | CH1 | PA8 | PB13 | 带互补 |
| TIM1 | CH2 | PA9 | PB14 | 带互补 |
| TIM1 | CH3 | PA10 | PB15 | 带互补 |
| TIM1 | CH4 | PA11 | 无 | 单端输出 |
| TIM1 | BKIN | PB12 | 无 | 刹车输入 |
| TIM8 | CH1 | PC6 | PA7 | 带互补 |
| TIM8 | CH2 | PC7 | PB0 | 带互补 |
| TIM8 | CH3 | PC8 | PB1 | 带互补 |
| TIM8 | CH4 | PC9 | 无 | 单端输出 |
| TIM8 | BKIN | PA6 | 无 | 刹车输入 |
实际接线下,TIM1_CH1 和 TIM1_CH1N 在 PA8 / PB13 上,这两脚在最小系统板上都引出来了,是调试互补 PWM 最方便的通道。TIM8 的 PC6 和 PA7 使用起来也没问题,但要注意 PA7 作为互补输出的时候,不能再把它当普通 IO 用,复用配置时容易搞混。
2. 把频率、占空比、死区的账一次算清
2.1 时基公式和频率/占空比
高级定时器本质上还是一个 16 位向上计数器,配合预分频器、自动重装载寄存器产生周期,比较寄存器产生占空比。核心公式还是那条:
PWM频率 = 定时器时钟 / ((PSC + 1) × (ARR + 1))
这里的 PSC 是预分频系数,ARR 是自动重装载值。在 F103 上,TIM1 和 TIM8 的时钟默认 72MHz,如果 PSC = 0,那么频率就完全由 ARR 决定。占空比则通过修改 CCR1 来实现:
占空比 = CCR1 / (ARR + 1) × 100%
这个方式很简单,但实际调频率时有个容易想错的地方:ARR 不但影响周期,也会影响占空比的百分比。比如 ARR 从 7200 改成 3600,周期缩短一半,但 CCR1 不变的话,绝对导通时间没变,占空比却变成了原来的两倍。所以做频率可调功能时,必须把 ARR 和 CCR1 一起更新,而不是只改一个。
2.2 死区 DTG 的实现逻辑
死区由 BDTR 寄存器的 DTG[7:0] 位控制,它不是线性的 0 到 255 映射,而是分成了几个区段,目的是在有限的 8 位寄存器里既支持小死区的精细步进,又支持大死区的粗略配置。以 72MHz 时钟为基准,tCK_INT 约等于 13.9ns:
- DTG[7:5] = 0xx:死区时间 = DTG × tCK_INT,步进 13.9ns,范围 0 ~ 1763ns。
- DTG[7:5] = 10x:死区时间 = (64 + DTG[6:0]) × 2 × tCK_INT,步进 27.8ns。
- DTG[7:5] = 110:死区时间 = (32 + DTG[6:0]) × 8 × tCK_INT,步进 111ns。
- DTG[7:5] = 111:死区时间 = (32 + DTG[6:0]) × 16 × tCK_INT,步进 222ns。
很多人直接往 DT 寄存器里塞一个时间数字,却发现实际死区完全不是预期,就是因为没搞懂这个分段逻辑。简单记法:当需要的死区在 1μs 以内时,DTG 写 0 到 127,此时死区 = DTG × 13.9ns;超过 1.7μs 后,要用更高的区段。CubeMX 里填死区时间时,它会自动帮你算 DTG,但直接用寄存器操作就必须自己算。
2.3 具体实例:20kHz、50%、1μs 死区
假设我需要一组 20kHz、占空比 50%、死区 1μs 的互补 PWM,定时器时钟 72MHz。
先算周期。20kHz 对应的周期是 50μs,也就是 50000ns。一个计数周期等于 tCK_INT × (ARR + 1),所以 ARR + 1 = 72000000 / 20000 = 3600,也就是 ARR = 3599。占空比 50%,则 CCR1 = 1800。
死区 1μs:因为 1000ns / 13.9ns ≈ 72,落在 DTG 第一区段(0 到 127),所以 DTG 直接写 72,实际死区约 999ns,误差不到 0.1%,完全可用。
再验证一下:如果要求死区 3μs,72MHz 下第一区段最大只能到 127 × 13.9ns ≈ 1763ns,不够了,就得用第二区段。3μs / (2 × 13.9ns) ≈ 108,落在 64 到 126 区间内,因此 DTG[6:0] = 108 - 64 = 44,DTG 整体写入 (2 << 5) | 44 = 108。这样算一次就错不了。
3. 寄存器配置链路:核心是MOE,别搞反
3.1 配置步骤拆解
这个实验我一开始直接复用通用定时器的 PWM 配置代码,结果引脚死活没有输出,查了半天才发现高级定时器比普通定时器多了一道工序:必须把 BDTR 寄存器的 MOE 位置 1,主输出才真正开放。下面把完整初始化流程写出来,用的是标准库风格,方便对照寄存器手册看:
void TIM1_ComPWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BDTRInitTypeDef TIM_BDTRInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE); // PA8 = TIM1_CH1,PB13 = TIM1_CH1N GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_Init(GPIOB, &GPIO_InitStructure); // 时基:20kHz,PSC=0,ARR=3599 TIM_TimeBaseStructure.TIM_Period = 3599; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // PWM 模式1,占空比 50%,主输出和互补输出同时使能 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 = 1800; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OCNPolarity = TIM_OCNPolarity_High; TIM_OCInitStructure.TIM_OCIdleState = TIM_OCIdleState_Reset; TIM_OCInitStructure.TIM_OCNIdleState = TIM_OCNIdleState_Reset; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); // 死区 DTG = 72,约 1μs TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Disable; TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Disable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime = 72; TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Disable; TIM_BDTRInit(TIM1, &TIM_BDTRInitStructure); // 关键:MOE 置 1,开放主输出 TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }这段代码里,TIM_CtrlPWMOutputs(TIM1, ENABLE)就是置位 MOE 的封装。如果漏掉这一步,前面的 CCR、ARR 配置得再准,引脚上也什么都看不到。这部分我也在 F407 和 F103 上分别验证过,行为一致。
3.2 为什么 MOE 是所有问题的根源
MOE 位是高级定时器单独的“总闸”。普通定时器只要 CR1 的 CEN 置位,计数器跑起来,输出就自然出现在引脚上。高级定时器不是这样,它把 PWM 生成逻辑和外部引脚输出之间加了一道安全隔离,MOE 就是这道隔离的开关。这样设计是有道理的:系统发生故障时,单片机只需要把 MOE 清零,就能瞬间切断所有 PWM 输出,不需要逐通道去关 CCR、改 CCER,这在电机堵转或过流场景下反应速度远快于软件逐通道操作。
初学阶段最容易踩的坑是:初始化代码顺序不对,导致上电瞬间引脚电平不确定。比如先进 BDTR、置 MOE,再配 PWM 模式,那么在 MOE 开放的一瞬间,输出引脚可能还是默认状态,在驱动电路上表现为一个短暂的误触发。稳妥的顺序是先把 GPIO 和通道模式都配好,最后再置 MOE。如果换成 OSSR 和 OSSI 位还能控制空闲状态的电平,这两个位在空闲模式下决定输出是关闭还是有效电平,建议实验阶段都配成 Disable,即关闭态输出无效电平。
3.3 同时配置 TIM8 时的第二次坑
TIM1 调通之后,我以为 TIM8 复制粘贴改个通道、改个 GPIO 就行了,结果实际配置才发现 TIM8 的互补输出在 PA7,而 PA7 在同一个最小系统板上还连接着一些其他外设,比如某些板子把它引到了 DAC 或者按键上。这就是同时在 TIM1 和 TIM8 上做实验容易出问题的典型原因:引脚冲突。
TIM8 的配置结构和 TIM1 完全一样,只要把 RCC、GPIO、定时器基地址换掉即可。代码如下:
void TIM8_ComPWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BDTRInitTypeDef TIM_BDTRInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM8, ENABLE); // PC6 = TIM8_CH1,PA7 = TIM8_CH1N GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7; GPIO_Init(GPIOA, &GPIO_InitStructure); // 其余时基、PWM、死区配置与 TIM1 相同,只是寄存器改为 TIM8 TIM_TimeBaseStructure.TIM_Period = 3599; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM8, &TIM_TimeBaseStructure); 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 = 1800; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OCNPolarity = TIM_OCNPolarity_High; TIM_OCInitStructure.TIM_OCIdleState = TIM_OCIdleState_Reset; TIM_OCInitStructure.TIM_OCNIdleState = TIM_OCNIdleState_Reset; TIM_OC1Init(TIM8, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM8, TIM_OCPreload_Enable); TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Disable; TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Disable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime = 72; TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Disable; TIM_BDTRInit(TIM8, &TIM_BDTRInitStructure); TIM_CtrlPWMOutputs(TIM8, ENABLE); TIM_Cmd(TIM8, ENABLE); }需要留意的是,F1 系列 TIM1 和 TIM8 的引脚没有重映射功能,必须严格按照上表接线。不像 TIM2 到 TIM4 可以靠 AFIO 重映射调整位置。
4. 频率和占空比的动态调节
4.1 修改 ARR 与 CCR 的同步问题
实验要求“频率可调、占空比可调”,最简单的实现方式是用按键去改变 ARR 和 CCR。但这里有一个容易翻车的细节:ARR 和 CCR 不是实时生效的,它们都有预装载影子寄存器,由 ARPE 位和 OC1PE 位控制。ARPE = 1 时,写入 ARR 的值会先缓存在预装载寄存器里,等更新事件到来才真正进入影子寄存器;OC1PE = 1 时,CCR1 同理。
这个机制的好处是:当同时修改 ARR 和 CCR1,并且两者都启用了预装载时,它们会在同一个更新事件瞬间一起生效,保证频率和占空比的切换是“原子操作”,中间不会出现一个周期比例失调的波形。如果关掉预装载,写入操作直接作用于当前寄存器,那么改 ARR 和改 CCR1 之间,输出波形就可能出现一个不正常的周期。
所以在初始化里,ARPE 和 OC1PE 必须都置 1。标准库中TIM_TimeBaseInit默认会把 ARPE 设为 Disable,需要手动打开TIM_ARRPreloadConfig(TIM1, ENABLE)。
实际动态调节还有一个常见问题是按键扫描和 PWM 更新之间的时序关系。按键消抖一般有十几毫秒延迟,对 20kHz 的 PWM 来说,一个周期只有 50μs,按键处理完全不影响更新事件。所以不用担心 PWM 周期和按键处理冲突。
4.2 按键调节的代码方案
我的实验方案是四个按键:KEY1 频率增加,KEY2 频率减少,KEY3 占空比增加,KEY4 占空比减少。频率范围设在 1kHz 到 50kHz,占空比限制在 5% 到 95%。部分代码如下:
void SetPWMFreqDuty(TIM_TypeDef* TIMx, uint32_t freq_hz, uint16_t duty_percent) { uint16_t arr, ccr; if (freq_hz < 1000) freq_hz = 1000; if (freq_hz > 50000) freq_hz = 50000; if (duty_percent < 5) duty_percent = 5; if (duty_percent > 95) duty_percent = 95; arr = 72000000UL / freq_hz - 1; ccr = (uint32_t)arr * duty_percent / 100; TIMx->ARR = arr; TIMx->CCR1 = ccr; // 如果希望立即生效,可以软件产生一次更新事件 TIMx->EGR |= TIM_EGR_UG; }按键事件里只需要维护两个全局变量,频率变量和占空比变量,然后调用这个函数分别更新 TIM1 和 TIM8:
void Key_Process(void) { if (KEY1_Pressed()) { freq_hz += 1000; if (freq_hz > 50000) freq_hz = 1000; SetPWMFreqDuty(TIM1, freq_hz, duty); SetPWMFreqDuty(TIM8, freq_hz, duty); } if (KEY3_Pressed()) { duty += 5; if (duty > 95) duty = 5; SetPWMFreqDuty(TIM1, freq_hz, duty); SetPWMFreqDuty(TIM8, freq_hz, duty); } }注意TIMx->EGR |= TIM_EGR_UG会产生一次软件更新事件,如果此时恰好接近硬件更新事件,两者可能重叠,虽然实际使用中很少出问题,但严谨的做法是在更新中断里做频率调整,或者使用影子寄存器的自然更新而不是软件强制更新。实验中我为了简化,直接软件触发,波形没有发现异常,只是逻辑上多做一次无伤大雅的事件。
4.3 使用 HAL 的注意点
如果用 CubeMX 生成工程,高级定时器的配置界面比标准库直观很多:时基、输出通道、互补通道、死区时间、刹车模式都是图形化配置。但在调用函数启动输出时有个经典坑,很多人都卡在这里。
在 HAL 库中,启动普通 PWM 用HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1),但这只启动了主输出。互补输出还需要单独调用:
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1);如果只调用第一个,PA8 上有波形,PB13 上一直为低,很多人的第一反应是硬件坏了,其实只是互补输出没有启动。HAL_TIMEx_PWMN_Start内部会把 MOE 置位,同时打开互补通道。调试时如果发现主输出正常、互补无输出,十有八九是少了这一句。
5. 实测验证、刹车保护与常见陷阱
5.1 示波器看死区的正确姿势
配置完成后,把示波器两个通道分别接 PA8 和 PB13,共地夹子都用板子的 GND。此时应该看到两路相位互补、占空比一致的 PWM 波形。把时基放大到 1μs/div 左右,仔细看 PA8 下降沿和 PB13 上升沿之间,有一段两个都为低电平的间隙,这就是死区。
我习惯把示波器触发设为 PA8 的下降沿,然后显示两路波形,这样能稳定观察每次切换瞬间的死区。正常情况下,两路信号会有一段短暂的重叠为低电平,时间大约 1μs。如果看不出明显的低电平间隙,可能的原因有两种:一是死区值写得太小,比如只写了 DTG = 1,13.9ns 的时间在示波器上几乎看不见;二是通道极性配反了,两路波形根本不是在预期相位上切换,看起来就没有正确的死区间隙。
如果你的示波器带有时间测量功能,直接把光标放到低电平间隙的两端,测出来的时间应该在 0.9μs 到 1.1μs 之间,基本对应配置值。没有示波器的话,用逻辑分析仪采样也能看,但采样率建议至少 50MHz 以上,否则死区时间可能被采样间隔吞掉,肉眼看不出来。
5.2 刹车功能实测
实验板的硬件上通常没有真实的过流信号,所以我用 GPIO 模拟了一路刹车输入,验证高级定时器的故障保护行为。上位操作是把 TIM1 和 TIM8 的刹车功能打开,配置 BKIN 引脚为输入,然后连接一个按键,按下时拉高电平模拟故障。
void Break_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // PB12 作为 BKIN 输入,下拉 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPD; GPIO_Init(GPIOB, &GPIO_InitStructure); } void Break_Enable(TIM_TypeDef* TIMx) { // 使能刹车功能,输入低电平触发,并且自动恢复输出 TIMx->BDTR |= TIM_BDTR_BKE; // 刹车使能 TIMx->BDTR &= ~TIM_BDTR_BKP; // 刹车输入有效电平为低 TIMx->BDTR |= TIM_BDTR_AOE; // 下个更新事件自动恢复 }故障发生时,把 BKIN 引脚拉低,MOE 位被硬件清零,PA8 和 PB13 立刻被封锁到无效电平。实测下来,从引脚电平变化到 PWM 消失的延迟在数百纳秒内,这个速度靠软件中断根本做不到。清除故障后,由于配置了 AOE,经过一个更新事件后输出自动恢复。
这个功能在做电机驱动时特别实用。过流比较器输出的信号直接接到 BKIN,一旦电枢电流超标,硬件自动封锁 PWM,MCU 只需要在中断中处理故障记录和状态显示。注意如果不需要自动恢复,就把 AOE 清掉,故障之后必须软件重新置位 MOE,防止故障源未解除时盲目恢复输出导致二次损坏。
5.3 调试现场遇到的典型问题
这次实验从零调通,前后记录的典型问题有五个,基本都是初学者或者第一次用高级定时器的人高频踩坑:
第一,引脚上毫无波形,排查半天发现是 PA8 和 PB13 的复用时钟没开,或者 GPIO 模式配成了推挽输出而不是复用推挽。F103 上必须配GPIO_Mode_AF_PP,普通推挽输出不会把定时器信号引到引脚。
第二,PWM 只有一个通道工作,另一个始终低电平。先检查TIM_OutputNState是否 Enable,再检查TIM_CtrlPWMOutputs是否执行。HAL 环境下注意双启动函数都要调用。
第三,死区时间实测和配置不符。排查方向两个:DTG 分段计算错误、或者定时器时钟不是 72MHz。如果 APB2 预分频配置不同,实际定时器时钟可能是 36MHz 或其他值,需要按实际时钟重算。
第四,调节频率时 PWM 偶发乱跳。这个基本是 ARR 和 CCR1 没有同步更新导致的,启用两个预装载位,并且在同一个代码路径里同时修改,能明显改善。
第五,上电瞬间输出异常高电平。这是有驱动负载时特别危险的信号,处理办法是确保初始化顺序是“GPIO 复用 → 通道模式 → 死区 → 最后置 MOE”,并且尽量把负载连接放在配置完成之后。更进一步,可以用TIM_OCIdleState设置空闲状态为有效或无效,让刹车状态下引脚电平确定。实验阶段先把 MOS 管和电机的连接断开,只观察波形,等确认无误再挂负载。
链路完全调通后的波形表现很稳定,TIM1 和 TIM8 输出的两组互补 PWM 在相位和死区上表现一致。按键切换频率时,前一个周期的最后一个脉冲可能会略有畸变,但下一个周期马上恢复,不影响实际使用。如果对切换瞬间的要求高,建议改用定时器更新中断,在更新事件里读写 ARR 和 CCR1,能保证切换点永远发生在周期的起点。
最后再分享一个调试经验:调高级定时器时不要在代码里一次性写完所有功能,我建议分成四步验证。第一步,只配普通 PWM 单通道,确认频率占空比正确;第二步,加互补通道,确认两路波形完全反相;第三步,加死区,用示波器确认间隙时间;第四步,加刹车,人为触发故障信号,观察 MOE 封锁行为。每步都验证通过再做下一步,出现问题时定位范围会小很多。这个实验做完之后,回头再看电机驱动的底层原理,很多之前觉得玄乎的东西其实都是高级定时器里的那点硬件机制。
本文还有配套的精品资源,点击获取