做51单片机开发,定时器是绕不开的一道坎。很多人把流水灯点亮了、按键扫描搞定了,结果一到定时器就卡住:寄存器名字看着眼熟,配置完时间不对,中断进了又出不来,数码管死活不走字。这种状态我在带新人、带课设的时候见过太多回了。
其实定时器没那么玄。它的本质就是一个在后台不停自己加数的计数器,加到头了翻出来告诉你一声。真正难的不是原理,而是把“什么时候数、数到几、数完干什么”这几个问题在寄存器和中断函数里组织清楚。这篇文章我按原理、寄存器、C51代码、典型应用、避坑手册的顺序,把51单片机定时器的原理、应用与编程实现一次性理清楚。不管你是刚开始学51的新人,还是正在赶课程设计、准备做小项目的同学,照着这套思路去写,基本不会再被定时器卡住。
1. 定时器解决的三个核心痛点
1.1 阻塞延时把CPU白白堵死
初学者最早接触的延时方式一般是软件延时,像delay(50000)这种写法。它最大的问题就是阻塞,单片机执行delay的时候,CPU只能在那里空转,其他事情一概不处理。做智能小车的时候,你要持续读取循迹传感器状态,还要控制电机转速,如果主循环里为了按键消抖加了一个50ms的延时,那小车在这50ms里完全无法响应传感器变化,直接就冲出去了。这种体验,做过小车的朋友应该都有过。
定时器解决的就是这个问题。它把“等待”这个动作从CPU手里拿走,你设定好时间之后,CPU继续干别的事,等到时间到了,硬件通过中断通知CPU:“到点了,该干活了”。整个过程中CPU没有一刻是被占用的。这个思路在实时性要求高的场景里非常关键。
1.2 软件轮询给不了“准点”的承诺
就算不用delay,有人会想:那我在主循环里反复查询一个标志位不就行了?问题是主循环每一次运行的时间是波动的。循环里要执行的语句多少、外部按键是否触发、数码管扫描到哪一位,都会影响单次循环耗时,所以你没法精确地告诉程序“500ms之后再处理这件事”。误差可能从几毫秒到几十毫秒不等,时间一长还会累积漂移。
定时器不一样。它由硬件独立运行,每过一个机器周期计数值加1,到溢出时触发中断,这个时间间隔是确定性的。同样是要50ms,它就是50ms,误差只取决于晶振频率和初值重装方式,跟主循环里跑了多少代码没有关系。这一点在需要精确计时的场合,比如电子时钟、交通灯倒计时、PWM周期控制,都是硬性要求。
1.3 硬件级计数能力是纯软件替代不了的
还有一些任务,纯软件根本做不了。比如要给风扇测转速,叶片每转一圈产生一个脉冲,需要统计一分钟内有多少个脉冲。这种信号变化速度很快,主循环去轮询GPIO电平的话,稍微忙一点就漏掉好几个脉冲。定时器工作在计数器模式时,每个下降沿都会让计数寄存器自动加1,CPU完全不用理会,等统计时间到了再读一次寄存器就行。
再比如测量某个外部信号的高电平持续时间。用51定时器的GATE门控功能,可以做到信号为高时自动计数,信号变低时自动停止,CPU只需要等待结束再读取计数值。这类场景靠软件逐条判断指令耗时几乎没法做,但在定时器面前就是配置几个位的事。所以定时器不只是“延时工具”,它是一个具备硬件级计时、计数、捕获能力的外设。
2. 从结构到寄存器:把定时器真正看透
2.1 一个加法计数器,四条关键线路
51单片机内部有T0和T1两个定时器。T0由TH0和TL0两个字节组成16位计数器,T1由TH1和TL1组成。你可以把它理解成一个从0数到65535的秒表,每过一个机器周期就加1。如果当作计数器使用,它的触发源就不是内部机器周期,而是T0引脚(P3.4)或T1引脚(P3.5)上的下降沿。
整个定时器可以拆成四根线来看:时钟来源,决定数得快还是慢;启停控制,决定什么时候开始数、什么时候停;溢出标志,数满之后翻出来的信号;中断开关,决定这个信号会不会让CPU停下当前任务去处理。把这四个问题理顺了,定时器的配置就成功了一大半。
2.2 TMOD:用“AND清零+OR置位”设置工作模式
TMOD是定时器工作方式寄存器,8位,高4位管T1,低4位管T0。实际项目中几乎不会同时修改两个定时器的配置,所以更安全的写法是用位操作,而不是直接给整个寄存器赋值。下面是每位的作用:
| 位 | 名称 | 作用 |
|---|---|---|
| 7 | GATE1 | T1门控位,1表示受INT1引脚和TR1共同控制 |
| 6 | C/T1 | T1定时/计数选择,1为计数模式 |
| 5~4 | M1M0 | T1工作方式选择 |
| 3 | GATE0 | T0门控位,1表示受INT0引脚和TR0共同控制 |
| 2 | C/T0 | T0定时/计数选择,1为计数模式 |
| 1~0 | M1M0 | T0工作方式选择 |
M1M0的四种组合对应四种工作方式:方式0是13位计数器,方式1是16位计数器,方式2是8位自动重装计数器,方式3是把T0拆成两个8位计数器。项目里方式1用得最多,因为它定时范围大,能覆盖到50ms级别;方式2适合高频固定周期的场景,因为初值自动重装,误差小。方式0比较老,能不用就别用;方式3属于特殊用法,一般把T0拆开用时才会碰到。
配置TMOD的典型代码是这样:
TMOD &= 0xF0; // 低4位清零,不影响T1的设置 TMOD |= 0x01; // T0方式1,16位定时器这里&= 0xF0表示把TMOD的低4位全部清零,高4位保持原样;|= 0x01表示低4位的M1=0、M0=1。如果直接用TMOD = 0x01,高4位会被清空,万一T1本来在用,这个操作就会把T1的配置破坏掉。这种细节在多人协作或者调试别人代码的时候特别容易踩坑。
2.3 TCON:启动、停止、溢出标志一次说清
TCON的低4位跟外部中断有关,高4位是定时器控制位:TF1、TR1、TF0、TR0。
TR0是T0的启动开关。TR0 = 1时定时器开始计数,TR0 = 0时暂停。TF0是溢出标志,计数从初值加到溢出,也就是16位计数器从FFFF再加1变成0000时,硬件会自动把TF0置1。如果开了中断,CPU响应中断后TF0会由硬件自动清零;如果用的是查询方式,就必须在软件里手动清零,否则它会一直保持1,再次查询时永远都是“已溢出”。
这里很多人会混淆一点:查询方式下,判断TF0 == 1后,要记得执行TF0 = 0清标志,不然下一次判断结果还是真。中断方式下虽然硬件会自动清零,但为了代码可读性和防止少数特殊情况下的异常标志残留,初始化阶段手动清一次也是好习惯。
2.4 中断是这个系统的灵魂
51单片机的中断入口是固定的,外部中断0、定时器0、外部中断1、定时器1、串口,优先级也基本固定。使用C51编程时,定时器0的中断服务函数要写成:
void Timer0_ISR(void) interrupt 1 { // 中断处理代码 }其中interrupt 1就是定时器0的中断号。定时器1对应interrupt 3,这个数字不能写错,写错了中断进不来或者进错函数,调试起来非常折磨人。要正常使用定时器中断,还要在初始化里开三处开关:总中断EA、对应的定时器中断ET0或ET1、启动位TR0或TR1。少一个都不行。
3. 编程实现:从零手写定时器驱动
3.1 初值计算,先算明白再动手
定时器的计时公式本质很简单:
定时时间 = 计数值 × 机器周期标准51单片机的一个机器周期等于12个时钟周期,所以:
机器周期 = 12 / 晶振频率晶振频率12MHz时,机器周期是1us。T0工作在方式1时是16位计数器,最多可以计65536个脉冲。想定时50ms,也就是50000us,计数值就是50000,初值等于:
初值 = 65536 - 50000 = 15536转成十六进制是0x3CB0,所以TH0 = 0x3C,TL0 = 0xB0。
不同晶振下的计算思路完全一样,只是机器周期变化。举例来说,如果用11.0592MHz晶振,机器周期约1.085us,定时10ms需要计9216次,初值是65536 - 9216 = 56320,也就是0xDC00。但要注意,这里9216是个整数,所以能算准;如果目标是1ms,需要计921.6次,不是整数,就只能用近似值,定时精度就会打折扣。这也是为什么很多对定时精度有要求的项目会优先选12MHz晶振,而对串口通信有要求的项目更愿意选11.0592MHz,因为11.0592MHz能分频出精确的9600等标准波特率。选哪种晶振,本质上是在“精确定时”和“标准串口波特率”之间做权衡。
方式2的初值计算更简单。它是8位计数器,最大计256次,所以:
初值 = 256 - (定时时间 / 机器周期)比如12MHz晶振下想得到250us的定时周期,250us对应250个机器周期,初值就是256 - 250 = 6,TH0和TL0都设成6,溢出后自动重装,不需要在中断里手动恢复初值。
3.2 一份可以直接抄的初始化代码
这里给出T0方式1定时50ms的完整初始化代码,基准是12MHz晶振:
#include <reg52.h> void Timer0_Init(void) { TMOD &= 0xF0; // 清空T0控制位,保留T1配置 TMOD |= 0x01; // T0工作方式1:16位定时器 TH0 = 0x3C; // 初值高8位,50ms TL0 = 0xB0; // 初值低8位 TF0 = 0; // 清溢出标志 ET0 = 1; // 开定时器0中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器0 } void Timer0_ISR(void) interrupt 1 { TH0 = 0x3C; // 重新装载高8位 TL0 = 0xB0; // 重新装载低8位 // 这里放你希望每隔50ms执行的代码 }几个细节值得注意。第一,初始化顺序上,先把工作模式、初值、标志位都处理好,最后再开EA和TR0,避免定时器已经开始跑但中断还没准备好的情况。第二,方式1不是自动重装,中断里必须重新给TH0和TL0赋值。赋值顺序习惯上是先高后低,虽然这里不会产生致命问题,但在更严格的场景下,先装高字节再装低字节,计数器的跳变会相对更平滑。第三,初始化函数最好单独写,不要堆在main函数开头一大片,后面维护起来会清爽很多。
3.3 中断服务函数里的讲究
中断服务函数有三个铁律:不能有返回值,不能有参数,不能在里面做太多事。
很多新手喜欢在中断里写delay、写数码管扫描、甚至写一整段按键处理逻辑,这会让中断执行时间超出定时周期,导致整个实时性崩掉。想象一下,一个50ms的中断,中断里却要执行80ms的事情,那下一次中断到来时,上一次还没处理完,程序的节奏就全乱了。
推荐的做法是“中断只打点,主循环做处理”。中断服务函数里只置一个标志位、累加一个计数器或者翻转一下IO口,真正复杂的业务逻辑放到主循环里根据标志位去执行。这样定时器的时基是准的,主循环负责的事情也清晰。
3.4 一个定时器拆出多个时间片
实际项目里经常需要好几个周期性的任务,比如按键扫描10ms一次,数码管刷新5ms一次,状态上报500ms一次。如果每个都用独立定时器,51的那点资源根本不够分。更实用的办法是用一个硬件定时器做基准,比如50ms中断一次,然后在中断里累加软件计数器,派生出多个不同的时间片:
volatile unsigned int t10ms; volatile unsigned int t100ms; volatile unsigned int t1s; void Timer0_ISR(void) interrupt 1 { static unsigned char cnt20; TH0 = 0x3C; TL0 = 0xB0; cnt20++; if(cnt20 >= 20) // 20个50ms就是1s { cnt20 = 0; t1s++; if(t1s >= 1000) t1s = 0; } if(++t10ms >= 100) t10ms = 0; if(++t100ms >= 100) t100ms = 0; }这里t10ms表示每50ms中断一次,每次都在自增,外部主循环只需要判断这些计数变量达到某个阈值就去执行对应任务。这种软件分频思路,在嵌入式系统的任务调度里非常经典,也完全不依赖具体平台。理解了这个,以后再碰RTOS里的时间片概念,会觉得特别眼熟。
4. 典型应用实战:定时器不是只能“延时”
4.1 电子时钟:秒、分、时怎么累加
用定时器做电子时钟,核心就是软件秒计数器。在50ms中断里累计20次就是1秒,再累加秒、分、时。代码如下:
volatile unsigned char sec_cnt = 0; volatile unsigned char sec = 0; volatile unsigned char min = 0; volatile unsigned char hour = 0; void Timer0_ISR(void) interrupt 1 { TH0 = 0x3C; TL0 = 0xB0; sec_cnt++; if(sec_cnt >= 20) { sec_cnt = 0; sec++; if(sec >= 60) { sec = 0; min++; } if(min >= 60) { min = 0; hour++; } if(hour >= 24) { hour = 0; } } }主循环里只负责把hour、min、sec显示到数码管或LCD上。这里有一个容易忽略的地方:中断和主循环共享这几个全局变量,显示之前最好短暂关闭中断,把数据一次性拷贝到显示缓冲区再开中断,否则可能出现主循环读到一半,中断把秒从59改成00的情况,导致显示出来的时间有一瞬间是错乱的。时间精度方面,由于中断里重装初值本身会消耗几条指令,累计下来每天会有一定偏差,想做得准可以考虑使用晶振校准或误差修正。
4.2 PWM输出:呼吸灯、调速与WS2811的底层思路
PWM就是固定频率下改变高电平占空比。LED的亮度、电机的速度、蜂鸣器的音调,本质都是靠PWM控制的。用51定时器做PWM的思路不复杂:定时中断按固定周期触发,每次中断里累加一个计数器,计数器值小于占空比时输出高,否则输出低。示例代码如下:
sbit LED = P1^0; volatile unsigned char pwm_cnt = 0; volatile unsigned char pwm_duty = 10; void Timer0_ISR(void) interrupt 1 { TH0 = 0xFF; // 约250us中断一次 TL0 = 0xE7; pwm_cnt++; if(pwm_cnt < pwm_duty) LED = 1; else LED = 0; } void main() { Timer0_Init(); while(1) { // 简单呼吸效果:占空比从小到大,再重置 pwm_duty++; if(pwm_duty >= 249) pwm_duty = 1; delay_ms(2); // 注意这里不能阻塞太久,只作示意 } }这个示例里,pwm_cnt累加到溢出后自动清零,周期固定,占空比跟着pwm_duty变,LED的亮度就会跟着变化。真实产品里驱动WS2811这类可寻址灯带,需要在非常短的时间窗口内精确翻转引脚,纯中断也能做,但时序要求会严格很多,更推荐用带硬件PWM的增强型51或者直接用STM32。不过PWM的控制思想是通用的,学会了51定时器实现PWM,再看STM32的定时器PWM输出模式,就是一通百通的事。
4.3 脉冲计数:用外部脉冲测转速
把定时器当作计数器用,只需要设置TMOD里对应定时器的C/T位,让计数脉冲来自外部引脚。比如T0计数,脉冲接P3.4,方式1,初值为0,这样每来一个下降沿,TL0/TH0组成的16位数就会加1。读取计数之前最好先暂停计数,把数据稳定读出来,再重新启动,不然读数的过程中计数还在变化,高8位和低8位可能不是同一个瞬间的值。
结合一个固定的时间窗口,就能算频率。比如用T1做1秒定时,T0做脉冲计数,每一秒读一次T0的计数值,这个值就是“每秒脉冲数”,也就是频率。实际项目里可以做风扇转速表、电机测速、流量计的脉冲累计,原理完全一样。
4.4 输入捕获的“平替”:用GATE位测高电平脉宽
51单片机没有硬件输入捕获,但GATE位能实现类似的效果。GATE=1时,定时器必须在TR0=1并且INT0引脚为高电平时才开始计数,INT0变低自动停止。所以可以用查询方式测量一个脉冲的高电平宽度:
sbit INT0 = P3^2; void Measure_Pulse_Width(void) { unsigned int value; TMOD &= 0xF0; TMOD |= 0x09; // T0方式1,定时模式,GATE=1 TH0 = 0; TL0 = 0; while(INT0 == 1); // 等待引脚变低 TR0 = 1; // 启动控制,实际计数由GATE控制 while(INT0 == 0); // 等待高电平到来,计数开始 while(INT0 == 1); // 等待高电平结束,计数停止 TR0 = 0; value = TH0 * 256 + TL0; // 高电平时间 = value * 机器周期 }这段代码的逻辑是利用GATE把一个外部脉冲的高电平时长转换成计数值。虽然受查询方式本身代码执行时间的限制,分辨率不算特别高,但理解这个原理之后,再看STM32定时器的输入捕获模式,就会发现核心思想一模一样:外部信号边沿到来时,硬件自动锁存当前计数器的值。所以这个“平替”例子不只是解决一个问题,它帮你打通了从51到32的底层概念。
4.5 交通灯课设:定时器+状态机的组合拳
这些年很多人的51课程设计选题都是交通灯,需求往往包含:按键设置倒计时时间、数码管倒计时显示、黄灯闪烁5次。用软件延时去做闪烁和倒计时,程序会写得非常痛苦,状态一多就乱。实际上用一个定时器做10ms时基,配合状态机,能把这些需求梳理得很清晰。
比如定义四个状态:红灯、绿灯、黄灯闪烁、设置倒计时。每次10ms中断累加一个counter,主循环根据当前状态去判断counter到了什么阈值就切换到下一个状态。黄灯闪烁其实就是黄灯状态下每500ms翻转一次IO口,闪烁5次就是翻转10次后切换状态。按键设置时间则是在设置状态下扫描按键,修改对应状态的时间变量。这样整个程序的逻辑是线性推进的,调试起来也方便,想加一个紧急模式只要新增一个状态就行。
5. 常见问题排查与避坑手册
5.1 定时器不工作,先查这四件事
我自己排查定时器问题时,基本按这个顺序来:一看EA,二看ET,三看TR,四看TMOD。这四个开关少开任何一个,定时器都不会正常工作。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 中断完全不触发 | EA没置1 | 确认EA = 1;已执行 |
| 定时器不计数 | TR0/TR1没置1 | 检查初始化末尾是否写了TR0 = 1; |
| 时间完全不对 | TMOD被其他代码覆盖 | 搜索代码里是否直接给TMOD整体赋值 |
| 中断触发了一次就再也不进 | 方式1没重装初值 | 确认中断里有TH/TL赋值 |
| 程序反复复位 | 中断函数里代码执行过长或死循环 | 屏蔽中断函数内容再试 |
5.2 时间不对,误差到底出在哪
定时时间不准,常见因素有好几个。晶振本身的频率精度是基础,标称12MHz并不代表实际就是精确的12.000000MHz;机器周期计算方式不同也有影响,比如STC等增强型51可以配置为1T模式,那机器周期就不再等于12个时钟周期,计算初值时必须先确认当前是什么模式;另外,方式1的中断重装本身有指令开销,重装前执行的那几条指令也会占用几个机器周期,所以每次中断的实际间隔会比理论值多一点点。如果要求高,优选方式2自动重装,或者对初值做一点补偿修正。
5.3 中断里千万别做“大事”
中断服务函数本质上是一个“紧急处理窗口”,窗口越短越安全。在里面调用延时函数、跑循环、做长时间的数值计算,都会拖垮主循环甚至造成中断嵌套丢失。我曾经遇到一个案例,中断里做了数码管动态扫描的完整逻辑,外部中断一进来,主循环根本执行不到,整个程序看起来就像死机一样。后来把数码管扫描挪出中断,只留一个刷新标志,问题立刻消失。记住这个原则:中断只负责“点一下火”,真正烧水的事情交给主循环。
5.4 共享变量和数据“撕咬”问题
定时器中断和主循环共享一个变量时,必须考虑数据一致性问题。如果变量只在中断里写、主循环里读,那主循环读的时候可能会读到中间状态,尤其是多字节变量。我的习惯是把共享变量声明成volatile,防止编译器优化掉对它的反复访问;读取多字节变量时先关中断、取完值再开中断。虽然51是8位机,对单字节变量的读写在大多数情况下是原子性的,但多字节变量一定要处理。
5.5 定时器不够用,怎么办
标准51只有T0、T1两个定时器,有些任务多起来确实不够用。我的第一个思路是用软件定时器,也就是前面说的时间片方式,一个硬件定时器当基准,派生出任意多个软件周期。第二个思路是引入实时时钟芯片,比如RTC类芯片,专门管理时间,把定时器资源省出来做PWM和计数。第三个思路是换主控,比如STC15系列的增强型51自带多路定时器和硬件PWM,或者直接上带多个通用定时器的STM32。硬件资源不够的时候,不要硬怼代码,换平台往往更划算。
6. 一些长期养成的工程习惯
6.1 晶振选型:通信和定时只能选一个重点
12MHz晶振适合精确定时,11.0592MHz晶振适合串口通信。实际项目里如果既要通信又要定时,我通常会选11.0592MHz,因为串口波特率误差会导致通信数据乱码,这比定时器的毫秒级误差更难接受。定时误差可以通过软件校准或自动重装方式尽量缩小,通信乱码就得换晶振才能根治了,两害相权取其轻。
6.2 初值别写魔法数字
代码里直接写TH0 = 0x3C虽然没错,但三个月后你自己都可能看不懂这个0x3C是什么意思。建议用宏定义或者可读的变量:
#define FOSC 12000000UL #define T0_RELOAD (65536 - FOSC / 12 / 1000) // 1ms定时 TH0 = (unsigned char)(T0_RELOAD >> 8); TL0 = (unsigned char)(T0_RELOAD & 0xFF);这样以后想改定时周期,只需要改一处宏定义。这类“可读性投资”,在调试和后期维护的时候回报率非常高。
6.3 项目里尽量统一一个时基
我见过有些同学一个功能用一个定时器,PWM一个、键盘扫描一个、显示刷新一个,最后配置乱成一团。好的做法是全项目尽量只用一个硬件定时器做统一时基,其余任务全部通过软件分频派生出来。这样的程序结构只有一个中断入口,状态清晰,排错简单。定时器资源本身是宝贵的硬件资源,不要拿来当多个独立时钟用。
6.4 学完51再看STM32定时器,会轻松很多
51的定时器虽然简单,但已经把定时器最核心的概念都涵盖了:计数时钟、自动重装、溢出中断、计数器模式、门控捕获思想。这些概念到了STM32里只是换了个名字:预分频器PSC、自动重装值ARR、输出比较CCR、输入捕获通道。很多买STM32开发板的朋友抱怨“定时器太复杂”,其实是因为51这一层没学扎实。先把51这套吃透,面对32的定时器时,大多数内容都能对上号,剩下的只是寄存器更多、功能更丰富而已。
我个人的体会是,定时器值得花一个完整的周末把它折腾明白。不要只看书,把电子时钟、呼吸灯、脉宽测量这几个经典例程逐个在开发板上跑一遍,踩几个坑、调几行代码之后,你对它的理解会比任何教程都深刻。后面再用到定时器的时候,基本就是手到擒来的事了。