简介:这是一套围绕意法半导体STM32系列中TIM1高级定时器输出指定个数PWM信号、并通过中断方式实现控制的工程资源,主要面向嵌入式系统开发者、电子竞赛参赛者以及正在系统学习STM32定时器原理的工程师。资源包共374个文件,压缩后约为8.34MB,内部除核心C语言源文件与头文件外,还包含工程配置文件、汇编启动文件、链接脚本、编译过程生成的中间文件以及调试输出文件等,目录结构清晰完整,便于按模块查阅,也方便直接导入IDE中检查运行效果。内容覆盖了TIM1定时器的工作原理、PWM模式初始化流程、各通道占空比配置方法、更新中断与捕获比较中断的服务函数写法、脉冲计数逻辑,以及达到指定脉冲数量后自动关闭输出或切换状态的处理策略;针对该方案在脉冲数量上可能存在的硬件限制,作者也提供了使用其他定时器联动或优化中断响应等思路,以保证输出实时性。已有654人浏览学习,对于希望获得可直接运行的参考样例、理解高级定时器中断式PWM控制细节的开发者来说,具有较高的实用价值。 做步进电机控制或者需要精确脉冲输出的兄弟,大概率都碰到过一个需求:让单片机对外输出固定数量的PWM,发完立刻停下来,最好还能给主程序一个中断通知。STM32 的定时器里,TIM1 和 TIM8 这两个高级定时器天生适合干这个活,因为它们的重复计数器(RCR)可以指定更新中断要隔多少个计数周期才触发一次。这篇文章就围绕 STM32 TIM1 高级定时器输出指定个数 PWM 中断方式,把原理、寄存器配置、标准库和 HAL 库的写法以及各种坑一次讲清楚。
这个需求在工业控制里太常见了:步进电机要走固定角度、激光振镜要打固定点数、变送器要输出固定个数的脉冲信号,本质上都是在问“怎么让硬件替我精确数脉冲,数够了喊我一声”。如果你还在用普通的延时翻 IO 实现,那频率稍微上去就全是问题;如果你用通用定时器的更新中断去计数,那 CPU 会被高频中断拖死。TIM1 的重复计数器就是为了解决这类问题设计的,下面直接切入正题。
1. 项目整体设计与 TIM1 选型分析
1.1 为什么是 TIM1 而不是通用定时器
STM32 的定时器家族里,TIM1 和 TIM8 是高级控制定时器,TIM2/TIM3/TIM4/TIM5 是通用定时器,TIM6/TIM7 是基本定时器。很多人觉得 PWM 输出用通用定时器就够了,确实,如果只是持续输出可调占空比的波形,TIM3 完全顶得住。但如果要“输出固定个数 PWM 然后停下来”,通用定时器就有点尴尬了。
通用定时器的更新事件是每个计数周期都产生的,比如 ARR 设成 999,CNT 从 0 数到 999 就会产生一次更新事件。你要是想在输出 100 个脉冲之后中断一次,就得在更新中断里做软件计数,每进一次中断加 1,加到 100 再关输出。这样 CPU 被中断按住了打,频率高一点就腾不出手干别的。
TIM1 就不一样。它内部多了一个重复计数器 RCR,作用是“把更新事件攒够 N+1 次再真正触发一次更新中断”。这相当于把“数脉冲”这个活从 CPU 搬到了硬件里,CPU 只需要在中断里收尾就行。TIM1 常被拿来产生多路带有死区保护的互补 PWM,但 RCR 这个特性让它成了精确脉冲控制的首选。
1.2 中断方式的核心思路拆解
“输出指定个数 PWM 中断方式”这句话拆开其实是三个动作:输出 PWM、数够指定个数、发中断。三个动作里只有“发中断”是 CPU 参与的事,前两个完全可以交给硬件完成。
实现路径是这样的:TIM1 工作在 PWM1 模式,计数方式设为向上计数。CNT 每数到 ARR 就认为完成一个 PWM 周期,理论上每周期产生一次更新事件,但因为 RCR 的存在,硬件会先把更新事件扣住,等扣满了 RCR+1 次才真正向上层报一次更新中断。所以,把 RCR 设为 N-1,就能在输出第 N 个 PWM 周期时触发一次更新中断。
中断服务函数里你要做的事通常是:关闭 TIM1 输出、记录状态、置一个标志位通知主循环,然后根据业务逻辑决定下一步是改占空比接着发,还是切到其他执行机构。整个过程中 CPU 没有持续干预 PWM 的翻转,实际输出的频率和个数完全由定时器硬件保证。
2. 核心原理拆解:PWM 周期和中断的对应关系
2.1 从 ARR/CCR 到 PWM 波形的生成过程
PWM 频率由预分频器 PSC 和自动重载值 ARR 共同决定。假设系统时钟是 72MHz,APB2 定时器时钟也是 72MHz,如果 PSC 设为 71,定时器计数频率就变成 72MHz / (71+1) = 1MHz,也就是每 1us 计数器加 1。如果 ARR 设为 999,CNT 从 0 数到 999 需要 1000us,也就是 1ms 一个周期,对应 PWM 频率 1kHz。
占空比由比较寄存器 CCR 决定。在 PWM1 模式、向上计数、输出极性为高的情况下,CNT 小于 CCR 时输出高电平,CNT 大于等于 CCR 时输出低电平。CCR 设为 499,占空比就是 50%;CCR 设为 999,占空比就是 100%;CCR 设为 0,占空比是 0%。这里有个小知识点,CCR 等于 ARR 时,部分芯片可能存在 100% 占空比输出异常的坑,后面我会单独说。
一次完整的 PWM 周期,就是 CNT 从 0 走到 ARR 再清零重新开始的过程。每一个这样的周期,硬件会产生一个更新事件,只是这个更新事件不一定能上升到中断层。
2.2 重复计数器 RCR 到底干了一件什么事
RCR,全称 Repetition Counter Register,重复计数器寄存器。它的机制可以用一个生活例子理解:你去食堂打饭,每打一碗饭,阿姨在记录板上加一个道道。如果只有一道横线,她打一碗加一个道道就擦掉重来;如果她设定的是一排五个道道,那就要打满五碗饭才停下来擦一次记录板。只要这个间隔足够稳定,RCR 就是那个“记录板”,更新中断就是“擦记录板的时机”。
具体到寄存器操作,RCR 的 16 位数值范围很大,但实际常用到的就是 0 到几千。关键公式是:实际触发中断需要的更新事件次数 = RCR + 1。也就是说,RCR=0 时每个 PWM 周期都触发中断,RCR=99 时每 100 个 PWM 周期触发一次。
所以,“输出 N 个 PWM 后进中断”这个目标,操作上就是把 RCR 赋值为 N-1。这里容易踩的坑就是把 RCR 直接赋成 N,最后发现中断要么提前触发,要么延后触发,脉冲个数总是对不上。我刷到过不少同学在论坛问“为什么我设了 100 个,实际只出来 99 个”,十有八九是忘了减这个 1。
3. 标准外设库实现:一个可直接复制的例程
3.1 硬件连接与时钟配置注意点
我用的是 STM32F103ZET6,TIM1_CH1 默认映射在 PA8。如果用到互补输出,CH1N 默认在 PB13。很多例程喜欢用重映射把 TIM1 挪到 PE9 这些脚上,但我的建议是新手尽量先用默认引脚,等熟悉流程再折腾重映射,不然 GPIO_Remap 和 AFIO 时钟忘开一个,查半天查不出来。
时钟这块有个容易忽略的细节:TIM1 挂在 APB2 总线上,和 USART1、ADC1 共用一条总线时钟。标准库初始化里一定要单独把 RCC_APB2PeriphClockCmd 里加上 RCC_APB2Periph_AFIO,因为 TIM1 的引脚复用需要开启 AFIO 时钟。虽然某些默认复用情况下不开启也能跑,但规范做法是开启,避免后续上重映射时莫名其妙的问题。
3.2 从 GPIO 到 TIM1 的完整配置代码
下面这段是用标准外设库写的完整初始化,我直接在工程里验证过,只要主频匹配,粘贴改一下参数就能用。
void TIM1_PWM_NPulse_Init(uint16_t freq_pulse_num, uint16_t pulse_cycle, uint16_t pulse_width) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 开启 TIM1 和 GPIOA 以及 AFIO 时钟,均挂在 APB2 上 RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. 配置 PA8 为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 配置定时器时基参数 // 假设系统时钟 72MHz,这里 PSC 设为 71,计数频率 1MHz TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = pulse_cycle - 1; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_RepetitionCounter = freq_pulse_num - 1; // 关键:N 个脉冲中断一次 TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // 4. 配置 PWM 输出通道 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = pulse_width - 1; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure); // 5. 预装载使能,防止运行中修改参数导致波形毛刺 TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); // 6. 开启更新中断并配置 NVIC TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM1_UP_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 7. 高级定时器必须有这一步:打开主输出使能 TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }有个地方要格外注意,步骤 7 的TIM_CtrlPWMOutputs只对高级定时器有效,通用定时器根本没有这个函数。这是因为高级定时器内部有个 MOE 主输出使能位,必须置 1 才能把 PWM 信号送到引脚上。我见过很多移植例程的人拿着通用定时器的代码往 TIM1 上搬,忘记这个函数,折腾半天发现引脚就是没有波形。
3.3 中断服务函数与后续动作
中断服务函数名称宏定义在头文件里,查一下启动文件就能确认。F103 系列 TIM1 的更新中断服务函数名是TIM1_UP_IRQHandler。
volatile uint8_t g_pwm_finished_flag = 0; void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM1, TIM_IT_Update); // 到达指定个数,关闭输出 TIM_Cmd(TIM1, DISABLE); TIM_CtrlPWMOutputs(TIM1, DISABLE); g_pwm_finished_flag = 1; } }中断里做完这三件事:清标志、关输出、置标志位。关输出用两条语句,一条是关计数器TIM_Cmd,一条是关主输出使能TIM_CtrlPWMOutputs,两个都关才保证引脚彻底没有输出波形。置标志位是给主循环一个信号,主循环发现这个标志置 1,就知道输出结束了,可以继续下一步动作。
这里有一点大家要理解:中断触发的时间点是第 N 个 PWM 周期结束,也就是 CNT 从 ARR 回到 0 的那一刻,而真正执行到关闭输出的代码还需要几个时钟周期。所以在示波器上数波形,可能看到输出完美跳变,也可能看到最后一个脉冲比前面稍微短了一点尾巴,这个取决于中断响应延迟。对绝大多数应用来说可以忽略,但对时序要求极其苛刻的场合,要么把关闭动作提前一个周期,要么改用 DMA 方式,这个我放在后面扩展讲。
4. 基于 CubeMX 和 HAL 库的快速实现
4.1 CubeMX 配置里容易被忽略的 RepetitionCounter
HAL 库搞这套逻辑更省事,CubeMX 图形化配置直接把好多底层细节屏蔽掉了。新建工程后,在 Timers 里选 TIM1,Mode 配置成 PWM Generation CH1,Parameter Settings 里把 Prescaler、Counter Period、Pulse 填好。关键参数就是 Counter Period 是 ARR,Pulse 是 CCR,这俩决定了 PWM 频率和占空比。
但是,很多人翻遍 CubeMX 界面都找不到 RCR 在哪。它藏在 TIM1 的 Configuration 页面,仔细找一下有个 Repetition Counter 输入框,默认是 0。你要输出 200 个脉冲,就在这里填 199。填完以后,CubeMX 生成的代码里,htim1.Init.RepetitionCounter就已经是 199 了。如果这一步漏掉,后面你用再多的回调函数也只会得到持续输出的波形,因为每个周期都在进中断。
NVIC 设置里记得勾选 TIM1 update interrupt,这个不开的话中断永远不会执行。
4.2 HAL 库启动与回调函数用法
HAL 库的启动方式比标准库抽象了一层,启动 PWM 输出并开启中断是这样写的:
HAL_TIM_PWM_Start_IT(&htim1, TIM_CHANNEL_1);这个函数内部会配置 CH1 输出并使能更新中断。当 RCR 计数完成,也就是指定个数的脉冲输出完毕,HAL 库会从更新中断里调用回调函数:
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { HAL_TIM_PWM_Stop_IT(&htim1, TIM_CHANNEL_1); // 这里可以继续做你的业务逻辑 } }需要提醒的是,HAL_TIM_PWM_PulseFinishedCallback这个名字容易让人误以为每个脉冲都会回调一次。实际上,它对应的是更新事件,只有更新中断被触发才会调用。如果你没改 RCR,那确实每个 PWM 周期都回调一次;改了 RCR 之后,就会变成多个周期回调一次。HAL 库对标志位的清除和中断入口管理都封装好了,你只管在回调里写业务逻辑就行,人为出错率比标准库低了不少。
HAL 库的坑也有。比如你在回调里直接调用HAL_TIM_PWM_Stop_IT,需要保证当前没有竞争条件。更稳妥的做法是只置一个标志位,回到主循环再停止定时器,避免在中断上下文里做太多操作。我在实际项目里一般只在回调里翻转一个 GPIO 和置标志位,剩下的放 while 主循环里处理。
5. 常见问题与排查技巧实录
5.1 输出个数不对,比设定值多一个或者少一个
这是最高频的问题,几乎每个月都能在论坛看到新帖。绝大多数情况都是 RCR 的值填错了。RCR = N-1,这件事我再强调一遍都不为过。填 N 就多一个周期才中断,填 N-1 还没到第 N 个就进中断了。
另一种情况是 PWM 计数起始位置。上电后计数器从 0 开始,第一段高电平的起点是 CNT 复位后立刻输出高电平,这个过程如果从示波器上看,第一个脉冲的开始时间和后续脉冲的周期并不是完全对齐的。但实际测试下来影响极小,除非你做高精度相位测量,否则基本不用管。
5.2 引脚完全没有波形,输出一直是低电平
这时候先别怀疑定时器配置,先查两件事。第一,TIM_CtrlPWMOutputs有没有执行,高级定时器不开 MOE 位,哪怕寄存器里所有值都对,引脚也是死的。第二,GPIO 模式是不是复用推挽,默认的 GPIO_Mode_AF_PP 有没有写成 GPIO_Mode_Out_PP。这两个问题占了 80% 的没有波形的情况。
还有一类隐蔽问题:某些开发板的 PA8 引脚被板上其他外设占用了,或者跳线帽没接。我给不少学生排查过,代码怎么看都对,最后发现是示波器探头接错了位置。先拿万用表量一下引脚电平变化,能省很多时间。
5.3 中断一直在进,停不下来
如果你发现程序一直卡在中断服务函数里,先看是不是中断标志没清除。标准库要调用TIM_ClearITPendingBit,HAL 库在回调返回后会自动清理,两种库的维护方式不同,但原理一致:更新事件的标志位如果不及时清零,中断会反复触发。
另外注意区分两个标志:更新中断标志TIM_IT_Update和捕获比较中断标志TIM_IT_CC1。很多人配置 PWM 的时候顺手把 CC 中断开了,结果明明是 PWM 输出,却不断进 CC 中断。排查时可以在中断服务函数里打断点,看它进的是TIM1_UP_IRQHandler还是TIM1_CC_IRQHandler,进去一看就明白了。我把常见问题整理成了一张表,方便对照排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 输出0个脉冲,引脚低电平 | 未调用TIM_CtrlPWMOutputs | 标准库调用TIM_CtrlPWMOutputs(TIM1, ENABLE) |
| 脉冲数比设定少1 | RCR设置错误 | RCR赋值N-1 |
| 脉冲数比设定多1 | RCR设置错误 | RCR赋值N-1 |
| 中断不断触发 | 更新标志未清除 | 中断里调用TIM_ClearITPendingBit或等HAL清理 |
| 占空比100%却无输出 | CCR=ARR时部分场景异常 | CCR设置为ARR-1或单独验证 |
| 频率比预期高一倍或对不上 | PSC/ARR未减1 | 计数频率和周期都补上那个1 |
5.4 100% 占空比下的输出异常现象
最后一个坑,和指定个数无关,但使用 PWM 模式时容易一起踩到。有的型号在主频较高、CCR 等于 ARR 的场景下,输出 100% 占空比会出现异常,比如偶尔丢一个脉冲或者高电平维持不住。我的经验是,如果确实需要 100% 占空比,可以把 CCR 设成 ARR-1,然后让输出极性反过来配合,或者直接切到强制输出高电平模式。这个属于应用层 workaround,不影响 PWM 计数逻辑。
6. 实际应用场景与个人经验扩展
TIM1 输出指定 PWM 中断方式最典型的场景就是步进电机脉冲控制。步进电机驱动器接收的是脉冲方向信号,一个脉冲对应一个步进角度,想要精确控制电机转多少圈,本质上就是精确控制脉冲个数。以前用软件延时发脉冲,电机低速还好,高速时 CPU 全被占满,整个系统其他任务全都卡死。换成 TIM1 加 RCR 之后,电机的脉冲发放在后台自动完成,CPU 可以在等待期间处理显示、通讯、按键这些事情,性能提升非常明显。
另外一个我个人觉得很有用的扩展是配合 DMA 实现更精确的关闭时机。前面提到中断方式存在毫秒级以下的响应延迟,如果要求极高的脉冲精度,可以在 PWM 发送到倒数第二个周期时,利用 DMA 把关闭寄存器值的序列放到内存里,由 DMA 在下一个更新事件时自动写入寄存器,实现零延迟关闭。这个玩法比较复杂,一般用的不多,但如果你做激光振镜这类设备,这个思路值得研究。
调试工具这块,我建议手头准备一个逻辑分析仪,几十块钱的就行,比示波器更容易抓到长时间的多路波形。用逻辑分析仪观察 TIM1_CH1 和 CH1N 两路输出,可以直观看到 PWM 个数和死区时间是否符合预期。很多时候代码没 bug,是示波器触发放置不对导致看不到完整波形,逻辑分析仪的连续记录功能能让你轻松数出每一个脉冲。
根据我自己的项目经验,还有一个小细节:中断服务函数里尽量只做标志位操作和必要的状态机切换,不要在里面初始化外设、执行耗时算法或者调用打印函数。中断里做打印,会瞬间把系统拖到崩溃边缘,因为串口输出一个字节的时间,足够定时器产生好几次溢出了。把这个习惯养成,后面做更复杂的多定时器协同项目时会少踩很多坑。
本文还有配套的精品资源,点击获取