简介:这是一份围绕STM32 TIM1高级定时器输出指定个数PWM中断方式的工程设计资源,面向嵌入式开发者和相关专业学生,解决在需要精确脉冲数量控制的场景下,如何通过定时器更新中断和通道比较中断配合计数逻辑来生成PWM波形的问题。资源包共374个文件,约8.34MB,以c/h源码、o/axf目标与可执行文件、uvprojx工程文件为主体,并含lst/map等编译过程文件,构成一套完整的Keil工程源码,目录结构清晰,便于直接编译与研究。目前已有657人学习。内容涉及TIM1初始化、PWM模式与占空比配置、中断服务例程中的计数控制及标志清除等关键实现,学习这些代码能帮助读者掌握高级定时器中断输出PWM的设计思路,并快速迁移至电机控制、电源管理等需要精确脉冲输出的实际项目中。
1. 场景先行:为什么需要“指定个数”的 PWM
在步进电机细分驱动、舵机角度控制或某些脉冲计数类应用里,我们经常遇到的不是“持续输出PWM”,而是“输出多少个脉冲后自动停下来”。比如步进电机走一段固定距离,本质上就是控制脉冲个数;如果用通用定时器做,通常得在中断里数次数,数次数的过程中还得小心处理临界区,一旦中断响应不及时,脉冲数就会漂移。某次调试一个模拟项目X的传送带定位系统时,我就是被这个“数脉冲不准”的问题卡了整整两天,后来才彻底转向用 TIM1 高级定时器的重复计数功能来解决。
STM32 的 TIM1 不只是多了一组互补输出和刹车功能,它内部有一个普通定时器没有的重复计数器寄存器 RCR。我们完全可以借助它让硬件自己数脉冲,数满指定个数后自动触发更新中断,CPU 只需要在中断里做收尾动作。这篇文章会把 TIM1 作为“可数数的 PWM 发生器”的实现思路、CubeMX 配置、寄存器细节、中断处理以及常见的坑完整拆一遍,最后给出几个二次开发方向。无论你是刚接触 STM32 还是已经写了几年固件,照本文走一遍基本能直接用起来。
2. TIM1 高级定时器核心拆解:从寄存器到中断链路
2.1 高级定时器和普通定时器到底差在哪
先看 TIM1 和 TIM8 之外的普通定时器的区别。外观上,通用定时器 TIM2-TIM5 有四个通道,但输出大多是独立的单端信号;而 TIM1/TIM8 除了有四个通道之外,还多了一组互补输出通道(CH1/CH1N、CH2/CH2N 等),这两个互补通道在控制电机 H 桥或者全桥 LLC 电路时可以直接输出带死区的互补 PWM,硬件上自动插入死区时间。
但这只是高级定时器“高级”的一部分。真正影响我们今天这个功能的是 TIM1 的重复计数器寄存器(RCR)。普通定时器在溢出时直接产生更新事件,更新事件会带走设置好的预装载值,但普通定时器本身没有“我再多跑几个周期”的概念。TIM1 则不一样:它允许你设置 RCR 的值,让定时器在计数溢出 RCR+1 次之后才真正触发一次更新事件。
如果你曾经用 HAL 库输出固定个数 PWM,大概会拿到一个很尴尬的代码:在中断里对脉冲计数,然后关闭输出。这种软计数方式存在两个隐患。第一个隐患是延迟和抖动:如果中断里还做了其他事,关闭输出的时刻就会往后拖,导致最后一个脉冲的宽度发生偏移。第二个隐患是计数错误:如果中断优先级不够高,在高速 PWM 的情况下完全可能丢一次中断,导致多输出或少输出一个脉冲。用 RCR 做硬件计数,以上问题在源头就不会出现。
2.2 重复计数器 RCR 的工作原理
重复计数器 RCR 是 TIM1 的 16 位寄存器,它存在于高级定时器 TIM1 和 TIM8 上,在 STM32 的参考手册里位于“重复计数器寄存器(TIMx_RCR)”一节。它在更新事件产生之前充当一个“分频式”的计数器,硬件行为如下:
- 每个计数周期结束(即计数器从 ARR 溢出回 0,或向上/向下计数到边界)时,RCR 内部计数值减 1。
- RCR 内部计数值减到 0 之后,再经历一次计数溢出,才会真正生成更新事件 UEV。
- 更新事件 UEV 会置位更新中断标志 UIF,同时把预装载寄存器里的 ARR、PSC、CCR 等值传输到影子寄存器。
也就是说,输出 N 个 PWM 脉冲时,ARR 决定了单个 PWM 的周期,CCR 决定了占空比,RCR 决定了一共输出多少次后才生成一次更新中断。注意这个措辞:RCR 设置为 N-1 才能得到 N 个脉冲。原因是计数器从 0 跑到 ARR 再回 0 才算一个周期,第一个周期结束时 RCR 被“预减”了 1,一直减到 0 之后的下一个溢出才触发更新事件。
// 伪代码:RCR 数值与输出脉冲数的关系 TIM1->RCR = 99; // 输出 100 个 PWM 脉冲后产生更新事件 TIM1->EGR |= TIM_EGR_UG; // 强制产生一次更新事件,把 RCR 预装载值装入影子寄存器上面的代码将重复计数器设置为 99。每次溢出 RCR 减 1,减到 0 后再溢出一次,才生成真正的中断信号。按照这个逻辑,我们可以把 RCR 理解为“周期数少 1”的计数器。实际使用中,我一般先算好需要的脉冲数,再在配置里写下 pulse_count - 1。
理论上,如果只需要输出单个 PWM,也可以不用中断,直接让 PWM 输出模式开启并由硬件自动停止。但在“指定个数”这个场景,我们还需要处理一个关键问题:输出完最后一个脉冲之后,引脚电平应该保持在哪里?常见需求是保持低电平,因此需要更新中断参与,在中断里把输出通道的电平强制拉低。这就是中断方式的意义所在。
2.3 中断方式的整体链路设计
先画一条完整的数据流,方便你对照后文的代码。假设要输出 30 个频率为 20 kHz、占空比为 50% 的 PWM 脉冲:
- 初始化阶段:配置 TIM1 通道 1 为 PWM 模式 1,设置 ARR 和 CCR 得到 20 kHz、50% 占空比;设置 RCR 为 29;使能更新中断;最后给 EGR 寄存器写 UG 位,确保预装载值立刻生效。
- 输出阶段:置位 TIM1_CR1 的 CEN 位启动计数器。计数器每溢出一次,RCR 内部值减 1。连续产生 30 个 PWM 周期后,硬件自动产生更新事件 UEV,UIF 置 1。
- 收尾阶段:更新中断被触发,进入 TIM1 中断服务函数(或 HAL 库回调函数)。在中断里关闭 CCR 对应通道的输出(把 CCER 的 CCxE 清掉,或者直接强制输出低),然后清标志位,关闭计数器。
- 状态通知:把全局状态变量置为“输出完成”,供主循环查询或触发下一次输出动作。
这个链路里所有关键动作都发生在硬件定时器内部,软件只需要在中断里做“关闭输出”和“清标志位”两件事,中间不涉及脉冲计数,因此不会出现软计数丢脉冲的问题。理解了这个整体流程,我们去 CubeMX 里做配置就有明确目标了。
3. 工程落地:CubeMX 配置和完整代码逐段讲
3.1 CubeMX 里的关键配置项
直接用寄存器写固然最直观,但在实际项目里,CubeMX 初始化外设时钟和引脚要省事得多。下面是我常用的配置步骤,以 STM32F103 系列为例,核心逻辑同样适用于 STM32F4/H7 系列。
- 时钟树:把 APB1 和 APB2 总线时钟确认好。TIM1 挂在 APB2 上,F103 的 APB2 最高 72 MHz。
- TIM1 配置:选择 Clock Source 为 Internal Clock,Channel 1 设置为 PWM Generation CH1。
- 参数区设置:Prescaler 按目标频率计算,Counter Period 就是 ARR,Pulse 就是 CCR。Auto-reload preload 建议使能。
- 关键一步:在 TIM1 的 Configuration 区域把 Repetition Counter 设置为目标脉冲数减 1。CubeMX 的界面上这一项位置比较隐蔽,在 TIM1 参数设置的“Repetition Counter (TIMx_RCR)”里,老版本可能显示为“Repeat Counter”。
- NVIC 设置:勾选 TIM1 update interrupt,优先级按需要设置。注意 TIM1 有多个中断源(更新、捕获比较、刹车、触发),这里只需要开更新中断。
- 生成代码后,在 main 里开启 PWM 输出之前,记得手动调用一次更新事件生成函数,把 RCR 装载进影子寄存器。
这里有个控制方式上的选择:到底是开启 PWM 输出然后干等,还是在中断里关闭输出。因为我们已经用了 RCR,所以不要再在中断里数次数,否则就绕回软计数的老路了。中断里只要做三件事:关闭通道输出、清更新标志、关闭计数器。这样设计的好处是中断执行时间极短,整个产生 N 个脉冲的过程 CPU 几乎不参与。
3.2 初始化代码:RCR 与 PWM 参数装载
CubeMX 生成的初始化代码只负责时钟、引脚和基础参数,重复计数器 RCR 的赋值也会放在其中,但 HAL 库接口在有些版本里不会在初始化时自动更新影子寄存器。我习惯在此基础上粘贴一段自己的初始化逻辑,专门处理“装载”和“预清标志”:
// 初始化:指定输出 100 个脉冲,PWM 频率 20kHz,占空比 50% // ARR 和 CCR 的计算基于 72MHz 时钟:20kHz -> 周期 50us -> ARR=72000000/20000-1 #define PWM_PERIOD_BASE 3599 #define PWM_PULSE_HALF 1800 #define PULSE_COUNT 100 void MX_TIM1_PWM_Init_Ext(void) { // 1. 使用 HAL 库先做基础时基和通道配置(CubeMX 已生成) // 这里直接补充关键参数 TIM1->PSC = 0; // 预分频器为 0,直接使用 72MHz 计数时钟 TIM1->ARR = PWM_PERIOD_BASE; // 自动重装载值,决定 PWM 频率 TIM1->CCR1 = PWM_PULSE_HALF; // 通道1比较值,决定占空比 TIM1->RCR = PULSE_COUNT - 1; // RCR: 硬件数 100 个脉冲后产生更新事件 // 2. 使能自动重装载预装载,更新事件后同步 ARR TIM1->CR1 |= TIM_CR1_ARPE; // 3. 强制产生一次更新事件,让 RCR/ARR/PSC 影子寄存器全部装载 TIM1->EGR |= TIM_EGR_UG; // 4. 清除更新中断标志,避免初始化的更新事件误触发中断 TIM1->SR &= ~TIM_SR_UIF; }参数说明:PSC 设为 0 意味着定时器直接以 72 MHz 计数,如果你用的主频不同,需要按比例调整。RCR 写的是 100 - 1 = 99,这是最容易写错的一行,少减一个 1,最终会输出 101 个脉冲。ARPE 位让 ARR 的影子寄存器功能生效,这样在更新事件发生时才装入新周期值。EGR 里的 UG 位是开发者最容易忽略的:如果 RCR 配置完成后没有触发一次更新事件,影子寄存器里可能还是复位值,导致实际输出的脉冲数和预期不符。
3.3 中断服务函数:完成指定个数之后的收尾
中断处理分为两个层面:如果你用的是 HAL 库,可以重写 HAL_TIM_PeriodElapsedCallback 回调函数;如果你想让中断处理最精简,也可以直接写自定义的中断服务例程,但注意不要和 HAL 库重复处理同一标志位。下面给出 HAL 风格的实现,因为它和 CubeMX 生成的工程兼容性最好:
// 全局状态标记:0 表示输出完成,1 表示正在输出 volatile uint8_t pwm_output_busy = 0; // HAL 库更新中断回调 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { // 1. 立刻关闭通道1输出,引脚回到空闲态(一般是低电平) TIM1->CCER &= ~TIM_CCER_CC1E; // 2. 关闭计数器,让定时器完全停下来 TIM1->CR1 &= ~TIM_CR1_CEN; // 3. 清除更新中断标志 __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_UPDATE); // 4. 状态置为完成,通知主循环可以进行下一步 pwm_output_busy = 0; } }这段代码的逻辑说明:进入回调的唯一可能原因是 RCR 计数到达终点后产生了更新事件。此时最后一个 PWM 周期其实已经完整输出完,引脚电平正处于 CCR 比较之后的状态,也就是低电平,但如果我们只是关闭计数器,引脚会停在当前输出状态,所以强制关闭 CCER 的 CC1E 位,让输出引脚释放为普通 IO 状态。clear 标志的顺序放在最后是为了防止中断返回后再次进入回调造成重复关闭。
如果项目里同时用到了多个定时器,HAL 回调会对所有定时器生效,所以要加一次 htim->Instance == TIM1 的判断。这是多定时器工程里最常见的健壮性问题,不加判定的回调代码碰上任何定时器更新都会执行这段逻辑,轻则误动 IO,重则打乱其他外设的时基时序。
3.4 主流程:启动一次限定脉冲输出
启动输出的代码要处理两个问题:第一次启动前清标志,启动后立刻把忙标志置 1。主循环里轮询忙标志,忙的时候就别重复启动。
// 启动 TIM1 输出 100 个 PWM 脉冲 void PWM_Start_Pulse_100(void) { // 确保前一次输出完全结束 if (pwm_output_busy) return; // 1. 重新配置 CCR 和 RCR,方便动态修改(这里保持默认值也可) TIM1->CCR1 = PWM_PULSE_HALF; TIM1->RCR = PULSE_COUNT - 1; // 2. 清更新标志,强制装载 TIM1->SR &= ~TIM_SR_UIF; TIM1->EGR |= TIM_EGR_UG; // 3. 使能通道1输出 TIM1->CCER |= TIM_CCER_CC1E; // 4. 置忙标志,启动计数 pwm_output_busy = 1; TIM1->CR1 |= TIM_CR1_CEN; }主函数里的调用逻辑就很简单:
while (1) { if (start_button_pressed) { PWM_Start_Pulse_100(); } if (pwm_output_busy == 0) { // 输出完成,在这里翻转向下一个动作 next_action(); } }这个流程看起来简单,但有两个细节想特别提醒。第一,TIM1->CCER |= TIM_CCER_CC1E要在启动计数器之前执行,否则计数器先跑起来了,但输出还没使能,第一个周期的电平状态是不确定的;第二,忙标志要在 CEN 置位之前写,保证中断无论多快来都能看到正在输出的状态,避免主循环二次启动。
4. 常见问题与避坑记录:我从调试现场带回来的经验
4.1 输出脉冲个数总是多一个或持续不停止
现象:用逻辑分析仪测量输出引脚,发现设置的 100 个脉冲变成了 101 个,或者偶尔出现脉冲数量总比预期多的情况。
原因:RCR 的装载时机和写值语义没有搞清楚。把 RCR 写成 100,硬件实际输出的就是 101 个 PWM 周期;另外,如果在初始化阶段手动触发过更新事件却没有重写 RCR,影子寄存器里的重复计数初值可能和你预期的不一致。
解决:设置 RCR 时统一用目标脉冲数 - 1的写法。在启动前,用TIM1->EGR |= TIM_EGR_UG强制装载一次影子寄存器,并在启动前把更新标志清干净。我后来养成的习惯是:每次启动输出前,都重新写一遍 CCR、RCR、UG 位、清 UIF,顺序固定,杜绝残留状态。
4.2 更新中断没有被触发,输出停止后没有任何回调
现象:代码看起来都正确,PWM 正常输出了指定个数,但 HAL_TIM_PeriodElapsedCallback 没有进入,程序卡在等待状态。
原因:CubeMX 里忘记勾选 TIM1 update interrupt,或者只使能了全局中断,但没有在 NVIC 里勾选 TIM1 的中断通道。还有一种情况是 HAL 库初始化时把时间基 TIM6/TIM7 的中断和 TIM1 更新中断混在一起,而代码里没有分清句柄。
解决:首先在 CubMX 的 NVIC Settings 里确认 TIM1 update interrupt 被打勾。生成代码后检查 stm32f1xx_it.c 文件,确认 TIM1_UP_IRQHandler 函数存在,并且调用了 HAL_TIM_IRQHandler。如果是自定义中断服务流程,至少要在中断入口处调用对应服务函数。
4.3 输出最后一个脉冲后引脚电平保持在高电平
现象:数据手册上 PWM 模式 1 在比较值大于 ARR 时输出高,但我们的场景里占空比正常,脉冲结束后引脚却保持高电平一段时间才恢复。
原因:关闭输出时关闭了 CCER 的 CC1E,但这时 PWM 的输出级仍然被最后的有效电平控制。尤其是通道配置为 PWM 模式 1 且比较值接近 ARR 时,计数周期结束瞬间下溢,引脚状态是上一周期的有效电平,直接关闭输出使引脚被悬空或保持最后状态。
解决:在更新中断里,先关闭 CCER,再强制把 CCR 写为 0,并且把计数器 CNT 清零。这样即使有输出缓冲,引脚也会因 CCR 为 0 而输出无效电平(在 PWM 模式 1 下为低)。另外,也可以用 TIM1 的强制输出功能直接操作输出引脚状态,但要注意强制输出与 CCER 的优先级关系。
4.4 高速 PWM 下中断响应不及时导致状态错乱
现象:PWM 频率调到 100 kHz 以上时,主循环里检测忙标志的速度跟不上,但更常见的是中断函数里做过多操作,导致下一次更新事件到来时标志未清。
原因:中断函数里如果调用了库函数甚至延时函数,执行时间会超过 PWM 周期。以 100 kHz 为例,一个周期只有 10 微秒,中断服务代码超过这个时间就会在退出前再次触发更新中断,看起来像是状态错乱。
解决:中断服务函数只做寄存器写入操作,禁止调用 HAL_Delay、打印函数、浮点运算。如果需要对脉冲完成做依赖外部状态的处理,把处理动作放到主循环,中断里只改状态标志。高速场景还可以把中断优先级提到最高,但优先级的调整只是辅助,核心原则还是中断代码越短越好。
4.5 使用 TIM1 的刹车功能时通道输出被莫名关闭
现象:明明代码没有关闭通道,某次调试时 PWM 突然停了,重新初始化才能恢复。
原因:TIM1 的刹车功能(Break)默认接收来自特定引脚或其他外设的刹车信号,如果引脚悬空或者误触发了刹车输入,所有通道输出会被硬件自动关闭。
解决:在不需要刹车功能的应用里,把 TIM1 的 Break 配置改成 Disable,或者在外部把这个引脚拉成安全电平。CubeMX 里在 Tim1 Break and Dead Time 设置区域把 Break 关掉。这个坑在单独测试 PWM 输出时最容易出现,因为很多人不会主动配置刹车输入,但悬空引脚的干扰足够让硬件刹车触发。
5. 二次开发改造:从单通道固定脉冲到灵活多轴输出
5.1 把单通道改成任意通道组合输出
TIM1 的四个通道可以同时输出不同占空比的 PWM,但 RCR 只有一个,所以同一时刻四个通道的脉冲数是统一的。如果需要每路不同个数,需要利用更新中断重新修改 RCR,并控制对应通道的输出。我一般用这样的思路改造:
// 多段脉冲输出:第一段输出 100 个,第二段输出 50 个 typedef struct { uint16_t pulse_count; uint16_t arr; uint16_t ccr; } PulseSegment; void PWM_Run_Segments(PulseSegment *seg, uint8_t count) { static uint8_t seg_index = 0; if (seg_index >= count) { seg_index = 0; return; } TIM1->ARR = seg[seg_index].arr; TIM1->CCR1 = seg[seg_index].ccr; TIM1->RCR = seg[seg_index].pulse_count - 1; TIM1->EGR |= TIM_EGR_UG; TIM1->SR &= ~TIM_SR_UIF; TIM1->CCER |= TIM_CCER_CC1E; TIM1->CR1 |= TIM_CR1_CEN; seg_index++; }这段代码的关键在于,更新中断里不仅关闭输出,同时还要装载下一段的参数。把 seg_index 改成 static 后,上一段完成的瞬间就开始了下一段的计数,段与段之间几乎没有缝隙。如果要做更复杂的插补运动,可以在中断里根据当前段更新方向和速度,但要保证中断耗时足够短,必要时把插补计算放到主循环。
5.2 量化验证:用逻辑分析仪而非示波器
验证固定个数 PWM 输出,示波器只能看到波形密度,数个数非常不便。逻辑分析仪是更好的工具,几块钱的 8 通道逻辑分析仪就能在软件里直接标出脉冲个数和频率。我通常会在主循环里加一个翻转测试引脚,把“输出完成”事件从主循环和中断两条路径分别引出,对照观察完成事件与最终脉冲的时序关系。
// 测试引脚翻转,用于验证完成事件和实际输出的间隔 void on_pwm_complete_isr(void) { GPIOB->ODR ^= GPIO_PIN_0; // 只在中断里翻转 }这样能直观看到:中断翻转发生在最后一个 PWM 下降沿后的纳秒级时间内,还是延迟了好几个周期。如果发现延迟过大,重新检查中断里是否混入了耗时操作。
5.3 从脉冲发生器变成步进电机驱动核心
如果目标是驱动步进电机,不要直接把 GPIO 翻转作为运动结束信号。步进电机的方向控制引脚、使能引脚都要和 PWM 完成事件协调。常见做法是定义一个运动控制结构体,包含脉冲数、方向、速度档位,然后在更新中断里按状态机推进。我从那以后每次写运动控制代码时,都会强制把“正在输出”和“输出完成”两类事件拆到不同的状态位,所有状态变化都以中断回调为准,主循环只做状态呈现和下一次指令下发。这个习惯让后续调试的返工量大为减少,也希望这篇文章能帮你在自己的 TIM1 项目里少走一段弯路。
本文还有配套的精品资源,点击获取