为了给板子上的状态灯做闪烁效果,我一开始偷懒在循环里写了个delay,结果整个程序直接被拖垮:按键扫描不灵敏,显示刷新一卡一卡,按下按键半天才有反应。当时我就意识到,凡是“每隔固定时间就做一次事”的场景,都不应该让CPU空转等着,而是应该交给硬件定时器去计时,时间到了用中断通知CPU。后来我把目标锁定在TIM基本定时器上,它是定时器家族里功能最单一也最好懂的那一级——不涉及输入捕获、不涉及PWM输出,就是老老实实定时。也正因为这份纯粹,它成了吃透定时器原理的最佳入口。
这篇文章我从头写一遍TIM基本定时器的实战使用过程:它在整个定时器体系里的定位、定时时间的计算方法、寄存器配置的逐行解释,以及我实际调板子时踩过的坑。刚开始接触定时器的新手,或者一直在用库函数封装、想弄清底层原理的朋友,应该都能在这里找到点东西。
1. 先看清基本定时器在“定时器家族”里的位置
1.1 三种定时器各管一摊子事
在常见的Cortex-M内核MCU里,定时器通常按能力分成三档:基本定时器、通用定时器、高级定时器。光看名字,有人会误以为通用定时器能完全覆盖基本定时器的功能,但实际恰恰相反——基本定时器虽然简单,却有它不可替代的定位。
| 能力项 | 基本定时器(TIM6、TIM7) | 通用定时器(TIM2~TIM5) | 高级定时器(TIM1、TIM8) |
|---|---|---|---|
| 定时中断/更新事件 | 支持 | 支持 | 支持 |
| 输入捕获 | 无 | 支持 | 支持 |
| 输出比较/PWM | 无 | 支持 | 支持 |
| 互补输出/死区/刹车 | 无 | 无 | 支持 |
| 外部引脚 | 不引出 | 引出 | 引出 |
从表格能看明白一件事:基本定时器没有对外引脚,它不跟外部信号打交道,唯一干的事情就是“按内部时钟计数,数到预定值,产生一次更新事件”。这个特性不是缺点,反而让初学阶段少了很多干扰项。等我把基本定时器的原理吃透了,再去看通用定时器的捕获、比较功能,会发现它们共用相同的时基结构,只是多了几条旁路而已。
1.2 基本定时器内部只有三个关键部件
基本定时器的数据通路可以简化成一条链:内部时钟 → 预分频器(PSC)→ 计数器(CNT)→ 自动重装载寄存器(ARR),最后CNT数到ARR的值时产生更新事件,触发中断。
拿生活里的场景类比:预分频器就像一个减速齿轮箱,把内部高速时钟“降速”后送给计数器;计数器像一个里程表,一格一格往上跳;ARR则是你在仪表盘上画的红线,里程表一走到红线位置,就发出“到点了”的信号。这套类比虽然简单,但对后面理解定时时间公式非常有用。
1.3 为什么入门要从基本定时器开始
我见过不少同学第一次学定时器就直接上通用定时器做PWM,结果既要配时基、又要配输出通道、还要调占空比,一个东西涉及好几组寄存器,出了问题根本不知道是时基错了还是输出配置错了。基本定时器就没有这个问题:不涉及引脚复用、不涉及通道映射、不需要配置输出极性,只需要关心时基本身。说白了,它就是一张白纸,让你能把“计数溢出”这个最核心的行为看得清清楚楚。
2. 定时时间怎么算:从时钟树到那条要抄在便签上的公式
2.1 先弄明白定时器时钟从哪来
在配置任何定时器之前,必须先知道给它提供时钟的源频率是多少。以一颗主频72MHz的Cortex-M内核MCU为例,定时器挂在APB1总线上。这里有一个非常容易让人迷糊的点:如果APB1分频系数设置为2,APB1外设时钟会变成36MHz,但定时器时钟并不会跟着变36MHz,而是自动倍频成72MHz。
原因很简单:芯片设计时故意让定时器能拿到比APB1外设更高一倍的时钟,就是为了保证定时器在高精度场景下依然够用。所以计算定时时间时,不能拿PDF表格里的“APB1=36MHz”直接去算,而要在心里多问一句:APB1分频系数是不是2?如果是,定时器时钟=72MHz;如果APB1分频系数是1,定时器时钟才是36MHz。这一步错了,后面所有定时时间都会翻倍或者减半,而且轻易看不出来。
2.2 核心公式:T = (ARR+1) x (PSC+1) / CK_INT
定时器从0向上计数,每来一个计数脉冲,CNT加1。配置好预分频值PSC之后,实际分频系数是PSC+1;自动重装载值ARR参与比较时也要加1,因为CNT是从0开始数到ARR的,一共数了ARR+1个脉冲。所以定时周期公式是:
T = (ARR + 1) * (PSC + 1) / CK_INT其中CK_INT是定时器输入时钟频率。这个公式是我建议所有刚接触定时器的人抄在便签上贴在显示器旁边的东西,因为后面不管是计算1ms、1s还是做微妙延时,全都是在套它。
2.3 实际算一组参数:从1ms到1s
以定时器时钟72MHz为例,如果我想产生1ms的定时中断:
- 先选预分频值,目的是把计数频率降到好算的量级。取PSC=71,则计数频率 = 72MHz / (71+1) = 1MHz,也就是说计数器每1微秒跳一次。
- 再选ARR。1ms = 1000微秒,计数器跳1000次需要ARR=999。
整个计算过程可以口算:PSC=71,ARR=999。代码里写的时候要注意,这两个值填的原始值是71和999,不要额外再去减1,因为寄存器硬件本身已经按“值+1”来处理了。
如果我想做1s定时,同样可以保持PSC=71不变,计数频率依然是1MHz,ARR改成999999。此时定时周期=(999999+1) x 1us = 1s。需要提醒的是,基本定时器的ARR是16位寄存器,最大值65535,所以1s这个配置已经超出范围了。真要做1s,通常把PSC加大,比如PSC=7199,计数频率=10kHz,ARR=9999,周期=(9999+1)x0.1ms=1s。权衡思路就是:先确定合理计数频率,再反推ARR,最后检查ARR是否落在16位范围内。
2.4 为什么PSC和ARR往往配成“整百整千”的数
在保证定时时间正确的前提下,我喜欢把计数频率配成1kHz、1MHz这种十进制友好的数值,不是因为它性能更好,而是因为调试时好换算。假设你在中断里加一个变量计数,想看到LED每500ms翻转一次,那只要算“500000次中断对应500000微秒”就行,不用拿着计算器反推。这在项目后期排查问题时能省很多脑力。
3. 从零配置一个1ms定时中断:寄存器逐行手记
3.1 标准外设库视角下的初始化代码
用标准外设库配置TIM基本定时器,代码量不大,关键是要知道每一行到底做了什么。下面是一段完整的初始化与中断服务函数,注释里写的是寄存器层面的行为:
#include "tim6.h" static volatile uint32_t g_tick_count = 0; void TIM6_Init(uint16_t prescaler, uint16_t period) { // 1. 打开TIM6外设时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); // 2. 配置时基 TIM_TimeBaseInitTypeDef timebase; timebase.TIM_Prescaler = prescaler; // PSC,实际分频系数为 prescaler+1 timebase.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数 timebase.TIM_Period = period; // ARR,自动重装载值 timebase.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM6, &timebase); // 3. 使能更新中断 TIM_ITConfig(TIM6, TIM_IT_Update, ENABLE); // 4. 配置中断优先级 NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel = TIM6_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); // 5. 启动定时器 TIM_Cmd(TIM6, ENABLE); } void TIM6_IRQHandler(void) { if (TIM_GetITStatus(TIM6, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM6, TIM_IT_Update); g_tick_count++; } }第2步里有个容易忽略的细节:TIM_TimeBaseInit内部会操作TIMx_ARR、TIMx_PSC这些寄存器,但PSC的写入要等到更新事件产生之后才会真正生效到计数逻辑。因此很多老工程师会在初始化后加一句TIM_GenerateEvent(TIM6, TIM_EventSource_Update),也就是软件产生一次更新事件,把预分频值“推”进生效寄存器,避免第一次周期的计数值不对。如果是上面这段代码,后续每次都走正常溢出,影响不大,但严谨起见建议加上。
3.2volatile到底保护了什么
我在代码里把g_tick_count声明成了volatile,这不是玄学。中断服务函数里修改这个变量,主循环里读这个变量,编译器默认不知道这个变量会“被外部改变”,很可能把它的值优化到寄存器里缓存,导致主循环一直读到旧值。加了volatile之后,编译器每次读取都老老实实从内存地址去取。这是多线程和中断编程里最容易踩、也最难用肉眼发现的问题。
3.3 主循环里怎么用它判断时间
有了1ms的tick计数,主循环就可以去掉各种delay,改成判断差值:
uint32_t last_time = 0; while (1) { uint32_t now = g_tick_count; if (now - last_time >= 500) // 每500ms翻转一次 { last_time = now; GPIO_ToggleBits(LED_PORT, LED_PIN); } // 按键扫描、显示刷新等任务都可以放在这里,不再被延时阻塞 }这里用差值判断而不是直接比较相等,是为了防止主循环执行周期波动导致漏掉某一拍的判断。用无符号整数做减法还有额外好处:即使tick发生回绕,只要时间差不超过该类型表示范围的一半,结果依然正确。
3.4 HAL库对应的写法
如果你用的不是标准外设库,而是HAL库,本质操作完全一样,只是封装方式不同:
__HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance = TIM6; htim6.Init.Prescaler = 71; htim6.Init.CounterMode = TIM_COUNTERMODE_UP; htim6.Init.Period = 999; htim6.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim6); HAL_TIM_Base_Start_IT(&htim6);中断回调是统一入口:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { g_tick_count++; } }HAL库依赖初始化函数里把句柄和实例绑定,同时应用里必须调用HAL_TIM_Base_Start_IT才开始真正走中断。不少人配好了初始化但忘了启动,现象是“寄存器看着都对,就是不进中断”。这个问题我在下一章也会详细说。
4. 实际调定时器时踩过的四个坑,以及完整的排查思路
4.1 坑一:写完程序烧进去,LED纹丝不动
第一次调定时中断时,我配置好代码、下载程序,LED压根不闪。当时第一反应是定时器没跑起来,于是用调试器查看CNT寄存器的值——结果发现它就是一直在变,说明定时器已经工作了。接着查中断是否发生,发现对应的中断标志位一直是SET,但程序就是没跳到中断服务函数。
排查到这一步,方向转向NVIC。逐个检查NVIC配置项,发现我照抄了某段通用定时器的例程,中断通道写成了别的定时器的IRQ,而不是TIM6_IRQn。时钟开了、定时器跑了、中断标志有了,但中断请求根本没送达CPU,自然不会进服务函数。
这个坑给了一个教训:遇到“定时器计数正常但程序不进中断”的情况,第一件事是确认中断通道是否匹配自己的定时器号,第二件事是确认优先级分组是否配置过。有些启动代码里没有调用NVIC_PriorityGroupConfig,虽然默认也能用,但不同芯片恢复的默认分组不同,最好显式配置一次。
4.2 坑二:定时时间对不上,所有时间都翻了一倍
有次我把PSC配成71,ARR配成999,满以为会得到1ms中断,用逻辑分析仪一量,居然是2ms。那一刻我第一个怀疑是公式记错了,又翻手册核对:预分频器分频确实应该加1,ARR的比较确实要加1,公式没问题。后来反复看时钟树才意识到,我用的这颗芯片APB1分频系数是2,APB1外设时钟已经变成36MHz,但定时器时钟自动倍频回了72MHz,也就是说定时器时钟确实是72MHz没错。
那问题出在哪?我在初始化代码里先调用了外设时钟配置,把APB1分频设成了2,但我是在这之后才开启TIM6时钟的。这时TIM6的输入时钟按理论应该是72MHz。逻辑分析仪为什么测出2ms?后来查代码发现,初始化里把TIM_Prescaler写成143,我是把“PSC+1=144”当成目标去反推的,算出来就是144倍分频,计数器自然变成500kHz,溢出周期变成2ms。问题出在变量名太长看花了眼,填值时多打了一个2。
这个经历说明:公式本身不难,难的是“寄存器里填的值”和“实际分频倍数”之间差一的关系。PSC=71,实际是72分频;PSC=143,实际是144分频。填值时多注意这一步,能省很多排查时间。
4.3 坑三:程序看起来死机了,其实是中断风暴
用调试器看,程序一直停在某个中断相关代码里,主循环完全跑不动,看起来像死机。单步跟踪发现,中断服务函数每次都进,而且总是在同一个位置重复进入。
根因是中断标志没有清除。我最初写服务函数时只做了标志判断,忘了调用清标志位的函数。TIM的更新中断标志UIF一旦置位,不清除的话,退出中断后标志依然有效,CPU会立刻再次进入中断。于是程序永远在“进中断→退出→立刻再进中断”中循环,主循环被饿死。
排查这种问题有一个很管用的观察法:在中断服务函数里加一个计数器,这个计数器增值很快说明中断频率异常;再用调试器看一眼SR寄存器的UIF位是否一直为1,如果是,基本就是没清标志。清标志的操作一定要放在处理逻辑之前还是之后反而无所谓,重点是不要遗漏。另外,有些库函数在读取标志时已经隐式清除了标志,有的没有,一定要以手册为准,不要想当然。
4.4 坑四:HAL库项目里回调函数写了但永远不进来
换到HAL库项目后,配置看着正常,回调函数也写了,但始终不触发。我重新检查初始化流程,发现只调用了HAL_TIM_Base_Init,没有调用HAL_TIM_Base_Start_IT。HAL库的启动函数才是真正去置位CR1寄存器CEN位、并使能更新中断的开关,只初始化不启动,等于车子挂好了挡但不踩油门。补上启动调用后,回调立刻正常涌入。
此外,HAL库允许多个定时器实例共用同一个HAL_TIM_PeriodElapsedCallback回调函数,如果工程里同时用了TIM6和TIM7,必须在回调里用句柄的Instance区分来源,否则两个定时器都会触发同一段逻辑,产生意料之外的联动。
5. 一个定时器不够用时:用定时中断撑起整个软件时间架构
5.1 从“一个tick变量”到“多个软件定时器”
把1ms中断跑通之后,我开始思考一个问题:工程里可能有LED翻转、按键消抖、超时检测、屏幕刷新,每一个都要不同的时间周期,总不能为每个功能开一个硬件定时器。硬件基本定时器就两个,根本不够用。
于是我用结构体数组做了几个“软件定时器”,每个定时器只记录目标值和当前状态:
typedef struct { uint8_t active; uint32_t target_tick; uint32_t last_tick; } SoftTimer;在主循环里,用g_tick_count去逐个检查g_tick_count - last_tick >= target_tick,满足就执行对应回调并更新last_tick。整个软件定时器只需要一个1ms硬件节拍,其余都是内存里的加减法。这也是许多轻量级操作系统的“时间片管理”雏形。
5.2 时间片轮转:给不同任务分配时间窗口
如果任务不需要精确到微秒,更简单的做法是时间片轮转。比如tick加1代表1ms过去,可以约定“每个tick末位为0时执行按键扫描,为5时执行显示刷新,为20时执行数据记录”。这种方式不用建结构体数组,只要在1ms中断里用switch (tick % 20)分发即可。
需要注意的点是:1ms中断服务函数里的代码要尽量短。中断里只置标志、加计数、记录时间点,不要做复杂的计算、通信或者延时操作。否则中断占用时间太长,会影响到主循环的实时性,也可能导致下一次中断被延后,产生时间抖动。
5.3 共享变量与临界区保护
只要涉及中断和主循环之间共享变量,就必须考虑数据一致性。对于32位MCU,一个32位变量的读取在硬件上通常是原子操作,但如果是结构体、数组、或者大于一个机器字长的数据,读写过程可能被中断打断,导致主循环读到“写到一半”的数据。这种情况下可以采用临界区保护:
__disable_irq(); local_var = shared_var; __enable_irq();或者更精确地用“关中断→取数据→恢复中断”的方式。在我自己维护的代码里,凡是中断服务函数和主循环共同访问的变量,我都统一加上volatile,多字节数据则用临界区保护。这是一个换过教训后的习惯。
5.4 利用定时器中断做时间基座后的工程体会
等我把这套时间架构跑起来之后,最直观的感受是主循环变清爽了:没有了各种delay嵌套,按键扫描不会因为等待而丢事件,屏幕刷新也稳定了。1ms的定时中断就像整个系统的“心跳”,所有任务都围着这个心跳排队执行。
我个人现在做定时需求时,习惯先写“定时周期”再反推PSC和ARR,而不是随手写个值再去量波形。算完之后用逻辑分析仪或示波器确认一次实际引脚翻转周期,确认无误后再继续往下做别的功能。这个习惯看起来非常基础,但在项目复杂度上去之后,能保证时间基准始终可靠。
6. 给新手的几条实测建议
- 一开始不要直接用最高主频配合大分频做1s长周期,先用1ms级别的中断观察现象,波形看得清楚,公式也容易验证。
- 尽量把第一个定时中断配置在某个空闲的GPIO上翻转,用示波器或逻辑分析仪看到方波,再开始“折腾”其他功能。
- 所有中断服务函数里跟主循环共享的变量都要加
volatile,多字节数据记得做临界区保护,不要等程序“偶尔异常”了再回来补。 - 如果遇到中断进不去,优先怀疑“中断使能位”“NVIC通道”“启动函数”这三个位置,比从头到尾读一遍代码更高效。
- 不管用什么库,最终都要能自己找到对应的寄存器位置。这一行代码会不会写不重要,能不能看懂它在操作哪个寄存器才重要。
写到这里,基本定时器的定时功能从原理到落地已经完整串了一遍。回过头看,定时器的本质就是一个能自动计数、数到目标值后通知CPU的硬件工具,难点从来不在配置多少行代码,而在于是否真正理解PSC、ARR、时钟源三者之间的关系。把基本定时器吃透之后,再去接触通用定时器的PWM输出、输入捕获,你会发现底层思想都是一样的,只是多了一些“旁路”和“分支”而已。