1. 为什么定时器是STM32入门的分水岭
很多人学STM32,GPIO点灯、按键扫描、串口打印这三板斧耍得挺溜,一到定时器就卡壳。我见过太多人卡在这一步:中断进不去、进了中断出不来、时间算不对、LED闪得跟抽风一样。其实定时器这东西,说穿了就是一个"会数数的硬件",你给它一个时钟源,它就开始数,数到某个值就告诉你一声。就这么简单。
但为什么大家觉得难?因为CubeMX里定时器的参数页面一打开,Prescaler、Counter Period、Clock Division、auto-reload preload、各种中断使能,密密麻麻一堆选项,不知道哪个该填什么。再加上HAL库的中断回调函数名字又长又绕,HAL_TIM_PeriodElapsedCallback,第一次见的人根本不知道这玩意儿该往哪儿放。
这篇内容就是来解决这个问题的。我以STM32F103C8T6这块最经典的入门芯片为例,用CubeMX从零配置一个定时器定时中断,让LED按照精确的1秒周期闪烁。整个过程拆解成可复现的步骤,每个参数为什么这么填、中断回调为什么这么写、时间怎么算出来的,全部讲透。不管你是刚学完GPIO的新手,还是被定时器折磨过想重新理一遍的老哥,这篇都能让你把定时器定时中断这条链路彻底跑通。
关键词就几个:CubeMX、STM32、定时器、HAL库、定时中断。围绕这几个词,我把配置逻辑、时钟树计算、代码结构、常见翻车点全部展开。看完你至少能做到三件事:第一,独立算出任意定时时间的Prescaler和Counter Period组合;第二,知道HAL库定时中断的完整调用链路;第三,遇到中断不触发或者频率不对的时候,有一套排查思路。
2. 定时器定时中断的硬件逻辑与CubeMX配置路径
2.1 定时器到底在数什么:从时钟源到更新事件
STM32的通用定时器(比如TIM2、TIM3、TIM4)挂载在APB1总线上,高级定时器(TIM1、TIM8)挂在APB2上。以TIM2为例,它的时钟来源是APB1的倍频输出。这里有个很多人忽略的细节:APB1的预分频系数如果不为1,定时器时钟会自动倍频。比如系统时钟72MHz,APB1预分频设为2,APB1总线时钟是36MHz,但定时器时钟是72MHz。这个规则在参考手册的时钟树章节写得很清楚,但CubeMX的时钟树界面会直接显示最终值,你只要看那个数字就行。
定时器的基本工作流程是这样的:时钟信号进入定时器后,先经过预分频器(Prescaler),把高频时钟降到一个合适的频率,然后驱动计数器(Counter)开始计数。计数器从0往上数,数到自动重装载寄存器(Auto-Reload Register,ARR)的值时,产生一个更新事件(Update Event),同时计数器清零重新开始。如果你使能了更新中断,这个更新事件就会触发中断,CPU跳转到中断服务函数执行你的代码。
用生活化的类比:预分频器就像水龙头的阀门,控制水流速度;计数器就像一个水桶在接水;ARR就是水桶的容量。水满了(计数到ARR)就溢出来一次(更新事件),然后桶清空重新接。你调节阀门(Prescaler)和换不同大小的桶(ARR),就能控制多久溢一次。
2.2 CubeMX中TIM3的逐项配置与参数计算
打开CubeMX,选好芯片型号STM32F103C8T6之后,在左侧外设列表里找到Timers,展开后点击TIM3。中间的引脚图会显示TIM3的四个通道引脚,但我们做定时中断不需要占用引脚,所以不用管通道配置,只配置NVIC和参数设置就行。
在TIM3的配置面板里,有几个关键项:
Clock Source选择Internal Clock,意思是使用内部时钟源。这个必须选对,如果选错了成External Clock Mode,定时器就等着外部信号触发,永远等不到。
Prescaler填7199。这个数字怎么来的?系统时钟72MHz,预分频器实际分频系数是Prescaler+1,所以7199+1=7200,72MHz除以7200等于10kHz。也就是说,经过预分频后,计数器每0.1毫秒加1。
Counter Mode选Up,向上计数。这是最常用的模式,从0数到ARR。
Counter Period填9999。这个就是ARR的值。计数器从0数到9999,一共数10000次。每次0.1毫秒,10000次就是1000毫秒,也就是1秒。所以定时周期就是1秒。
auto-reload preload建议使能。这个选项的作用是:当你中途想改ARR的值时,新值不会立即生效,而是等当前计数周期结束后才生效。这样可以避免计数过程中改ARR导致时序错乱。对于固定周期的应用,使能与否影响不大,但养成使能的习惯没坏处。
Trigger Event Selection保持默认的Reset就行,这个跟定时中断没关系。
配置完参数后,切到NVIC Settings标签页,找到TIM3 global interrupt,勾选Enabled。这一步是使能定时器的更新中断,不勾的话中断永远不会触发。
注意:很多人配置完参数忘了开NVIC中断,然后烧录进去发现LED不闪,查半天代码没问题,最后发现是中断没使能。这个坑我踩过不止一次。
2.3 时钟树确认:别让默认值骗了你
CubeMX的时钟树页面(Clock Configuration)一定要看一眼。默认情况下,如果你选了外部晶振(HSE),CubeMX会自动配置PLL,把系统时钟拉到72MHz。但如果你用的是芯片内部的HSI,系统时钟可能只有8MHz或者64MHz,这时候你按72MHz算出来的定时参数就不对了。
具体确认方法:在时钟树页面,找到APB1 Timer clocks这一栏,看它显示的数字。如果是72MHz,那上面的计算没问题。如果是36MHz,那Prescaler和ARR都要重新算。比如36MHz时钟,想要1秒定时,可以Prescaler填3599(分频后10kHz),ARR填9999,结果一样。
我一般习惯在时钟树页面截个图,把各个总线的时钟频率记下来,配定时器的时候直接对照,省得来回切页面。
3. 中断回调的代码结构与HAL库调用链路
3.1 从main函数到回调函数:完整调用链路拆解
CubeMX生成代码后,main函数里会自动调用MX_TIM3_Init()完成定时器初始化。但初始化完定时器还不会开始工作,你需要在main函数里手动调用HAL_TIM_Base_Start_IT(&htim3)来启动定时器并使能中断。
这个函数的调用位置很关键。必须在所有初始化完成之后、进入主循环之前调用。我一般放在MX_GPIO_Init()之后,while(1)之前。
启动之后,定时器开始计数,每满一个周期触发一次更新中断。中断触发后,CPU自动跳转到中断向量表里TIM3对应的入口,执行TIM3_IRQHandler()。这个函数在stm32f1xx_it.c文件里,CubeMX会自动生成。函数体里调用HAL_TIM_IRQHandler(&htim3),这是HAL库的中断处理函数。
HAL_TIM_IRQHandler()内部会判断中断类型,如果是更新中断(Update Interrupt),就清除中断标志位,然后调用HAL_TIM_PeriodElapsedCallback()。这个回调函数是一个弱函数(weak function),默认是空的,需要你自己在代码里重写。
完整链路是这样的:
TIM3计数溢出 → 硬件触发更新中断 → TIM3_IRQHandler() → HAL_TIM_IRQHandler(&htim3) → 判断中断类型 → 清除标志位 → HAL_TIM_PeriodElapsedCallback(&htim3) → 你写的业务代码理解这条链路非常重要,因为后面排查问题时,你需要知道中断卡在哪一环。
3.2 回调函数写在哪里:位置和写法都有讲究
HAL_TIM_PeriodElapsedCallback()这个函数,我建议写在main.c文件里,放在main()函数后面、while(1)外面。不要写在stm32f1xx_it.c里,虽然写那里也能跑,但代码组织上不清晰,后期维护麻烦。
写法如下:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } }注意几个细节:第一,函数名必须和HAL库里的弱函数完全一致,大小写都不能错。第二,参数类型是TIM_HandleTypeDef *,不是TIM_TypeDef *,这两个容易搞混。第三,一定要判断htim->Instance,因为如果你同时用了多个定时器,所有定时器的更新中断都会进这个回调,不判断的话就乱套了。
提示:如果你只用一个定时器,不判断Instance也能跑,但这是坏习惯。我见过一个项目用了TIM2和TIM3两个定时器,回调里没判断,结果TIM2中断触发了TIM3的业务代码,查了一下午才发现。
3.3 中断优先级配置:什么时候需要改,什么时候不用管
CubeMX的NVIC配置页面里,TIM3 global interrupt默认的抢占优先级和响应优先级都是0。对于只有一个中断源的简单应用,用默认值完全没问题。
但如果你同时用了串口中断、外部中断、定时器中断,就需要考虑优先级了。抢占优先级高的中断可以打断抢占优先级低的中断。比如串口接收中断需要快速响应,可以把它的抢占优先级设高一点;定时器中断只是闪个LED,优先级低一点无所谓。
STM32F103的NVIC支持4位优先级,CubeMX里可以设置0到15。数值越小优先级越高。我一般的分配原则是:通信类中断(串口、SPI)优先级最高,设1或2;定时器中断中等,设3或4;按键外部中断最低,设5或6。这样保证通信不丢数据,定时器也不会被按键抖动频繁打断。
4. 定时时间计算的通用公式与参数速查
4.1 从公式到直觉:三步算出任意定时周期
定时时间的计算公式:
定时周期 = (Prescaler + 1) × (Counter Period + 1) / 定时器时钟频率反过来,如果你想要一个特定的定时周期,可以这样算:
第一步,确定定时器时钟频率。STM32F103通常TIM2、TIM3、TIM4是72MHz,TIM1、TIM8也是72MHz。
第二步,选定Prescaler。Prescaler决定了计数器的计数频率。我一般喜欢把计数频率定在1kHz到10kHz之间,这样ARR的值不会太大也不会太小。比如72MHz时钟,Prescaler填7199,得到10kHz计数频率。
第三步,用计数频率乘以想要的定时时间,得到ARR的值。比如想要1秒,10kHz × 1s = 10000,ARR填9999(因为从0开始数)。
想要0.5秒?ARR填4999。想要2秒?ARR填19999。想要10毫秒?ARR填99。
4.2 常用定时周期参数对照表
| 定时周期 | 定时器时钟 | Prescaler | 计数频率 | Counter Period | 实际周期 |
|---|---|---|---|---|---|
| 1秒 | 72MHz | 7199 | 10kHz | 9999 | 1.000s |
| 500毫秒 | 72MHz | 7199 | 10kHz | 4999 | 0.500s |
| 100毫秒 | 72MHz | 7199 | 10kHz | 999 | 0.100s |
| 10毫秒 | 72MHz | 7199 | 10kHz | 99 | 0.010s |
| 1毫秒 | 72MHz | 7199 | 10kHz | 9 | 0.001s |
| 1秒 | 36MHz | 3599 | 10kHz | 9999 | 1.000s |
| 1秒 | 8MHz | 799 | 10kHz | 9999 | 1.000s |
这张表可以直接抄作业。注意最后一列"实际周期",因为Prescaler和ARR都是整数,有时候算出来不是刚好整数,会有微小误差。比如你想要1秒,但时钟是72MHz,Prescaler填7199、ARR填9999,实际周期是(7199+1)×(9999+1)/72000000 = 72000000/72000000 = 1.000秒,刚好。但如果时钟是64MHz,Prescaler填6399、ARR填9999,实际周期是64000000/64000000 = 1.000秒,也刚好。只要时钟频率能被整除,就没有误差。
4.3 当定时时间超过单次计数范围怎么办
16位定时器的ARR最大是65535。如果你用10kHz计数频率,最大定时时间是6.5535秒。想要更长的定时时间怎么办?
方法一:降低计数频率。比如Prescaler填7199改成71999,计数频率变成1kHz,ARR最大65535,最大定时时间65.535秒。但这样定时精度就降到1毫秒了。
方法二:在中断里加一个软件计数器。比如定时器每100毫秒中断一次,中断里count++,count到10就是1秒。这样既能保证定时精度,又能实现长时间定时。
static uint8_t count = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { count++; if (count >= 10) { count = 0; HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } } }方法三:用32位定时器。STM32F4、F7等系列有32位定时器,ARR可以到4294967295,但F103没有,所以入门阶段还是用前两种方法。
5. 中断不触发、频率不对的排查链路
5.1 从现象反推:LED完全不闪和闪得太快分别查什么
LED完全不闪,可能的原因按概率排序:
第一,NVIC中断没使能。回CubeMX检查TIM3 global interrupt有没有勾上。这个是最常见的,我估计占所有问题的四成以上。
第二,HAL_TIM_Base_Start_IT()没调用。CubeMX生成的代码只初始化定时器,不会自动启动。你必须在main函数里手动调用这个函数。检查一下是不是漏了。
第三,回调函数名写错了。HAL_TIM_PeriodElapsedCallback,注意是PeriodElapsed,不是PeriodElapsedCallback的拼写错误,也不是Period_Elapsed。HAL库的弱函数名是固定的,写错了就不会被调用。
第四,GPIO初始化有问题。如果你用的是PC13接LED,检查MX_GPIO_Init()里有没有把PC13配置成推挽输出。CubeMX里要在GPIO页面手动设置PC13为GPIO_Output。
第五,中断服务函数被删了或者改了。stm32f1xx_it.c里的TIM3_IRQHandler()是CubeMX生成的,如果你不小心删了或者改了函数名,中断就进不去。
LED闪得太快,可能的原因:
第一,Prescaler或ARR算错了。比如想要1秒,结果Prescaler填了719,ARR填了999,那就是0.1秒闪一次。重新按公式算一遍。
第二,时钟树配置和预期不符。比如你以为系统时钟是72MHz,实际是8MHz,那定时时间就差了9倍。去时钟树页面确认APB1 Timer clocks的值。
第三,中断里做了耗时操作。比如在回调里调用了HAL_Delay(),这会导致中断响应变慢,但不会让LED闪得更快。闪得太快一般是参数问题。
5.2 用调试器和串口打印定位中断是否真的进了
如果LED不闪,但你不确定是中断没进还是GPIO有问题,可以用调试器打断点。在HAL_TIM_PeriodElapsedCallback()函数第一行打个断点,全速运行,看能不能停在这里。能停,说明中断进了,问题在GPIO或者业务代码;不能停,说明中断链路有问题,回去查NVIC和启动函数。
没有调试器的话,可以在回调里翻转另一个GPIO,用示波器或者逻辑分析仪看波形。或者用串口打印,在回调里发一个字符,串口助手看有没有输出。但注意串口打印本身比较耗时,如果定时周期很短(比如1毫秒),在中断里打印会导致中断处理时间过长,影响下一次中断。
我一般用GPIO翻转法:在回调里翻转一个空闲引脚,用示波器测频率。如果测出来是500Hz,说明中断每2毫秒进一次,对应定时周期2毫秒。这个方法最直接,不需要打断点,也不影响中断时序。
5.3 中断标志位没清除导致的"卡死"现象
有一种情况是LED闪了一下就不闪了,或者程序跑飞了。这很可能是中断标志位没清除,导致中断反复触发,CPU一直卡在中断里出不来。
HAL库的HAL_TIM_IRQHandler()内部会自动清除更新中断标志位,正常情况下不需要你手动清。但如果你在回调函数里又触发了其他中断,或者中断优先级配置有问题,可能会导致标志位清除不及时。
排查方法:在TIM3_IRQHandler()里,HAL_TIM_IRQHandler(&htim3)之前,加一句__HAL_TIM_CLEAR_IT(&htim3, TIM_IT_UPDATE)手动清除标志位。如果加了这句问题就解决了,说明是标志位清除的问题。
不过更常见的情况是:你在中断回调里调用了HAL_Delay()。HAL_Delay()依赖SysTick中断,而SysTick的优先级默认比TIM3低。如果TIM3中断一直在触发,SysTick中断就永远得不到执行,HAL_Delay()就卡死了。所以记住一条铁律:中断回调里绝对不要用HAL_Delay()。
6. 从定时中断延伸出去的几个实用场景
6.1 用定时中断做精准的软件延时替代HAL_Delay
HAL_Delay()的精度受SysTick影响,而且它是阻塞式的,调用期间CPU什么都干不了。用定时中断可以做一个非阻塞的延时:
volatile uint32_t tick = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { tick++; } } void delay_ms(uint32_t ms) { uint32_t start = tick; while ((tick - start) < ms); }这个delay_ms()不阻塞其他中断,而且精度取决于定时器中断周期。如果定时器1毫秒中断一次,delay_ms(100)就是等100个tick,精度在1毫秒以内。
6.2 定时中断驱动状态机:按键消抖和LED呼吸灯
按键消抖的经典做法是用定时中断每10毫秒扫描一次按键,连续几次读到相同状态才确认。这样不需要在中断里延时,也不会阻塞主循环。
LED呼吸灯可以用定时中断配合PWM,但PWM本身有硬件支持,不需要中断。如果非要用中断做,可以在中断里改变PWM的占空比,每10毫秒调整一次,实现渐变效果。
6.3 多定时器协同:一个做系统滴答,一个做业务定时
STM32F103有多个定时器,可以一个做系统滴答(比如TIM2每1毫秒中断一次,维护系统tick),另一个做业务定时(比如TIM3每100毫秒中断一次,扫描传感器)。两个定时器中断优先级可以设成一样,也可以设成系统滴答优先级高一点。
注意:如果两个定时器都用HAL_TIM_PeriodElapsedCallback(),一定要在回调里判断htim->Instance,否则会互相干扰。
7. 我踩过的几个坑和总结的经验
第一个坑:CubeMX里改了定时器参数,但忘记重新生成代码。CubeMX修改配置后必须点GENERATE CODE,否则代码还是旧的。我有时候改完参数直接编译烧录,发现没变化,查半天才发现没生成。
第二个坑:在中断回调里调用了printf()。printf()底层是串口发送,串口发送是阻塞的,在中断里调用会导致中断处理时间过长。如果串口波特率是115200,发一个字符大概87微秒,发一行字符串就是几毫秒。定时器1毫秒中断一次的话,中断还没处理完下一次又来了,程序就卡死了。
第三个坑:Prescaler和ARR填反了。Prescaler是预分频,ARR是计数上限。有人把7199填到ARR,9999填到Prescaler,结果定时时间完全不对。记住:Prescaler一般填大数(几千到几万),ARR填相对小的数(取决于定时周期)。
第四个坑:用了HAL库的HAL_TIM_Base_Start()而不是HAL_TIM_Base_Start_IT()。前者只启动定时器不使能中断,后者才使能中断。名字差一个_IT,功能差很多。
第五个坑:时钟树里APB1预分频系数不为1时,定时器时钟是APB1时钟的2倍。这个规则在F103上成立,但不同系列可能不一样。最保险的方法就是看CubeMX时钟树页面显示的APB1 Timer clocks值,以那个为准。
最后分享一个我常用的调试技巧:在CubeMX里配置一个空闲GPIO,在定时器中断回调里翻转它。用示波器或者逻辑分析仪测这个GPIO的频率,就能直观看到定时器中断的实际周期。这个方法比串口打印更可靠,也不会影响中断时序。测出来的频率如果是500Hz,说明中断周期是2毫秒;如果是1kHz,说明中断周期是1毫秒。跟你的预期对比,就能快速判断参数配置对不对。
定时器这东西,入门的时候觉得复杂,用熟了之后发现它是STM32里最灵活的外设之一。定时中断只是最基础的用法,后面还有输入捕获、输出比较、PWM生成、编码器接口等等。把定时中断这条链路彻底搞明白,后面的高级用法都是在这个基础上叠加功能。