ARTICLE DETAIL

资讯详情

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

51单片机秒表实战:TX-1C开发板定时器中断与数码管显示教程

51单片机秒表实战:TX-1C开发板定时器中断与数码管显示教程 我见过很多人拿到天祥TX-1C开发板之后选的第一个综合练手项目就是秒表。板子上电数码管能亮程序也能烧进去可一按开始键数字要么疯跑要么卡在一个值上死活不动要么按一下暂停屏幕直接闪到头昏。问题基本不在C语言语法而在于还没搞懂51单片机这类实时系统里“中断里计数、主循环刷屏”的协作方式。这篇就把我在TX-1C上写秒表程序的完整过程、代码、调试坑一次讲清楚适合刚学完定时器和中断、想拿一个实实在在的项目巩固一遍的人。天祥TX-1C这块板子的核心是STC89C52RC板上带了8位数码管、独立按键、蜂鸣器、DS1302、ADC等外设。秒表项目正好把其中最常用的三样东西串起来定时器中断负责计时数码管动态扫描负责显示独立按键负责开始暂停清零。这个组合不涉及外部存储、不涉及通信协议但已经把51单片机最核心的“资源争抢”问题暴露得明明白白——CPU只有一个你要让它在同一时间既计时又刷新显示又响应按键靠的就是中断和短小精悍的主循环。下面按我做这个项目的顺序来讲。1. 秒表这道题为什么是TX-1C上最有练手价值的综合项目很多教程会把秒表归为“单片机入门小项目”我觉得这个定位说低了。它代码量不大算法也不难但它逼着你把几个基础模块从“会单独用”变成“能协同跑”这是质的区别。先看单点能力点亮数码管只需要会查段码表知道锁存器的用法按键点灯只需要读IO口会写个if判断定时器闪灯只需要会配置定时器和中断。这三个分开练几乎人人都能在一小时内搞定。但秒表是三个功能同时跑不能分开练了。为什么这么说因为51单片机是单核单线程的同一时刻CPU只能执行一条指令。你想让数码管每秒刷新几十次按键随时都能被响应同时计时还得精确到0.1秒这三件事如果都在主循环里轮流做任何一个环节耗时过长其他环节就会被拖住。举个最常见的例子有人用delay函数做秒表计时每个循环延时100毫秒然后变量加1。理论上也能得到0.1秒的分辨率但延时期间数码管不刷新手指按按键也没反应整个系统像死机一样。更严重的是如果按下暂停键的那一瞬间刚好卡在延时里秒表还会继续“偷跑”几十毫秒视觉上就是时间跳了一格才停下。这就是中断存在的意义。定时器溢出时CPU放下手头的活去执行中断服务函数把“计时”这件事从主循环里抽离出来。主循环只需要专心做两件事刷新显示、扫描按键。这是51单片机项目里非常典型的分工模式秒表项目能把这个分工训练得非常扎实。TX-1C板子上的资源也很适合这个题目。8位数码管足够显示“分:秒:0.1秒”的完整格式4个独立按键可以直接用P3口的K1、K2、K3不需要额外接矩阵键盘。板载晶振通常是11.0592MHz提这个是因为后面算定时器初值必须用到它。整个项目涉及硬件资源少但机制完整所以我才说它是TX-1C上练手价值最高的一道题。还有一点是这个题目的宽容度。秒表不是通信协议不需要和外部设备严格握手就算时序有几十个机器周期的误差人眼也看不出来。但你又不能随意到用delay去计时因为累计误差会随着时间放大。这个“既要精确、又不过分追求精确”的度刚好适合学习。2. 项目开始的3个准备原理图确认、晶振确认、定时器选型2.1 先花10分钟把原理图看明白别急着写代码TX-1C经历过多个批次板子型号一样引脚定义却可能有细微差别。最稳妥的做法是打开你的开发板原理图确认三件事。第一数码管是共阴还是共阳。TX-1C板载数码管按共阴设计的比较常见但这不是绝对的你手上的批次可能不一样。下面的段码表按共阴给出共阳板子需要取反或者换表。第二段码和位选分别接了哪个端口、哪根锁存线。TX-1C一个常见版本是P0口作为数据口段选锁存端接P2.6位选锁存端接P2.7位选信号经74HC138译码后驱动8个数码管。如果你的板子锁存端不是这两个引脚把代码里的sbit定义改掉就行。这一步看原理图只需要几分钟但能避免后面烧进去显示全是乱的。第三晶振频率是多少。看板子上晶振外壳丝印11.0592MHz和12MHz在这块板子上都出现过。这个数值直接决定定时器初值用错了秒表就会整体偏快或偏慢。提示不要凭印象写代码。很多同学喜欢直接抄网上资料但网上的TX-1C秒表程序至少有三四个引脚版本抄过来不能直接跑才是正常现象对照自己板子的原理图改引脚是基本功。2.2 定时器选型为什么默认用T0方式1STC89C52内部有T0和T1两个定时器秒表用T0就够了。T1尽量留着因为以后如果做串口通信T1要用来产生波特率。这算是51项目里的一个通用习惯现在就要养成。T0有4种工作方式秒表场景下选方式1也就是16位定时器。原因很直白方式0是13位最多计数8192次方式2是8位自动重装只适合高频中断场景方式3只在个别情况下用。方式1计数范围是0到65535配合12MHz左右的晶振能一次定时50ms这个时间长度对秒表非常合适——太短则中断太频繁太长则做0.1秒精度需要更多次中断累加。定时的本质是计数给TH0和TL0装入一个初值然后每个机器周期让计数器加1加到65535再溢出触发中断。所以初值的计算公式是初值 65536 - 定时时间 / 机器周期机器周期在12T模式下等于12除以晶振频率。后面这个“除以晶振频率”的环节特别容易错有人会直接把机器周期当作1微秒来算结果在11.0592MHz晶振下就出现了系统性偏差。2.3 初值计算11.0592MHz和12MHz各自怎么算先算11.0592MHz默认12T模式机器周期 12 / 11.0592MHz ≈ 1.085 微秒 定时50ms需要计数次数 50000 / 1.085 ≈ 46080 初值 65536 - 46080 19456 0x4C00所以TH00x4CTL00x00。这是TX-1C上最常见的配置。如果板载晶振是12MHz机器周期 12 / 12MHz 1 微秒 定时50ms需要计数次数 50000 初值 65536 - 50000 15536 0x3CB0所以TH00x3CTL00xB0。两种情况对比可以做成下面这张表方便以后复查晶振频率机器周期50ms所需计数次数TH0TL011.0592MHz约1.085us460800x4C0x0012.000MHz1.000us500000x3C0xB0我记得第一次写这个项目时照着网上程序用了0x4C/0x00结果板子根本没走时——后来一看那块板子晶振是12MHz。这是所有秒表坑里最基础也最容易忽略的一个。所以拿到板子先看晶振丝印这不是磨蹭是省时间。3. “0.1秒精度”的时间累计是怎么设计出来的3.1 为什么不能用延时循环做秒表直接把秒表的核心逻辑写成下面这样看起来没问题while(1) { delay(100); // 延时100ms ms10; Display(); }一旦按了开始键整个程序就卡在delay函数里。数码管不刷新按键不响应时间倒是自己在走可你没法暂停、没法清零只能等它跑完。更麻烦的是单片机执行delay函数时定时器中断如果开了只有在延时函数里的指令间隙才能被响应整个系统响应极不稳定。正确的思路是把计时完全交给定时器中断主循环只负责“看时间、显示时间、响应按键”。定时器每50ms触发一次中断中断里对变量累加主循环里把累加结果换算成时分秒显示出来。这样计时是精确且独立的不会受到主循环里任何代码的影响。3.2 用两个变量管理时间tick和ms10我倾向于用两个变量来管理时间而不是直接在中断里做复杂的进制换算。tick表示“50ms中断发生了多少次”。因为秒表显示的精度是0.1秒而一次中断是50ms所以每2次中断时间就应该前进0.1秒。ms10保存这个“0.1秒”的累计值它才是秒表的真实时间。为什么不直接用一个float变量保存秒数51单片机做浮点运算很慢而且浮点数累加有误差。用整数计数是最稳的显示时再临时换算成“分、秒、0.1秒”三个部分。中断服务函数里通常这样写void Timer0_Isr(void) interrupt 1 { TH0 0x4C; TL0 0x00; if (run_flag) { tick; if (tick 2) { tick 0; ms10; if (ms10 5999) { ms10 5999; run_flag 0; // 到达59分59.9秒自动停止 } } } }这里有三个细节值得说。第一个重装TH0和TL0必须放在中断服务函数的最前面。有人习惯在最后重装但在中断处理业务比较耗时的情况下重装晚一个周期就意味着定时器从溢出到重新开始计数之间额外跑了几个机器周期时间就会产生几微秒的误差。这种误差单次看可忽略但秒表跑久了会累积。第二个tick是在run_flag为1时才递增的暂停期间tick保持不变。这样暂停后再次开始不会出现0.1秒边界突然跳一下的情况。如果tick在暂停时还继续累加重新开始时tick可能已经是奇数或偶数0.1秒的相位就乱了。第三个秒表到59分59.9秒就自动停止这是一个合理的设计选择。如果希望到点自动清零重新计时把run_flag0改成ms100;tick0即可。这个由你的需求决定不是硬性规定。3.3 暂停和清零的边界条件暂停的逻辑很简单run_flag取反即可。但清零要注意顺序。正确的清零流程是先把run_flag置0停止计时再把ms10置0tick置0。如果顺序反了清完变量之后中断又跑了一次时间就会变成0.1秒而不是0。虽然看起来是小问题但实际调试时会出现“清零之后数字马上变成1”这种诡异现象。清零后显示应该立刻回到“00-00-0”的状态。这个状态由主循环里的显示换算函数生成只要ms10是0显示自然就是0不需要额外处理。另外还要考虑一个边界在暂停状态下清零和正在计时状态下清零行为应该一致都是回到0并停止。上面的代码把run_flag0放在最前面就同时覆盖了这两种情况。4. 数码管动态扫描的代码结构和显示换算4.1 用8位数码管排布“分:秒:0.1秒”8位数码管如果全用来显示数字可以设计成“分分秒秒毫毫”但这样可读性很差。我在TX-1C上常用的排布是位0 位1 位2 位3 位4 位5 位6 位7 分十 分个 - 秒十 秒个 - 数位 0.1秒第2位和第5位显示横杠“-”用来分隔分和秒、秒和0.1秒。横杠在共阴数码管上的段码是0x40。第6位通常不参与或者固定显示0第7位显示0.1秒的个位。从ms10换算成显示数据也很直接。ms10的单位是0.1秒所以分钟数 ms10 / 600因为1分钟等于600个0.1秒秒数 (ms10 % 600) / 100.1秒数 ms10 % 10对应代码void FormatDisplay(void) { uchar minute ms10 / 600; uchar second (ms10 % 600) / 10; uchar tenth ms10 % 10; disbuf[0] minute / 10; disbuf[1] minute % 10; disbuf[2] 10; // 横杠 disbuf[3] second / 10; disbuf[4] second % 10; disbuf[5] 10; // 横杠 disbuf[6] 0; disbuf[7] tenth; }这里的disbuf是一个长度为8的无符号字符数组保存每一位要显示的段码表索引。段码表里第10个元素对应横杠。换算函数应该放在主循环里调用而不是放在中断里因为它只和数据变化有关和中断频率没有直接关系。4.2 动态扫描每次只点亮一位8位数码管共用一组段选线如果同时给8个位送数据它们会显示一模一样的数字。动态扫描的原理是快速轮流点亮每一位利用人眼的视觉暂留造成“同时显示”的错觉。TX-1C这套硬件上显示一位的过程是先送段码到P0口拉高段选锁存端P2.6再拉低把段码锁存住再送位选编码到P0口拉高位选锁存端P2.7再拉低让本次显示只选中第pos位。void ShowOneBit(uchar pos) { P0 duan[disbuf[pos]]; LATCH_DU 1; LATCH_DU 0; P0 pos; // 位选编码适配74HC138 LATCH_WE 1; LATCH_WE 0; }注意位选编码这里TX-1C用的是74HC138译码器输入低三位二进制编码输出端低电平选通对应一位。所以直接送pos本身0到7即可。如果你的板子位选不是经138译码而是P2.0-P2.2直连就改成P2 (P2 0xF8) | pos;如果是用位掩码直接选通那要送的是类似0x01、0x02、0x04这样的值。总之写之前查原理图这个适配动作几乎避不开。扫描函数就更简单了用静态变量轮流切换位置void DisplayScan(void) { static uchar i 0; ShowOneBit(i); i; if (i 8) { i 0; } }4.3 扫描周期的把握不要在主循环里放长延时动态扫描最怕的是某个位停留时间过长。假设你在扫描函数里加了一个delay(2)保持亮度那么8位扫完一轮至少16ms加上其他代码整个刷新周期可能超过20ms。人眼对20ms的闪烁已经比较敏感了屏幕就会显得暗而且闪。我的做法是扫描函数里不做任何延时每扫一位就立刻返回靠主循环整体循环速度形成自然刷新频率。主循环里如果只有按键扫描、显示换算、显示扫描这几件事一轮循环大约在几百微秒到几毫秒之间8位扫完一轮通常不到10ms视觉上很稳定。如果你发现刷新太快导致亮度不均可以在ShowOneBit函数里插入几条_nop_()做微调而不是用长延时。至于到底要插几条得看你位选稳定时间这个只能实测。5. 按键处理边沿检测和松手确认别用while死等5.1 为什么while消抖会让秒表“偷跑”按键消抖的经典教学是检测到低电平后延时10ms再读一次如果还是低电平就认为按下有效。这句话本身没错但它没说清楚“延时”会卡住什么。上面这个死等版本等于在主循环里插入了一个最长10ms的阻塞。10ms对于秒表意味着什么定时器中断每50ms发生一次在这10ms里中断仍然发生时间仍然在走但屏幕不刷了。如果按住按键不放阻塞时间可能长达几百毫秒这段时间秒表一直在累计松开时数字已经跳了一大截。另一个更隐蔽的问题是如果按键检测写在了中断服务函数里比如在中断里检测K1是否按下那么时间累加和按键检测就会互相干扰可能按一下开始键run_flag被反复取反好几次。正确处理按键必须满足三个原则不阻塞主循环只响应一次边沿而不是持续响应低电平有消抖确认但用非阻塞的方式实现5.2 用一个简单的状态机管按键我的按键模块用一个三态状态机空闲态、确认态、松手等待态。空闲态没检测到按下继续扫描。 确认态检测到低电平连续多次采样仍然为低电平时认为按下有效执行按键功能然后进入松手等待态。 松手等待态按键释放后回到空闲态防止用户在长按期间功能被重复触发。对应K1按键的代码uchar KeyScan(void) { uchar key 0; static uchar s1 0, c1 0; switch (s1) { case 0: if (K1 0) { s1 1; c1 0; } break; case 1: if (K1 0) { if (c1 5) { key 1; s1 2; } } else { s1 0; } break; case 2: if (K1 1) { s1 0; } break; default: s1 0; break; } return key; }这个函数每次被主循环调用时只执行一次不做延时。连续采样5次都读到低电平才确认按下实际耗时取决于主循环速度大约几毫秒足以覆盖机械抖动的典型时间。确认后立刻返回按键编号同时进入松手等待态保证按一次只触发一次。K2按键的处理逻辑完全一样如果同时按住两个键只需要在KeyScan内部加第二个状态机返回的key值用位标志组合即可。提示状态机的计数次数不是固定的。主循环跑得快计数5次可能只需要1ms主循环里有其他耗时操作5次可能要5ms。这个次数选3到10都比较安全。如果你觉得按键不够灵敏可以减到3如果按下经常没反应可能是时间太短加到8。6. 完整可用的秒表参考代码把前面几节的内容整合成一份可直接编译的完整程序。以下代码基于TX-1C最常见版本的硬件连接P0口接数据线P2.6控制段选锁存P2.7控制位选锁存晶振11.0592MHz。#include reg52.h #define uchar unsigned char #define uint unsigned int sbit K1 P3^0; sbit K2 P3^1; sbit LATCH_DU P2^6; sbit LATCH_WE P2^7; code uchar duan[] { 0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x6f, 0x40 // 横杠 }; uchar disbuf[8]; bit run_flag 0; uint ms10 0; uchar tick 0; void Timer0_Init(void) { TMOD 0xF0; TMOD | 0x01; TH0 0x4C; TL0 0x00; ET0 1; EA 1; TR0 1; } void Timer0_Isr(void) interrupt 1 { TH0 0x4C; TL0 0x00; if (run_flag) { tick; if (tick 2) { tick 0; ms10; if (ms10 5999) { ms10 5999; run_flag 0; } } } } void FormatDisplay(void) { uchar minute ms10 / 600; uchar second (ms10 % 600) / 10; uchar tenth ms10 % 10; disbuf[0] minute / 10; disbuf[1] minute % 10; disbuf[2] 10; disbuf[3] second / 10; disbuf[4] second % 10; disbuf[5] 10; disbuf[6] 0; disbuf[7] tenth; } void ShowOneBit(uchar pos) { P0 duan[disbuf[pos]]; LATCH_DU 1; LATCH_DU 0; P0 pos; LATCH_WE 1; LATCH_WE 0; } void DisplayScan(void) { static uchar i 0; ShowOneBit(i); i; if (i 8) { i 0; } } uchar KeyScan(void) { uchar key 0; static uchar s1 0, c1 0; static uchar s2 0, c2 0; switch (s1) { case 0: if (K1 0) { s1 1; c1 0; } break; case 1: if (K1 0) { if (c1 5) { key | 0x01; s1 2; } } else { s1 0; } break; case 2: if (K1 1) { s1 0; } break; default: s1 0; break; } switch (s2) { case 0: if (K2 0) { s2 1; c2 0; } break; case 1: if (K2 0) { if (c2 5) { key | 0x02; s2 2; } } else { s2 0; } break; case 2: if (K2 1) { s2 0; } break; default: s2 0; break; } return key; } void main(void) { uchar key; Timer0_Init(); while (1) { key KeyScan(); if (key 0x01) { run_flag ~run_flag; } if (key 0x02) { run_flag 0; ms10 0; tick 0; } FormatDisplay(); DisplayScan(); } }代码量不大但每一块都有自己的职责。中断服务函数只管时间累加主循环只管按键、换算和扫描两者互不干扰。K1控制开始暂停K2控制清零复位。如果你的板子晶振是12MHz把Timer0_Init里的TH0改成0x3C、TL0改成0xB0即可。如果位选不是经138译码把ShowOneBit里的P0pos改成对应控制方式。这两处是这份代码最主要的适配点。7. 实机调试踩过的坑和排查思路代码写出来只是第一步烧进板子后真正的问题才开始。下面几个问题是我在TX-1C上调这个项目时真实遇到的按出现频率排序。7.1 上电显示乱码现象是上电后数码管显示一堆不规则的横杠或者乱跳的数字按下复位可能又变一个样。优先排查顺序先看主程序开头有没有做显示初始化。如果你没有清空锁存复位后IO口电平不定P2.6或P2.7可能处于高电平P0口上的随机数据就被锁存进去了数码管自然显示乱码。解决办法是在main函数开头加一段P0 0x00; LATCH_DU 1; LATCH_DU 0; LATCH_WE 1; LATCH_WE 0;还有一个常见来源是中断和主循环在同时改disbuf。如果FormatDisplay写在中断里扫描函数循环里读到一半的disbuf就会出现个别位显示新旧混合数据。解决方法是把FormatDisplay放到主循环中扫描和换算都在同一个上下文里执行不存在并发问题。7.2 按键按一次却触发多次或者按一下时间乱跳这个坑几乎人人都会踩一次。现象是按下K1秒表开始跑了再按一次想暂停结果它停了一下又继续跑好像按键自己会弹两下。原因分析下来有两个。一是机械抖动导致一次按下被识别成两次下降沿老式delay消抖没写好。二是按键检测代码放在了中断服务函数里比如每50ms进一次中断中断里只要读到K1为0就取反run_flag按一次键期间会进很多次中断run_flag被反复取反最终结果完全随机。修复方法是把我上面写的状态机按键函数装上去。它同时满足了三件事连续采样消抖、只响应一次、松手后才允许下一次触发。7.3 计时偏快或者偏慢这是秒表项目里最有意思的坑因为现象不那么明显跑几秒感觉不出来但跑一分钟能差出零点几秒用手机秒表一对比就很明显。优先检查晶振频率和初值是否匹配。11.0592MHz和12MHz的50ms初值差了约4000个计数换算成时间大约是4毫秒累计一分钟误差接近0.5秒肉眼很容易发现。其次是检查中断服务函数里是否有其他耗时操作。如果你在中断里调用了FormatDisplay、DisplayScan甚至用了for循环延时中断执行时间就会变长。定时器溢出到重装初值之间多出来的时间虽然不多但会持续累积。如果误差稳定且不大比如每分钟偏快0.2秒可以微调初值。假设你希望每分钟慢0.2秒那相当于每次50ms中断里要减少的时间是0.2秒 / 1200次 约166微秒转换成机器周期大约是153个11.0592MHz下。把初值增加153也就是TH0和TL0的组合数值加153就能把时间调慢。反过来如果走慢了就减小初值。这种微调没有固定套路但理解计算思路后你可以用自己的实验数据反推出合适的初值。7.4 数码管闪烁、亮度不均现象是整个屏幕在可感知地闪动或者中间某一位特别亮、两边特别暗。先检查有没有阻塞性延时卡在主循环里。按键检测里的while等待是最大的嫌疑人它会瞬间打乱扫描节奏。扫描期间如果能看到一个位一直亮着大概率就是while死等卡在那里。再检查扫描函数的执行时间是否足够短。如果ShowOneBit里为了处理复杂逻辑做了很多赋值操作单次执行时间过长8位扫完一轮的总周期就会变大导致闪烁。对于51单片机这种低速平台显示扫描代码要写得尽量精简不要在扫描函数里做除法。亮度不均的另一个来源是位选编码错误。比如某一位根本没有被正确选通它就一直处于熄灭状态其他位为了弥补亮度把扫描频率调高反而会更明显地闪。用万用表或逻辑笔逐个检查位选输出能快速定位是哪一位的驱动出了问题。7.5 中断重装和业务逻辑的顺序问题最后再提醒一次重装TH0和TL0务必放在中断服务函数开头这是我在好几个项目里用教训换来的习惯。有些人在中断里先做时间累加再做显示刷新最后重装初值。一旦时间累加或显示刷新里出现了分支跳转中断执行时间就不固定相当于每次溢出的周期被拉长了几个到几十个机器周期长时间运行误差就出来了。8. 后续扩展秒表只是这套代码的起点这个秒表程序跑通之后我后来在它基础上做过几个小改动都挺有意思。一个是把K3加进去作为暂停/继续键和K1区分开这样比赛计时时可以左手开始、右手暂停另一个是加蜂鸣器在到达设定的目标时间后响一声提示。改动其实都不大核心还是这套“定时器中断维护时间 主循环刷新显示 非阻塞按键扫描”的框架。如果想进阶可以试试把数码管换成LCD1602显示“MM:SS.d”格式读数更舒服或者把单次计时改成多组存储按一次K3记录一组成绩这在体育课计时场景里很实用。TX-1C板载的DS1302和ADC也可以慢慢往这个项目上挂思路是一样的硬件模块永远是通过中断和主循环的协作被纳入整个系统。我在实际调试中发现秒表项目最大的价值不是“做出一个能计时的东西”而是逼着你把中断、定时器、键盘、显示这几块知识真正拧在一起。当你习惯了“中断里只做最少的事主循环里不出现长阻塞”这套写法之后后面做串口通信、做PWM、做各种传感器读取都会顺手很多。
返回列表