ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

51单片机定时器详解:原理、寄存器配置与C51编程实战

51单片机定时器详解:原理、寄存器配置与C51编程实战 做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电平的话稍微忙一点就漏掉好几个脉冲。定时器工作在计数器模式时每个下降沿都会让计数寄存器自动加1CPU完全不用理会等统计时间到了再读一次寄存器就行。再比如测量某个外部信号的高电平持续时间。用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。实际项目中几乎不会同时修改两个定时器的配置所以更安全的写法是用位操作而不是直接给整个寄存器赋值。下面是每位的作用位名称作用7GATE1T1门控位1表示受INT1引脚和TR1共同控制6C/T1T1定时/计数选择1为计数模式5~4M1M0T1工作方式选择3GATE0T0门控位1表示受INT0引脚和TR0共同控制2C/T0T0定时/计数选择1为计数模式1~0M1M0T0工作方式选择M1M0的四种组合对应四种工作方式方式0是13位计数器方式1是16位计数器方式2是8位自动重装计数器方式3是把T0拆成两个8位计数器。项目里方式1用得最多因为它定时范围大能覆盖到50ms级别方式2适合高频固定周期的场景因为初值自动重装误差小。方式0比较老能不用就别用方式3属于特殊用法一般把T0拆开用时才会碰到。配置TMOD的典型代码是这样TMOD 0xF0; // 低4位清零不影响T1的设置 TMOD | 0x01; // T0方式116位定时器这里 0xF0表示把TMOD的低4位全部清零高4位保持原样| 0x01表示低4位的M10、M01。如果直接用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 0x3CTL0 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 6TH0和TL0都设成6溢出后自动重装不需要在中断里手动恢复初值。3.2 一份可以直接抄的初始化代码这里给出T0方式1定时50ms的完整初始化代码基准是12MHz晶振#include reg52.h void Timer0_Init(void) { TMOD 0xF0; // 清空T0控制位保留T1配置 TMOD | 0x01; // T0工作方式116位定时器 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位能实现类似的效果。GATE1时定时器必须在TR01并且INT0引脚为高电平时才开始计数INT0变低自动停止。所以可以用查询方式测量一个脉冲的高电平宽度sbit INT0 P3^2; void Measure_Pulse_Width(void) { unsigned int value; TMOD 0xF0; TMOD | 0x09; // T0方式1定时模式GATE1 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的定时器时大多数内容都能对上号剩下的只是寄存器更多、功能更丰富而已。我个人的体会是定时器值得花一个完整的周末把它折腾明白。不要只看书把电子时钟、呼吸灯、脉宽测量这几个经典例程逐个在开发板上跑一遍踩几个坑、调几行代码之后你对它的理解会比任何教程都深刻。后面再用到定时器的时候基本就是手到擒来的事了。
返回列表