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为例如果系统时钟是72MHzAPB1的定时器时钟通常是72MHz注意APB1预分频不为1时定时器时钟会倍频这个后面细说。第二个是预分频器Prescaler它的作用是把输入时钟降速。72MHz太快了计数器根本来不及反应所以要先分频。比如预分频值设为71实际分频系数就是71172时钟变成1MHz也就是每1微秒计数一次。第三个是自动重装载寄存器Auto-Reload RegisterARR它决定了计数器数到多少就溢出。如果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。这个规则在参考手册的时钟树章节有明确说明但很多人看时钟树的时候只看到APB136MHz就以为定时器也是36MHz算出来的时间全错了。实操心得在CubeMX的Clock Configuration页面把鼠标悬停在APB1的定时器时钟节点上它会直接显示实际频率。F103配好之后应该显示72MHz。如果你用的是F4系列APB1定时器时钟通常是84MHzAPB2定时器时钟是168MHz别搞混。3.2 TIM2参数配置预分频和重装载值怎么算在CubeMX左侧的Timers列表里找到TIM2点击进入配置。Clock Source选择Internal Clock这是使用内部时钟源。接下来是核心参数Prescaler预分频器设为71Counter Mode计数模式Up向上计数Counter Period自动重装载值设为999auto-reload preloadEnable使能自动重装载预装载算一下定时器时钟72MHz预分频71172得到1MHz的计数频率。ARR999计数1000次溢出所以中断周期是1000/1MHz1ms。如果你想改成500ms中断一次有两种做法。一是改预分频Prescaler设为7199ARR设为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不要设00留给最紧急的故障处理也不要设太高否则会打断其他重要中断。注意如果你同时用了串口中断和定时器中断串口的抢占优先级要高于定时器否则串口数据可能丢失。这个优先级分配的原则是越实时、越不能丢数据的中断优先级越高。3.4 生成代码前的最后检查在Project Manager页面设置好工程名称、路径、IDEMDK-ARM或STM32CubeIDE。Code Generator页面勾选Generate peripheral initialization as a pair of .c/.h files这样每个外设的初始化代码会单独成文件工程结构更清晰。点击GENERATE CODECubeMX会自动生成完整的工程。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的频率。这样可以把中断响应时间和实际定时时间分开看快速定位问题出在定时器配置还是中断处理上。