1. 为什么每个单片机项目最后都会撞上定时器
刚接触单片机的朋友,十有八九是从点亮一颗LED、按一下按键、串口打印一句“hello”开始的。这些实验跑通之后,你会觉得自己已经入门了。但接下来只要你想做点“有时间感”的东西——比如让LED每隔500毫秒闪一次、按键要消抖、串口要按固定波特率收发、蜂鸣器要发出某个音调、舵机要转到指定角度——你就会发现,绕来绕去都绕不开同一个外设:定时器/计数器。
我见过太多初学者的代码里,延时全靠delay()死等,主循环里塞满了for循环空转。这种写法在小实验里能跑,一旦系统里有两个以上需要“同时”处理的任务,立刻就崩了:按键响应变慢、数码管闪烁、串口丢数据。问题的根子就在于——CPU被延时函数绑死了,没有空闲去干别的事。而定时器存在的意义,恰恰是把CPU从“数数”这种机械劳动里解放出来。
这篇笔记我打算用最直白的方式,把定时器/计数器这个东西讲清楚。它是什么、内部怎么工作、四个工作模式各自适合什么场景、初值到底怎么算、代码怎么写、踩过哪些坑。内容基于51系列单片机(这也是绝大多数人入门的第一颗芯片)展开,但里面的思路对STM32、AVR、PIC这些平台同样通用,只是寄存器名字换了而已。不管你是刚学完流水灯的新手,还是已经能写点小项目但总被时序问题折磨的老哥,这篇应该都能帮你把这块知识补扎实。
2. 定时器和计数器到底是不是一个东西
2.1 从“数脉冲”这个本质说起
很多人第一次看到“定时器/计数器”这个并列的叫法会犯迷糊:到底是定时器还是计数器?其实它俩在硬件上是同一个电路,区别只在于数的是什么脉冲。
- 当它数的是单片机内部机器周期产生的脉冲时,因为机器周期的时间是固定的(比如12MHz晶振下,一个机器周期正好是1微秒),所以数多少个脉冲就等于过了多少固定时间——这就是定时器**功能。
- 当它数的是外部引脚(比如T0对应P3.4,T1对应P3.5)上送进来的脉冲时,每来一个外部信号就加一,数的是外部事件发生的次数——这就是计数器功能。
所以你可以这么理解:定时器是“数自己心跳”,计数器是“数别人敲门”。硬件是同一套,靠一个控制位(C/T位)来切换数谁的脉冲。这个设计非常巧妙,省了硬件资源,也让使用者只需要理解一套寄存器。
2.2 定时功能:给CPU装一个闹钟
定时器的核心是一个加1计数器。你给它一个初始值,它就从这个值开始,每个机器周期加1,一直加到溢出(对于16位定时器就是加到65536,也就是0x10000)。溢出那一刻,硬件会自动把溢出标志位TF置1,如果开了中断,CPU就会跳去执行中断服务函数。
这里有个关键点必须想明白:溢出不是结束,而是“时间到了”的信号。你想要的定时时间,本质上就是“从初值加到溢出”所需要的机器周期数乘以每个机器周期的时间。初值越大,离溢出越近,定时越短;初值越小,要数的数越多,定时越长。这个反直觉的关系,是初学者算初值最容易搞错的地方,后面我会专门用一节来讲。
2.3 计数功能:外部来了多少个信号
切换到计数模式后,定时器不再关心内部机器周期,而是盯着外部引脚。每检测到一个有效的电平跳变(通常是下降沿),计数值加1。这个功能在实际项目里用得非常多,比如:
- 测电机转速:霍尔传感器每转一圈输出若干脉冲,数脉冲就知道转了多少圈。
- 测按键按下次数:虽然按键一般用中断处理,但某些场景下用计数器统计更省CPU。
- 测外部事件频率:固定时间内数到多少脉冲,就能反推频率。
注意:计数模式下,外部脉冲的频率不能太高。51系列要求外部脉冲的高电平和低电平至少各保持一个机器周期,否则可能漏计数。12MHz晶振下,这意味着外部脉冲频率最好不要超过500kHz,实际工程里留足余量,控制在几十kHz以内最稳。
3. 51单片机定时器的内部结构拆解
3.1 T0和T1:两个独立的16位定时器
标准的51单片机(比如经典的AT89C51、STC89C52)内部有两个定时器:T0和T1。它们都是16位的,但物理上是由两个8位寄存器拼起来的:
| 定时器 | 低8位 | 高8位 | 外部计数引脚 |
|---|---|---|---|
| T0 | TL0 | TH0 | P3.4 (T0) |
| T1 | TL1 | TH1 | P3.5 (T1) |
为什么要拆成两个8位?因为51是8位机,一次只能处理8位数据,16位寄存器得分两次读写。这个设计带来一个经典坑:读16位计数值时,如果先读低8位再读高8位,中间恰好发生进位,读出来的值就错了。正确做法是先读高8位,再读低8位,再回读一次高8位,如果两次高8位不同就重新读。这个技巧在需要精确读取计数值的场合非常重要。
3.2 核心寄存器:TMOD和TCON
控制定时器就靠两个寄存器,把它们搞明白,定时器就掌握了一大半。
TMOD(定时器模式寄存器),地址89H,不能位寻址,只能整体赋值。它的8个位分成两组,低4位管T0,高4位管T1:
| 位 | 名称 | 作用 |
|---|---|---|
| 7 | GATE(T1) | 门控位 |
| 6 | C/T(T1) | 1=计数,0=定时 |
| 5 | M1(T1) | 模式选择高位 |
| 4 | M0(T1) | 模式选择低位 |
| 3 | GATE(T0) | 门控位 |
| 2 | C/T(T0) | 1=计数,0=定时 |
| 1 | M1(T0) | 模式选择高位 |
| 0 | M0(T0) | 模式选择低位 |
TCON(定时器控制寄存器),地址88H,可以位寻址。高4位管定时器,低4位管外部中断:
| 位 | 名称 | 作用 |
|---|---|---|
| 7 | TF1 | T1溢出标志,硬件置1,中断后硬件清0 |
| 6 | TR1 | T1运行控制,1=启动,0=停止 |
| 5 | TF0 | T0溢出标志 |
| 4 | TR0 | T0运行控制 |
| 3 | IE1 | 外部中断1标志 |
| 2 | IT1 | 外部中断1触发方式 |
| 1 | IE0 | 外部中断0标志 |
| 0 | IT0 | 外部中断0触发方式 |
实际写代码时,TMOD通常整体赋值,比如TMOD = 0x01;表示T0工作在模式1、定时方式。TCON则常用位操作,比如TR0 = 1;启动T0,TF0 = 0;手动清溢出标志。
3.3 门控位GATE:一个容易被忽略的开关
GATE位平时用得少,但理解了它,你对定时器启动条件的认识会更完整。当GATE=0时,只要TR0=1定时器就跑;当GATE=1时,除了TR0=1,还要求INT0引脚(P3.2)为高电平,定时器才运行。
这个功能有什么用?最典型的场景是测量外部脉冲宽度。把外部信号接到INT0,配置GATE=1、TR0=1,那么定时器只在信号高电平期间计数,信号变低就停。读出的计数值乘以机器周期,就是高电平的持续时间。这个技巧在测脉宽、测占空比时非常实用,不用中断就能硬件自动完成。
4. 四种工作模式:什么时候用哪一种
TMOD里的M1、M0两位决定了工作模式,共四种。很多人学完就忘了模式2和模式3,其实它们各有不可替代的场景。
4.1 模式0:13位定时器,历史遗留
M1M0=00时是模式0,T0由TL0的低5位和TH0的8位组成一个13位计数器。这是早期8051为了兼容更老的芯片保留的,现在几乎不用。13位最大计数值8192,初值计算麻烦,功能还被模式1完全覆盖。新手可以直接跳过,知道有这么回事就行。
4.2 模式1:16位定时器,最常用
M1M0=01时是模式1,TH0和TL0组成完整的16位计数器,最大计数值65536。这是最常用的模式,适合需要较长定时时间的场合。缺点是每次溢出后,计数器归零,需要在中断里手动重装初值,这会带来几个机器周期的误差。如果对定时精度要求极高,这个误差累积起来就不可忽视了。
4.3 模式2:8位自动重装,串口波特率的好搭档
M1M0=10时是模式2,只用TL0计数,TH0保存重装值。TL0溢出时,硬件自动把TH0的值重新装入TL0,不需要软件干预。这个“自动重装”特性让它的定时精度非常高,没有重装误差。
模式2最经典的用途就是给串口产生波特率。T1工作在模式2时,溢出率稳定,配合波特率加倍位SMOD,能产生标准的串口速率。所以你会看到几乎所有51串口例程里都有这么一句:TMOD |= 0x20;——这就是把T1设成模式2。
4.4 模式3:T0拆成两个8位,T1基本停摆
M1M0=11时是模式3,只有T0能用,而且被拆成两个独立的8位定时器:TL0用T0的控制位(TR0、TF0),TH0借用T1的控制位(TR1、TF1)。此时T1虽然还能设成模式2用于串口,但不能再产生中断。
模式3适合需要两个额外8位定时器的场合,比如同时要产生两路不同频率的方波。但代价是牺牲了T1的中断能力,用之前得想清楚。实际项目里用得不多,属于“知道它能干什么”就够了的级别。
| 模式 | M1M0 | 位数 | 重装方式 | 典型用途 |
|---|---|---|---|---|
| 0 | 00 | 13位 | 手动 | 基本淘汰 |
| 1 | 01 | 16位 | 手动 | 通用定时 |
| 2 | 10 | 8位 | 自动 | 串口波特率 |
| 3 | 11 | 两个8位 | 手动 | 特殊双路需求 |
5. 定时初值计算:一个公式解决所有问题
5.1 核心公式推导
定时时间的计算,本质就是一道小学算术题。先明确几个量:
- 机器周期 Tcy:51单片机一个机器周期等于12个振荡周期。如果晶振频率是Fosc,那么Tcy = 12 / Fosc。
- 计数值 N:从初值加到溢出需要的脉冲数,N = 溢出值 - 初值。
- 定时时间 t:t = N × Tcy。
反过来,已知想要的定时时间t,求初值:
初值 = 溢出值 - t / Tcy
对于模式1(16位),溢出值是65536;对于模式2(8位),溢出值是256。
5.2 一个完整的计算实例
假设晶振12MHz,要定时50ms,用T0模式1,算初值。
第一步,算机器周期:Tcy = 12 / 12,000,000 = 1微秒。
第二步,算需要的计数值:N = 50ms / 1us = 50000。
第三步,算初值:初值 = 65536 - 50000 = 15536。
第四步,转成十六进制:15536 = 0x3CB0,所以TH0 = 0x3C,TL0 = 0xB0。
代码里就这么写:
TMOD = 0x01; // T0模式1,定时 TH0 = 0x3C; // 装初值高8位 TL0 = 0xB0; // 装初值低8位 TR0 = 1; // 启动T05.3 最大定时时间不够怎么办
12MHz晶振下,模式1最长定时是65536微秒,约65.5毫秒。如果要定时1秒呢?两个办法:
办法一:用循环计数。定时50ms,中断里累加20次就是1秒。这是最常用的做法,代码简单,精度也够。
void Timer0_ISR() interrupt 1 { TH0 = 0x3C; TL0 = 0xB0; count++; if(count >= 20) { count = 0; flag_1s = 1; // 1秒到了 } }办法二:降低晶振频率。比如用6MHz晶振,机器周期变成2微秒,最长定时就翻倍到131毫秒。但降频会影响串口波特率等其他功能,一般不推荐为了定时去改晶振。
实操心得:中断里重装初值会引入误差。如果定时50ms,中断服务函数本身执行需要几个微秒,那么实际定时就是50ms加上这几个微秒。短时间看不出来,累加20次成1秒后,误差就放大了。对精度要求高的场合,要么用模式2自动重装,要么在初值里把这几个机器周期补偿进去。
6. 中断方式 vs 查询方式:两种写法对比
6.1 查询方式:简单但占CPU
查询方式不开中断,主循环里不停检测TF0标志:
TMOD = 0x01; TH0 = 0x3C; TL0 = 0xB0; TR0 = 1; while(1) { if(TF0 == 1) { TF0 = 0; // 手动清标志 TH0 = 0x3C; // 重装初值 TL0 = 0xB0; // 执行定时到了要做的事 } // 这里还能干别的,但必须频繁回来查 }查询方式的优点是逻辑直观,不用理解中断。缺点是CPU必须不停地回来查标志,如果中间有别的耗时操作,就可能错过溢出时刻,导致定时不准。适合单一任务、对精度要求不高的场景。
6.2 中断方式:CPU解放,精度更高
中断方式下,定时器溢出会自动触发中断,CPU暂停主程序去执行中断服务函数,执行完再回来。主程序该干嘛干嘛,完全不用管定时器。
void main() { TMOD = 0x01; TH0 = 0x3C; TL0 = 0xB0; ET0 = 1; // 开T0中断 EA = 1; // 开总中断 TR0 = 1; // 启动T0 while(1) { // 主循环处理其他任务 } } void Timer0_ISR() interrupt 1 { TH0 = 0x3C; // 重装初值 TL0 = 0xB0; // 定时到了要做的事 }中断方式的好处是定时精度只取决于硬件和中断响应时间,不受主程序影响。这是实际项目里的标准做法。中断号要记牢:T0是interrupt 1,T1是interrupt 3,外部中断0是interrupt 0,串口是interrupt 4。
6.3 两种方式的选择建议
| 对比项 | 查询方式 | 中断方式 |
|---|---|---|
| CPU占用 | 高,需轮询 | 低,自动触发 |
| 定时精度 | 受主程序影响 | 高,仅受中断延迟影响 |
| 代码复杂度 | 简单 | 需理解中断机制 |
| 适用场景 | 单一简单任务 | 多任务、高精度场合 |
我的建议是:只要项目里不止一个任务,无脑用中断。查询方式只适合教学演示或者极其简单的场景。很多初学者怕中断,觉得寄存器多、概念绕,但跨过这道坎之后,你会发现中断才是单片机编程的精髓。
7. 计数器模式实战:数外部脉冲
7.1 配置步骤
把T0设成计数器模式,只需要把TMOD的C/T位置1:
TMOD = 0x05; // T0模式1,计数器方式 TH0 = 0; TL0 = 0; TR0 = 1; // 启动计数外部脉冲从P3.4(T0引脚)输入,每来一个下降沿,计数值加1。当计数值从65535溢出到0时,TF0置1,触发中断。
7.2 一个测频率的思路
假设要测一个外部信号的频率。可以用T1做定时(比如定时1秒),T0做计数。1秒内T0数到多少个脉冲,频率就是多少Hz。
void Timer1_ISR() interrupt 3 { TH1 = 0x3C; // 重装50ms初值 TL1 = 0xB0; count_50ms++; if(count_50ms >= 20) { // 满1秒 count_50ms = 0; freq = (TH0 << 8) | TL0; // 读计数值 TH0 = 0; // 清零重新计数 TL0 = 0; } }这里读计数值时,就遇到了前面说的16位读取问题。如果频率不高,直接读一般没事;如果要求严格,就得用“读高-读低-再读高”的方法确认。
注意:计数器模式下,外部信号的电平跳变要干净。如果信号有抖动或毛刺,会多计数。实际接传感器时,最好加一个施密特触发器整形,或者在软件里做数字滤波。
8. 那些年我踩过的定时器坑
8.1 初值算错:最常见的翻车现场
初学者最容易犯的错,就是把初值直接当成定时时间。比如要定时50ms,就写TH0 = 50;,这完全不对。初值是“从哪个数开始数”,不是“数多少下”。记住那个公式:初值 = 溢出值 - 定时时间/机器周期。算完之后一定要转成十六进制,TH0装高8位,TL0装低8位,别装反了。
8.2 忘记重装初值:模式1的经典陷阱
模式1溢出后计数器归零,如果不手动重装,下次定时就变成从0数到65536,时间完全不对。我见过有人调了半天发现LED闪烁频率越来越慢,最后发现就是中断里忘了重装。模式2没这个问题,但模式2只有8位,定时短。
8.3 中断里做太多事:定时不准的元凶
中断服务函数应该尽可能短。有些人把数码管刷新、按键处理全塞进定时器中断,结果中断执行时间超过了定时周期,导致中断嵌套或者丢失。正确的做法是:中断里只置标志位或者做最简单的操作,复杂逻辑放到主循环里根据标志位处理。
8.4 忘记开总中断:EA没置1
开了ET0(定时器0中断允许),但忘了EA=1(总中断允许),中断永远不会触发。这个错误很隐蔽,因为寄存器配置看起来都对。记住:任何中断要生效,EA必须为1。
8.5 晶振频率记错:差之毫厘谬以千里
有人用11.0592MHz晶振算定时,却按12MHz的机器周期算,结果定时时间差了8%左右。11.0592MHz这个频率是为了串口波特率精确而选的,它的机器周期是12/11059200≈1.085微秒,不是1微秒。算初值前先确认晶振频率,这是基本功。
| 常见问题 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 定时时间不对 | LED闪太快或太慢 | 初值算错 | 重新按公式计算 |
| 只中断一次 | 第一次正常后面乱 | 模式1没重装初值 | 中断里重装TH/TL |
| 完全不中断 | 定时器没反应 | EA没开或TR没启动 | 检查EA、ET、TR |
| 定时逐渐变慢 | 时间越来越长 | 中断里任务太重 | 精简中断服务函数 |
| 计数值跳变 | 读数忽大忽小 | 16位读取不同步 | 用高-低-高读取法 |
9. 从定时器延伸出去:它还能这么用
定时器学明白之后,很多之前觉得难的东西会突然变得简单。比如PWM调光,本质就是用定时器控制高低电平的持续时间比例;舵机控制,靠的是定时器产生周期20ms、脉宽0.5到2.5ms的信号;软件串口,用定时器精确控制每一位的发送时刻;多任务调度,用一个定时器产生固定时基,在主循环里轮询各个任务的时间片。
我个人的体会是,定时器是单片机里性价比最高的外设——硬件成本几乎为零,但学会之后能解决的问题翻了好几倍。刚开始学的时候,把模式1的中断方式练熟,初值计算练到不用查公式就能算出来,后面再学PWM、输入捕获这些高级功能,会发现底层逻辑都是通的。
最后分享一个我常用的调试技巧:如果怀疑定时时间不对,可以在中断里翻转一个IO口,用示波器或者逻辑分析仪看波形周期。没有仪器的话,就让这个IO口驱动一个LED,用手机慢动作录像数闪烁次数,虽然土但管用。调通了再把IO口还原,比盯着代码瞎猜效率高得多。