news 2026/10/14 1:13:28

嵌入式定时器/计数器完全入门:从原理到PWM与输入捕获实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
嵌入式定时器/计数器完全入门:从原理到PWM与输入捕获实战

前阵子调试一块控制板,按一下按键,要等好一会儿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,有的只支持基本定时;有的有输入捕获,有的是高级定时器带刹车和死区功能,可以控制电机。

配置前一定要做三件事:查手册确认所选定时器支持的功能、确认它挂在哪条总线上、确认它的时钟源来源和分频关系。这三件事全对了,定时器配置基本不会跑偏。

说了这么多,总结下来就一句话:定时器/计数器这个模块,会配置只是入门,真正理解它的时钟链路、影子寄存器和中断机制,才叫会用。我自己也是从"照着抄初始化代码"开始,到后来能根据需求自由选参数、灵活切换各种模式,中间隔着的正是这些看似小的细节。如果这篇文章能让你少踩一两个坑,那就值了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:13:13

C# using关键字深度解析:从指令到资源释放的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:13:00

Ansible【第二课】:实施 Ansible Playbook

RH294 第 2 章&#xff08;实施 Ansible Playbook&#xff09;这一章讲怎么写、配、跑 Ansible 自动化脚本&#xff08;Playbook&#xff09;。1、Inventory 主机清单清单就是记录所有要管理的机器&#xff0c;告诉 Ansible 要操作哪些服务器。静态清单&#xff1a;直接写文本文…

作者头像 李华
网站建设 2026/10/14 1:12:58

ElasticSearch父子文档:从数据建模到关联查询的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:12:55

从Spring XML到SpringBoot:配置进化与自动配置原理剖析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:12:31

Python用matplotlib实现多波形叠加对比完全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:12:29

Python getattr()与map()组合实战:动态属性访问与批量数据处理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华