先把结论放在前面:秒表程序看上去只是按两下按键、看几个数字跳变,但它是51单片机学习路上一个绕不过去的整合型实验。天祥TX-1C开发板这块板子在学生手里流转了小二十年,主控是STC89C52RC,板载数码管、独立按键、蜂鸣器这些外设,做秒表刚好把定时器、中断、动态扫描、按键消抖这几块硬骨头一锅端了。这篇文章我会从定时器初值怎么算、中断函数怎么写、数码管扫描为什么放主循环不放中断,到最常见的计时不准、按键失灵、数码管重影,逐层拆给你看。
这套程序适合刚把跑马灯玩明白、准备进入"定时器+中断"阶段的初学者,也适合做课程设计想找一个说得清原理的参考实现。秒表这题目太经典了,网上抄代码的一大把,但真正能讲清楚"为什么这么写"的很少,我这篇尽量把它讲透。
1. 做秒表之前,先把天祥TX-1C的硬件资源摸清楚
1.1 这块开发板用到了哪些板载外设
天祥TX-1C是郭天祥《十天学会单片机》配套的那块经典学习板,整套设计就是为教学服务的。主控芯片是STC89C52RC,带8K Flash、512字节RAM,传统8051内核,12T模式。板上东西很全:8个LED接在P1口、8位数码管通过74HC138译码器选位、P0口做段选信号、4个独立按键、4x4矩阵键盘、有源蜂鸣器、DS1302时钟芯片、DS18B20温度传感器、LCD1602/LCD12864接口、串口和USB下载电路,几乎把所有单片机入门外设集齐了。
做秒表这个项目,真正用到的板载资源其实只有三样:8位数码管负责显示时间、2个独立按键负责启动暂停和清零、定时器中断负责计时。其他东西属于"顺带能用",比如蜂鸣器可以在计满或者按键按下时响一声做反馈,但核心功能不依赖它。
这里要特别记住一个信息:这块板子的晶振是11.0592MHz,不是12MHz。这个数值在后面算定时器初值时非常关键,很多新手把初值抄错了,根本原因就是没注意到晶振是多少。11.0592MHz这个频率在51世界里很常见,因为它分频之后能精确产生9600波特率,串口通信方便。但在定时器计时这件事上,11.0592MHz反而让"整毫秒"变得稍微有点别扭,后面我会详细算。
1.2 秒表的功能拆解:启动、暂停、清零,背后是状态机
秒表看着简单,三个按键动作(或两个按键组合),但代码层面要解决的其实是三件事:计时要准、显示要稳、按键要"按一下是一下"。稍微把功能想清楚一点,就能写出结构清晰、不容易出 bug 的程序。
我把秒表的状态拆成两个:运行(RUN)和停止(STOP)。按键A负责切换状态,运行的时候按一下变停止,停止的时候按一下变运行,这就是一个翻转开关;按键B负责清零,不管当前是运行还是停止,只要按下清零,时间变量全部归零,显示回到0。如果是课程设计想要更花哨一点,还可以把清零键设计成"只在停止状态下有效",或者在清零的同时自动回到停止状态,这些都属于逻辑层面的小变化,不影响底层结构。
这个"运行/停止"的设计本质就是一个状态机。单片机程序最怕的就是逻辑打结,用状态变量把所有可能的情况枚举清楚,写出来就是几行if判断的事。我见过很多新手在这个地方翻车:不用状态变量,直接用两个按键分别控制启动和停止,就会出现"启动之后没停又按了启动,时间会不会乱"这种问题。状态机思想要从这个秒表程序开始建立。
时间变量这边,我习惯用三个变量:minute(分钟)、second(秒)、centisecond(百分秒),再加上一个毫秒计数器cnt_ms。因为定时器中断设成1ms触发一次,所以每进一次中断cnt_ms加1,加到1000就是1秒,second加1;second加到60,minute加1。百分秒其实不需要额外的中断源,每10ms算一个百分秒,这样显示最直观:分:秒.百分秒。
2. 定时器初值计算:秒表走时准不准,全看这一步
2.1 为什么选定时器T0,工作方式1是多少位的计数器
STC89C52RC内部有两个16位定时器/计数器,T0和T1。做秒表计时,用T0还是T1完全看个人习惯,我习惯用T0,因为它是教材里第一个讲的定时器,后面学串口的时候T1通常要留作波特率发生器,从小养成"T0做定时、T1留给串口"的习惯能少走弯路。
定时器有四种工作方式:方式0是13位计数器,方式1是16位计数器,方式2是8位自动重载,方式3是拆成两个8位。秒表程序我选方式1,也就是16位计数器。原因很简单:定时器的计数范围在11.0592MHz晶振、12分频下,16位最大能定约71ms,而8位最大只能定约0.28ms。秒表需要一个相对长的中断间隔,1ms、10ms都可以,8位计数器每次中断要自己重新装初值,还要在中断里处理重装时机,稍微不注意就会产生累积误差。16位方式1在中断里只要重新赋一遍初值就行,简单可靠。
定时器的工作方式由TMOD寄存器控制。T0用方式1的话,TMOD的低四位配置成0001,用十六进制写就是0x01。这条代码基本是固定的,但你要明白它为什么是0x01,而不是背下来就完事。TMOD高四位管T1,低四位管T0,方式1对应的二进制是0001,合在一起TMOD = 0x01,就这么来的。
2.2 1ms中断的初值计算全过程(11.0592MHz晶振)
秒表计时需要一个时间基准,这个基准就是定时器中断。中断触发间隔越短,计时粒度越细,但我选了1ms而不是更细,因为秒表显示的最小单位是百分秒,1ms中断足够支撑百分秒的精度,而且1ms的初值计算和中断消耗时间都比较好控制。
下面进入全篇最核心的计算环节,我一步一步写:
晶振频率 f = 11.0592MHz。51单片机的机器周期是12个晶振周期,所以:
机器周期 = 12 / 11.0592MHz ≈ 1.085微秒
这句话意思是:定时器每计数一次,实际经过的时间大约是1.085微秒。注意这里不是1微秒,很多人默认12MHz晶振的机器周期是1微秒,然后直接套公式,初值就偏了。
如果要让定时器每1ms(也就是1000微秒)中断一次,需要计数多少次?
计数次数 = 1000微秒 / 1.085微秒 ≈ 922次
定时器从初值开始往上计数,计到65536溢出触发中断。所以:
初值 = 65536 - 922 = 64614
转成十六进制:64614 = 0xFC66
所以 TH0 = 0xFC,TL0 = 0x66。
这里有个细节必须说清楚:因为11.0592MHz不是12的整倍数,922次计数实际经过的时间是922 x 1.085微秒 = 1000.37微秒,比1ms多了0.37微秒。也就是说,一个"1ms"中断实际是1.00037ms,累积1秒会偏差约0.37ms,一分钟偏差约22ms。对秒表这种显示到百分秒的场景来说,这个误差大概率看不出来,因为人眼对秒表"准不准"的感知主要看分钟级累积,22毫秒的偏差完全在可接受范围。
但如果你较真,想用这块板子的晶振做高精度时钟,那就得用软件校准。省事的做法是:不把每1000次中断当成1秒,而是通过实测1分钟走时误差,调整中断里"够多少ms算1秒"的阈值。比如实测1分钟慢了30ms,那就在秒计数那里每1000次中断悄悄加一点补偿,这在后面"扩展"部分我会再提到。
2.3 中断服务函数里的计数逻辑与变量设计
定时器溢出会触发中断,程序跳进中断服务函数。这个函数里做的事情越少越好,我的原则是"中断里只计数,不做显示,不做按键扫描"。为什么?因为中断服务函数执行时间过长,会占用主循环的时间片,如果显示扫描刚好需要中断配合,两者互相抢占,就会出现数码管亮度不均、闪烁之类的怪问题。
中断服务函数大致长这样:
void timer0_isr() interrupt 1 { TH0 = 0xFC; TL0 = 0x66; // 重新赋初值,保证每次中断间隔都是1ms cnt_ms++; if(cnt_ms >= 1000) // 1秒到了 { cnt_ms = 0; centisecond++; if(centisecond >= 100) { centisecond = 0; second++; if(second >= 60) { second = 0; minute++; if(minute >= 99) // 最大显示99分钟,防止溢出 { minute = 0; } } } } }这里有两个常见坑。第一个坑是忘记重新赋初值,定时器就会从0开始重新计数,中断间隔变成固定的65536个机器周期约71ms,秒表走时直接乱套;第二个坑是把显示更新放在中断里,破坏主循环的节奏。我的做法是中断里只更新那三个时间变量,主循环里每次查询一次这三个变量,把它们的值刷新到显示缓冲区。变量对这些操作是原子性的吗?严格来说在8051上,对int变量非原子操作可能存在读到一半被中断改掉的情况,但因为主循环里读变量的操作很快,而且就算偶尔读到新旧混合值,下一轮扫描立刻会修正,视觉上完全无感,所以实战中没人较真这个问题。
3. 数码管动态扫描和按键消抖,看着简单坑不少
3.1 动态扫描原理:人眼视觉暂留如何被"利用"
天祥TX-1C板上有8位数码管,位选由74HC138译码器控制。数码管没办法同时亮起8位,因为硬件上所有位共用同一组段选线,所以只能一位一位地点亮:先选中第1位,把这一位要显示的数字段码送到P0口,延时一小会儿,然后关掉第1位,再选中第2位,送段码,延时……以此类推。只要整个循环跑得足够快,人眼因为视觉暂留效应,看到的就是所有位同时在亮。
这个原理说起来简单,实际操作中有两个关键参数:单次点亮时间不能太短,否则亮度不够;整个扫描周期不能太长,否则会闪烁。我实测的经验值是:8位扫一圈的时间控制在8ms到16ms之间,也就是每位点亮1到2ms。如果主循环里没有其他耗时操作,这个目标很容易达到。
扫描函数我建议写成"每次调用只送一位"的结构,而不是"一次调用循环送完8位":
void display_scan(void) { static unsigned char i = 0; // 记录当前扫描到第几位 P0 = 0xff; // 消隐,先熄灭所有段,防止切换位选时出现拖影 switch(i) { case 0: LSA = 0; LSB = 0; LSC = 0; break; case 1: LSA = 1; LSB = 0; LSC = 0; break; case 2: LSA = 0; LSB = 1; LSC = 0; break; case 3: LSA = 1; LSB = 1; LSC = 0; break; case 4: LSA = 0; LSB = 0; LSC = 1; break; case 5: LSA = 1; LSB = 0; LSC = 1; break; case 6: LSA = 0; LSB = 1; LSC = 1; break; case 7: LSA = 1; LSB = 1; LSC = 1; break; } P0 = seg_code[display_buf[i]]; // 送段码 i++; if(i >= 8) i = 0; }主循环每次转一圈都会调用一次这个函数,相当于每次只刷新一位,8次循环刷新完8位。这样做的最大好处是:主循环的其他任务(按键检测、刷新缓冲区)不会长时间阻塞显示,亮度均匀,也不会出现"按一下键显示就闪一下"的问题。天祥TX-1C的位选控制脚是P2.4、P2.5、P2.6,也就是上面代码里的LSA、LSB、LSC,段码走P0口,这是板子原理图设计好的。
3.2 显示缓冲区设计:先拆数,再查表,再扫描
秒表需要显示分、秒、百分秒,各两位,一共6位。8位数码管还空2位,我的处理是:前两位显示分钟,中间两位显示秒,后两位显示百分秒,空出来的两位熄灭。显示格式大致是"00:00.00",但因为有2位空着,实际上看起来是"00 00:00"或者我干脆只用6位,第7、8位不点亮。
在代码层面,不能直接把minute这个变量送去显示,要把它拆成十位和个位两个数字。这就要引入一个显示缓冲区数组display_buf[8],每个元素存一位要显示的数字(0到9)。主循环里的刷新函数负责把minute、second、centisecond三个变量拆成6个数字填进缓冲区。
void refresh_display_buf(void) { display_buf[0] = minute / 10; display_buf[1] = minute % 10; display_buf[2] = second / 10; display_buf[3] = second % 10; display_buf[4] = centisecond / 10; display_buf[5] = centisecond % 10; display_buf[6] = 10; // 不显示 display_buf[7] = 10; // 不显示 }为什么要把"拆数"和"扫描"分开?因为拆数只在时间变量变化时才需要更新,而扫描是每一轮都要跑的。如果每次扫描都现拆现算,代码逻辑会很乱。把这两件事拆开,各自干各自的活,逻辑清晰,调试也方便。段码表这里用共阳数码管的编码,比如数字0对应0xC0,数字1对应0xF9。天祥TX-1C板上用的是共阳数码管,如果你手头的板子不是,把段码表全部取反就行,这个判断方法很简单:写个测试程序给P0口送0xC0,如果显示0,就是共阳;显示8或乱码,换0x3F试试,0x3F是共阴的0。
3.3 按键消抖和防重复触发的处理
机械按键按下和松开时,触点会抖动几百微秒到几毫秒,期间电平会跳变多次。如果不处理,一次按键可能被单片机判断成按了好几次,秒表就会出现"按一下启动结果立刻又暂停"的抽风现象。
消抖有两种方案:一种是检测到按下后延时10到20ms再确认电平;另一种是每隔几毫秒采样一次,连续多次采样都是低电平才认为确实按下。第一种在教材里最常见,代码直观,适合秒表这种按键频率不高的场景。但延时消抖有个问题:delay(20)期间CPU卡在那里什么都不干,如果数码管正在扫描,就会闪一下。解决办法也简单:消抖延时用短一点的10ms,而且只在检测到电平变化时才延时,平时主循环跑得飞快,显示基本不受影响。
防重复触发比消抖更容易被忽略。按键在消抖之后如果只判断电平而不处理"松开"事件,按下不松手的状态下,主循环每轮都会检测到低电平,状态变量会被反复翻转,表现出来就是秒表"启动-停止-启动-停止"乱跳。我的处理方法是:确认按键按下并执行完动作之后,用while等待按键释放,没释放就卡在那里。这样一次物理按键只触发一个动作。
天祥TX-1C板上的独立按键是按下为低电平、平时高电平,我把启动/暂停键定义在P3.4,清零键定义在P3.5。按键扫描放主循环里,写法大概是这样:
void key_scan(void) { if(KEY_START == 0) { delay_ms(10); // 消抖 if(KEY_START == 0) { run_flag = !run_flag; // 翻转运行状态 while(KEY_START == 0); // 等待松开 } } if(KEY_CLEAR == 0) { delay_ms(10); if(KEY_CLEAR == 0) { minute = 0; second = 0; centisecond = 0; cnt_ms = 0; while(KEY_CLEAR == 0); } } }这个写法有个细节:判断完启动键之后,清零键的判断还是会执行。如果启动键按下的同时清零键也是低电平,理论上两个动作都会触发。实际体验中两个键同时按下的概率很低,但如果你想做得更严谨,可以在结构上让两个按键的判断互斥。另外delay_ms(10)这个延时函数在秒表里其实会影响主循环节奏,但因为单次只有10ms,在可接受范围内。高级一点的写法是用定时器做一个非阻塞的按键扫描状态机,但那是后话了,秒表这个级别用延时消抖完全够用。
4. 完整秒表程序代码走读:从初始化到主循环
4.1 程序整体框架:一个主循环加一个中断
整个秒表程序的核心结构就是两部分:初始化配置、无限主循环。定时器T0的初始化包括TMOD赋值、TH0和TL0赋初值、ET0开启T0中断、EA开总中断、TR0启动定时器。初始化完成后,定时器就开始走,中断开始触发,主循环只需要反复做三件事:扫描按键、刷新显示缓冲区、驱动数码管扫描。我把完整代码整理如下,直接在Keil里建工程,新建main.c粘贴进去就能编译下载。
#include <reg52.h> #define uchar unsigned char sbit KEY_START = P3^4; // 启动/暂停键 sbit KEY_CLEAR = P3^5; // 清零键 sbit LSA = P2^4; // 74HC138译码器位选线A sbit LSB = P2^5; // 位选线B sbit LSC = P2^6; // 位选线C uchar code seg_code[] = { 0xc0, 0xf9, 0xa4, 0xb0, 0x99, 0x92, 0x82, 0xf8, 0x80, 0x90, 0x88, 0x83, 0xc6, 0xa1, 0x86, 0x8e }; // 共阳数码管段码表 0~F uchar display_buf[8]; // 显示缓冲区 uchar minute = 0; // 分钟 uchar second = 0; // 秒 uchar centisecond = 0; // 百分秒 uint cnt_ms = 0; // 毫秒计数 bit run_flag = 0; // 运行标志,0停止,1运行 void delay_ms(uint ms) { uint i, j; for(i = 0; i < ms; i++) for(j = 0; j < 120; j++); } void timer0_init(void) { TMOD = 0x01; // T0,方式1,16位定时 TH0 = 0xFC; TL0 = 0x66; // 11.0592MHz晶振,1ms中断 ET0 = 1; EA = 1; TR0 = 1; } void timer0_isr() interrupt 1 { TH0 = 0xFC; TL0 = 0x66; if(run_flag) { cnt_ms++; if(cnt_ms >= 1000) { cnt_ms = 0; centisecond++; if(centisecond >= 100) { centisecond = 0; second++; if(second >= 60) { second = 0; minute++; if(minute >= 99) { minute = 0; } } } } } } void refresh_display_buf(void) { display_buf[0] = minute / 10; display_buf[1] = minute % 10; display_buf[2] = second / 10; display_buf[3] = second % 10; display_buf[4] = centisecond / 10; display_buf[5] = centisecond % 10; display_buf[6] = 10; // 10代表无内容 display_buf[7] = 10; } void display_scan(void) { static uchar i = 0; P0 = 0xff; switch(i) { case 0: LSA = 0; LSB = 0; LSC = 0; break; case 1: LSA = 1; LSB = 0; LSC = 0; break; case 2: LSA = 0; LSB = 1; LSC = 0; break; case 3: LSA = 1; LSB = 1; LSC = 0; break; case 4: LSA = 0; LSB = 0; LSC = 1; break; case 5: LSA = 1; LSB = 0; LSC = 1; break; case 6: LSA = 0; LSB = 1; LSC = 1; break; case 7: LSA = 1; LSB = 1; LSC = 1; break; } if(display_buf[i] == 10) P0 = 0xff; // 空位不显示 else P0 = seg_code[display_buf[i]]; i++; if(i >= 8) i = 0; } void key_scan(void) { if(KEY_START == 0) { delay_ms(10); if(KEY_START == 0) { run_flag = !run_flag; while(KEY_START == 0); } } if(KEY_CLEAR == 0) { delay_ms(10); if(KEY_CLEAR == 0) { minute = 0; second = 0; centisecond = 0; cnt_ms = 0; run_flag = 0; while(KEY_CLEAR == 0); } } } void main(void) { timer0_init(); refresh_display_buf(); while(1) { key_scan(); refresh_display_buf(); display_scan(); } }4.2 秒表核心代码逐段说明
初始化这部分有一个很多人都忽略的细节:清零按键那里我把run_flag也清成了0。意思是什么?清零之后秒表自动回到停止状态,而不是保持原来的运行状态。这个设计是故意的,因为用户清零之后大概率想从0开始重新计时,如果清零之后还保持运行,数字会立刻翻动,体验比较怪。当然如果你觉得"清零但保持运行"更顺手,把run_flag = 0去掉就行,这种小逻辑根据自己的使用习惯调。
中断服务函数里我用run_flag作为门控:只有运行状态下才累加时间。这个做法省掉了在外面控制TR0开关定时器的步骤。很多人会纠结要不要在按键里直接TR0 = 0停表、TR0 = 1启表,这两种思路都能实现。我用run_flag门控的好处是:定时器始终在跑,中断始终在触发,main函数不需要关心定时器状态,逻辑统一。坏处是中断每次都在进,即使停止状态也白跑一趟,但对51这种单片机来说这点开销可以忽略。
刷新显示缓冲区这里有一个性能上的小优化点:其实display_buf只在时间变量变化时才需要刷新,而时间变量只在每次秒进位时才变化。理论上可以定义一个"时间更新标志",在中断里置位,主循环检测到标志才调用refresh_display_buf。但我实测发现,即使每轮主循环都刷新一次缓冲区,代码执行时间也只有几微秒,对显示扫描没有可感知的影响,就保留了这个更直观的写法。写程序有时候不用过度优化,先让逻辑直白好懂。
4.3 几个容易忽略的代码细节
第一个细节:display_buf里我用了数字10来标记"不显示"。为什么不用-1或者其他值?因为数组类型我定义成uchar,是无符号字符型,只能存0到255,-1会被转成255,判断反而不方便。用10很自然,因为段码表只有0到15共16项,10比9大,不可能和正常数字冲突。当然你可以在段码表里给10单独定义一个全灭的编码,但我更倾向于在送段码前判断,这样段码表保持干净。
第二个细节:delay_ms延时函数里的循环次数120,是我在11.0592MHz下直接试出来的近似值,让这个函数大约延时1ms。严格来说这个循环延时和编译器优化级别有关,如果开了高优化,循环可能被优化掉,延时就不准了。但这个函数在这里只用于按键消抖,就算时间偏一点问题也不大。你要是追求严谨,可以用逻辑分析仪实测调整,或者干脆用定时器做一个非阻塞延时。
第三个细节,也是我踩过最深的坑:display_scan函数里那个static局部变量i。static的意思是这个变量在函数退出后不会销毁,下次进入函数时还保留上次的值。这个往小了说是写扫描函数的惯用法,往大了说是在教你保存"运行到哪一步了"的现场信息。如果你把static忘了,每次进函数i都从0开始,数码管就永远只显示第1位,其他7位全灭。
5. 调试实录:秒表跑起来之后,这些问题最常见
5.1 问题速查表,先对号入座
我把秒表调试中遇到过的典型问题整理成一张表,方便你对号入座:
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 走时明显偏快或偏慢 | 定时器初值算错,或晶振频率不是11.0592MHz | 重新计算初值,确认板子晶振 |
| 数码管亮度很不均匀 | 某一位点亮时间过短,或有一段位选逻辑写错 | 检查display_scan里每位是否都执行到位 |
| 数码管有拖影、重影 | 位选切换前没有消隐,或P0口输出没先清理 | 扫描函数开头P0 = 0xff |
| 按键按一下触发两次 | 消抖不够,或没有等待松开 | 加强消抖、加while等待释放 |
| 按下清零,数字不变 | 按键端口引脚定义和原理图不一致 | 查原理图确认KEY_CLEAR的引脚 |
| 编译报错"redefinition" | 头文件或变量重复定义 | 检查是否重复包含reg52.h |
| 下载程序后数码管全乱码 | 上电时P0口未初始化,显示缓冲区没刷新 | main开头先refresh_display_buf |
5.2 计时不准,先怀疑初值,再怀疑晶振
计时不准是秒表程序里最让人头大的问题。我的排查思路是分层排除:
第一步,用秒表或手机计时器做对拍。让单片机跑1分钟,同时手机秒表计时。如果1分钟误差在几十毫秒级别,初值计算基本没问题,误差来自晶振频率和初值取整,可以接受。如果误差达到几秒甚至十几秒,初值大概率错了。
第二步,验证初值。把代码里的TH0和TL0注释掉,在中断服务函数里随便置一个固定值,比如TH0 = 0x00; TL0 = 0x00,看看数码管是不是在快速乱跳。如果乱跳,说明中断本身在触发,问题在初值;如果没反应,检查ET0、EA、TR0有没有都开着,中断向量号interrupt 1写没写对。
第三步,确认晶振。有示波器的直接测晶振引脚,没有的话看板子上的晶振外壳,11.0592MHz通常印着"11.0592",12MHz印着"12.000"。
万一真是初值误差累积导致的走时偏差,偏小还好说,偏大就要做软件校准。校准的思路是这样的:实测1分钟走时,假设快了0.36秒,也就是360ms。这意味着每1分钟要多出360个1ms中断。可以把秒进位的判断从"cnt_ms >= 1000"改成"cnt_ms >= 994",也就是每1000次中断少计6次,一分钟恰好少360次,走时就准了。这个粗略校准在课程设计答辩的时候很好用,实测从"肉眼可见偏快"调整到"和手机秒表几乎同步"。
5.3 数码管闪烁、重影,八成是扫描节奏的问题
数码管显示问题,可以从现象反推原因。闪烁是扫描周期太长,人眼能察觉出亮灭变化,通常意味着主循环里有大延时或者display_scan没有被频繁调用。重影,或者叫拖影,是切换位选时上一位的余晖串到了下一位,因为P0口段码还没换,位选就已经切过去了。解决办法就是扫描函数开头那句P0 = 0xff,先把段码全部置高(共阳管全灭),再切位选,再送新段码。
还有一个隐蔽的坑:STC89C52RC的P0口是开漏输出,不能输出高电平驱动数码管段选,所以TX-1C板子在P0口到数码管之间加了上拉电阻。如果你是在面包板上自己搭电路,忘了加上拉,数码管会非常暗。板载电路不存在这个问题,但用洞洞板复刻的同学注意了。
如果数码管在按下按键的瞬间闪烁一下,那基本就是按键消抖的delay在作怪。10ms的delay卡住了主循环,数码管扫描暂停了10ms,亮度瞬间降一截。高情商的做法是把消抖改成非阻塞式,用时间戳判断;低情商但有效的做法是保持现状,因为10ms的闪烁肉眼其实不容易捕捉到。我自己写秒表这类简单程序时,通常会选择后者,把精力花在更重要的逻辑上。
6. 后续还能怎么扩展这个秒表程序
秒表程序做完只是开始,它像一个毛坯房,后续想怎么装修都行。我个人强烈建议在现有基础上做以下几个方向的扩展,每一个都能把知识点往深挖一截。
第一个方向:加蜂鸣器反馈。天祥TX-1C板载有源蜂鸣器,按下按键响一声、计满99分钟报警,这些功能本质上都是在一个事件发生时让蜂鸣器引脚翻转产生声音。有源蜂鸣器只要给高低电平就能响,程序实现非常简单,但可以让作品的完成度提升一个档次,课程设计拿出去展示观感完全不一样。
第二个方向:引用状态机思维重构按键逻辑。现在按键扫描是简单地判断电平、翻转标志,但如果你把"空闲-按下-确认-释放"这几个状态用变量枚举出来,写成一个专门的按键状态机,后面做矩阵键盘、做复杂菜单时就能直接复用。天祥TX-1C板上还有矩阵键盘,很多课设题目会要求用矩阵键盘输入时间或者切换模式,那时候按键状态机这套东西就派上大用场了。
第三个方向:掉电保存计时数据。STC89C52RC内部有EEPROM,可以在断电前把分钟、秒、百分秒写进EEPROM,下次上电恢复。这对"秒表计时到一半突然断电"的场景很实用。代码上就是调STC的IAP读写函数,难度不大,但能让你把数据存储的概念摸清楚。
第四个方向,也是我自己做秒表时觉得最有意思的:用串口把计时数据实时发到上位机。1ms中断每秒钟产生一次秒数据,用串口发送一个字符串"00:01:23"到电脑的串口助手,电脑端就能实时看到秒表状态,相当于给秒表加了一个"远程显示"。这背后就是51单片机串口通信、波特率发生器、中断发送这些知识,正好把T1定时器用起来。如果后续要接蓝牙模块、ESP8266之类的无线器件,这个串口发送的函数就是现成的底子。
第五个方向:显示增强。把数码管显示格式做成"00-00-00"带分隔符,或者利用TX-1C板上的LED灯做秒表心跳指示,每秒闪一下LED。这些改动都能加深对板载资源灵活运用的理解。
我个人在做完秒表之后的最大收获,不是学会了怎么写一个具体的秒表,而是弄懂了"时间片"这个概念。51单片机是单核的,同一时刻只能干一件事,但通过中断和主循环的配合,它可以一边计时一边扫描数码管一边检测按键,这三件事看起来是同时发生的。秒表程序把这种"并发"的雏形摆到你面前,理解透了这个模型,后面学更复杂的单片机、上操作系统,思路都会顺很多。建议你烧录成功之后,别急着做下一个项目,先把定时器初值重新算三遍,把每个寄存器的位含义默写一遍,再把整个程序的执行流程画成时间线,画完你就能体会到这玩意儿的价值了。