news 2026/10/5 1:20:06

51单片机秒表程序设计:定时器中断与数码管动态扫描实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
51单片机秒表程序设计:定时器中断与数码管动态扫描实战

用天祥TX-1C单片机开发板写一个秒表程序,几乎是每个学51单片机的人都会碰到的“标准动作”。这块板子上有STC89C52主控、8位数码管、独立按键、LED和蜂鸣器,资源不算豪华,但定时器、中断、数码管动态扫描、按键消抖这些单片机基础模块,恰好都能在一个秒表项目里串起来。很多人拿到开发板后跟着教程点亮几个LED、跑个流水灯就觉得自己会了,可真到要自己写一个能开始、暂停、复位的秒表时,才会发现中断和动态扫描之间有那么多的坑要填。

这篇文章我就以自己的实际调试经历为线索,把TX-1C开发板上做秒表程序的完整思路、原理解析、代码实现、烧录验证和常见问题一次讲透。不管你是刚学完C语言和GPIO操作、正在做课程设计,还是想把手头开发板真正玩明白的入门爱好者,都可以照着我这套方案直接上手。代码用Keil C51编写,下载到板子上就能跑,烧录部分也会说明STC-ISP的操作细节。

1. 项目概述与需求拆解

1.1 TX-1C开发板的硬件家底

天祥TX-1C是一块很经典的51学习板,核心芯片是STC89C52RC,属于8051内核,片上有8KB Flash程序存储、512字节RAM、3个定时器/计数器(T0、T1、T2)、6个中断源,典型工作频率12MHz。对做秒表这个项目来说,板上真正用到的资源就三类:定时器、数码管、按键。

具体硬件连接方面,TX-1C的8位数码管是共阴结构,段选通过74HC573锁存驱动,位选也通过74HC573锁存驱动,两个锁存器的LE控制引脚分别是P2.6和P2.7,数据输入都挂在P0口上。独立按键有4个,常见接法是K1接P3.0、K2接P3.1、K3接P3.2、K4接P3.3,按下时引脚被拉低。如果你手里的板子接线不同,稍微改一下引脚定义就行,逻辑不用变。

这块板子的设计思路很清楚:P0口加上拉电阻后作为数据总线,通过锁存器分时复用,所以它的LED、数码管、液晶背光都共用P0口。好处是省引脚,坏处是操作前必须搞清楚当前操作的锁存器是谁。写代码时最忌讳的就是忘记切换锁存器,疯狂改P0但数码管死活没反应,十有八九就是段选和位选锁存顺序搞错了。

1.2 秒表需要哪些核心功能

一个能拿得出手的秒表程序,至少要满足三个基本需求:可以随时启动和暂停计时、可以一键归零重新开始、显示界面要稳定不闪烁。往细了看,还隐含着几个隐藏需求:精确到十分之一秒或秒级的时间基准、四位数码管的动态刷新、按键防抖与防误触。

我设计的版本用了四位数码管,显示“分钟:秒钟”的格式,比如“00:00”、“01:35”。中间用第二位数码管的小数点当作分隔符,视觉上类似电子表上的冒号。考虑到秒表的实际使用场景,复位键按下后无论当前是否在计时,都立即清零并且回到暂停状态,这个逻辑更符合“重新开始”的心理预期。K1负责启动/暂停切换,K2负责归零,按键逻辑清晰,用户上手零学习成本。

1.3 方案选型:定时器、中断、动态扫描怎么搭

实现计时的方案无非三条路:软件延时、专用于延时的定时器查询、定时器中断。软件延时在按键扫描期间CPU会被卡住,秒表走时必然受影响;定时器查询虽然不用中断,但主循环必须不停读定时器标志,显示刷新和按键检测会被挤占。所以最稳妥的方案是定时器0工作在方式1(16位计数模式),产生50ms固定间隔的中断,中断里对50ms次数累加,每20次进1秒。主循环专心做两件事:扫描按键、刷新数码管。

数码管显示只有4位,动态扫描非常简单。所谓动态扫描,就是同一时刻只点亮一位数码管,快速轮流点亮第0位、第1位、第2位、第3位,利用人眼视觉暂留效应让人感觉四位数码管同时亮着。扫描频率要大于50Hz,我的写法里一次主循环刷新一位,4次循环完整轮一圈,速度远高于肉眼极限,显示稳定不闪。

2. 三个关键技术点的原理解释

2.1 定时器为什么能“计时”

STC89C52的定时器本质上就是一个加法计数器。T0工作在方式1时,是16位的计数器,计数脉冲来自系统时钟经过12分频后的机器周期。板载12MHz晶振时,机器周期就是1微秒,计数器每微秒加1。从0加到65535溢出需要65536微秒,约65.536毫秒。

我们想让定时器每50ms产生一次中断,就需要提前给TH0和TL0装入一个初值,让计数器从初值开始跑,等它加到65536溢出时正好过去50ms。初值计算是:65536 - 50000 = 15536,换算成十六进制是0x3CB0,所以TH0 = 0x3C,TL0 = 0xB0。代码里用(65536 - 50000) / 256和(65536 - 50000) % 256分开写,这样一眼就能看出逻辑,修改计时周期时也只需要改50000这个数。

中断产生后,我在中断服务程序里对cnt_50ms做累加,累加到20就说明已经过了1秒,然后把秒计数器加1。这种“先设初值、再溢出中断、中断里累计”的做法,是51单片机所有精准延时项目的通用模板,只要晶振频率稳定,秒表走时就会非常准确。

2.2 数码管动态扫描是怎么回事

共阴数码管内部是多个LED段共用一个阴极,段选控制哪些段亮,位选控制哪一位通电。TX-1C用了两块74HC573做锁存,锁存器的LE引脚为高电平时,输出跟随P0数据变化;LE拉低后,输出保持之前的数据,这样P0就可以腾出来去操作另一个锁存器。

动态扫描的关键在于“先选位、再送段”。我的刷新函数先把段选锁存器打开,P0输出0x00,把当前段的显示全部灭掉,这样可以防止切换位选时残留上一轮段码造成拖影;接着打开位选锁存器,P0输出位选数据,选中当前要显示的数码管;最后再打开段选锁存器,P0输出当前位的段码。这个过程用生活中的例子来比喻,就像打地鼠游戏里快速指挥四个人轮流举手,谁举手谁亮牌,速度快了大家看到的就是四个人同时举着牌子。

2.3 按键消抖,怎么消才不闪屏

机械按键在按下和松开的瞬间,金属触点会因弹性产生几毫秒到十几毫秒的抖动,波形就是连续的高低电平跳变。如果不去抖,一次按下可能被误判成多次触发,秒表就会出现按一下跳好几秒的情况。

最常见的消抖方法是软件延时消抖:检测到按键为低后,延时10ms再检测一次,如果还是低就确认按下有效。10ms这个时间足够避开机械抖动,又不会让用户觉得按键迟钝。不过这里有个坑:很多新手在按键消抖时直接调用一个大延时函数,比如DelayMs(100),导致主循环里的数码管刷新被中断,整个显示明显闪一下。我的做法是消抖延时控制在10ms左右,并且只在待机状态下扫描按键,运行状态下每次主循环也会快速刷新显示,这样同时满足了防抖和显示稳定两个要求。

3. 代码实现与核心环节拆分

3.1 工程结构、引脚定义与全局变量

用Keil C51新建工程时,芯片型号选STC89C52RC或者Atmel 89C52都可以,反正内核一样。工程里只需要一个源文件,我习惯命名为main.c,把所有代码集中在一个文件里,因为这个项目还不到拆成多文件的体量。

代码开头是头文件和引脚宏定义。这里要特别强调,TX-1C的段选和位选锁存引脚是固定的P2.6和P2.7,如果换其他开发板必须先看原理图确认。全局变量一共五个:显示缓冲区disp_buf[4]、秒计数器sec、分钟计数器min、50ms中断累加器cnt_50ms、运行标志位running。running用bit类型定义,节省RAM也方便直接赋值,这是51单片机C语言里的经典小技巧。

#include <reg52.h> sbit DU = P2^6; // 段选锁存,TX-1C上接74HC573的LE sbit WE = P2^7; // 位选锁存,TX-1C上接74HC573的LE sbit K1 = P3^0; // 启动/暂停键 sbit K2 = P3^1; // 复位键 unsigned char code seg_code[10] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; unsigned char disp_buf[4] = {0, 0, 0, 0}; unsigned char sec = 0; unsigned char min = 0; unsigned char cnt_50ms = 0; bit running = 0;

段码表对应共阴数码管的0到9,比如显示数字0时,需要点亮a、b、c、d、e、f六段,二进制是0b00111111,也就是0x3F。如果发现显示的数字缺笔画或者多笔画,优先检查段码表,而不是怀疑硬件。

3.2 定时器初始化与中断服务程序

定时器0初始化四步走:设置TMOD、装初值、开中断、启动定时。TMOD的低四位控制T0,TMOD &= 0xF0是为了只改低四位、不动高四位(高四位是T1的配置)。方式1就是两个8位组成16位计数器,模块代码如下。

void Timer0_Init(void) { TMOD &= 0xF0; TMOD |= 0x01; // T0方式1:16位定时 TH0 = (65536 - 50000) / 256; TL0 = (65536 - 50000) % 256; ET0 = 1; // 允许T0中断 EA = 1; // 开总中断 TR0 = 1; // 启动T0 } void Timer0_ISR(void) interrupt 1 { TH0 = (65536 - 50000) / 256; TL0 = (65536 - 50000) % 256; cnt_50ms++; if (cnt_50ms >= 20) { cnt_50ms = 0; if (running) { sec++; if (sec >= 60) { sec = 0; min++; if (min >= 100) min = 0; } } } }

这里要特意说一下:方式是方式1,不是自动重装模式,所以进入中断后必须重新给TH0和TL0赋值。错过这一步,定时器会从0x0000接着跑,计时周期变成65.536ms,秒表会明显越走越慢。另外,我在复位键逻辑里会把cnt_50ms也清零,这样归零后下一次秒数更新是从完整的50ms开始计算,不会出现复位后第1秒只走了0.9秒就跳变的情况。

3.3 数码管显示驱动

显示驱动是整个程序的“门面”,写得不好会直接看到闪烁、拖影、亮度不均。我的方案是按“灭段、选位、送段”的顺序封装成一个原子操作,每次调用只操作一位。显示缓冲区更新和硬件刷新分开,主循环里先更新数据,再刷新当前位,逻辑清晰。

void Display_OneBit(unsigned char pos, unsigned char num, bit point) { DU = 1; P0 = 0x00; // 段选全灭,消除残影 DU = 0; WE = 1; P0 = 0x01 << pos; // 选中第pos位数码管 WE = 0; DU = 1; P0 = seg_code[num]; if (point) P0 |= 0x80; // 点亮小数点 DU = 0; } void RefreshDisplay(void) { static unsigned char pos = 0; Display_OneBit(pos, disp_buf[pos], (pos == 1)); pos = (pos + 1) & 0x03; } void UpdateDispBuf(void) { disp_buf[0] = min / 10; disp_buf[1] = min % 10; disp_buf[2] = sec / 10; disp_buf[3] = sec % 10; }

位选数据使用的是0x01 << pos,也就是第0位选中左边第一位数码管,第3位选中右边第四位。由于TX-1C的78HC573输出端在LE高电平时跟随输入变化,所以在P0赋值前要先保证锁存器处于打开状态,赋值后再立刻关闭锁存,保证P0在切换第二个锁存器时数码管显示内容不变。

3.4 按键扫描与主循环

按键扫描放在主循环里,用“检测低电平、延时消抖、再检测低电平、等待释放”四步流程。等待释放的while (K1 == 0);看起来有点粗暴,但实际上能避免按住按键时反复触发切换。缺点是长按期间主循环卡住,数码管会短暂停止扫描,但人眼对10毫秒级停顿感知不强,实际使用中影响很小。

void ScanKey(void) { if (K1 == 0) { DelayMs(10); if (K1 == 0) { running = !running; while (K1 == 0); } } if (K2 == 0) { DelayMs(10); if (K2 == 0) { running = 0; sec = 0; min = 0; cnt_50ms = 0; while (K2 == 0); } } } void main(void) { Timer0_Init(); while (1) { ScanKey(); UpdateDispBuf(); RefreshDisplay(); } }

主循环四个环节:扫描按键、更新显示数据、刷新一位数码管,然后回到起点继续。这里有一个容易忽略的小细节:RefreshDisplay每循环一次只刷新一位,四位数码管合起来需要四次循环才完整刷新一轮,但每次循环还穿插了按键扫描和显示数据更新,整体循环周期仍然很短,人眼看过去四个数字就是同时亮着的。

3.5 完整可直接烧录的源码清单

考虑到方便你直接对照验证,我把完整代码放在这里。这个版本就是我在TX-1C上实测跑的,功能为K1启动/暂停、K2复位、四位共阴数码管显示“分钟:秒钟”。换到其他开发板,请先确认锁存引脚和按键引脚,再改动定义部分。

#include <reg52.h> sbit DU = P2^6; // 段选锁存,TX-1C上接74HC573的LE sbit WE = P2^7; // 位选锁存,TX-1C上接74HC573的LE sbit K1 = P3^0; // 启动/暂停键 sbit K2 = P3^1; // 复位键 unsigned char code seg_code[10] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; unsigned char disp_buf[4] = {0, 0, 0, 0}; unsigned char sec = 0; unsigned char min = 0; unsigned char cnt_50ms = 0; bit running = 0; void DelayMs(unsigned int ms) { unsigned int i, j; for (i = 0; i < ms; i++) for (j = 0; j < 125; j++); } void Timer0_Init(void) { TMOD &= 0xF0; TMOD |= 0x01; TH0 = (65536 - 50000) / 256; TL0 = (65536 - 50000) % 256; ET0 = 1; EA = 1; TR0 = 1; } void Timer0_ISR(void) interrupt 1 { TH0 = (65536 - 50000) / 256; TL0 = (65536 - 50000) % 256; cnt_50ms++; if (cnt_50ms >= 20) { cnt_50ms = 0; if (running) { sec++; if (sec >= 60) { sec = 0; min++; if (min >= 100) min = 0; } } } } void Display_OneBit(unsigned char pos, unsigned char num, bit point) { DU = 1; P0 = 0x00; DU = 0; WE = 1; P0 = 0x01 << pos; WE = 0; DU = 1; P0 = seg_code[num]; if (point) P0 |= 0x80; DU = 0; } void RefreshDisplay(void) { static unsigned char pos = 0; Display_OneBit(pos, disp_buf[pos], (pos == 1)); pos = (pos + 1) & 0x03; } void UpdateDispBuf(void) { disp_buf[0] = min / 10; disp_buf[1] = min % 10; disp_buf[2] = sec / 10; disp_buf[3] = sec % 10; } void ScanKey(void) { if (K1 == 0) { DelayMs(10); if (K1 == 0) { running = !running; while (K1 == 0); } } if (K2 == 0) { DelayMs(10); if (K2 == 0) { running = 0; sec = 0; min = 0; cnt_50ms = 0; while (K2 == 0); } } } void main(void) { Timer0_Init(); while (1) { ScanKey(); UpdateDispBuf(); RefreshDisplay(); } }

4. 烧录、调试与常见问题排查

4.1 烧录流程与必须注意的细节

STC系列单片机用的是串口下载,烧录前需要接一根USB转TTL线,RX接板子的TX、TX接板子的RX,还要共地。TX-1C板上有现成的串口电路,直接用USB线通过板载CH340或者外接USB转TTL模块都可以。

打开STC-ISP烧录软件,选择单片机型号STC89C52RC,打开编译生成的hex文件,波特率默认就好,然后在软件里点“下载/编程”。关键一步来了:STC芯片是“冷启动”下载,要先让开发板断电,点击下载按钮后再给开发板上电,软件才能给芯片写入程序。用一句话概括就是“先点下载,再上电”。顺序反了或者板子一直上着电,软件就会一直卡在“正在检测目标单片机”的提示上。

烧录成功后,开发板会立刻运行新程序。正常现象是数码管显示00:00,按K1开始计时,K2归零。如果程序没反应,先别急着改代码,检查三件事:电源指示灯亮了没有、串口接线有没有接反、烧录时是否执行了“断电再上电”的操作。这三个问题占了新手烧录失败原因的九成。

4.2 常见故障速查表

我在调试过程中把最容易踩的坑整理成了一张速查表,你可以对照排查。

症状可能原因解决办法
上电后数码管乱码或者完全不亮段选和位选锁存引脚搞反确认原理图,交换P2.6和P2.7定义
显示数字缺笔画或多笔画共阴/共阳段码表用错换用正确段码表,共阳则取反
按按键没反应按键引脚定义错误,或P3口被其他功能占用检查K1/K2接的是不是P3.0/P3.1
秒表走时明显偏慢定时器没有重新赋初值确认中断里重写TH0和TL0
秒表走时偏快定时器初值计算有误或晶振不是12MHz重新计算初值,适配实际晶振
显示有拖影残影切换位选前没有灭段在选位前先让段选输出0x00
按一下K1连续切换多次缺消抖或消抖延时太短延时消抖加到10ms左右
复位后秒表第一次跳秒不均匀复位时没清零cnt_50ms复位逻辑里把cnt_50ms赋为0
按住按键时数码管闪烁等待释放的while卡住主循环接受少量闪烁,或改为边沿触发方式

这里重点讲两个容易忽略的问题。第一个是P0口是开漏输出,如果没有外部上拉电阻,驱动74HC573会不稳定,好在TX-1C板上已经给P0加了上拉排阻,所以你不用额外处理。第二个是中断里的操作要尽量精简,不要在中断里调用耗时的显示函数或延时代码,否则会破坏定时精度,即使显示速度还行,秒表也会随着显示内容变化出现细微走时偏差。

4.3 调一套秒表程序的调试思路

很多初学者遇到问题喜欢从头到尾重写代码,这是很低效的做法。我建议用“先分后合”的思路排查:先写一个只点亮一位数码管显示固定数字的程序,确认段选和位选锁存都能正常控制;再写一个四位数码管轮流刷新显示固定数字的程序,确认动态扫描没问题;然后加入定时器中断,用秒数变量驱动LED翻转来验证中断周期是否正确;最后再把按键加进去。

每加一个模块就烧录一次验证,出问题的时候范围就很明确。比如显示固定数字正常、按键单独测试也正常,但合在一起后按键反应变慢,那基本可以断定是消抖延时太长或者主循环被某个操作卡住。这个“增量验证”的方法,比一次写完再从头查编译错误要可靠得多,也符合真实项目开发的节奏。

5. 个人心得与扩展方向

5.1 踩过几次坑后我总结的经验

做这个秒表项目时,我印象最深的坑有两个。第一个是第一次上手时把段选和位选锁存顺序写反了,结果数码管永远是乱码,那会儿不懂看原理图,折腾了一整个晚上,最后发现只是P2.6和P2.7写反。所以拿到开发板第一件事一定是看原理图,哪怕这块板子你已经用得很熟,也要把关键引脚抄在代码注释里,人的记忆真的不可靠。

第二个坑是复位键逻辑。最开始我把复位设计成“清零所有计数并自动开始计时”,结果实际测试时想记一个新时刻,按下复位又马上走起来了,根本来不及准备。后来改成复位后回到暂停状态,使用起来才顺手。这个细节告诉我,功能设计不能只看代码能不能跑,更要考虑用户怎么操作,程序里的状态切换要符合直觉。

还有一个值得说的经验:动态扫描的刷新顺序其实会影响亮度均匀性。如果某一位置于刷新顺序的更后面,它每次点亮的时间间隔就会略长,肉眼可能感觉那一位比其他位置暗。我这个版本刷新一轮只要四次,间隔差异微乎其微,但如果把程序扩展到8位数码管,就要注意轮流顺序和扫描周期了,必要时可以把主循环里的刷新函数放在中断里定时触发,确保刷新周期恒定。

5.2 这个秒表还能怎么升级

秒表程序虽然基础,但它是很多复杂功能的地基。想继续进阶的话,可以从三个方向入手。

第一方向是改用定时器1做系统时钟,把T0留给PWM输出或者外部信号计数。比如配一个霍尔传感器或者光电码盘,就能把秒表改造成转速表,中断里同时计数和计时,这套思路在电机测速项目里非常常见。

第二方向是增强交互和显示。TX-1C有蜂鸣器,可以在秒表计时到整分钟时响一声;也可以把显示格式改成“秒 + 十分位”,这样需要一个0.1秒级的时间基准,实现起来就是在中断里每5次50ms事件给十分位加1,显示改成小数点后一位或两位。如果你想把秒表做成真正的体育计时器,还需要加入记录分段时间的功能,也就是按一次键记录当前时间,再按一次显示下一条记录。

第三方向是升级主控或接口。如果以后换到STC15系列或者STM32,定时器初值、中断向量这些会变,但“定时器中断产生时间基准 + 主循环动态扫描 + 按键状态机”的整体架构完全可以直接迁移。这也是我建议初学者认真吃透这个小项目的原因,它背后的设计思想,比代码本身值钱得多。

我个人在实际调试中的体会是,单片机项目没有真正意义上的“小项目”。一个秒表看着简单,但把定时、显示、输入、状态切换全部组合到一起之后,系统复杂度立刻上来了。把这个项目做通、做透,后面不管是做电子钟、计数器还是更复杂的仪表,都会顺畅很多。如果你在照着抄代码的过程中遇到和我当时一样的问题,不用怀疑自己,先把原理图放大了看,把每一步的执行顺序在纸上画出来,问题很快就能定位。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 1:19:47

蓝桥杯8x8点阵驱动:74HC595与38译码器动态扫描详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:18:18

中文小样本分类的数据增强:Faiss+Chinese-SimBERT语义检索方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:17:49

QT中基于FFTW实现可信功率谱密度分析的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:17:24

STM32F103RC 与 MR25H40CDF MRAM 工业存储方案实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:16:49

Matlab 2023b安装配置MOSEK 10.1.25:从License到路径的完整避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华