前阵子调试一块控制板,按一下按键,要等好一会儿LED才有反应。查了半天,问题出在一个最土的写法上:主循环里用了delay做按键扫描,结果整个循环被卡得死死的,什么都干不了。后来把扫描任务挪到定时器中断里,一个10ms的定时中断搞定,按键响应立刻正常了。从那以后我给新手讲嵌入式,第一个强调的外设就是定时器/计数器。这个模块说简单也简单,说深也能玩出花——定时、延时、PWM、输入捕获、编码器接口,全都靠它。这篇就把我自己的使用经验和踩过的坑摊开讲,适合刚开始接触定时器的朋友,也适合已经会用、但想弄清楚内部机制的人。
1. 先掰清楚一个概念:定时器和计数器到底是不是同一个东西
很多教材把"定时器/计数器"放在一起写,第一次看的人会懵:这到底是两个外设还是一个外设?答案是:在绝大多数芯片里,它们就是同一个硬件模块,只是工作时的计数脉冲来源不一样。
1.1 同一个模块,两种工作身份
这个模块核心其实就是一个计数器:每来一个脉冲,内部寄存器就加一,加到溢出,产生一个事件。
- 当计数脉冲来自芯片内部的时钟信号时,脉冲周期是固定的、已知的,所以计数值可以精确换算成时间——这时它叫定时器。
- 当计数脉冲来自外部引脚输入的事件时,比如光电编码器的A相脉冲、流量计的信号,它数的是"外部发生了多少次事件"——这时它叫计数器。
用生活场景类比:定时器就像秒表,秒针按固定节奏走,你知道走了多久;计数器就像在停车场门口数车的保安,来一辆数一辆,他只知道今天来了多少辆车,并不关心车的到达间隔。
这个区分很关键,因为配置的时候,你要想清楚:我的脉冲源是内部的还是外部的?这决定了整个寄存器配置的方向。
1.2 计数方向不是只有向上加
多数定时器支持三种计数模式:
- 向上计数:从0往上数,数到自动重装载值(ARR)后溢出归零。
- 向下计数:从自动重装载值往下数,数到0溢出。
- 中央对齐计数:先向上数到ARR,再向下数到0,如此反复,产生对称波形。
向上计数用得最多,日常定时、延时基本都靠它。向下计数在某些场景有优势,比如要在特定时刻触发事件,可以从某初值往下数,到了0那一刻去启动别的外设,时序上更直观。中央对齐模式一般用在对波形对称性有要求的电机控制场景,PWM波形中心对称,谐波小,普通场合用不上。
1.3 一句话记住本质
能不能把"计数值"换算成"时间值",取决于计数脉冲是不是来自内部已知频率的时钟。外部脉冲源的时候,你数的是"多少个事件",不是"多少微秒"。
2. 从内部结构看定时器的完整工作链路
理解了身份,再看内部结构就不容易晕。整个定时器模块就是一条流水线:时钟源 → 预分频器 → 计数寄存器 → 自动重装载寄存器 → 溢出事件/中断。
2.1 预分频器:把高频时钟降下来
芯片的主频通常几十上百MHz,比如72MHz,如果计数器直接以72MHz的频率去数,计数器数值增长极快,16位计数器最大只能数65535个脉冲,换算下来不到1ms就溢出了,这肯定不够用。
预分频器(PSC)的作用就是把输入时钟先分频,降低计数频率,从而扩大单次定时范围。它和计数寄存器一样有最大值限制,常见的是16位,也就是预分频值PSC范围0到65535。实际分频倍数是PSC加1:比如主频72MHz,PSC设为7199,计数脉冲频率就是72MHz / (7199 + 1) = 10kHz,每个计数脉冲是0.1ms。
很多人第一次配置时会困惑:为什么预分频值要写7199而不是7200?因为硬件就是从0开始计数的,0到7199共7200个值,对应7200分频。
2.2 自动重装载:省掉重复赋初值
计数器的值(CNT)从0加到ARR(Auto-Reload Register,自动重装载寄存器),到达之后产生溢出事件,然后CNT自动回到0重新开始。这个"自动回到0"的过程就叫自动重装载,不用你在中断里手动给CNT赋初值。
这里有一个非常重要的细节:很多定时器有影子寄存器。你往ARR寄存器写新值,不会立刻生效,而是先存到预装载寄存器里,等发生一次更新事件后,才真正加载到影子寄存器中参与比较。
这就导致一个新手经常遇到的现象:配置完定时器,第一次溢出的时间和预期不一样,但从第二次开始就正常了。原因是第一次运行时ARR用的还是复位后的默认值,而你写入的新值还没被加载。解决办法是在初始化收尾时,手动把更新标志位置位(通常是把EGR寄存器的UG位置1),强制产生一次更新事件,让影子寄存器立即加载新值。
2.3 溢出事件、更新中断和中断标志
计数器从ARR溢出到归零的瞬间,会产生一个更新事件。这个事件可以做两件事:
- 触发中断,让CPU跳进定时器中断服务函数;
- 触发其他外设,比如ADC采样、DAC输出,实现"定时到了就自动采一次数"。
中断配置涉及两个标志:中断标志位在状态寄存器(SR)里,中断使能位在中断使能寄存器(DIER)里。这二者非常容易搞混。标志位表示"发生了一次更新事件",使能位表示"这个事件能不能触发中断"。必须把使能位置1,中断才会真正响应。
2.4 时钟源:不只是内部时钟这一条路
多数定时器还支持外部时钟模式:计数器直接数外部引脚输入的脉冲,或者由另一个定时器的输出作为时钟。分两大方向:
- 外部时钟模式1:选择某个输入引脚(如TI1、TI2)的边沿作为计数脉冲。
- 外部时钟模式2:通过专门的触发引脚(ETR)输入脉冲。
这在测量外部信号频率、做硬件计数时非常有用。配外部时钟时,寄存器配置方式和内部时钟有差异,很多人沿用内部时钟的配置导致计数器不动,实际上是因为没有正确配置时钟源选择位。
3. 定时中断配置实操:寄存器级代码,一边写一边讲
说实话,现在很多集成开发环境能自动生成定时器初始化代码,鼠标点几下就完事。但我还是强烈建议新手亲手用寄存器写一遍初始化流程,因为自动生成代码一旦出问题,你连从哪排查都不知道。下面用类似寄存器描述的写法演示配置一个10ms定时中断。
3.1 初始化代码六步走
假设定时器时钟来自APB总线,主频72MHz,目标是10ms中断一次。具体流程如下:
// 1. 打开定时器所在总线的时钟 // 不同芯片、不同定时器所在总线不同,这个一定要查手册确认 RCC->APB1ENR |= (1 << 0); // 使能TIM2所在总线的时钟 // 2. 设置预分频值 // 72MHz / (7199 + 1) = 10kHz,即一个计数脉冲 = 0.1ms TIM2->PSC = 7199; // 3. 设置自动重装载值 // 数100次,每次0.1ms,总计10ms TIM2->ARR = 99; // 4. 手动产生一次更新事件 // 把UG位置1,让PSC和ARR的新值立即加载到影子寄存器 TIM2->EGR |= (1 << 0); // 5. 清除更新中断标志,避免一开中断就立刻进一次 TIM2->SR &= ~(1 << 0); // 6. 使能更新中断,再使能定时器 TIM2->DIER |= (1 << 0); // 更新中断使能 TIM2->CR1 |= (1 << 0); // 计数器使能,开始计数写完之后还有一步不能忘:在中断控制器(NVIC)里使能对应的定时器中断通道。很多芯片自动生成的代码会把这一步放在单独的函数里,但如果你手写初始化却忘了它,定时器标志照常置位,CPU却不会跳进中断函数,程序看起来就像"什么都没发生"。
3.2 中断服务函数:先清标志,再干活
中断服务函数的形状大致如下:
void TIM2_IRQHandler(void) { // 判断是不是更新中断 if ((TIM2->SR & (1 << 0)) != 0) { // 第一步永远是清除标志位! TIM2->SR &= ~(1 << 0); // 到这里才是正式的10ms定时任务 key_scan(); led_toggle(); } }有两个关键点必须养成肌肉记忆:
- 进入中断第一件事清标志。不清标志的后果是:中断函数执行完退出,标志还是置位状态,CPU立刻再次进入中断,形成"中断风暴",程序看起来就是卡死。
- 中断函数里不要做耗时操作。比如不要放delay、不要放耗时的打印输出,更不要在中断里做复杂运算。这些操作会拉长中断执行时间,导致下一轮定时不准确,甚至错过其他中断。正确做法是在中断里只置一个标志位,回到主循环再处理具体业务。
3.3 用IO口翻转验证定时精度
配置完成后怎么确认定时时长真的对?方法很简单:在中断里翻转一个GPIO,用示波器或者逻辑分析仪看这个引脚的波形周期。
实际操作时,把某个IO口放在中断里翻转:
void TIM2_IRQHandler(void) { if ((TIM2->SR & (1 << 0)) != 0) { TIM2->SR &= ~(1 << 0); GPIOA->ODR ^= (1 << 1); // 翻转PA1 } }示波器上看到的波形周期应该是20ms(因为一次翻转是半周期,10ms高电平、10ms低电平)。如果测出来差很多,先检查主频是否真的配到了预期值。这是个非常经典的坑:系统时钟分频配置不对,定时器时间就全部按比例偏移,而代码本身看着完全正常。
4. 定时时间精确计算:公式、实例和边界
定时时间的计算不只是套公式,还要会反推参数,并且避开边界陷阱。
4.1 核心公式:一切以计数脉冲为准
设定时器输入时钟频率为Fclk,预分频值为PSC,自动重装载值为ARR,则:
计数脉冲频率 = Fclk / (PSC + 1)
一次溢出所需时间 = (ARR + 1) 个计数脉冲的时间
T_overflow = (ARR + 1) × (PSC + 1) / Fclk
举例:Fclk = 72MHz,PSC = 7199,ARR = 99,则:
T = 100 × 7200 / 72,000,000 = 0.01s = 10ms
这里有个很容易算错的地方:无论是PSC还是ARR,公式里都要加1。习惯写代码时经常直接把7199和99填进去,心里清楚即可,但推导公式时千万别丢了那个加1。
4.2 反推参数:要1秒定时怎么配
如果需求是1秒定时,有很多组合。一种做法是继续用10kHz的计数频率,ARR设为9999,这样10000次计数正好1秒:
PSC = 7199,ARR = 9999
检查一下:10000 × 7200 / 72,000,000 = 1s,正确。
另一种做法是提高计数频率换取更多中间时刻,比如计数频率1MHz(PSC = 71),ARR = 999999,也是一秒。但注意,16位ARR最大只到65535,999999显然放不进去,这就需要换方案了。
4.3 超长定时:单次周期不够怎么办
16位定时器能定的最大时间有上限。比如72MHz主频,PSC取最大65535,计数频率约1098.6Hz,ARR取最大65535,单次溢出最长约59.6秒。如果需求是2分钟甚至更久,怎么办?
常见的做法有三种:
- 软件计数:让定时器每100ms中断一次,在中断里用一个软件变量计数,累计20次就是2秒。这个变量本质上是把定时器位数扩展了,从16位扩展到任意位。实用、直观、代码量小,我平常用得最多。
- 选择32位定时器:部分芯片提供32位定时器,ARR可以到4,294,967,295,单次周期能达到几小时。
- 级联定时器:一个定时器的溢出事件作为另一个定时器的时钟源,用硬件实现超长周期,不占用CPU中断。这种方式适合需要高精度、低CPU占用率的场景,但配置相对复杂。
4.4 边界值比想象中更容易出错
常见错误是写PSC = 65536或ARR = 65536。16位寄存器的存储范围是0到65535,写成65536会直接溢出回0,相当于预分频值变成0,计数频率变成72MHz,定时时间完全乱套。
还有一个隐含问题:不要设置PSC = 0、ARR = 0。这意味着计数频率等于主频,每次只有1个计数脉冲就溢出,中断频率高得吓人,CPU会一直耗在中断里。
5. 定时器的进阶玩法:PWM、输入捕获和外扩计数器模式
定时器做定时只是基本功。它真正强大的地方在于:不需要CPU干预,硬件就能自动完成波形输出、信号测量等工作。这几个玩法会直接影响你的项目选型。
5.1 PWM波形:比较寄存器才是主角
PWM输出时,定时器工作的核心不是ARR,而是一个比较寄存器(CCR)。计数器CNT不断向上计数,硬件持续比较CNT和CCR的大小:
- CNT < CCR时,输出引脚为一种电平;
- CNT ≥ CCR时,输出引脚翻转成另一种电平;
- CNT溢出归零后,重新开始下一周期。
这样ARR决定PWM的周期,CCR决定PWM的占空比,两者独立配置。要改占空比,只需要修改CCR的值,比如LED亮度调节、电机速度控制,都是这样实现的。
有个非常实用的细节:定时器支持预装载功能,修改CCR的值不会立即生效,而是等到更新事件才统一加载。好处是PWM波形不会在周期中间突然跳动,形成毛刺。如果开启了预装载,更新PWM频率和占空比要注意修改时机,避免产生一次半周期的异常输出。
5.2 输入捕获:测频率和脉宽的思路
输入捕获模式做的是这么一件事:外部信号发生跳变(上升沿或下降沿)时,硬件把当前CNT的值锁存到捕获寄存器里。
测外部信号频率的思路:配置上升沿捕获,第一次上升沿到来,记下CNT1;第二次上升沿到来,记下CNT2。CNT2 - CNT1的差值乘以计数脉冲周期,就是信号的周期,取倒数就是频率。
测脉宽同理:先配上升沿捕获,记下CNT1;再切换配置为下降沿捕获(或者用双通道分别捕获),记下CNT2,差值就是高电平持续时间。
我常用的一个案例是测遥控接收头的脉冲宽度:通过输入捕获把每一段高电平和低电平的时间都记录下来,然后按协议解析按键编码。这个过程完全不需要CPU去轮询引脚,硬件自动完成采样,CPU只需要在捕获中断里取走数据。
5.3 外部计数器:数脉冲不只是累加
前面说过,外部时钟模式下,定时器直接数引脚脉冲。配置方法通常是把时钟源切到外部引脚,并设置脉冲边沿触发方式。
这个功能在做转速测量时非常实用:比如编码器每转一圈发出几百个脉冲,用定时器计数器模式直接累加,再配合另一个定时器定时读取计数值,就能算出转速。整套流程不占用CPU轮询,硬件一直在跑,精度和实时性都远好于软件模拟。
6. 实战调试中踩过的坑:中断风暴、影子寄存器和裸机误差
最后分享几个我在实际调试中踩过的坑,每一个都曾让我对着屏幕怀疑人生。把这些经验写下来,希望你能少走弯路。
6.1 中断风暴:一个标志位引发的假死机
有一次程序启动后,刚开始执行就像死机一样卡住。后来定位到原因:我在初始化里先使能了定时器,又在NVIC里打开了中断,但忘了在初始化时清除一次中断标志。结果定时器一启动,可能因为更新事件标志处于置位状态,CPU立刻进入中断,执行完退出又马上进入,无限循环,主循环永远跑不到。
解决办法我在前面已经说过:先清标志位,再使能中断。这应该成为初始化定时器的标准动作。
6.2 影子寄存器陷阱:第一次溢出时间为什么不对
某个项目要求非常精确的毫秒级定时,可我用示波器测出来,第一次中断比预期晚了几乎一倍,之后才恢复正常。
原因就是影子寄存器。初始化时我把PSC和ARR写入了预装载寄存器,但更新事件还没发生,新值没有加载到实际参与计数的影子寄存器。定时器第一次运行用的是旧值,当然不准。
处理办法还是那句:写完PSC和ARR后,手动触发一次更新事件(把EGR的UG位置1),让配置立即生效。
6.3 中断服务函数写成"大杂烩"的代价
还有一次,我把显示刷新、按键处理、数据打包全塞进定时中断里,结果发现中断执行时间比定时周期还长。现象非常诡异:主循环里的逻辑运行速度漂移,还偶尔出现程序卡顿。
后来用示波器对比才发现,中断函数整体占用时间太长,下一轮定时中断被延迟。正确的是把中断函数做"瘦身",只做最紧急的事(置标志位、存数据),其余事务切到主循环处理。如果业务复杂,可以考虑用状态机写主循环,让不同的任务分时执行。
6.4 同一颗芯片上不同定时器的差异
最后提醒一点:芯片上各个定时器模块的能力并不完全相同。有的定时器支持PWM,有的只支持基本定时;有的有输入捕获,有的是高级定时器带刹车和死区功能,可以控制电机。
配置前一定要做三件事:查手册确认所选定时器支持的功能、确认它挂在哪条总线上、确认它的时钟源来源和分频关系。这三件事全对了,定时器配置基本不会跑偏。
说了这么多,总结下来就一句话:定时器/计数器这个模块,会配置只是入门,真正理解它的时钟链路、影子寄存器和中断机制,才叫会用。我自己也是从"照着抄初始化代码"开始,到后来能根据需求自由选参数、灵活切换各种模式,中间隔着的正是这些看似小的细节。如果这篇文章能让你少踩一两个坑,那就值了。