ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

51单片机定时器完全攻略:寄存器配置、初值计算与中断实战

51单片机定时器完全攻略:寄存器配置、初值计算与中断实战

1. 定时器是51单片机的“心脏节拍器”

很多人学51单片机到定时器这一章就开始犯怵,觉得又是寄存器又是中断向量,抽象得很。我当年第一次接触定时器的时候也是满脑子问号:计数器到底是怎么“自己走”的?为什么装上初值就能精准定时?中断又是怎么“突然跳出来”打断主程序的?

把这些概念打散之后你会发现,定时器的核心逻辑其实简单得惊人——它就是单片机内部一个不断往上累加的计数器,加满之后产生一个标志信号,你只需要在它加满之前给它预设好起点,剩下的全部交给硬件自动完成。

这次要分享的是51单片机定时器的完整玩法,从寄存器配置到代码实现,从初值计算到常见坑点,配合实际可跑的示例程序,帮你一口气把这块硬骨头啃下来。适合正在学51单片机、被定时器搞得头大的同学,也适合想复习定时器细节的老朋友。

说实话,定时器这一章学透了,后面做电子时钟、PWM调速、按键消抖、超声波测距,全都是顺着这条路往下走的,重要性不比中断低。

2. 定时器到底“定时”在哪儿

2.1 定时器的工作本质是数脉冲

51单片机里的定时器,本质就是一个二进制加法计数器。给它一个脉冲,它就加一,加到满(溢出)之后清零并往标志位上写1。这个流程完全由硬件完成,不占用CPU时间去一条一条数数。

所以“定时”这件事的本质,就是“数固定频率的脉冲”。脉冲来源有两种:一种是单片机内部的机器周期时钟,这时候它叫定时器;另一种是外部引脚输入的脉冲,这时候它叫计数器。同一个硬件,两种角色,靠配置寄存器来切换。

常用的51单片机型号里,定时器数量不太一样。标准8051是T0和T1两个16位定时器,增强型型号会多出T2甚至更多。最常用、最经典的还是T0和T1,这一篇就围绕这两个展开。

2.2 定时器相关的四个关键寄存器

定时器操作就围绕着四个寄存器转:TMOD、TCON、THx、TLx。逐个拆开看,一点都不吓人。

TMOD是工作模式寄存器,高四位管T1,低四位管T0。每一位的含义是:GATE(门控位)、C/T(定时计数选择位)、M1M0(模式选择位)。GATE位比较特殊,它决定定时器是不是受外部引脚INT0/INT1控制。正常情况下我们让GATE = 0,表示不受外部引脚控制,写软件启动指令就开跑;GATE = 1的时候,还得INT引脚同时为高电平才开始计数,这个用途在测量外部脉冲宽度时会用到,平时用不到。

C/T位是好理解的:C/T = 0选内部机器周期,定时模式;C/T = 1选外部引脚脉冲,计数模式。

M1M0组合对应四种工作方式,见下表:

M1M0工作方式说明
00方式013位定时器,兼容老设备用的
01方式116位定时器,最大计数值65536
10方式28位自动重装,用完自动补初值
11方式3两个8位独立定时器,特殊用法

TCON是控制寄存器,里面有两个最核心的位:TRx是启动位,置1开始计数,清零停止;TFx是溢出标志位,计数溢出时硬件自动置1,如果开了中断就会进入中断服务函数,如果用查询方式就得手动清零。

THx和TLx是计数初值寄存器。THx存高8位,TLx存低8位。16位定时器模式下,这两个拼起来就是完整的16位初值。

3. 初值计算是定时器最需要动脑的一步

3.1 机器周期和晶振的关系

要算定时时间,第一步是搞清机器周期。51单片机一个机器周期等于12个时钟周期。如果晶振频率是12MHz,那机器周期就是12 ÷ 12MHz = 1微秒。如果晶振是11.0592MHz,机器周期就是12 ÷ 11.0592MHz ≈ 1.085微秒。

为什么要区分这两种晶振呢?因为很多开发板上用的是12MHz,方便计算;而用到串口通信的时候,常用11.0592MHz来保证波特率整除精准。这两种晶振下的定时器初值计算方式一模一样,就是具体数值有区别。

定时器在16位模式下,从初值开始加,加到65536溢出经历65536 - 初值,也就是65536 - 初值个机器周期。所以定时时间 = (65536 - 初值) × 机器周期。

3.2 由定时时间反推初值

公式反过来用就是初值 = 65536 - 定时时间 ÷ 机器周期。

拿12MHz晶振、机器周期1微秒来说,如果想让定时器溢出周期是50毫秒,那么定时时间除以机器周期等于50000,初值就是65536 - 50000 = 15536,换算成十六进制是0x3CB0。于是TH0 = 0x3C,TL0 = 0xB0。

这里有一个非常关键的点:定时器溢出周期不等于你想要的最终定时周期。因为定时器是溢出一次就中断一次,你需要靠软件在中断服务程序里做分频。50ms中断一次,想要1秒就要在中断里数20次;想要10ms中断一次,数100次才是1秒。

3.3 12MHz和11.0592MHz下的常用初值对照

我大概整理过几个常用定时时长的初值,直接抄作业:

晶振频率机器周期目标定时重装初值(16位)THxTLx
12MHz1μs1ms64536 = 0xFC180xFC0x18
12MHz1μs10ms55536 = 0xD8F00xD80xF0
12MHz1μs50ms15536 = 0x3CB00x3C0xB0
11.0592MHz约1.085μs10ms56320 ≈ 0xDC000xDC0x00

11.0592MHz那个10ms的初值我用的是近似数,实际会有微小误差。如果要求非常精确,需要把小数部分也折算进去调整初值。日常做实验、控制继电器这些,完全够用。

注意:任何定时器都存在不可避免的误差来源——一个是初值取整带来的量化误差,另一个是中段响应本身需要时间,中断函数被执行时CPU还要完成现场保护和恢复,这部分时间也会叠加进实际定时周期。对精度要求不高的场合不用管,做时钟之类的还是要考虑。

4. 定时器完整代码实现与寄存器配置

4.1 最基础的定时器初始化模板

定时器的初始化其实就四步:配TMOD选模式、装THx/TLx初值、开中断、启动定时器。下面是一段完整的定时器0、方式1、50ms中断的初始化代码:

#include <reg51.h> void Timer0_Init(void) { TMOD &= 0xF0; // 清除T0的控制位,不影响T1的配置 TMOD |= 0x01; // 设置T0为方式1(16位定时器),GATE=0 TH0 = 0x3C; // 装入初值高8位:50ms @12MHz TL0 = 0xB0; // 装入初值低8位 ET0 = 1; // 打开T0中断开关 EA = 1; // 打开总中断开关 TR0 = 1; // 启动T0 }

注意TMOD那一行的写法。很多新手直接写TMOD = 0x01,这在单独使用T0、不影响T1的时候没问题,但如果你TMOD的高四位还配置了T1的工作方式,直接赋值就会把T1的配置冲掉。写TMOD &= 0xF0再TMOD |= 0x01,是“只改自己、不动别人”的做法,养成这个习惯,后面同时用两个定时器的时候就不会莫名其妙出bug。

4.2 中断服务函数怎么写得对

51单片机的中断服务函数有固定写法,C51编译器用interrupt关键字标记,后面跟的编号是中断号,不能随便填。定时器0的中断号是1,定时器1的中断号是3。

void Timer0_ISR(void) interrupt 1 { static unsigned int count = 0; TH0 = 0x3C; TL0 = 0xB0; count++; if(count >= 20) { count = 0; P1 = ~P1; // 每1秒翻转一次P1口 } }

中断函数里必须重装初值,这是方式1和方式0跟方式2最大的区别。方式2是8位自动重装,硬件会自动把初值从初值寄存器装回去,不需要软件干预,精度更高、响应更快。但方式2只能计255个脉冲,定时短,适合PWM等需要高频率稳定中断的场景。方式1虽然要手动重装,但16位范围大,做长定时方便。

4.3 一段完整的LED闪烁Demo

把前面的初始化函数和中断函数组合起来,就是完整的1秒翻转LED的工程。主函数只需要调用初始化,然后进入死循环等待中断就好。

void main() { Timer0_Init(); while(1); }

这段程序跑起来的效果就是LED以1秒为周期闪烁,如果你手边有开发板,直接烧进去就能看到效果。这个Demo虽然简单,但把“定时器中断 + 软件分频”的经典套路完整走了一遍,后面几乎所有定时器应用都是这个框架的扩展。

5. 实战案例:组合按键扫描和PWM呼吸灯

5.1 用定时器驱动按键消抖和长按检测

按键消抖常见做法是在主循环里检测到低电平后延时10ms再确认,这个方法的问题在于阻塞了CPU,延时期间什么都干不了。如果用定时器做10ms周期的节拍,在主循环里检查节拍标志,就能不阻塞地完成消抖。

我在一个具体项目里这样写过:定时器0做1ms中断,软件里维护一个计数器,每10ms置一次KeyScanFlag。主循环检测到KeyScanFlag为1时,就执行一次按键扫描函数,扫描函数里做两次采样确认,两次之间刚好间隔一个周期,消抖效果非常好。长按检测也可以做,维护几个计数值,按键连续按下超过500ms就判为长按,这些都靠定时器节拍实现,主循环依旧轻巧。

这个思路的核心是“用一个周期性中断作为全局心跳”,所有需要时间的逻辑都可以挂在这个心跳上。做电子钟、做状态机控制、做按键多层功能,全部适用。我后面做矩阵键盘复杂按键逻辑的时候,还是用这套心跳方案,一个都没改。

5.2 用定时器输出PWM做呼吸灯

PWM本质上也是定时器的活儿。一个周期内高电平占的比例叫占空比,通过定时器精确控制占空比的变化就能实现LED从暗到亮再变暗的呼吸效果。

另一种更常用的方式是用定时器中断维护一个软PWM:把周期切成100份,每1ms中断一次,用变量控制前N次输出高电平、后100 - N次输出低电平,N从0渐变到100再回到0,就是呼吸灯了。

void Timer0_ISR(void) interrupt 1 { static unsigned int pwm_count = 0; static unsigned char duty = 0; static bit increasing = 1; TH0 = 0xFC; TL0 = 0x18; // 1ms @12MHz pwm_count++; if(pwm_count >= 100) { pwm_count = 0; if(increasing) { duty++; if(duty >= 100) increasing = 0; } else { duty--; if(duty <= 1) increasing = 1; } } if(pwm_count < duty) LED_PIN = 1; // 前duty个节拍输出高 else LED_PIN = 0; // 后续节拍输出低 }

这个40行不到的代码就能实现呼吸灯,而且不阻塞主循环。呼吸效果好不好看,关键在占空比的步进值和时间片长度,步进值决定了亮度变化的细腻程度,时间片太粗或者步进太大就会出现明显的“跳变感”。我个人习惯用100级、每级1ms,一整个周期刚好100ms,呼吸循环频率合适。

6. 定时器常见翻车现场与排查思路

6.1 进不了中断,先从头顺一遍

这是定时器应用里面出现频率最高的问题。排查顺序其实有固定套路:

  1. EA总开关有没有开?这个最基础也最容易忘。
  2. ETx分中断开关有没有开?开了总开关忘了分支开关也是白搭。
  3. TRx有没有置1?没启动定时器,它压根不会数数。
  4. 初值有没有怼进去?初值是0,定时器一启动就会溢出,看起来像是不停进中断,实际上是被初值坑了。
  5. 中断服务函数名和interrupt编号对不对?interrupt 1是T0,interrupt 3是T1,写反了就不执行。

我见过有个同学查了半天中断进不去,最后发现是中断服务函数里加了多余的形参,编译器连报错都不报但就是不进中断。这种坑属于编译器层面的,排查起来最恼火。

6.2 初始化代码被反复执行

定时器初始化放进主循环里是新手常见问题。理论上循环每次执行都会重装初值,但关键是TR0每次都被置1,定时器永远从头开始数,最后表现出来的就是定时时间异常长或者干脆不动作。初始化代码必须只执行一次,放在主函数最开始,或者用某种初始化标志来保护。

6.3 方式2自动重装与中断标志的细节

方式2模式下,重装是自动的,所以中断函数里面不需要再写TH0、TL0赋值。如果还像方式1那样手动重装一遍,不会报错,但属于多此一举。另一个容易搞混的是TFx溢出标志位,在中断模式下硬件会自动清零,查询模式下必须软件清零。这个区别在中断和查询两种模式下特别容易混淆。

6.4 定时器初值更新的时机问题

有时候我们需要在程序运行中改变定时器溢出周期,比如一会儿10ms一会儿50ms。这里有个坑:重装初值如果发生在中断函数里,那下一次溢出才生效;如果发生在主循环里,有可能正好撞上定时器计数到一半,导致本次周期极短或极长。稳妥做法是修改定时器配置时先TRx = 0停表,改完初值再TRx = 1启动,保证配置生效干净利落。

6.5 寄存器位操作技巧

配置TMOD的时候建议只动需要的位。举一个实际例子:

TMOD &= 0x0F; // 把高四位清零,低四位保留(T0不受影响) TMOD |= 0x10; // T1设为方式1

这个写法保证一次只配置T1,完全不碰T0。如果你直接写TMOD = 0x10,看起来没问题,但你忘了T0之前是什么配置,直接就把T0覆盖了。这种“动谁就只动谁”的位操作思路不光适用于定时器寄存器,IO口配置、中断使能标志,全部通用。

7. 定时器深水区:周期测量和频率测量

定时器还能直接测外部信号的频率或者周期,这个思路很实用也很有趣。

周期测量的思路是:用T0定时,比如配置成16位模式去数内部机器周期,同时用外部INT引脚作为门控信号,在信号的一个周期内启动和停止定时器。GATE位置1之后,外部引脚为高时计数、为低时停止,天然就实现了“只在高电平期间计数”。测出高电平持续了多少个机器周期,再乘以机器周期,就是高电平的持续时间。

频率测量的思路反过来:让定时器工作在计数模式,C/T位置1,外部脉冲接到T0引脚(P3.4)上,然后配合另一个定时器作为标准时间闸门,比如T1定时100ms,在100ms内数T0计了多少个外部脉冲,乘以10就是每秒的脉冲数,也就是频率值。

这些玩法已经非常接近“数字频率计”的原理了。我自己做过一个小项目,就是用这种方式测风扇的转速,传感器输出脉冲,单片机数脉冲算转速,整套系统逻辑非常清晰。定时器从基础到进阶,全部串起来之后,你会发现在51单片机这个平台上,定时器是真的强大。

8. 定时器中断代码的风格建议

最后分享几个我写定时器相关的代码时长期坚持的小习惯。

中断函数里最忌讳做耗时的事,比如串口打印、大数组复制、复杂浮点运算。中断函数要求快速进出,原则是“能放主循环的绝不放中断里”,中断里最多做置标志、累加计数值、刷新PWM占空比之类的轻量操作。这个原则我踩过坑——早期做音乐播放器的时候在中断里放了个延时,结果整个系统卡死,排查了半天才反应过来是中断函数耗时太长,抢占了主循环的执行时间。

中断函数和主循环共享的变量要用volatile修饰,防止编译器优化掉。比如在主循环里检查的中断标志变量,不加volatile有时候编译出来完全不更新,这个坑是编译器优化导致的,特别隐蔽。

命名规范方面,中断服务函数名虽然有编译器默认名字可以用,但建议自己按功能命名,并且用注释标明中断源。我自己的习惯是Timer0_ISR、Timer1_ISR这种。另外初始化函数、变量名、注释要保持清晰,这种代码过两个月自己回来看也不会陌生。

定时器用熟了以后你会发现,很多看起来花哨的功能其实都是定时器在各种应用场景下的组合。学完这一章,后面的PWM、串口定时配合、多任务时间片调度都会顺很多。做几个小实验感受一下,比看十篇文章都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进