ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

51单片机定时器从原理到实战:初值计算、中断配置与排障

51单片机定时器从原理到实战:初值计算、中断配置与排障

从软件延时切换到51单片机定时器,是我当年入门后第一个觉得“原来程序还能这么写”的转折点。那时候做某个小控制板,主循环里塞满了delay,按下按键要等几百毫秒才有反应,换成定时器中断驱动之后,整个程序都清爽了。接下来我想把定时器的内部原理、初值计算、配置代码、中断注意事项和实战排障一起讲个痛快。如果你刚学单片机,或者已经在写裸机程序但一直被时间控制不准困扰,这篇内容值得照着敲一遍。

1. 定时器原理:它到底在“数”什么

1.1 一个机器周期计数器

想理解定时器,先别把它想成一个能直接算毫秒的“秒表”。它本质上是一个加法器:每个机器周期到来一次内部脉冲,计数寄存器自动加1,加到溢出时,TCON中的TF0或TF1被硬件置1。这个溢出信号才是“时间到”的标志。

具体到标准51单片机,一个机器周期等于12个时钟周期。如果你用的是12MHz晶振,每个机器周期恰好是1微秒,计数器每过1微秒加1。从这个角度再去看“定时”,就能解释很多困惑:定时器的间隔不是直接写给寄存器看的毫秒数,而是由“初值+计数脉冲来源+模式位数”共同决定的。

为什么说理解这个很重要?因为很多新手拿着板子,直接用网上复制的TH0和TL0,发现换一块板或改一个晶振后定时完全不对,又不知道怎么调。根子就在于没意识到定时器是数机器周期的,初值必须跟着晶振和机器周期走。这个问题在第二章会反复出现。

1.2 定时和计数只是C/T位的差别

TMOD寄存器里有一个C/T位,平时容易被忽略,但它决定定时器到底“数”什么。置0时,定时器对内部机器周期计数,这就是定时模式;置1时,定时器转而对外部引脚T0或T1上的下降沿计数,变成了计数器。这个功能在做脉冲计数时很有用,比如流水线上数零件、测转速传感器的脉冲个数。

我见过最典型的问题是这样的:某块板子一开始定时正常,后来为了功能复用把引脚接上了一路外部信号,结果定时器数值跳得乱七八糟。查到最后,是初始化代码里把C/T位置成了1,定时器以为要数外部脉冲,内部的机器周期它反而不管了。所以配置定时器前,先看一眼TMOD里的C/T位到底是不是0,能省很多排查时间。

1.3 配置前先记住这组寄存器

配置定时器,真正碰到的寄存器并不算多。TMOD管工作模式,TCON里的TR位负责启动/停止,TF位负责溢出标志,IE里的EA和ET0/ET1负责中断开关,再加上TH0/TL0或TH1/TL1作为计数的初值区。把这几个名字理顺,后面的代码就是往里面填值而已。

其中TMOD是最容易写错的寄存器,因为一个字节同时管理定时器0和定时器1,高4位属于定时器1,低4位属于定时器0。每一位的含义分别是GATE、C/T、M1、M0。如果你直接把整个寄存器赋值覆盖,另一个定时器很可能被悄悄改掉,这种“定时器一会正常工作、一会不正常”的毛病,多数就是TMOD被整字节覆盖造成的。

至于TCON,里面的TR0和TR1相当于阀门,初始化阶段先把阀门关着,配置完TH/TL、开好中断,最后再打开阀门。TF0/TF1是溢出标志,做查询模式时必须在用完后手动清零,但在中断模式下,硬件会自动清掉标志,这一点后面还会提到。

1.4 四种模式怎么选,模式1和2使用频率最高

模式位数特点典型用途
模式013位,TH高8位+TL低5位历史遗留,写法复杂基本不主动用
模式116位,TH+TL全用计数范围最大,溢出后需手动重装通用定时
模式28位,自动重装TL溢出后硬件自动装入TH的值固定短周期、波特率源
模式3拆成两个8位定时器借用定时器1的控制位特殊场景,慎用

实际上项目里大多数时候只在模式1和模式2之间选。要定时几十毫秒、甚至通过多次中断组成秒级任务,模式1最直观;如果只是要一个很短且稳定的周期,比如生成精确波特率,模式2最方便,因为它不用在中断里手动重装初值,误差更小。模式0能不用就不用,除非你要维护旧代码。模式3则更适合那种“定时器不够用”的极端情况,新人不建议把时间花在这里。

2. 定时器初值计算:从晶振频率到TH/TL

2.1 机器周期才是时间基准

定时器的计数脉冲来源是机器周期,所以一切初值计算都从机器周期开始。标准51的机器周期公式是12÷晶振频率,单位是秒。以12MHz为例,12÷12,000,000=0.000001秒,也就是1微秒;用11.0592MHz晶振的话,机器周期约等于1.085微秒,不是一个整数。

于是另一个“坑”出现了。很多教程和例程都默认用12MHz,因为1微秒好算。但如果你学的是带串口通信的项目,开发板为了波特率更准,往往用的是11.0592MHz晶振。照搬12MHz的初值到11.0592MHz板子上,定时时间就差了一点,短周期无所谓,累积到秒级就能看出来。

计算初值的通用公式可以写成:初值 = 2的n次方 - 需要计数的机器周期数。n是计数器位数,模式1取16,模式2取8。机器周期数等于目标时间除以机器周期。只要目标时间和晶振确定,这个公式能应对所有情况。

2.2 50ms初值手把手计算

以最常见的12MHz晶振、模式1、定时50ms为例。机器周期是1微秒,50ms等于50000微秒,需要计数50000次。十六位计数器总状态数是65536,所以初值就是65536-50000=15536。换算成十六进制,15536等于0x3CB0,因此TH0=0x3C,TL0=0xB0。

容易写反的地方是这里:0x3CB0这个十六进制数,高字节0x3C要给TH0,低字节0xB0要给TL0。如果写成TH0=0xB0、TL0=0x3C,计数起点就错得离谱,定时效果自然不对。代码里我习惯先用“(65536 - 计数次数) >> 8”取高字节,再取低字节,这样不容易错。

如果换用11.0592MHz,情况会变成这样:机器周期约1.085微秒,50ms需要的计数次数大约是46080,初值就是65536-46080=19456,也就是0x4C00,TH0=0x4C、TL0=0x00。因为11.0592MHz下机器周期不是整数微秒,所以这种方法有舍入误差,但多数对时序要求不高的场景完全够用。

2.3 模式2自动重装:为什么更适合做波特率源

模式2只有8位,理论上最大计数256次,单次定时很短。它的核心优势在于“自动重装”:TL0加1溢出后,硬件会在下一个计数周期自动把TH0里的值重新拷贝到TL0里,整个过程不需要软件干预。

对比模式1就能看出差别。模式1溢出后,计数器会从0开始继续跑,如果你在中断服务函数里重新赋初值,那从“溢出发生”到“初值重写完成”之间有一段延迟;如果这段延迟长度还不固定,最终周期就会有可见的抖动。模式2不存在这个问题,初值一直放在TH里,溢出后自动续上,所以特别适合波特率发生器。

以9600波特率为例,很多51项目用11.0592MHz晶振、定时器1模式2,把TH1设为0xFD,再配合串口中断就能稳定输出。这里的0xFD不是随便写的,它的依据就是波特率计算公式,本质上还是让定时器在那个频率下数出指定周期。串口的细节这次不展开,但你要记住模式2在“需要稳定短周期”时的价值。

2.4 常见初值速查表与注意事项

把12MHz、模式1下的常用初值列出来会比较方便,我在项目里也经常直接查表:

目标时间需要计数初值(TH / TL)
1ms10000xFC / 0x18
10ms100000xD8 / 0xF0
20ms200000xB1 / 0xE0
50ms500000x3C / 0xB0

这里说的都是12MHz晶振、模式1。使用这个表之前,先确认你的板子晶振确实是12MHz,否则必须按公式重算。另外,模式1的初值如果写成0x0000,对应的不是最短定时,而是最长定时65536个机器周期,也就是65.536ms;这个方向别看反了。

还有一点经常被忽略:如果目标时间超过65535个机器周期,例如12MHz下面要定时1秒,你不能直接把初值设到寄存器里,那不够用。正确做法是让定时器先产生一个较短的中断周期,例如50ms,然后在中断服务函数里用一个变量累加,每计满20次就相当于1秒。这个“分频”思路几乎贯穿所有长定时场景,后面进入系统心跳时会再次用到。

3. 定时器驱动代码模板:从闪烁到系统心跳

3.1 中断方式:20次50ms实现1秒翻转

光讲原理始终有点空,直接上一个最经典的LED闪烁例子。目标还是12MHz晶振,定时器0模式1,每50ms进一次中断,20次之后翻转一次LED,最终看到1Hz闪烁。

#include <reg51.h> sbit LED = P1^0; volatile unsigned char count_50ms = 0; void Timer0_Init(void) { TMOD &= 0xF0; // 清空定时器0的配置位 TMOD |= 0x01; // 定时器0,模式1:16位 TH0 = 0x3C; TL0 = 0xB0; ET0 = 1; // 开启定时器0中断 EA = 1; // 开启总中断 TR0 = 1; // 启动定时器0 } void Timer0_ISR(void) interrupt 1 { TH0 = 0x3C; // 重新装入初值 TL0 = 0xB0; count_50ms++; if (count_50ms >= 20) { count_50ms = 0; LED = !LED; } } void main(void) { Timer0_Init(); while (1) { // 主循环可以处理按键,也可以什么都不做 } }

这个例子的重点不只是LED,而是初始化顺序。我习惯先清TMOD,再设置模式,然后赋初值,开中断,最后启动定时器。TR0放在最后有明确理由:一旦TR0置1,定时器立刻从TH0/TL0的值开始计数,如果你先启动TR0再去改初值,第一个周期就是不确定的,后续中断间隔就会对不齐。如果你用的是查询方式也要注意,就算不依赖中断,TR0和初值的关系也一样,改动顺序可能让你第一次延时不准确。

代码里的interrupt 1是常见51开发环境下定时器0中断的写法,编译器看到这个修饰符,会自动为中断函数生成现场保存与恢复代码,不用你在函数里手动压栈弹栈。如果你换用其它工具链,中断修饰符可能会写成__interrupt(1)之类的形式,但配置寄存器的流程是一样的,先看懂TMOD、TCON、IE这几样,迁移起来不会太难。

3.2 查询方式:适合单次短延时

中断方式适合后台任务,但有时候你需要的只是一个不依赖delay的短延时函数,那就用查询方式,查询方式的核心就是等待TF0溢出标志。

void Delay_T0(unsigned int us) { TMOD &= 0xF0; TMOD |= 0x01; TH0 = (unsigned char)((65536 - us) >> 8); TL0 = (unsigned char)(65536 - us); TR0 = 1; while (!TF0) {} // 等待溢出 TR0 = 0; TF0 = 0; // 手动清标志 }

这个函数传入的是机器周期个数,在12MHz下单位正好是微秒,最大能延时65535微秒,约65ms。使用时要注意,它本质上还是占着CPU在等,不适合放在主循环里频繁调用。它跟中断模式的关系有点像“忙等”和“事件驱动”,一个简单直接,一个灵活不阻塞。

写完查询方式还要强调清TF0这一步:查询模式下TF0不会自动清除,如果不清掉,下一次调用函数时while条件一开始就满足,延时会直接跳过,程序看起来就像“定时器坏了”。这个细节虽然小,但实际调试中非常容易遇到。

3.3 心跳tick:把定时器变成系统时钟

查询和中断其实都还只是“单次定时”的思路,真正改变裸机编程体验的,是把定时器做成一个全局心跳。所谓心跳,就是定时器每隔固定时间产生一次中断,中断里只做一个“计数加1”的动作,主循环通过观察这个计数器来判断时间过去了多久。

下面是一个常见的1ms心跳框架,依然以12MHz、定时器0模式1为例。因为是1ms,需要计数1000次,初值是65536-1000=64536,也就是0xFC18,代码里直接写成表达式会更容易维护:

volatile unsigned int sys_tick = 0; void SysTick_Init(void) { TMOD &= 0xF0; TMOD |= 0x01; TH0 = (unsigned char)((65536 - 1000) >> 8); TL0 = (unsigned char)(65536 - 1000); ET0 = 1; EA = 1; TR0 = 1; } void Timer0_ISR(void) interrupt 1 { TH0 = (unsigned char)((65536 - 1000) >> 8); TL0 = (unsigned char)(65536 - 1000); sys_tick++; }

主循环里,各任务通过检查sys_tick的差值来运行:

unsigned int last_key_time = 0; unsigned int last_disp_time = 0; void main(void) { SysTick_Init(); while (1) { if ((unsigned int)(sys_tick - last_key_time) >= 20) { last_key_time = sys_tick; KeyScan(); } if ((unsigned int)(sys_tick - last_disp_time) >= 4) { last_disp_time = sys_tick; DisplayRefresh(); } } }

这里用了无符号减法做差值判断,即使sys_tick发生了回绕,只要两次读取间隔不超过65535个tick,结果依然正确。这种写法在工程里非常常见,比“等到5000就执行一次”要健壮得多。sys_tick必须用volatile修饰,因为它在中断里被修改,主循环里被读取,编译器如果不把它当可变对象,可能做出错误优化,导致任务一直不触发。

把1ms或者10ms的心跳跑起来之后,按键防抖、数码管扫描、蜂鸣器响铃持续时间,全都可以挂在这一个时钟源上,不需要再写一堆互相嵌套的delay。你的主循环也变得非常干净,每个任务只在满足时间条件时才去执行,而不是反复空转。

3.4 中断和查询怎么取舍

前面两个代码模板放在一起难免会纠结,到底该用中断还是查询。我的判断标准很简单:如果这个定时任务只是临时用一下,比如做一次短延时、产生一个触发信号,查询方式更省事;如果它会被长期调用,或者主循环还有别的事情要干,那就用中断方式。

中断的好处是不阻塞CPU,但代价是每次进中断都有现场保存和恢复的开销,如果中断频率太高,开销就不能忽略。查询的好处是代码直观、完全没有异步问题,缺点是CPU空等。实际项目往往是两种思路混用:系统心跳用中断,偶尔需要精确短延时的时候用查询,两者用不同的定时器或者错开使用,互不影响。

4. 定时器不工作或不准?从现象到根因的排查清单

4.1 进不了中断:先查三个控制位

定时器配置了,但中断就是不触发,这种问题我见过太多次。排查顺序一般就三步:先看总中断EA有没有置1,再看对应的ET0或ET1有没有置1,最后看TR0或TR1有没有置1。三个位缺一个,定时器都跑不起来。

用寄存器视角去查是最快的。把TMOD、TCON、IE这三个寄存器读出来摆在眼前,模式选对没有、初值有没有写进去、开关是否打开,一目了然。有些新手会只写了TMOD和TH/TL,忘了开EA,中断自然进不来;还有的把ET0和EX0搞混,开的是外部中断0而不是定时器中断0,也会百思不得其解。

4.2 中断服务函数里做了太多事,时间怎么会漂

定时器“看着在跑,但周期慢慢就偏了”,最可能是中断服务函数本身太慢。比如在ISR里刷了好几次数码管,还用软件延时慢慢移位,进中断的时间就已经超过定时周期,甚至下一次溢出已经发生,重新赋初值时就会丢失一段计数。

解决思路是缩短ISR,让它只做“置标志”或“计数加1”,真正耗时的显示刷新、数据处理全部放到主循环里完成。如果你确实需要在ISR里输出波形,那也要先把重装初值的指令放在函数最前面,而不是先处理别的任务再回头重装。这个原则听起来简单,但实际写代码时很容易被忽略,尤其是当你觉得“ISR里多写几行也没什么大不了”的时候,误差就在一点点累积。

4.3 GATE位故障:定时器莫名停止

GATE位是一个容易被忽略的坑。当GATE为1时,定时器的运行不直接受TR控制,还要看外部引脚INT0或INT1的电平,只有引脚为高时定时器才继续跑。如果无意中把GATE置1,而板子上这个引脚恰好悬空或拉到低电平,定时器就会表现成“时走时停”甚至完全不走。

正确做法是通用定时场景下把GATE置0。万一你的项目本来就要用门控测量脉宽,那另说,但那种场景下你也应该清楚引脚的物理状态。排查时看到“定时器跑一下就停,重新上电又正常跑一下”这种诡异现象,优先去查TMOD的最高位。

4.4 TMOD赋值的坑:两个定时器互相覆盖

一个项目里同时用定时器0和定时器1很常见,比如定时器0做系统心跳,定时器1做串口波特率。如果两处初始化都直接写TMOD=0x01或者TMOD=0x20,后执行的代码就会把前面的配置覆盖掉,结果要么波特率乱掉,要么主定时周期突然不对。

推荐的写法是位操作,只改自己负责的那一半。定时器0相关初始化先执行TMOD &= 0xF0,把低4位清零,再TMOD |= 0x01;定时器1相关初始化先执行TMOD &= 0x0F,把高4位清零,再TMOD |= 0x20。这样谁后执行都不会破坏对方。

4.5 用逻辑分析仪验证,别肉眼估时间

“看起来大概一秒闪一次”这种直觉,在调试时序时极不可靠。最好在某个空闲引脚上翻转电平,用逻辑分析仪或者示波器抓相邻边沿的时间间隔,看它是不是真的等于你想要的周期。

我在某项目里就踩过这种坑,程序逻辑明显跑偏,用光看LED亮度变化怎么也判断不了差了多少,后来用逻辑分析仪一抓,发现周期多了将近10%,原因是晶振实际频率比标称值略高,加上ISR重装初值占用了几个机器周期。知道了偏差来源,就明白这不是配置错误,而是误差补偿层面的事。

4.6 常见问题对照表

现象可能原因排查方向
定时器完全没有输出EA、ETx、TRx没开全,TMOD模式选错读TMOD、TCON、IE三个寄存器
定时时间变成两倍模式选成13位,或者初值高低字节写反对照模式表,检查TH/TL赋值
周期越来越偏ISR重装太晚,ISR里做太多耗时操作把重装初值放ISR最前面,缩短ISR
串口乱码定时器1被别的代码整字节改掉TMOD改用位操作,初始化统一管理
查询模式直接跳过延时忘了清零TF0while结束后TF0=0
定时器时走时停GATE位置1,外部INT引脚电平不对确认GATE为0,检查引脚电平

5. 定时器还能玩出什么:PWM、测频和状态机

5.1 软件PWM:用定时器让灯呼吸

定时器除了定期翻转,还能用来构造软件PWM。原理很简单:把PWM周期切分成若干段,每次中断让计数变量加1,再把这个计数变量和某个占空比变量比较,决定此刻IO输出高还是低。

下面是一段极其基础的呼吸灯思路,12MHz下定时器0每2ms进入一次中断,中断里维护一个0到99循环的pwm_cnt,主循环不断改变duty的值,LED的亮度就会跟着变化:

unsigned char pwm_cnt = 0; unsigned char duty = 50; void Timer0_ISR(void) interrupt 1 { TH0 = (unsigned char)((65536 - 2000) >> 8); TL0 = (unsigned char)(65536 - 2000); pwm_cnt++; if (pwm_cnt >= 100) pwm_cnt = 0; LED = (pwm_cnt < duty) ? 1 : 0; }

这段代码的精度不算高,但作为入门示范完全足够。把它迁移到真实项目时,记得把duty的值做成可变输入,让占空比可以连续调整,而不是固定在50。软件PWM的优点是灵活、不占额外硬件,缺点是一直占用定时器中断,中断频率太高时会明显抢走CPU资源,所以要保持ISR极短,只做比较和赋值。

5.2 外部计数模式:测频率的思路

定时器加计数模式,合在一起可以做一个简易测频工具。思路是让定时器0先输出一个固定时间的窗口,例如100ms,定时器1切换到计数模式,统计窗口内外部信号的脉冲个数,再用总时间除一下,脉冲频率就出来了。

如果把外部信号接在T1引脚,定时器0用模式1定时100ms,定时器1用C/T=1的计数模式跑,100ms后把TH1和TL1的值组合读取,得到脉冲个数。用脉冲数除以0.1秒,就是信号频率。这个方案精度取决于定时窗口是否稳定,用定时器本身去开窗口,比软件延时可靠得多,也适合作为下一步学习输入捕获的前置概念。

5.3 中断预算和任务设计原则

定时器项目做多了,会积累一个重要经验:中断服务程序要短,主循环要轻。所谓中断预算,就是每次进入ISR的执行时间必须明显小于中断周期,留出余量给其他中断嵌套和现场恢复。如果ISR里真的需要处理耗时的操作,宁可先在中断里把数据拷贝到缓冲区,主循环再来处理。

把定时器当成系统的“节拍器”之后,整个程序会自然走向状态机风格。每个任务不再依赖于自己写死的delay,而是根据tick差值决定什么时候该执行,这样多个任务的时间片就能合理错开。对51这种性能不高的单片机来说,这种设计比追求复杂操作系统可靠得多,也更容易让新手看清代码的执行路径和问题所在。

最后分享一点我在多个项目里的实际体会:定时器不是锦上添花的配置,而是整个裸机系统的“心脏”。只要定时中断跑稳,后面的状态机和任务调度自然清晰,主循环永远是轻的,问题也更容易定位。我自己现在做51相关的小板子,都会先把一个定时器统一配置成1ms或10ms心跳,初值表写在配置函数上面,所有周期任务都用tick差来判断。遇到“定时器不工作”先别急着怀疑芯片,量一下晶振有没有起振,再查TMOD、TCON和IE,最后看中断服务程序里是不是做了什么超时操作,绝大多数问题都出在这些细节里。

RELATED READING

延伸阅读

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