1. 为什么定时器是STM32入门的分水岭
搞STM32的人都有一个共识:GPIO点灯只是热身,真正让你感觉自己"入门了"的,是第一次用定时器做出精准的定时中断。我见过太多人卡在这一步——CubeMX里配置了一堆参数,生成代码后不知道该在哪儿写逻辑,或者中断进了但程序跑飞了,又或者时间怎么算都不对。这些问题其实都不难,但没人给你点透那一层窗户纸,你就得自己耗上大半天。
这篇内容就是要把这层窗户纸捅破。我用的芯片是STM32F103系列,开发工具是CubeMX加HAL库,目标很明确:用定时器TIM实现一个精确的定时中断,比如每500毫秒翻转一次LED,或者每1毫秒做一个计数累加。你只要跟着走一遍,6分钟从零到跑通完全不是问题。
适合谁看?如果你刚学完GPIO和串口,对中断有点概念但没实际配过定时器,这篇就是给你写的。如果你已经用过定时器但总是靠"试参数"来凑时间,那正好可以看看参数到底怎么算。我还会把预分频器、自动重装载值、中断优先级这些关键概念用最直白的方式讲清楚,让你以后配任何定时器都不再心虚。
注意:本文基于STM32F103和HAL库,但思路适用于F0、F4、G0等全系列。不同系列的时钟树和定时器位宽有差异,我会在涉及的地方特别说明。
2. 定时器到底在干什么:从生活场景理解核心原理
2.1 用"沙漏"类比定时器的三个核心寄存器
STM32的通用定时器本质上就是一个会自己数数的计数器。你可以把它想象成一个沙漏:沙子从上往下漏,漏完一次就翻转一次,然后重新开始。这个过程中有三个关键角色。
第一个是时钟源,也就是沙子往下漏的速度。STM32的定时器不是独立运行的,它挂载在APB总线上,时钟来自系统时钟的分频。以F103为例,如果系统时钟是72MHz,APB1的定时器时钟通常是72MHz(注意APB1预分频不为1时定时器时钟会倍频,这个后面细说)。
第二个是预分频器(Prescaler),它的作用是把输入时钟降速。72MHz太快了,计数器根本来不及反应,所以要先分频。比如预分频值设为71,实际分频系数就是71+1=72,时钟变成1MHz,也就是每1微秒计数一次。
第三个是自动重装载寄存器(Auto-Reload Register,ARR),它决定了计数器数到多少就溢出。如果ARR设为999,计数器从0数到999,一共1000个计数,配合1MHz的时钟,正好是1毫秒溢出一次,产生一次中断。
所以定时时间的计算公式就是:
定时周期 = (Prescaler + 1) × (ARR + 1) / 定时器时钟频率
这个公式你记住就行,后面配参数全靠它。
2.2 三种定时器模式的区别与选择
STM32的定时器有基本定时器、通用定时器和高级定时器之分。基本定时器(如TIM6、TIM7)功能最简单,只能做定时中断和触发DAC。通用定时器(如TIM2到TIM5)多了输入捕获、输出比较、PWM输出等功能。高级定时器(如TIM1、TIM8)还支持互补输出和死区控制,一般用于电机控制。
做定时中断这件事,三种定时器都能干。我一般推荐用通用定时器TIM2,原因是它挂在APB1上,资源丰富,不会占用高级定时器那些宝贵的外设功能。如果你只是做定时,用TIM6也完全可以,配置更简单。
2.3 中断和轮询的本质区别
有人会问:我直接在主循环里用HAL_Delay不行吗?为什么要搞中断?
HAL_Delay是阻塞式的,它让CPU在原地空转等待。你调用HAL_Delay(500),这500毫秒里CPU什么都干不了。而定时器中断是异步的,CPU该干嘛干嘛,时间到了自动跳进中断服务函数执行任务。这两者的效率差距在简单程序里不明显,但一旦你的系统需要同时处理按键、串口、传感器,阻塞式延时就会让整个程序变得卡顿。
中断的另一个好处是精度。HAL_Delay的精度受主循环其他代码影响,而定时器中断由硬件触发,精度只取决于时钟源,误差在微秒级别。
3. CubeMX配置全流程:每一步都告诉你为什么
3.1 时钟树配置:定时器时钟到底是多少
打开CubeMX,先选芯片型号STM32F103C8T6(或者其他你手头的型号)。进入Clock Configuration页面,这里有个坑很多人踩过。
F103的外部晶振一般是8MHz,经过PLL倍频到72MHz作为系统时钟。APB1预分频器通常设为2,所以APB1的时钟是36MHz。但关键来了:当APB1预分频系数不为1时,定时器的时钟会自动倍频。也就是说,虽然APB1总线是36MHz,但挂在上面的TIM2、TIM3、TIM4的时钟是72MHz。
这个规则在参考手册的时钟树章节有明确说明,但很多人看时钟树的时候只看到APB1=36MHz,就以为定时器也是36MHz,算出来的时间全错了。
实操心得:在CubeMX的Clock Configuration页面,把鼠标悬停在APB1的定时器时钟节点上,它会直接显示实际频率。F103配好之后应该显示72MHz。如果你用的是F4系列,APB1定时器时钟通常是84MHz,APB2定时器时钟是168MHz,别搞混。
3.2 TIM2参数配置:预分频和重装载值怎么算
在CubeMX左侧的Timers列表里找到TIM2,点击进入配置。Clock Source选择Internal Clock,这是使用内部时钟源。
接下来是核心参数:
- Prescaler(预分频器):设为71
- Counter Mode(计数模式):Up(向上计数)
- Counter Period(自动重装载值):设为999
- auto-reload preload:Enable(使能自动重装载预装载)
算一下:定时器时钟72MHz,预分频71+1=72,得到1MHz的计数频率。ARR=999,计数1000次溢出,所以中断周期是1000/1MHz=1ms。
如果你想改成500ms中断一次,有两种做法。一是改预分频:Prescaler设为7199,ARR设为4999,这样计数频率是10kHz,计数5000次就是500ms。二是保持预分频不变,把ARR改成499999。但注意ARR是16位的,最大65535,所以500ms用1MHz时钟的话ARR需要500000,超了。这时候就必须调预分频。
我一般习惯把预分频设得大一些,让ARR留出余量,方便后续微调。
3.3 NVIC中断优先级配置:别让中断打架
在TIM2配置页面的下方找到NVIC Settings,勾选"TIM2 global interrupt"的Enabled。然后去NVIC配置页面设置优先级。
STM32F103的中断优先级分抢占优先级和响应优先级,各4位。我一般把定时器中断的抢占优先级设为1或2,不要设0(0留给最紧急的故障处理),也不要设太高,否则会打断其他重要中断。
注意:如果你同时用了串口中断和定时器中断,串口的抢占优先级要高于定时器,否则串口数据可能丢失。这个优先级分配的原则是:越实时、越不能丢数据的中断,优先级越高。
3.4 生成代码前的最后检查
在Project Manager页面设置好工程名称、路径、IDE(MDK-ARM或STM32CubeIDE)。Code Generator页面勾选"Generate peripheral initialization as a pair of .c/.h files",这样每个外设的初始化代码会单独成文件,工程结构更清晰。
点击GENERATE CODE,CubeMX会自动生成完整的工程。
4. 代码实现:中断回调函数才是你该写逻辑的地方
4.1 HAL库定时器启动的标准流程
CubeMX生成的代码里,MX_TIM2_Init()函数已经帮你配好了所有寄存器。你需要在main函数里做两件事:启动定时器、开启中断。
HAL_TIM_Base_Start_IT(&htim2);这一行代码做了两件事:启动定时器计数,同时使能更新中断。注意是_IT后缀,不是HAL_TIM_Base_Start。如果你只调用了不带IT的版本,定时器会跑但不会进中断,这是新手最常犯的错误之一。
4.2 中断回调函数的正确写法
HAL库把中断处理分成了两层。底层的中断服务函数TIM2_IRQHandler在stm32f1xx_it.c里,它调用HAL_TIM_IRQHandler,这个函数会判断中断类型,然后调用对应的回调函数。
你要写逻辑的地方是回调函数:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } }这个函数放在main.c里就行,HAL库用__weak关键字定义了它,你重新定义就会覆盖默认的空实现。
实操心得:回调函数里一定要判断是哪个定时器触发的。如果你用了多个定时器,不判断的话每个定时器溢出都会执行同一段代码。判断方式就是比较
htim->Instance。
4.3 中断里能做什么、不能做什么
中断服务函数的执行时间要尽可能短。翻转LED、设置标志位、更新计数器这些操作没问题。但千万不要在中断里调用HAL_Delay、打印串口大量数据、或者做浮点运算。这些操作要么阻塞太久,要么耗时太长,会导致其他中断响应不及时。
正确的做法是在中断里设置一个volatile标志变量,主循环检测到标志后再执行耗时操作。
volatile uint8_t timer_flag = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { timer_flag = 1; } } int main(void) { // ...初始化代码... HAL_TIM_Base_Start_IT(&htim2); while (1) { if (timer_flag) { timer_flag = 0; // 执行耗时操作 } } }4.4 验证定时精度的方法
怎么确认你的定时器真的准?最直接的方法是用示波器或者逻辑分析仪看LED引脚。1ms中断翻转一次的话,LED引脚上应该是500Hz的方波,周期2ms。
没有仪器的话,可以用串口打印时间戳。在中断里累加一个计数器,主循环里每隔一秒打印一次计数值。如果1ms中断一次,1秒后计数值应该是1000左右。误差在几个计数以内算正常,因为串口打印本身有延迟。
5. 常见问题排查:这些坑我都替你踩过了
5.1 中断不进的五种原因
这是问得最多的问题。中断不进,按以下顺序排查:
| 排查项 | 检查方法 | 常见错误 |
|---|---|---|
| 定时器是否启动 | 确认调用了HAL_TIM_Base_Start_IT | 只调用了不带IT的版本 |
| 中断是否使能 | 检查NVIC配置中TIM2中断是否勾选 | CubeMX里忘了勾 |
| 回调函数是否写对 | 函数名必须是HAL_TIM_PeriodElapsedCallback | 拼写错误或参数类型不对 |
| 时钟是否使能 | 检查__HAL_RCC_TIM2_CLK_ENABLE()是否被调用 | 手动改代码时删掉了 |
| 优先级分组是否正确 | 检查HAL_NVIC_SetPriorityGrouping | 分组设置冲突导致优先级无效 |
5.2 定时时间不对的排查思路
时间偏差大,先算一遍公式。确认定时器时钟频率、预分频值、ARR值三个数都对。最常见的错误是把定时器时钟当成APB1总线频率,忘了倍频那回事。
如果时间差得不多但有小误差,检查外部晶振的实际频率。有些板子的晶振标称8MHz但实际有偏差,可以用示波器测一下MCO引脚输出的时钟。
5.3 多个定时器同时使用的注意事项
用多个定时器时,每个定时器的回调函数里都要判断Instance。另外注意中断优先级的分配,如果两个定时器中断同时触发,高优先级的先执行,低优先级的等它执行完再进。
还有一个容易忽略的点:如果两个定时器共用同一个回调函数,判断逻辑要写清楚,别让TIM3的中断执行了TIM2的代码。
5.4 CubeMX重新生成代码后用户代码丢失的问题
CubeMX重新生成代码时,只会保留/* USER CODE BEGIN */和/* USER CODE END */之间的内容。所以你的回调函数、标志变量、业务逻辑都要写在这个区间内。写在区间外的代码会被覆盖掉,这个坑我踩过不止一次。
实操心得:我习惯把回调函数写在main.c的
/* USER CODE BEGIN 4 */区间里,标志变量写在/* USER CODE BEGIN PV */区间里。这样不管重新生成多少次代码,我的逻辑都不会丢。
6. 进阶扩展:从定时中断到实际项目应用
6.1 用定时中断实现软件RTC
有了1ms的定时中断,你就可以做一个简单的软件实时时钟。在中断里维护秒、分、时的计数,主循环里读取并显示。精度虽然不如硬件RTC,但对于不需要掉电保存的场景完全够用。
volatile uint32_t ms_count = 0; volatile uint8_t sec = 0, min = 0, hour = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { ms_count++; if (ms_count >= 1000) { ms_count = 0; sec++; if (sec >= 60) { sec = 0; min++; } if (min >= 60) { min = 0; hour++; } if (hour >= 24) { hour = 0; } } } }6.2 定时中断配合状态机
实际项目里,定时中断最常见的用法是驱动状态机。比如一个按键扫描任务,每10ms执行一次;一个传感器读取任务,每100ms执行一次;一个显示刷新任务,每200ms执行一次。用一个1ms的定时中断做时基,在中断里设置不同的标志位,主循环根据标志位调度任务。
这种架构比用HAL_Delay堆砌的代码清晰得多,而且各个任务的执行周期互不干扰。
6.3 定时器级联实现超长定时
16位定时器最大定时时间有限。72MHz时钟、最大预分频65536、最大ARR 65535,算下来最大定时约59.6秒。如果需要更长的定时,可以把一个定时器的溢出事件作为另一个定时器的时钟输入,两个16位定时器级联就能实现接近1小时的定时。
具体做法是把TIM2配置为主模式,TRGO输出选择Update事件;TIM3配置为从模式,时钟源选择ITR2。这样TIM2每溢出一次,TIM3计数一次。两个定时器的ARR都设最大,总定时时间就是两者之积。
6.4 低功耗场景下的定时器唤醒
在电池供电的项目里,可以让MCU进入Stop模式,用定时器中断来唤醒。配置定时器在指定时间后产生中断,MCU在中断里退出低功耗模式执行任务,任务完成后再次进入Stop模式。这样平均功耗可以降到微安级别。
需要注意的是,进入Stop模式前要确保定时器时钟源在低功耗下仍然可用。F103的定时器在Stop模式下会停止,需要用RTC或者外部低速时钟来唤醒。具体方案要根据芯片型号查参考手册的低功耗章节。
7. 我个人的实操体会
配定时器这件事,说到底就是三个参数的事:时钟频率、预分频、重装载值。把这三个数的关系搞清楚,任何型号的STM32你都能上手。我刚开始学的时候总想着抄别人的配置,结果换一个芯片就懵了。后来强迫自己每次都用公式算一遍,算多了就形成肌肉记忆了。
另外说一个很多人忽略的细节:CubeMX生成的代码里,MX_TIM2_Init()函数中有一行htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;。这个预装载功能的作用是让ARR的修改在下一个更新事件时才生效,避免当前周期被截断。做固定定时的时候开不开无所谓,但如果你需要在运行时动态改ARR,这个一定要开。
最后分享一个调试技巧:如果中断进了但时间不对,先在回调函数里翻转一个空闲GPIO,用示波器测这个GPIO的频率。这样可以把中断响应时间和实际定时时间分开看,快速定位问题出在定时器配置还是中断处理上。