ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

51单片机电子钟设计:50ms时基、数码管动态扫描与状态机

51单片机电子钟设计:50ms时基、数码管动态扫描与状态机

简介:华南理工大学2020年微机课程设计报告,面向学习微机原理、51单片机与汇编语言的学生及课程设计参考者,围绕电子日历时钟项目提供较完整的实践方案。压缩包内仅含1个doc文件,约3.89MB,集中呈现课程设计报告书正文、功能要求、方案考虑与结果分析。内容除北京时间显示与校准、公历日期显示与校准、秒表、闹钟及PC联机校准外,还涉及4位LED数码管动态扫描、矩阵键盘“一键多用”、INT0/INT1中断切换年、月日、时分、秒毫秒、闹钟和秒表六种模式,以及74HC595串行送段码、闰年平年判断、按键校准与蜂鸣器提醒等实现细节。已有1145人学习,适合据此理解51单片机接口设计、汇编程序结构、软硬件协同调试和课程设计报告组织方式,也可为同类课设的功能拆解与方案撰写提供参照。

1. 一个 4 位数码管电子钟,真正的难点从来不是显示

很多人接到这类题目,第一反应是“显示而已”——4 位数码管、几个按键、一个定时器。真动手才发现,微型计算机这门课里最硬的部分全挤在这个小项目里:时间不是读出来的,是一级一级数出来的;按键不是孤立输入,是要和显示抢 CPU 的状态机;用 51 单片机和汇编写,连一次除法都得自己安排 A 和 B 谁装商谁装余数。这份课程设计把六个显示模式、日历进位、闹钟、秒表全塞进一颗 STC 单片机,恰好能把“时基—扫描—交互”三层怎么咬合看清楚。搞懂它,你手里就不只是一个能走的钟,而是一套可迁移的裸机时间管理骨架。

2. 时基链:50ms 中断如何被数成一整年

单片机没有 RTC,也没有哪个寄存器里存着“现在几点”。它只有晶振,晶振只会一秒钟抖一千万下。所谓“北京时间”,是把这些抖动切成一格一格,再用变量一格一格往上加出来的。这条加法链有多长、每级的溢出条件是什么,决定了这个钟是准还是飘。

2.1 0x3CB0 这个初值是怎么算出来的

定时器初值不是玄学,是可以手算的。这份设计用 12MHz 晶振,51 的机器周期是 12 分频,也就是 1μs 一条指令周期。定时器工作在方式 1(16 位),从初值一直数到 65536 溢出,所以想让它 50ms 溢出一次,初值就该是 65536 − 50000 = 15536,十六进制正好 0x3CB0,拆成高 8 位 0x3C 给 TH0、低 8 位 0xB0 给 TL0。

MOV TMOD,#11H ; T0、T1 都工作在方式1:16 位定时器 MOV TL0 ,#0B0H ; 低 8 位 MOV TH0 ,#03CH ; 高 8 位,合起来 0x3CB0 = 15536 MOV TL1 ,#0B0H ; 秒表用的 T1,同样 50ms MOV TH1 ,#03CH

参数只有两个:晶振频率和定时长度。换了 11.0592MHz 晶振,这个初值立刻失效,算出来是 65536 − 46080 = 19456 = 0x4C00。所以看到别人代码里搬 0x3CB0,先问一句晶振多少,不然时钟会稳定地走快或走慢,而且误差是线性的,一天能差出好几分钟。

为什么不选 1ms?因为方式 1 最长只能定到 65.536ms,1ms 当然定得出来,但中断频率变成每秒 1000 次,每次中断都要压栈、重装初值、判断、出栈、RETI,十几到几十个机器周期白白烧掉,CPU 被切得太碎,动态扫描的时序会明显抖动。为什么不选 65ms?因为太贴近 16 位上限,重装初值时那点指令延迟占比就上来了,长期累积的误差反而更大。50ms 是个折中:一秒只进 20 次中断,开销可控,误差又能被“整秒”这个边界吃掉。原文档里那句“定时时间应是秒的整除数,且长短适宜”,翻译成数字就是这句话。

2.2 分频链:秒、分、时、日各有各的溢出条件

中断里只干一件事——数次数。R6 从 0 数到 20 就是 1 秒,然后清零,把进位任务交给秒变量。这一层是纯粹的整数计数,逻辑最简单,但也最容易被写错,因为一旦某一级忘了清零,后面的时间会整段错位。

常见的实现思路是这样:

EINT0: ; T0 中断,每 50ms 进来一次 PUSH ACC PUSH PSW INC R6 CJNE R6,#20,EINT0_EXIT ; 20 × 50ms = 1s,未到就退出 MOV R6,#0 LCALL INC_SECOND ; 到 1s,走进位链 EINT0_EXIT: MOV TL0,#0B0H ; 务必在退出前重装初值 MOV TH0,#03CH POP PSW POP ACC RETI

进位链的每一级“模”都不一样,这是电子日历和普通计时器最大的区别:

INC_SECOND: INC SECOND CJNE SECOND,#60,DONE ; 秒模 60 MOV SECOND,#0 INC MINUTE CJNE MINUTE,#60,DONE ; 分模 60 MOV MINUTE,#0 INC HOUR CJNE HOUR,#24,DONE ; 时模 24 MOV HOUR,#0 INC DAY LCALL CHECK_DAY ; 日不是取模,是按月查表 DONE: RET

时和分是固定模,日不是。日的上限随月份浮动,2 月还要看年份,这一级必须单独抽出来做,否则你会写出“每个月都是 31 天”的钟——它在 3 月 1 日到 3 月 3 日之间看起来完全正常,等到 4 月才露馅。

2.3 月大月小与闰年:日期进位本质是一次查表加一次特判

月大月小的规则很死:4、6、9、11 月 30 天,其余 31 天,2 月单算。用汇编实现,最省事的是把 12 个月的天数做成常量表,用 MOVC 查。

MONTH_DAYS: ; 输入 A = 月份 1..12,输出 B = 当月天数 MOV DPTR,#DAYS_TAB DEC A ; 表从 0 开始索引 MOVC A,@A+DPTR MOV B,A RET DAYS_TAB: DB 31,28,31,30,31,30,31,31,30,31,30,31

2 月要再补一刀。闰年的判定条件是:能被 4 整除且不能被 100 整除,或者能被 400 整除。汇编里没有取模运算符,只能拆成位运算和除法:

IS_LEAP: ; 输入 YEAR_H:YEAR_L,闰年返回 Z=1 MOV A,YEAR_L ANL A,#03H ; 低两位为 0 才可能被 4 整除 JNZ NOT_LEAP MOV A,YEAR_L MOV B,#100 DIV AB ; B = 低字节对 100 的余数 MOV A,B ORL A,YEAR_H ; 高字节也得为 0,才算整百年 JNZ LEAP ; 不能被 100 整除 → 闰年 ; 到这里说明能被 100 整除,只有能被 400 整除才是闰年 MOV A,YEAR_L MOV B,#40H ; 400 的低字节,实际要按 16 位除法处理 ; 本世纪内只需特判 2000 这类年份 ... NOT_LEAP: CLR C RET LEAP: SETB C RET

这段里有个容易被忽略的点:年份用两个字节 YEAR_H、YEAR_L 存,实际存的是 20 和 20,拼起来才是 2020。做 400 的整除判断时,如果只拿低字节去除,2000 和 2400 会得到同样的余数,判出来是错的。在这类课设的年份范围里,实用做法是把能整除 100 的年份单独列一张小表——2000 是闰年,2100 不是。这样写看着不优雅,但绝对不会翻车。我在第一次写这段时就吃过亏,把 2100 年判成了闰年,2 月 29 日硬生生多出来一天,测试时没发现,因为没人会把年份调到 2100。

2.4 中断优先级:为什么定时器 0 必须压过按键

51 有两个中断优先级,靠 IP 寄存器里的 PT0、PX0、PT1 这几个位设。原代码里设成了定时器 0 最高、外部中断 0 次之、定时器 1 和外部中断 1 最低。这个顺序不是随手排的,是从“谁断了谁就会出错”倒推出来的。

定时器 0 是时基,它一旦被延迟响应,50ms 的计数就会被拉长,误差直接进到秒。外部中断 0 是模式切换,人按键的反应时间在几十毫秒量级,晚一点没人察觉,但它里面要调蜂鸣器和延时,属于长活,不能压过时基。外部中断 1 里要扫矩阵键盘,更长,所以排最低。定时器 1 是秒表用的,精度本来就粗,排最低没意见。

这里有个隐含的成本:外部中断 1 进校准模式后,会在中断服务程序里扫描键盘并等待按键,这段时间定时器 0 还在抢,所以时间不会停。但如果把键盘扫描放在比定时器 0 更高的优先级里,扫描期间时基就被冻住了——你会看到秒表在按键时“卡一下”。优先级表本质是一张“谁可以等、谁不能等”的清单,写之前先想清楚这个,比事后调 bug 省事得多。

3. 动态扫描:4 位数码管怎么用 3 根线点亮

4 位数码管如果静态驱动,每位都要一组段码线,4 位就是 32 根,单片机根本拿不出来。动态扫描的思路是把“同时亮”换成“轮流亮得足够快”,人眼就自动把它们拼成一幅完整画面。代价是时序必须精确,任何一次写入顺序错了,屏幕上就是糊的。

3.1 段码与位选:P0 口一根线干两件事

这份设计的硬件很抠门:段码不走并行口,而是通过 74HC595 移位寄存器串行送进去,只占 P0.7(SER 数据)、P0.4(SRCK 移位时钟)、P0.5(RCK 锁存时钟)三根线;位选直接用 P0.0 到 P0.3,一位一根。于是 P0 口的低 4 位是位选、高 4 位是 595 控制线,一个口同时承担两种职责。

这个复用带来了一个必须遵守的纪律:任何一次写 P0,都不能把低 4 位顺手带出去。所以代码里反复出现 ANL A,#0F0H 再 MOV P0,A 的写法,先把位选全清掉(也就是全部熄灭),保住高 4 位不变,然后再单独把某一位点亮。

SEG_DISPLAY: ACALL CODE_CHANGE ; 把一个字节拆成 4 位 BCD,查表得段码 DISPLAY1: MOV A,P0 ANL A,#0F0H ; 清低 4 位:熄灭所有位,保住 595 控制线 MOV P0,A MOV A,GEWEI ACALL SEND_BYTE ; 串行送出个位段码 ORL P0,#08H ; P0.3 置 1,只点亮最右一位 MOV R7,#10H LCALL DELAY ; 保持一段时间,保证亮度

段码表是共阴还是共阳,取决于数码管接法。这里的 NUM_TABLE 从 0x3F(数字 0)、0x06(数字 1)一直到 0x6F(数字 9),是标准共阴码,最后再补一个 0x80 作为索引 10,专门用来只点亮小数点。模式 2 显示时和分、模式 4 显示闹钟,就是靠这个索引 10 把小数点塞进去做区分的。

3.2 消影:起辉时间和余辉时间之间的那笔账

动态扫描最容易出的问题不是不亮,是“发虚”。你在第 1 位写了个 1,第 2 位写了个 8,理论上应该看到 18,实际看到的是 8 上面淡淡叠了个 1。原因在 LED 的余辉:段码撤掉之后,管子不会立刻灭,还会亮那么一小会儿。如果新段码在旧段码还没灭干净时就送进去,两段光就叠在一起了。

解法只有一个:写新内容之前,先让所有位都熄灭,等余辉过去,再写新段码。这就是消影。代码里那些 MOV P0,#00H 加一个 R7=#03H 的短延时,干的就是这个活。

MOV P0,#00H ; 全部熄灭 MOV R7,#03H ; 短延时,等余辉衰减 LCALL DELAY MOV A,BAIWEI ACALL SEND_BYTE ; 现在才送新段码 ORL P0,#02H ; 点亮第二位

这个延时的长度是个平衡点。太长,4 位轮一遍的时间被拉长,刷新率掉到 50Hz 以下,人眼开始看到闪烁;太短,余辉没消干净,还是有重影。按文档里的说法,驱动信号的维持时间要大于起辉时间,间歇时间要小于余辉时间,而这两条是互相拉扯的——维持时间长意味着间歇短,间歇短又可能不够消影。实践中的做法是:维持时间给足(R7=0x10),消影时间给到刚好够(R7=0x03),两个数字都是试出来的,不是算出来的。这也是硬件调试里少数几个必须靠眼睛定参数的地方。

3.3 SEND_BYTE 的移位时序:9 次循环里的第 9 次是干嘛的

74HC595 是串入并出,数据一位一位进,8 位进满之后拉一下锁存脚,8 位才一起出现在输出端。所以送一个字节要 9 个动作:8 次移位加 1 次锁存。

SEND_BYTE: MOV BYTE,A ; 暂存待发送字节 MOV A,#80H ; 掩码,从最高位开始试探 MOV R7,#9 ; 8 位数据 + 1 次锁存 SB_LOOP: DJNZ R7,SB_SHIFT CLR RCK ; 第 9 轮:RCK 上升沿,把移位寄存器推到输出 SETB RCK CLR RCK RET SB_SHIFT: RL A ; 掩码循环左移,逐位对准 MOV B,A ANL A,BYTE ; 测试当前位是 0 还是 1 JNZ SB_SET CLR SER ; 该位是 0 MOV A,B AJMP SB_CLK SB_SET: SETB SER ; 该位是 1 MOV A,B SB_CLK: CLR SRCK ; 移位时钟一个上升沿,数据进一位 SETB SRCK CLR SRCK AJMP SB_LOOP

三个参数要记牢:SER 是数据,SRCK 是移位节拍,RCK 是锁存。前 8 轮只往移位寄存器里灌数据,输出端纹丝不动,所以你看不到屏幕上有中间态;第 9 轮拉 RCK 的瞬间,8 位才同时出现在段码引脚上。这个“先攒后放”的特性正是 595 的价值——它把“送数据”和“改显示”这两件事在时间上分开了,避免了逐位送的时候屏幕上出现乱码。

需要留意的是移位方向:先送出去的位最终落在 QH 还是 QA,取决于芯片内部结构和你的接线。第一次点亮时最稳的办法是写个只亮一段的测试码,逐位点亮核对一遍,把段码表调对。这一步花十分钟,比后面盯着重影找半天强。

3.4 亮度、刷新率与占空比:R7=0x10 到底在平衡什么

4 位扫描,每一位只在 1/4 的时间里亮着,占空比 25%。要让平均亮度够看,单次的驱动电流就得给足,但电流又受数码管极限功耗和 595 驱动能力的限制,不能一直往上加。所以亮度实际是被三个东西共同卡住的:单次保持时间、总刷新率、驱动电流。

按人眼视觉暂留,刷新率超过 50Hz 基本看不出闪,也就是 4 位轮一遍要在 20ms 内完成,平均每位 5ms 以内。保持时间 R7=0x10 配的那段延时,量级在几百微秒到 1ms 之间,加上消影的几十微秒,一轮下来落在 2 到 4ms,刷新率 250Hz 以上,余量很足。如果哪天你把 R7 调到 0x40 想让它更亮,刷新率会掉到 60Hz 附近,边缘就开始抖——这不是代码错了,是你把预算花超了。

4. 状态机:两个外部中断怎么撑起六个模式

硬件上只有两个独立按键接到 INT0 和 INT1,加一个 4×4 矩阵键盘,但要完成六种显示加六种校准。这个数量差不是靠加按键解决的,是靠“一键多用”——同一个物理按键,在不同状态下干不同的事。听起来像技巧,本质上是一次状态机设计。

4.1 MODE 与 TD:一键多用的本质是状态变量复用

整个交互被拆成两个正交的维度。MODE 变量(0 到 5)决定“现在显示什么”:0 显示年、1 显示月日、2 显示时分、3 显示秒和毫秒、4 显示闹钟、5 显示秒表。TD 变量(0、1、2)决定“现在是不是在改”:0 是正常走时,1 是刚进入校准,2 是校准已激活。

INT0 只动 MODE,按一下加一,到 6 回绕到 0;INT1 只动 TD,按一下在 0→1→2→0 之间循环。两个变量互不干扰,组合起来就是 6×3 种状态,但代码只需要写两段自增逻辑。

PRO_INT0: ; 外部中断 0:切模式 PUSH ACC MOV R7,#10 ACALL DELAY ; 先等一下,让抖动过去 JB P3.2,INT0_RET ; 再读一次引脚,还是高电平说明是抖动 MOV A,TD CJNE A,#1,INT0_NEXT MOV TD,#2 ; 校准状态下按 INT0:退出校准 AJMP INT0_RET INT0_NEXT: INC MODE ACALL BEEP_SHORT ; 按键提示音 MOV A,MODE CJNE A,#6,INT0_RET MOV MODE,#0 ; 六个模式循环 INT0_RET: POP ACC RETI

TD 那个 1→2 的分支值得单独说。它让“进入校准”和“退出校准”共用同一个键,但行为不同:第一次按是进门,第二次按是确认。这实际上是把“编辑—提交”这个模型压进了两个状态里,省掉一个确认键。代价是用户得记住按两下,所以代码里配了不同长度的提示音来区分——短音是切换,长音是进入。用声音补信息,是按键数量受限时最便宜的办法。

矩阵键盘的四个键 S11、S12、S15、S16 在不同 MODE 下含义完全不同。模式 0 下它们分别调年份的千位、百位、十位、个位;模式 1 下 S11/S12 调月份加减、S15/S16 调日期加减;模式 2 和模式 4 下调时和分的加减;模式 3 下 S11/S12 调秒的十位和个位;模式 5 下变成秒表的开始、暂停、清零。同一组键,六套语义,靠 MODE 分发。

4.2 矩阵键盘为什么放在中断里扫

KEYSCAN1 是在外部中断 1 的服务程序里被调用的。这个选择有它的道理:键盘扫描是个轮询活,如果放主循环里,主循环每一轮都要花时间去查有没有键按下,而这些时间本来可以用来刷新数码管。放进中断,只有真的按下 INT1 才进去扫,不按的时候一点开销都没有。

但中断里扫键盘有个硬约束——扫描过程不能太长。中断服务程序执行期间,同级和低级中断被阻塞,如果 KEYSCAN1 里带上大段延时等待按键释放,定时器 0 就被推迟,时基开始漂。所以这类代码通常写成“扫一轮就返回”,靠主循环反复进来,而不是在中断里死等。

PRO_INT1: ; 外部中断 1:进入/退出校准 PUSH ACC MOV R7,#10 LCALL DELAY JB P3.3,INT1_RET ; 消抖 MOV A,TD INC A MOV TD,A CJNE A,#1,INT1_RET ; 只有 TD=1 时才扫键盘 LCALL KEYSCAN1 ; 扫一轮矩阵键盘,处理加减 INT1_RET: POP ACC RETI

这里有个真实的坑:TD=1 表示“刚进入校准”,此时扫一轮键盘是对的;但如果用户按着键不放,下一次进中断时 TD 已经变成 2,就不会再扫了,表现为“按住只加一次”。要支持连加,得在 TD=2 时也扫,或者干脆把扫描挪到主循环。原设计选了前者还是后者,取决于交互上要不要连加——课设场景下“按一次加一”反而更可控。

4.3 闹钟比较为什么放在主循环而不是中断里

闹钟的判定逻辑很朴素:当前的小时等于闹钟小时,且当前分钟等于闹钟分钟,就响。这段代码被放在主程序 START 的最开头,每轮循环都执行一次。

START: MOV DPTR,#NUM_TABLE MOV A,CLOCK_H CJNE A,HOUR,TIMER_EXIT ; 时不等,跳过 MOV A,CLOCK_M CJNE A,MINUTE,TIMER_EXIT LCALL BEEP_CLOCK ; 时和分都相等,闹钟响 TIMER_EXIT: MOV A,MODE ; 后面按 MODE 分发到六个显示函数

放主循环的收益是:中断里只保留最要紧的时基,不掺业务逻辑,中断服务时间短,时基稳。代价有两个,而且都不小。

第一,主循环一轮的耗时是不固定的。显示函数里有大段 DELAY,BEEP_CLOCK 本身还有三段短音加一段长音,全是延时。如果在响铃期间时间跳过了那一分钟,下一轮比较就不再相等,闹钟只响一次,这是对的;但如果某一轮主循环恰好卡在显示延时里,比较发生的时刻晚了几十毫秒,一般也发现不了。

第二,更麻烦的是“相等持续一整分钟”。当前分钟等于闹钟分钟这个条件,在整整 60 秒里都成立。如果 BEEP_CLOCK 执行完之后主循环又转回来,条件还是成立,就会再响一遍。实际表现是闹钟在一分钟里反复响,而不是响一次。常见的补法是加一个“已响过”标志,在分钟发生变化时清掉;或者把判定收紧到“秒从 59 跳到 0 的那一次”。

我自己的习惯是把闹钟判定挪进定时器中断的秒进位分支里,只在秒归零那一刻比一次,天然只触发一次,还顺带省掉了主循环每轮的两次比较。代价是中断里多几行代码,但比在主循环里维护标志位干净。

4.4 秒表:定时器 1 和 6 个显示位的错位

秒表是这份设计里最“取巧”的部分。它没有独立的硬件,靠定时器 1 每 50ms 中断一次,在中断里累加 WATCH_MSEC、WATCH_SEC、WATCH_MIN 三个变量。显示的时候,把 WATCH_SEC 除以 10 拆成十位和个位,然后把十位和 WATCH_MIN 相加,塞进高位显示。

SHOW_WATCH: MOV A,TD CJNE A,#0,SW_NEXT CLR TR1 ; TD=0,停表 SW_NEXT: MOV A,WATCH_SEC MOV B,#10 DIV AB ; A = 十位,B = 个位 ADD A,WATCH_MIN ; 分直接加到秒十位上 MOV TEMP2,A MOV TEMP1,B MOV A,#10 MUL AB ADD A,WATCH_MSEC ; 毫秒叠加到个位显示 MOV TEMP1,A LCALL SEG_DISPLAY

这段代码能跑,但藏着两个精度问题。第一,WATCH_MSEC 每 50ms 加一,可它显示在“毫秒”位上,视觉上让人以为是 1ms 一格,实际上走一格是 50ms,读出来会比真实时间慢 50 倍。第二,“分”被直接加到“秒的十位”上,等于假设秒的十位永远不会超过 5,一旦秒表跑到 60 秒以上,这个加法就会把进位吃掉,显示跳变。要做得准,得把定时器 1 改成 1ms 中断(方式 2 的 8 位自动重装更合适),并且给毫秒、秒、分各留独立的进位逻辑——但那样中断频率就上去了,跟前面时基的取舍是同一笔账。

5. 避坑与排查:开发板上真实翻过的车

纸面上推得通,焊到板子上未必通。下面这几条都是这类项目里出现频率最高的故障,每一条都按现象、原因、解决三步记,遇到时可以直接对号入座。

5.1 现象:数码管发虚,相邻两位串码

现象很典型:单独显示 1111 时一切正常,一旦显示 1234,1 和 2 之间就糊了一层淡影,尤其是数字差得大的时候最明显。

原因是消影没做干净。写新段码之前虽然清了位选,但清完之后立刻又送了新段码,中间没有留时间让 LED 的余辉衰减。余辉长短跟驱动电流正相关,电流给得越大,余辉越长,越容易拖影。

解决是把消影和点亮拆成三个明确的阶段:先把 P0 低 4 位清零,给一个短延时(R7 取 3 到 5 之间),再送段码,最后置位选。短延时不能省,也不能太长——太长会让刷新率掉下去。调试时可以把这段延时临时调到很大,如果拖影消失了,就说明问题确实在这,然后往回调到刚好干净为止。

5.2 现象:按一次键,模式连跳两级

按一下 INT0,本该从模式 0 到模式 1,实际直接跳到了模式 2。

原因是机械按键的抖动。触点闭合的几毫秒里电平会反复跳,而外部中断 0 设的是边沿触发(IT0=1),每一个下降沿都会进一次中断,抖动几下就跳几下。

解决办法是在中断入口先延时再复查电平:延时约 10 个机器周期,等抖动过去,再读一次 P3.2,如果已经回到高电平,说明刚才那次是抖动,直接返回,不动 MODE。这段逻辑在原代码里就是JB P3.2,INT0_RET那一行。

还有一层容易被忽略:如果按键接了下拉电阻但没接上拉,悬空时会随机触发中断,表现是“没人按键它自己跳模式”。这种要查硬件,软件消抖救不了。

5.3 现象:闹钟该响的时候不响,或者响了停不下来

两个方向都出现过。不响的情况通常是:闹钟设在 8:31,但那一刻正好在模式 3 或模式 5 下,主循环大部分时间花在显示和秒表运算上,某几轮循环恰好跳过了 8:31 这一分钟,比较条件再也没成立。

停不下来的情况更常见:条件“时相等且分相等”在整整一分钟里都成立,BEEP_CLOCK 执行完回到主循环,第二轮又判一次,于是一分钟里连响好几次。

解决方案分两步。判定改成只在“秒归零”那一刻做一次,把它挪进定时器中断的秒进位分支,天然去重。同时给闹钟加一个使能位,响过之后置位,下一次分钟变化时清除,这样即使判定逻辑放在主循环也不会重复触发。

5.4 现象:秒表读数和真实时间差 50 倍

秒表显示 0.50,掐表看实际过了 25 秒左右。或者反过来,显示 25.00,实际只过了半分钟。

原因是 WATCH_MSEC 每 50ms 中断加一,但显示代码把它当成 1ms 一格的单位来排位,视觉上每跳一格代表 50ms,读数自然差了 50 倍。

解决有两条路。要么改显示,把毫秒位按 20 进制处理,满 20 进一秒(因为 20×50ms=1s);要么改时基,让定时器 1 走 1ms 中断,方式 2 的 8 位自动重装正好适合,初值设成 256−1000/1 对应的值。前一种改动小,后一种精度高但中断开销大。课设场景下前一种够用,前提是你得在显示上明确标出单位不是毫秒。

5.5 现象:手动改完月份,31 号还挂在屏幕上

从 1 月 31 日进入校准模式,把月份改成 2 月,日期那一栏仍然显示 31,没有任何修正。

原因是日期合法性检查只写在秒中断的进位链里,也就是 CHECK_DAY 只在“日加一”时被调用。手动修改走的是 KEYSCAN1 那条路,只改了 MONTH 变量,没有回头校验 DAY 是否还合法。

解决办法是把校验抽成公共子程序,在按键修改返回后统一调一次:如果 DAY 大于当月天数,就把它截到当月最后一天;如果是在进位语境下调,就改成进到下个月 1 号。前者适合手动修改,后者适合自动走时,两个场景的行为不一样,不能共用一套逻辑。

6. 取舍的边界:50ms 时基、汇编手写、还有那些不敢删的延时

回头看,这个项目里所有看起来“随便定的”参数,背后都有一笔账。

选型选了什么得到了什么付出了什么
时基50ms 定时中断中断开销低,误差被整秒吸收秒表精度只能到 50ms,要更细得另开定时器
显示动态扫描 + 595 串行省下 20 多根 IO,成本极低必须严格消影,时序错一点就拖影
交互2 个中断键 + 4×4 矩阵,一键多用按键数量压到最少状态组合多,用户要记,代码分支也多
语言汇编时序可控到机器周期,代码体积小除法要手动分商余,结构全靠标号和跳转
闹钟主循环比较中断保持干净一分钟内可能重复触发,得加标志

最值得琢磨的是那个 50ms。它同时是时基的粒度和秒表的精度上限,一个参数定了两件事。要提精度,最直接的办法是把定时器 1 从方式 1 换成方式 2 的 8 位自动重装,1ms 中断,重装由硬件完成,不占指令周期。但这样一来,中断频率变成 20 倍,主循环里那些长延时就会被切得更碎,动态扫描的时序开始抖。升级从来不是免费的,它只是把成本从一头挪到另一头。

汇编和 C 的取舍也是同一类问题。汇编能精确控制到每一条指令,写延时的时候你知道这段循环到底耗多少个机器周期,写消影的时候你知道两个动作之间隔了多久。换成 C,一个 for 循环编译出来是几条指令你并不确定,优化等级一改,时序就变了。但汇编的代价是可读性——六个模式的分发全靠 CJNE 加 AJMP,改一个逻辑要顺着标号跳七八处。如果这个项目要长期维护,我会把状态机和显示分离,用查表代替分支;如果只是一次性交作业,现在这样反而更省事。这是场景决定的,不是技术水平决定的。

最后提一个我一直没敢删的东西:DISPLAY1 里那两个 R7=0x03H 的短延时。从纯逻辑看它们完全多余,删掉代码照样能跑,仿真也看不出问题。但它们就是消影的全部。我试过删掉省几个周期,屏幕上立刻出现一层淡淡的鬼影,而且只在特定数字组合下出现,测了半天才想起来是自己动的刀。从那以后我每次改动态扫描的时序,都会先把消影延时单独打上标记,改完必须整屏扫一遍 0 到 9 的所有组合,确认没有拖影再往下走。硬件项目里,那些看起来“没用”的延时,往往就是它不翻车的原因,希望帮到你。

本文还有配套的精品资源,点击获取

返回列表