
1. 为什么每个单片机学习者都绕不开定时器/计数器刚接触单片机那会儿我总觉得GPIO点灯、按键读取这些才是正经活定时器嘛无非就是个延时工具delay()函数能搞定的事何必折腾寄存器。直到我做了一个需要同时处理按键消抖、数码管动态扫描、串口收发和PWM调光的项目用delay()搭出来的框架直接崩了——按键响应迟钝、数码管闪烁、串口丢数据整个系统像得了帕金森。那一刻我才真正理解定时器/计数器不是外设它是单片机的心跳。这篇文章想聊的就是这颗心跳到底怎么跳、为什么这么跳、以及在实际项目中怎么让它跳得稳。我会从最基础的概念讲起把定时器和计数器的本质区别、核心寄存器配置、工作模式选择、中断优先级处理这些关键点全部拆开揉碎。不管你是刚学完GPIO的新手还是已经能写简单项目但总被时序问题困扰的进阶者这篇笔记都能帮你把定时器这块硬骨头啃下来。需要提前说明的是不同厂商、不同系列的单片机在定时器模块上差异很大有的叫TIM、有的叫Timer、有的分高级定时器和通用定时器。我下面讲的内容以最常见的8位和32位MCU通用定时器为蓝本具体寄存器名称和位定义你需要对照自己芯片的参考手册来映射。但底层逻辑是相通的——理解了计数这个核心换任何芯片都能快速上手。2. 定时器与计数器的本质同一个硬件两种视角2.1 从数数说起定时器和计数器到底有什么区别很多人初学时会困惑定时器和计数器明明是同一个模块为什么要有两个名字答案藏在时钟源的选择上。想象你面前有一个闹钟它每秒钟滴答一次。如果你用它来计量过了多少秒这就是定时器——你知道每次滴答代表固定的时间间隔数够60次就是1分钟。但如果你把这个闹钟接到一个旋转的齿轮上齿轮每转一圈就触发一次滴答那你数的就是齿轮转了多少圈这就是计数器——你关心的是外部事件发生的次数而不是时间本身。在单片机内部这个滴答的来源就是时钟信号。当计数器的输入时钟来自单片机内部系统时钟经过分频后时它工作在定时器模式因为内部时钟频率是已知且稳定的计数值乘以时钟周期就是精确的时间。当输入时钟来自外部引脚比如T0引脚上的脉冲信号时它工作在计数器模式此时计数值反映的是外部脉冲的个数。这里有个关键细节容易被忽略外部脉冲的频率不能超过单片机时钟频率的一半有些芯片甚至要求更低。原因很简单——如果外部信号变化太快内部采样电路根本来不及识别就会丢脉冲。我在一个电机测速项目里就踩过这个坑霍尔传感器输出的脉冲频率到了200kHz而MCU主频才16MHz结果计数值始终偏小后来加了硬件分频才解决。2.2 溢出与重载定时器的轮回机制定时器的核心是一个递增或递减计数器寄存器比如8位定时器的计数范围是0~25516位是0~65535。当计数值达到最大值后再来一个脉冲它就会溢出——要么回滚到0重新开始要么自动重载一个预设值。这个溢出事件就是定时器中断的触发源。为什么需要重载因为实际应用中我们很少需要定时器从0数到65535那么久。比如你要产生1ms的定时中断假设系统时钟是1MHz1个计数周期1微秒那只需要数1000次就够了。这时候你会给定时器设一个重载值也叫预装载值让它从65536 - 1000 64536开始往上数数到65535溢出刚好1000个计数周期也就是1ms。这个计算过程可以用一个通用公式表达定时时间 (重载值到溢出值的计数次数) × 时钟周期 × 分频系数反过来已知需要的定时时间求重载值重载值 最大值 - (定时时间 / (时钟周期 × 分频系数))举个例子16位定时器系统时钟72MHz预分频设为72-1需要定时1ms。先算分频后的时钟频率72MHz / 72 1MHz即每个计数周期1微秒。1ms需要1000个计数周期。重载值 65536 - 1000 64536。把这个值写入重载寄存器启动定时器它就会每1ms触发一次中断。2.3 预分频器定时器的变速箱预分频器Prescaler的作用是把输入时钟按比例降低。为什么需要它因为单片机主频可能很高几十到几百MHz而定时器需要的计数频率往往低得多。如果没有预分频器16位定时器在72MHz时钟下最大定时时间只有65536 / 72MHz ≈ 0.91ms连1ms都到不了根本不够用。预分频器的原理很简单它是一个递减计数器每来一个输入时钟脉冲就减1减到0时输出一个脉冲给主计数器同时重新加载预设的分频值。所以实际分频系数 预分频寄存器值 1。比如写入71就是72分频。这里有个实操中容易犯的错误预分频器在定时器运行中修改后不会立即生效要等到下一次溢出事件才会更新。如果你在中断里动态改分频值来调整定时周期必须等一个完整周期后新值才起作用。我在做红外解码时就因为这个延迟导致时序对不上后来改成直接修改重载值才解决。3. 定时器工作模式全解析从基础到进阶3.1 模式一普通定时模式——最常用的节拍器普通定时模式是定时器最基础也最常用的工作方式。配置流程通常是这样的使能定时器时钟这一步千万别忘很多新手调半天没反应就是时钟没开设置预分频值和重载值配置计数方向向上/向下/中央对齐使能更新中断如果需要使能定时器以某款32位MCU为例关键寄存器操作如下// 使能定时器2时钟 RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 预分频72-1计数时钟1MHz TIM2-PSC 72 - 1; // 重载值1000-1定时1ms TIM2-ARR 1000 - 1; // 使能更新中断 TIM2-DIER | TIM_DIER_UIE; // 使能定时器 TIM2-CR1 | TIM_CR1_CEN;这段代码看起来简单但有几个细节值得注意。第一PSC和ARR寄存器都有影子寄存器写入的值不会立即生效而是在下一个更新事件时从预装载寄存器传送到影子寄存器。如果你希望立即生效需要手动产生一个更新事件置位EGR寄存器的UG位。第二中断使能后还需要在NVIC中配置优先级并使能对应的中断通道否则中断永远不会被触发。普通定时模式最典型的应用就是系统时基——给整个系统提供一个稳定的时间基准比如每1ms执行一次任务调度、每10ms扫描一次按键、每100ms刷新一次显示。我习惯在项目里开一个1ms的定时中断然后在中断里维护一堆软件计数器主循环根据这些计数器来决定该执行什么任务。这种时间片轮询的架构比裸写delay()可靠得多。3.2 模式二PWM输出模式——定时器的副业PWM脉冲宽度调制是定时器最实用的衍生功能之一。它的原理其实很简单定时器正常计数同时有一个比较寄存器CCR。当计数值小于CCR时输出高电平大于等于CCR时输出低电平或者反过来取决于极性配置。这样输出波形的频率由重载值决定占空比由CCR决定。PWM频率 计数时钟频率 / (重载值 1)占空比 CCR / (重载值 1) × 100%比如计数时钟1MHz重载值999那PWM频率就是1MHz / 1000 1kHz。如果CCR设为300占空比就是30%。PWM模式有几个关键配置项容易搞混PWM模式1和模式2模式1是计数值小于CCR时有效电平模式2相反。选哪个取决于你的驱动电路需要高电平有效还是低电平有效。输出极性可以配置有效电平是高还是低。比如驱动LED时如果LED是低电平点亮那有效电平就应该配成低。自动重载预装载使能后修改ARR值会在下一个周期生效避免波形出现毛刺。我在做电机调速时发现一个现象低速时电机抖动明显。后来用示波器看PWM波形才发现是因为占空比太低时MOS管开关时间占比不均衡导致的。解决办法是把PWM频率从1kHz提高到20kHz超出人耳听觉范围同时电机转动也平滑了很多。所以PWM频率的选择要综合考虑负载特性和噪声要求不能随便设一个了事。3.3 模式三输入捕获模式——测量外部信号输入捕获是定时器的另一个重要功能用来测量外部信号的频率、周期或脉宽。工作原理是当检测到引脚上的指定边沿上升沿、下降沿或双边沿时硬件自动把当前计数值锁存到捕获寄存器中并触发中断。通过两次捕获值的差就能算出时间间隔。测量频率的典型流程配置定时器为输入捕获模式选择捕获边沿第一次捕获时记录值到变量A第二次捕获时记录值到变量B周期 (B - A) × 计数周期频率 1 / 周期这里有个坑16位定时器在低频信号测量时会溢出。比如信号周期100ms而定时器每1ms溢出一次那两次捕获之间可能溢出了100次。如果不处理溢出算出来的周期就完全错了。解决办法是在更新中断里维护一个溢出计数器计算时把溢出次数乘上65536再加进去。我在做超声波测距模块时就用了输入捕获。超声波模块的回响信号高电平持续时间对应距离用输入捕获测这个高电平脉宽精度能到微秒级。但要注意超声波信号容易受干扰我在捕获中断里加了简单的软件滤波——连续三次捕获值偏差在允许范围内才认为有效否则丢弃。这个技巧在实际产品中非常必要。3.4 模式四编码器接口模式——电机控制的利器编码器接口模式是高级定时器才有的功能专门用来读取增量式编码器的A、B两相信号。硬件自动根据两相脉冲的相位关系判断旋转方向并增减计数值完全不需要CPU干预。这个模式的好处是不占用CPU资源且不会丢脉冲。如果用外部中断来读编码器高速旋转时中断频率可能高达几十kHzCPU根本忙不过来。而编码器接口模式是纯硬件解码即使电机高速旋转也能准确计数。配置时需要注意编码器模式有几种计数方式只在A相计数、只在B相计数、AB都计数计数精度不同输入滤波器要配置合适的值滤除信号抖动计数值是16位的高速长时间旋转会溢出需要定期读取并累加我在一个云台控制项目里用编码器接口读电机位置配合PID算法做闭环控制。实测下来硬件解码比软件中断方式稳定太多低速时不会丢步高速时也不会漏计。4. 中断优先级与实时性定时器用得好的分水岭4.1 中断嵌套什么时候该让定时器插队单片机的中断系统通常支持优先级配置。当多个中断同时发生时高优先级的中断会先执行如果低优先级中断正在执行高优先级中断可以打断它这就是中断嵌套。定时器中断的优先级设置需要根据实际需求来定。一般来说系统时基定时器优先级设为中等因为它需要稳定运行但偶尔延迟几微秒问题不大输入捕获定时器优先级设高因为捕获值不及时读取会被覆盖PWM定时器通常不需要开中断或者优先级设低通信相关定时器根据协议时序要求通常设较高优先级我见过一个典型的错误配置把串口接收中断优先级设得比系统时基还低结果串口数据量大的时候时基中断频繁打断串口中断导致接收缓冲区溢出。后来把串口中断优先级提高问题就解决了。所以优先级配置不是拍脑袋决定的要分析各个中断的实时性要求和执行时间。4.2 中断服务函数里的三秒原则定时器中断服务函数ISR应该尽可能短小精悍。我给自己定了一个三秒原则——当然不是真的三秒而是说ISR执行时间不能超过定时周期的三分之一。比如1ms的定时中断ISR执行时间最好控制在300微秒以内。为什么因为如果ISR执行时间太长主循环就没时间干活了。更严重的是如果ISR执行时间超过定时周期中断就会嵌套自己导致栈溢出、程序跑飞。在ISR里应该只做最紧急的事清除中断标志、更新必要的变量、设置标志位。耗时的处理比如计算、显示刷新、数据存储全部放到主循环里根据标志位来执行。这就是所谓的前后台架构——中断是前台负责响应主循环是后台负责处理。volatile uint8_t flag_1ms 0; void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_UIF) { TIM2-SR ~TIM_SR_UIF; // 清除中断标志 flag_1ms 1; // 只设标志不做其他事 } } int main(void) { while (1) { if (flag_1ms) { flag_1ms 0; // 在这里执行1ms任务 } } }4.3 中断标志清除的时机一个容易被忽略的细节很多新手会忘记清除中断标志导致中断连续触发程序卡死在ISR里。但清除标志的时机也有讲究。有些芯片要求在ISR入口就清除标志有些则建议在ISR末尾清除。区别在于如果在入口清除ISR执行期间新来的同类型中断会被挂起等ISR结束后再触发一次如果在末尾清除ISR执行期间的中断会被忽略。具体用哪种取决于你的应用场景。如果是计数类中断比如外部脉冲计数漏掉一个都不行那就应该在入口清除让新中断排队。如果是定时类中断偶尔漏一个不影响大局那在末尾清除也可以。我在用某款8位单片机时遇到过一个问题在ISR里先执行了一段耗时操作最后才清标志结果这段时间内又发生了两次溢出但标志只清了一次导致后续中断节奏全乱了。后来改成入口就清标志问题消失。所以清标志的时机要根据中断类型和实时性要求来决定。5. 实战配置流程从零搭建一个多任务定时框架5.1 需求分析与方案设计假设我们要做一个简单的温控器需要实现以下功能每500ms读取一次温度传感器每10ms扫描一次按键每2ms刷新一次数码管显示温度超过阈值时PWM输出驱动风扇占空比根据温差动态调整如果用delay()来实现代码会写成这样while (1) { read_temp(); delay_ms(500); scan_key(); delay_ms(10); refresh_display(); delay_ms(2); update_pwm(); }这种写法的问题很明显所有任务的执行周期都是500102的倍数根本不准而且任何一个任务耗时变化其他任务的周期都会跟着变。更致命的是如果温度读取用了I2C通信耗时可能几毫秒那按键扫描的10ms周期就完全没法保证了。正确的做法是用一个定时器产生统一时基然后在主循环里根据计数器来调度任务。5.2 定时器初始化与配置我选择用一个16位定时器产生1ms时基。假设系统时钟8MHz预分频设为8-1计数时钟1MHz重载值1000-1定时1ms。void Timer_Init(void) { // 使能定时器时钟 RCC-APB1ENR | RCC_APB1ENR_TIM3EN; // 预分频8-1计数时钟1MHz TIM3-PSC 8 - 1; // 重载值1000-1定时1ms TIM3-ARR 1000 - 1; // 使能更新中断 TIM3-DIER | TIM_DIER_UIE; // 配置NVIC NVIC_SetPriority(TIM3_IRQn, 2); NVIC_EnableIRQ(TIM3_IRQn); // 使能定时器 TIM3-CR1 | TIM_CR1_CEN; }5.3 软件计数器与任务调度在1ms中断里维护一组软件计数器volatile uint16_t cnt_2ms 0; volatile uint16_t cnt_10ms 0; volatile uint16_t cnt_500ms 0; volatile uint8_t flag_2ms 0; volatile uint8_t flag_10ms 0; volatile uint8_t flag_500ms 0; void TIM3_IRQHandler(void) { if (TIM3-SR TIM_SR_UIF) { TIM3-SR ~TIM_SR_UIF; if (cnt_2ms 2) { cnt_2ms 0; flag_2ms 1; } if (cnt_10ms 10) { cnt_10ms 0; flag_10ms 1; } if (cnt_500ms 500) { cnt_500ms 0; flag_500ms 1; } } }主循环里根据标志位执行对应任务int main(void) { Timer_Init(); while (1) { if (flag_2ms) { flag_2ms 0; refresh_display(); } if (flag_10ms) { flag_10ms 0; scan_key(); } if (flag_500ms) { flag_500ms 0; read_temp(); update_pwm(); } } }这个框架的好处是每个任务的执行周期由硬件定时器保证非常精确任务之间互不干扰一个任务耗时长了不会影响其他任务的触发时机扩展新任务只需要加一个计数器和标志位非常灵活。5.4 PWM输出的动态调整风扇调速用另一个定时器的PWM通道实现。假设PWM频率设为20kHz超出人耳听觉范围计数时钟72MHz预分频设为72-1得到1MHz计数时钟重载值设为50-1则PWM频率 1MHz / 50 20kHz。void PWM_Init(void) { // 使能定时器4时钟 RCC-APB1ENR | RCC_APB1ENR_TIM4EN; // 预分频72-1计数时钟1MHz TIM4-PSC 72 - 1; // 重载值50-1PWM频率20kHz TIM4-ARR 50 - 1; // PWM模式1使能预装载 TIM4-CCMR1 | TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1PE; // 初始占空比0 TIM4-CCR1 0; // 使能输出 TIM4-CCER | TIM_CCER_CC1E; // 使能自动重载预装载 TIM4-CR1 | TIM_CR1_ARPE; // 使能定时器 TIM4-CR1 | TIM_CR1_CEN; } void update_pwm(uint8_t duty) { // duty范围0-100映射到CCR值0-50 TIM4-CCR1 (duty * 50) / 100; }这里有个细节修改CCR值时如果使能了预装载新值会在下一个PWM周期生效不会产生波形毛刺。如果不使能预装载修改瞬间可能输出一个异常窄或异常宽的脉冲对电机驱动来说可能造成电流冲击。6. 常见问题与排查技巧实录6.1 定时器不工作先查这五个地方排查项可能问题解决方法时钟使能忘记使能定时器时钟检查RCC寄存器对应位中断配置NVIC未使能或优先级错误检查NVIC_ISER和IPR寄存器重载值值为0或超出范围确认ARR在有效范围内计数使能CEN位未置1检查CR1寄存器引脚复用输出引脚未配置为复用功能检查GPIO AFR寄存器我遇到过最隐蔽的一次是定时器配置全部正确但就是不进中断。查了半天发现是启动文件里的中断向量表名字写错了实际中断服务函数根本没被链接进去。所以如果所有寄存器配置都确认无误但中断还是不触发不妨检查一下中断向量表。6.2 定时不准这几个因素在捣鬼定时器定时不准通常有以下几个原因时钟源精度内部RC振荡器精度通常只有1%~5%如果对定时精度要求高必须用外部晶振中断响应延迟从中断触发到ISR第一条指令执行有几十个时钟周期的延迟高频中断时这个延迟占比不可忽略ISR执行时间如果ISR执行时间超过定时周期实际中断频率会低于设定值预分频器写入时机运行中修改预分频值不会立即生效我在一个需要精确计时1秒的项目里用内部RC振荡器做时钟源实测每天误差好几分钟。换成外部晶振后误差降到每天几秒以内。所以对时间精度有要求的场合外部晶振是必须的。6.3 中断丢失或重复触发标志位管理的艺术中断丢失和重复触发往往是一对孪生问题根源都在标志位管理上。中断丢失的常见原因ISR执行时间太长错过了后续中断中断标志在ISR中被意外清除优先级配置不当导致中断被屏蔽。重复触发的常见原因忘记清除中断标志清除标志的时机不对硬件自动清除和软件手动清除冲突。我的经验是在ISR入口处第一时间清除标志然后用一个局部变量记录中断类型最后根据类型执行对应操作。这样即使ISR执行期间有新中断到来也不会因为标志被覆盖而丢失。6.4 多个定时器协同工作时的注意事项当项目里用了多个定时器时需要注意时钟使能顺序先使能时钟再配置寄存器否则配置无效中断优先级分配根据实时性要求合理分配避免高优先级中断被低优先级阻塞资源共享如果多个定时器共用同一个中断向量有些芯片是这样设计的需要在ISR里判断是哪个定时器触发的中断功耗考虑不用的定时器及时关闭时钟降低功耗我在一个低功耗项目里所有定时器都关了但功耗还是降不下来。后来发现是某个定时器的时钟使能位没清虽然定时器没启动但时钟还在供给白白耗电。所以低功耗设计时外设时钟管理要格外仔细。7. 进阶技巧让定时器发挥更大价值7.1 用定时器实现软件串口在没有硬件串口的引脚上可以用定时器加GPIO模拟串口通信。原理是用定时器精确控制每一位的持续时间。比如波特率9600每位时间约104微秒定时器每104微秒触发一次中断在中断里根据当前位序号设置TX引脚电平或者采样RX引脚电平。这个技巧在引脚资源紧张时非常有用但要注意软件串口会占用大量CPU时间高速通信时尤其明显。而且多个软件串口同时工作时定时器资源可能不够用。我一般只在低速、单路、非关键的通信场景下用这个方案。7.2 定时器级联扩展位数8位或16位定时器在需要长时间定时时会溢出。除了用软件计数器累加溢出次数外还可以把两个定时器级联——一个定时器的溢出事件作为另一个定时器的时钟输入。这样两个16位定时器级联就能得到32位的计数范围定时时间大大延长。级联配置的关键是主从模式设置主定时器的更新事件输出作为从定时器的时钟输入。具体寄存器配置因芯片而异需要查参考手册。这个技巧在需要长时间精确计时的场合很有用比如数据记录仪的时间戳。7.3 利用定时器触发ADC采样很多MCU支持定时器事件触发ADC转换这样可以实现精确的等间隔采样不受软件调度延迟影响。配置方法是把定时器的更新事件或比较事件映射到ADC的外部触发源然后使能ADC的外部触发模式。我在做音频采样时用了这个方案定时器每125微秒触发一次ADC采样率正好8kHz。这样采出来的波形非常均匀做FFT分析时频谱干净没有因为采样间隔不均导致的杂散。7.4 定时器在低功耗模式下的应用在低功耗应用中定时器可以配置为在睡眠模式下继续运行定时唤醒CPU。比如让定时器每1秒产生一次中断CPU被唤醒后执行必要操作然后继续睡眠。这样平均功耗可以降到微安级别。配置要点选择低功耗时钟源如内部低速振荡器关闭不必要的定时器功能中断唤醒后及时清除标志并重新进入睡眠注意唤醒延迟有些时钟源启动需要时间我在一个电池供电的传感器节点里用了这个方案纽扣电池能撑一年多。关键就是让CPU绝大部分时间都在睡觉定时器负责按时叫醒它干活。8. 我踩过的那些坑与经验总结回头看这些年用定时器的经历有几个教训印象特别深。第一个是时钟树没搞明白。早期用某款芯片时以为定时器时钟就是系统时钟结果配置出来的定时时间总是差一倍。后来查手册才发现那个芯片的APB1和APB2总线时钟不同定时器挂在APB1上而APB1的时钟是系统时钟的二分频。所以配置定时器前一定要确认它的时钟来源和频率这是最基础也最容易出错的一步。第二个是中断优先级冲突。在一个项目里我把系统时基中断和串口中断设成了相同优先级结果串口数据量大时时基中断被延迟导致整个系统的时间基准漂移。后来把时基中断优先级提高问题解决。所以中断优先级不是随便设的要根据各中断的实时性要求仔细分配。第三个是重载值计算错误。有次做PWM输出想要1kHz频率计数时钟1MHz我直接把重载值设成了1000结果实际频率是1MHz/1001≈999Hz。虽然差得不多但在需要精确频率的场合就是问题。记住公式是频率 时钟 / (重载值 1)那个1不能丢。第四个是忘记使能预装载。在运行中修改PWM占空比时如果没有使能预装载输出波形会出现毛刺。这个毛刺在驱动MOS管时可能导致瞬间大电流严重时烧管子。所以任何运行中需要修改的定时器参数都要确认预装载功能已使能。最后一个经验是定时器中断里尽量不做浮点运算。很多低端MCU没有硬件浮点单元浮点运算靠软件模拟耗时可能几百个时钟周期。在1ms中断里做一次浮点乘法可能就占用了大半的ISR时间。我的做法是全部用定点数运算需要浮点时在ISR里只做整数部分小数部分留给主循环处理。这些经验听起来都是小细节但实际项目中往往就是这些小细节决定了系统是稳定运行还是频繁出问题。定时器作为单片机最核心的外设之一值得花时间把它彻底搞透。一旦你真正理解了它的工作原理和配置方法很多之前觉得棘手的问题都会迎刃而解。