
1. 为什么每个单片机项目最后都会撞上定时器刚接触单片机的朋友十有八九是从点亮一颗LED、按一下按键、串口打印一句“hello”开始的。这些实验跑通之后你会觉得自己已经入门了。但接下来只要你想做点“有时间感”的东西——比如让LED每隔500毫秒闪一次、按键要消抖、串口要按固定波特率收发、蜂鸣器要发出某个音调、舵机要转到指定角度——你就会发现绕来绕去都绕不开同一个外设定时器/计数器。我见过太多初学者的代码里延时全靠delay()死等主循环里塞满了for循环空转。这种写法在小实验里能跑一旦系统里有两个以上需要“同时”处理的任务立刻就崩了按键响应变慢、数码管闪烁、串口丢数据。问题的根子就在于——CPU被延时函数绑死了没有空闲去干别的事。而定时器存在的意义恰恰是把CPU从“数数”这种机械劳动里解放出来。这篇笔记我打算用最直白的方式把定时器/计数器这个东西讲清楚。它是什么、内部怎么工作、四个工作模式各自适合什么场景、初值到底怎么算、代码怎么写、踩过哪些坑。内容基于51系列单片机这也是绝大多数人入门的第一颗芯片展开但里面的思路对STM32、AVR、PIC这些平台同样通用只是寄存器名字换了而已。不管你是刚学完流水灯的新手还是已经能写点小项目但总被时序问题折磨的老哥这篇应该都能帮你把这块知识补扎实。2. 定时器和计数器到底是不是一个东西2.1 从“数脉冲”这个本质说起很多人第一次看到“定时器/计数器”这个并列的叫法会犯迷糊到底是定时器还是计数器其实它俩在硬件上是同一个电路区别只在于数的是什么脉冲。当它数的是单片机内部机器周期产生的脉冲时因为机器周期的时间是固定的比如12MHz晶振下一个机器周期正好是1微秒所以数多少个脉冲就等于过了多少固定时间——这就是定时器**功能。当它数的是外部引脚比如T0对应P3.4T1对应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位外部计数引脚T0TL0TH0P3.4 (T0)T1TL1TH1P3.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位名称作用7GATE(T1)门控位6C/T(T1)1计数0定时5M1(T1)模式选择高位4M0(T1)模式选择低位3GATE(T0)门控位2C/T(T0)1计数0定时1M1(T0)模式选择高位0M0(T0)模式选择低位TCON定时器控制寄存器地址88H可以位寻址。高4位管定时器低4位管外部中断位名称作用7TF1T1溢出标志硬件置1中断后硬件清06TR1T1运行控制1启动0停止5TF0T0溢出标志4TR0T0运行控制3IE1外部中断1标志2IT1外部中断1触发方式1IE0外部中断0标志0IT0外部中断0触发方式实际写代码时TMOD通常整体赋值比如TMOD 0x01;表示T0工作在模式1、定时方式。TCON则常用位操作比如TR0 1;启动T0TF0 0;手动清溢出标志。3.3 门控位GATE一个容易被忽略的开关GATE位平时用得少但理解了它你对定时器启动条件的认识会更完整。当GATE0时只要TR01定时器就跑当GATE1时除了TR01还要求INT0引脚P3.2为高电平定时器才运行。这个功能有什么用最典型的场景是测量外部脉冲宽度。把外部信号接到INT0配置GATE1、TR01那么定时器只在信号高电平期间计数信号变低就停。读出的计数值乘以机器周期就是高电平的持续时间。这个技巧在测脉宽、测占空比时非常实用不用中断就能硬件自动完成。4. 四种工作模式什么时候用哪一种TMOD里的M1、M0两位决定了工作模式共四种。很多人学完就忘了模式2和模式3其实它们各有不可替代的场景。4.1 模式013位定时器历史遗留M1M000时是模式0T0由TL0的低5位和TH0的8位组成一个13位计数器。这是早期8051为了兼容更老的芯片保留的现在几乎不用。13位最大计数值8192初值计算麻烦功能还被模式1完全覆盖。新手可以直接跳过知道有这么回事就行。4.2 模式116位定时器最常用M1M001时是模式1TH0和TL0组成完整的16位计数器最大计数值65536。这是最常用的模式适合需要较长定时时间的场合。缺点是每次溢出后计数器归零需要在中断里手动重装初值这会带来几个机器周期的误差。如果对定时精度要求极高这个误差累积起来就不可忽视了。4.3 模式28位自动重装串口波特率的好搭档M1M010时是模式2只用TL0计数TH0保存重装值。TL0溢出时硬件自动把TH0的值重新装入TL0不需要软件干预。这个“自动重装”特性让它的定时精度非常高没有重装误差。模式2最经典的用途就是给串口产生波特率。T1工作在模式2时溢出率稳定配合波特率加倍位SMOD能产生标准的串口速率。所以你会看到几乎所有51串口例程里都有这么一句TMOD | 0x20;——这就是把T1设成模式2。4.4 模式3T0拆成两个8位T1基本停摆M1M011时是模式3只有T0能用而且被拆成两个独立的8位定时器TL0用T0的控制位TR0、TF0TH0借用T1的控制位TR1、TF1。此时T1虽然还能设成模式2用于串口但不能再产生中断。模式3适合需要两个额外8位定时器的场合比如同时要产生两路不同频率的方波。但代价是牺牲了T1的中断能力用之前得想清楚。实际项目里用得不多属于“知道它能干什么”就够了的级别。模式M1M0位数重装方式典型用途00013位手动基本淘汰10116位手动通用定时2108位自动串口波特率311两个8位手动特殊双路需求5. 定时初值计算一个公式解决所有问题5.1 核心公式推导定时时间的计算本质就是一道小学算术题。先明确几个量机器周期 Tcy51单片机一个机器周期等于12个振荡周期。如果晶振频率是Fosc那么Tcy 12 / Fosc。计数值 N从初值加到溢出需要的脉冲数N 溢出值 - 初值。定时时间 tt N × Tcy。反过来已知想要的定时时间t求初值初值 溢出值 - t / Tcy对于模式116位溢出值是65536对于模式28位溢出值是256。5.2 一个完整的计算实例假设晶振12MHz要定时50ms用T0模式1算初值。第一步算机器周期Tcy 12 / 12,000,000 1微秒。第二步算需要的计数值N 50ms / 1us 50000。第三步算初值初值 65536 - 50000 15536。第四步转成十六进制15536 0x3CB0所以TH0 0x3CTL0 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 1T1是interrupt 3外部中断0是interrupt 0串口是interrupt 4。6.3 两种方式的选择建议对比项查询方式中断方式CPU占用高需轮询低自动触发定时精度受主程序影响高仅受中断延迟影响代码复杂度简单需理解中断机制适用场景单一简单任务多任务、高精度场合我的建议是只要项目里不止一个任务无脑用中断。查询方式只适合教学演示或者极其简单的场景。很多初学者怕中断觉得寄存器多、概念绕但跨过这道坎之后你会发现中断才是单片机编程的精髓。7. 计数器模式实战数外部脉冲7.1 配置步骤把T0设成计数器模式只需要把TMOD的C/T位置1TMOD 0x05; // T0模式1计数器方式 TH0 0; TL0 0; TR0 1; // 启动计数外部脉冲从P3.4T0引脚输入每来一个下降沿计数值加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中断允许但忘了EA1总中断允许中断永远不会触发。这个错误很隐蔽因为寄存器配置看起来都对。记住任何中断要生效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口还原比盯着代码瞎猜效率高得多。