简介:华南理工大学2020年微机课程设计报告,面向学习51单片机、汇编语言与微机原理的学生及需要完成电子日历时钟课设的读者。报告完整记录电子日历时钟的设计与实现,涵盖显示北京时间、时间与日期校准、公历日期、秒表、闹钟及PC联机校准等基础与扩展功能,并讨论4位LED数码管动态扫描、矩阵键盘“一键多用”、INT0/INT1中断切换六种显示模式、74HC595串行驱动、闰年平年判断与定时中断基准等软硬件方案。压缩包共1个doc文件,约3.89MB,内容为可直接参考的课程设计报告书,包含功能要求、方案考虑、程序入口逻辑、各模式按键操作与结果分析,便于对照搭建STC-10F08XE开发板实验、理解汇编程序结构和排查显示与校准问题。已有1145人学习,适合作为课设选题、方案论证和报告撰写的综合参考。
1. 时钟准不准,取决于中断服务程序有多短
一台用51 单片机和汇编写出来的电子日历时钟,走时准不准,八成不取决于晶振。12 MHz 的石英片本身误差在几十 ppm 量级,一年也就偏几分钟;可同类的课程设计跑一晚上偏出十几分钟是常事。差的那部分不在硬件里,在中断服务程序里。用微型计算机这门课的视角看,这活儿表面是把数码管、矩阵键盘、蜂鸣器接起来,再写一堆CJNE分支;真正难的地方是让互不相关的东西由一个共同的 50 ms 时基对齐——计时中断、动态扫描、按键消抖、闹钟比较,全都在抢同一份 CPU 时间。搞懂它们怎么排队、谁让步、代价落在哪,那些看着零散的知识点才会自己咬合起来。
2. 时基:50 ms 的中断周期是怎么定下来的
定时器初值这一行代码,是整台时钟的地基。它决定了"秒"这个概念在硬件里到底以多大的颗粒被切分,也决定了后面所有显示、按键、闹钟逻辑的时间预算。这一段先把初值反推出来,再看软件计数怎么往上垒,最后算一笔精度的账。
2.1 从 0x3CB0 反推:初值不是随手填的
方式 1 是 16 位定时器,从初值数到 0xFFFF 溢出,所以装载的数值等于"65536 减去要数的机器周期数":
MOV TMOD,#11H ; 定时器0、1 均工作在方式1(16位定时,无自动重装) MOV TL0,#0B0H ; 低 8 位 MOV TH0,#03CH ; 高 8 位,合起来初值 0x3CB0 MOV SP,#60H ; 堆栈挪到 60H,避开寄存器组和位寻址区0x3CB0 = 15536,65536 − 15536 = 50000。在 12 MHz 晶振、12 分频的方案里,机器周期是 1 μs,50000 个机器周期正好 50 ms。这就是"两个 50 ms 一次中断"的来源——它不是随便挑的整数,而是被 16 位定时器的天花板逼出来的。
上限很清楚:方式 1 最长只能数 65536 个机器周期,约 65.5 ms。所以"1 秒中断一次"在硬件层根本做不到,必须靠软件计数补。下限则由中断服务程序本身的长度决定——如果中断里要压栈、比较、写显示,花掉 200 μs,那么中断周期再短于这个数就会出现"上一次还没退出来,下一次溢出标志又立起来了"的堆积。50 ms 距离这个下限有两个数量级的余量,做实验时怎么折腾都不会崩。
2.2 分层计数:50 ms 数 20 次才是 1 秒
硬件只负责到 50 ms,往上的"秒、分、时、日、月、年"全部是软件垒出来的。典型的写法是一个寄存器做分频计数:
EINT0: ; 定时器0 溢出中断 PUSH ACC PUSH PSW INC R6 ; R6 记录 50ms 的个数 CJNE R6,#20,EINT0_EXIT ; 不满 20 次直接返回 MOV R6,#0 INC SECOND ; 满 20 次 = 1 秒 ; ……秒→分→时→日→月→年的连锁进位 EINT0_EXIT: POP PSW POP ACC RETI这段结构里藏着两个设计选择。第一,中断里只做计数,不做显示。显示扫描放在主循环,中断退出后主循环该刷哪一位刷哪一位。如果把四位数码管的扫描也塞进中断,一次中断就要几百微秒,中断密度一高,主循环里的按键扫描和闹钟比较基本没机会跑。第二,连锁进位要一次走完。秒到 60 归零的同时必须让分加一,分到 60 归零必须让时加一,一路到年,中间任何一级漏掉,都会出现"23:59:60"这种显示。这类 bug 平时不显形,等你在实验室守到整点才炸,血泪经验都是这么攒下来的。
日月年的进位比时分秒麻烦,因为每月天数不固定。多数实现会把"这个月有几天"做成一个查表加特判的函数,在日进位那一刻调用,而不是每秒都算一遍。
2.3 中断优先级:谁可以让步,谁不能让
51 的中断系统只有两个优先级,靠PX0、PT0、PX1、PT1这几个位设置,置 1 就是高优先级。代码里初始化段是这样:
SETB EX0 ; 允许外部中断0(模式切换键) SETB EX1 ; 允许外部中断1(进入/退出校准) SETB ET0 ; 允许定时器0 溢出中断 SETB IT0 ; 外部中断0 下降沿触发 SETB IT1 SETB PT0 ; 定时器0 置高优先级 SETB PX0 ; 外部中断0 置高优先级 SETB PT1 ; 定时器1 置高优先级(秒表用) SETB EA ; 总中断开关设计意图是"时间最要紧,按键可以让一让"。这个判断是对的:显示晚刷新 1 ms 没人看得出来,秒计数漏一拍就是永久误差。但要注意,51 在同一优先级内部还有一条硬件查询顺序:外部中断 0 → 定时器 0 → 外部中断 1 → 定时器 1。上面把三个中断都置成了高优先级,等于它们之间仍然按这条查询顺序排队,外部中断 0 实际上还排在定时器 0 前面。想让定时器 0 真正压过按键,就得把按键留在低优先级。这一点在代码里看不出来,调优先级时很容易自我说服"我已经设最高了"。
2.4 精度账本:误差到底累积在哪一项
把误差拆开看,一共三笔。
第一笔是晶振本身的频偏,这是物理层面的,只能靠校准抵消。第二笔是重装初值的时间差:方式 1 没有自动重装,定时器溢出后还在继续数,等中断服务程序执行到重装那两行,已经过去了一段时间,这部分计数被下一次装载抹掉了。每个周期误差固定,周期越短,单位时间内的次数越多,这笔误差反而越大。第三笔是粒度误差:如果基准是 1 秒,中途丢一次中断就损失整整一秒;基准是 50 ms,丢一次只损失 50 ms,而且可以用"每多少秒补一次"的方式做微调补偿,长周期没有这个调整余量。
所以"基准越短越准"这句话得加限定:短基准换来的是细粒度和可补偿性,代价是中断开销和重装误差的次数变多。50 ms 落在这条曲线的弯处——20 次凑一秒,就算中断服务程序占了 100 μs,CPU 占用也就 0.2% 量级,剩下的算力足够刷四位数码管和扫矩阵键盘。这也是为什么几乎所有的单片机时钟课设都停在这个数量级上。
3. 显示:74HC595 串行移位与动态扫描的时序契约
四位数码管要显示年月日时分秒,引脚不够用是常态。解决办法是把"段码"从并行改成串行,用移位寄存器省下 8 根 I/O;代价是每次刷新都要在软件里一位一位把字节推出去。这套机制里,位序、锁存时机、消影三件事任何一个搞错,看到的现象都是"显示乱",但原因完全不同。
3.1 SER/SRCK/RCK:一个字节怎么变成八段
移位寄存器只用三根线:数据SER、移位时钟SRCK、锁存时钟RCK。核心循环是这样:
SEND_BYTE: MOV BYTE,A ; 待发送的段码先存起来 MOV A,#80H ; 起始掩码:1000 0000 MOV R7,#9 ; 循环 9 次:8 次发数据,第 9 次做锁存收尾 JUDGE_R7: DJNZ R7,SEND_LOOP CLR RCK ; 先拉低 SETB RCK ; 产生上升沿,把移位寄存器内容打进输出锁存器 CLR RCK RET SEND_LOOP: RL A ; 掩码循环左移,逐位对准 MOV B,A ; 暂存掩码 ANL A,BYTE ; 取当前位 JNZ SET_SER CLR SER ; 该位是 0 MOV A,B SRCK_1: CLR SRCK ; 时钟低电平期间准备数据 SETB SRCK ; 上升沿把 SER 上的电平移入寄存器 CLR SRCK AJMP JUDGE_R7 SET_SER: SETB SER ; 该位是 1 MOV A,B AJMP SRCK_1关键在于R7从 9 开始:DJNZ是"先减再判",减到 0 的那一次不跳转,直接落到锁存那三行。也就是说前 8 次循环把 8 个 bit 依次推进去,第 9 次专门做RCK的上升沿。数据移位和输出锁存必须分成两步,否则数码管在移位过程中会看到中间态,出现一闪而过的错误笔画——这就是必须用带锁存的 595 而不是纯串入并出移位寄存器的原因。
P0口在这个设计里被复用得很彻底:P0.7是SER,P0.4是SRCK,P0.5是RCK,P0.6是复位端(初始化时CLR RST拉低,因为高电平复位,拉低才是正常工作)。剩下P0.0~P0.3直接当四位数码管的位选线。所以每次改位选之前都要先读回P0、保住高四位,只动低四位,这个套路后面还会反复出现。
3.2 位序为什么不是标准的高位在前
把掩码序列摊开看:起始 80H,然后RL A得到 01H,再 RL 得 02H……一直到 40H。也就是说发送顺序是 bit7、bit0、bit1、bit2……bit6,而不是教科书的 bit7 到 bit0。第一次读这段代码会觉得别扭,但只要想到移位寄存器是"先进后出"的位移链,改变发送顺序等价于改变输出脚到数码管段引脚的对应关系。
这里没有对错,只有"代码和走线必须配套"。板子上 Q0~Q7 到 a~g、dp 的接线顺序定了,软件里的位序就得跟着定。常见的实现思路是:先按标准 MSB-first 写一版,烧进去发现段码全乱,再一个个试出来。真要改,最省事的办法不是改走线,而是把NUM_TABLE里的段码表按实际顺序重排一遍——改数据比改循环安全。
3.3 消影:写新内容之前为什么必须先全灭
动态扫描的原理是"同一时刻只点亮一位,靠视觉暂留骗人"。四位轮一遍如果够快,人眼看到的是四位数同时亮;但如果切换位选时旧的段码还没从锁存器里撤掉,那一位就会短暂地把上一轮的段码显示在错误的位置上,看起来就是"重影"或者"鬼影"。
代码里对应的处理是每次切换位选前先关断:
DISPLAY1: MOV A,P0 ANL A,#0F0H ; 保留高四位(595 的三根控制线 + 复位线状态) MOV P0,A ; 低四位全清 0 → 四位数码管全部熄灭 MOV A,GEWEI ACALL SEND_BYTE ORL P0,#08H ; 只把 P0.3 置 1,点亮最右边那一位 MOV R7,#10H LCALL DELAY ; 保持点亮,决定亮度这里的读-改-写不是可有可无的讲究。P0高四位是 595 的控制线,如果直接MOV P0,#00H,顺手把SER、SRCK、RCK也清了,移位寄存器的状态就被打乱。所以必须先读回、屏蔽、再写——保留控制线,只动位选。位选全清的那一瞬间,四位数码管没有公共端回路,段码再怎么变都不发光,这才叫干净的消影。
3.4 段码拆分:DIV AB 与四位缓冲
显示函数只接收两个字节:TEMP2是高两位要显示的数,TEMP1是低两位。拆分靠 51 的除法指令:
CODE_CHANGE: MOV A,TEMP1 MOV B,#10 DIV AB ; A = 十位,B = 个位 MOV TEMP1,B MOVC A,@A+DPTR ; 查段码表 MOV SHIWEI,A MOV A,TEMP1 MOVC A,@A+DPTR MOV GEWEI,A MOV A,TEMP2 MOV B,#10 DIV AB MOV TEMP2,B MOVC A,@A+DPTR MOV QIANWEI,A MOV A,TEMP2 MOVC A,@A+DPTR MOV BAIWEI,A RETDIV AB一条指令同时给出商和余数,用在 BCD 拆分上非常划算——这是 51 汇编为数不多比 C 语言更顺手的地方。拆出来的四位存进QIANWEI、BAIWEI、SHIWEI、GEWEI四个字节,每个字节是已经查好表的段码,扫描时直接SEND_BYTE,不用再算。
段码表里还留了一个特殊项:索引 10 对应的字形是"只亮小数点"。MODE4(闹钟)和 MODE5(秒表)显示的时间格式和 MODE2(时分)几乎一样,全靠这个小点在固定位置点亮来区分。MOV A,#10然后MOVC A,@A+DPTR再发一次,就是这种"用最小视觉差异换一个状态位"的做法——按键不够、指示灯不够的时候,这一点很有用。
3.5 起辉与余辉:亮度、功耗、闪烁的三角
位选点亮之后要停多久,是R7这个延时参数说了算。时间太短,LED 还没到正常亮度就灭了,整体发暗;时间太长,单个数码管的平均电流上去了,超过器件极限功耗会烧段,而且轮到下一位的时间被拉长,四位扫一遍超过 20 ms 左右人眼就开始察觉闪烁。
真正约束这个参数的是一对物理量:起辉时间和余辉时间。驱动信号的维持时间必须大于起辉时间,否则那一位根本没亮透;间歇时间必须小于余辉时间,否则视觉暂留接不上,看到的就是断续闪烁。驱动电流越大,起辉越快、余辉也越长,但电流上限又被板子的供电能力和数码管极限功耗卡着。
代码里MOV R7,#10H之后调LCALL DELAY,DELAY是个三层嵌套的DJNZ循环,靠反复压栈出栈拖时间。改这个数就是调亮度:调大更亮但更容易闪,调小更省电但发暗。这是纯经验参数,没有公式能一次算准,只能烧进去看。
4. 交互:MODE 状态机、外部中断按键与闹钟比较
按键只有六个,功能却要覆盖年月日时分秒的校准、闹钟设置、秒表启停。把这些动作压进有限个按键,靠的不是键盘本身,而是一个显式的模式状态机。
4.1 六个 MODE 加一个 TD:状态机怎么管住全部按键动作
MODE变量取值 0 到 5,分别对应显示年、月日、时分、秒毫秒、闹钟、秒表。切换靠外部中断 0:
PRO_INT0: PUSH ACC MOV R7,#10 ACALL DELAY ; 软件消抖 JB P3.2,INT0R ; 再读一次,还是按下才算数 MOV A,TD CJNE A,#1,INT0_NEXT ; 如果正处在"校准中",先退出校准态 MOV TD,#2 AJMP INT0R INT0_NEXT: INC MODE ; 切到下一个模式 ACALL BEEP_SHORT ; 短促提示音,确认按键生效 MOV A,MODE CJNE A,#6,INT0R MOV MODE,#0H ; 到 6 就绕回 0 INT0R: POP ACC RETI第二个变量TD是校准开关,外部中断 1 每次把它加一:0 是正常走时,1 是进入校准,2 是退出。这个两态设计解决了"同一个按键在不同上下文里含义不同"的问题——不进入校准态,矩阵键盘上那四个键按下去什么也不做,避免误触改坏时间。
进入校准后,四个矩阵键S11、S12、S15、S16的含义随MODE变化:
| MODE | 显示内容 | S11 | S12 | S15 | S16 |
|---|---|---|---|---|---|
| 0 | 年份 | 千位调整 | 百位调整 | 十位调整 | 个位调整 |
| 1 | 月日 | 月 +1 | 月 −1 | 日 +1 | 日 −1 |
| 2 | 时分 | 时 +1 | 时 −1 | 分 +1 | 分 −1 |
| 3 | 秒毫秒 | 秒十位 | 秒个位 | 毫秒十位 | 毫秒个位 |
| 4 | 闹钟 | 时 +1 | 时 −1 | 分 +1 | 分 −1 |
| 5 | 秒表 | 清零 | 开始 | 暂停 | — |
六个模式、二十四个动作、四个物理按键,这就是"一键多用"的全部内容。它的代价是隐式状态多:用户必须记住当前在哪个模式、有没有进校准,否则按下去的反应完全猜不到。所以显示格式上要留出可辨识的差异——MODE2 用两个小数点分隔时分,MODE4 在固定位置点亮一个小点,MODE5 的布局又不一样。这些看着是装饰,其实是状态机的可见反馈。
4.2 边沿触发加软件延时消抖:在中断里做按键的代价
外部中断配置成下降沿触发(SETB IT0),机械按键按下的瞬间触点会弹跳几下,产生一串下降沿。如果不在服务程序里做二次确认,一次按下会被当成好几次,MODE直接跳过两三个。
代码里的消抖就是中断入口先ACALL DELAY拖一小段,然后JB P3.2,INT0R重新读引脚,如果这时候已经松开(高电平)就直接返回,不认这次按键。这个办法简单,但有一个明显副作用:在中断里消耗时间。消抖延时加上后续的BEEP_SHORT蜂鸣,一次按键中断能占掉可观的 CPU。而这段时间里定时器 0 如果也是高优先级,就会插进来把秒计数做掉——这正是 2.3 节讨论的优先级问题在真实场景里的表现。
正规一点的写法是把"检测到按键"和"处理按键"分开:中断里只置一个标志位就RETI,主循环看到标志位再消抖、再改状态。中断短了,抖动的窗口也更容易被完整覆盖。
4.3 闹钟检测放在主循环:位置决定行为
闹钟不是中断驱动的,它在主循环的最开头,每转一圈查一次:
START: MOV A,CLOCK_H CJNE A,HOUR,TIMER_EXIT ; 时不同,直接跳过 MOV A,CLOCK_M CJNE A,MINUTE,TIMER_EXIT ; 分不同,跳过 LCALL BEEP_CLOCK ; 时和分都相等 → 响 MOV R7,#40 LCALL DELAY TIMER_EXIT: MOV A,MODE ; ……按 MODE 分派到各个显示函数这个位置选择带来三个直接后果。
第一,秒不参与比较。只要时和分相等,整个一分钟里主循环每转一圈就响一次,直到分钟跳变才停。这其实复刻了老式闹钟"响一分钟自动停"的行为,算是歪打正着;但如果想要"响几声就停",就得额外加一个"今天已响过"的标志位,或者把比较条件收紧到秒等于 0 的那一拍。
第二,蜂鸣期间不刷新显示。BEEP_CLOCK里面是三次短音加一次长音,每个音都靠DELAY撑着,这段时间主循环卡在函数里,四位数码管保持最后一次写入的状态不再扫描,看起来就是画面定住或者变暗。想让闹钟响的时候显示照常,就得把蜂鸣改成中断里驱动、主循环只管置标志。
第三,校准模式下也会触发。因为闹钟检测在MODE分派之前,不管你正在改时间还是看秒表,只要撞上闹钟时间一样会响。多数时候无所谓,正在改闹钟时间的时候被自己刚设的闹钟吓一跳,就很烦。
4.4 日历运算:月大月小与闰年
日期显示的难点不在显示,在进位。每月天数不固定,2 月还要看年份:
- 4、6、9、11 月是 30 天,其余月份 31 天,2 月单独处理;
- 普通年份能被 4 整除且不能被 100 整除的是闰年,能被 400 整除的也是闰年;
- 平年 2 月 28 天,闰年 29 天。
在汇编里实现这段判断,寄存器紧张是主要矛盾。DIV AB只能给你商和余数,判"能否被 4 整除"就是看余数是不是 0;判"能否被 100 整除"要再做一次除法;"能否被 400 整除"还得再来一次。三条判断串起来,加上压栈保存中间结果,代码长度不小。常见的省法是把"每月天数"做成一张 12 字节的表,闰年时单独把二月那一项改成 29,日进位时查表比较就行,闰年判断每年只跑一次。
校准方向也一样要处理边界:日期减到 0 要回绕到上月最后一天,月份减到 0 要回绕到 12 月并让年份减一。这类"反向进位"比正向更容易漏,因为正常走时只会往前加,测试时也只在加的方向上验证。
4.5 秒表:用定时器 1 做一路独立计时
秒表复用了定时器 1,和主时钟的定时器 0 互不干扰,各自有初值、各自有溢出中断。启停靠TR1这一位:
SHOW_WATCH: MOV A,TD CJNE A,#0,SHOW_WATCH_NEXT CLR TR1 ; TD 为 0 表示退出校准态,顺手停表 SHOW_WATCH_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 RET这里有个很典型的资源压缩手法:四位数码管要显示"分、秒十位、秒个位、毫秒",但显示函数只吃两个字节。于是把"分 + 秒十位"塞进TEMP2,"秒个位 + 毫秒"塞进TEMP1——因为分的值永远小于 10、秒的十位也小于 6,两个数不会溢出。这种编码省寄存器、省函数参数,代价是耦合得厉害:任何一个字段的取值范围变了,整个拼接就得重写,而且中间结果在调试器里看是一堆没有意义的值。
5. 避坑与排查:这类时钟里反复翻车的几个点
下面几条都是"现象看着一样、原因完全不同"的典型,按现象、原因、解决三段记,出问题时对着查比盲改代码快得多。
5.1 数码管有重影,尤其是跳变的位
现象:数字切换的瞬间,某一位上会短暂闪出别的数字,静态看又正常;把刷新速度调慢之后重影更明显。
原因:两个可能,一是切换位选前没有把上一位关断,上一轮的段码还留在锁存器输出上,新位选一开就把旧内容显示出来了;二是SEND_BYTE里移位和锁存没有分开,RCK在数据只移了一半的时候就产生了上升沿,输出锁存器吃进了中间态。
解决:先确认每次切位选前都有"读 P0、与 0F0H、写回"这一组消影动作;再确认锁存的三行CLR RCK / SETB RCK / CLR RCK在 8 位数据全部发完之后才执行。如果重影只在某一位出现,八成是那一位的位选线和别的功能复用了,写的时候把控制线也带下去了。
5.2 一晚上走慢十几分钟
现象:刚烧进去对得挺准,跑一夜下来比标准时间慢一截,而且慢的量还挺稳定。
原因:不是晶振的问题,是中断被拖住。定时器 0 溢出后如果没能及时进中断,重装初值时已经把这段时间抹掉了。常见触发点是显示扫描里的DELAY太长、按键中断里做蜂鸣和消抖占用太多时间、或者在主循环里加了长时间的阻塞延时。另一个隐蔽原因是中断里执行了LCALL到很深的函数,压栈层数多,进出开销变大。
解决:先把中断服务程序精简到只剩计数和置标志,显示和蜂鸣全部挪到主循环;再检查中断优先级,确保计时中断能打断按键中断;最后用示波器或者翻转一个 I/O 口的方式量一下中断的实际周期,看是不是稳定在 50 ms。如果偏差是固定的比例,那才轮到怀疑晶振频率和初值算错。
5.3 按一次键跳两个模式
现象:按一下INT0,显示从 MODE0 直接跳到 MODE2;有时候按了没反应,有时候连跳好几下。
原因:机械按键的抖动被当成多次下降沿。消抖延时太短(R7给得太小)、或者消抖期间被高优先级中断打断,等回来再读引脚时抖动还没结束。边沿触发方式对抖动特别敏感,电平触发反而更宽容,但会带来按住不放持续触发的问题。
解决:把消抖延时加长,并且确认这段延时不会被计时中断打断——也就是让按键中断的优先级高于定时器中断,或者干脆改成"中断只置标志、主循环里延时后再确认"。另一个检查点是释放抖动:按下确认了,松手时还会再来一串边沿,如果代码没有"等松手"的逻辑,一次按压会被算成两次。
5.4 时间改完不连锁,出现 65 分或者 32 号
现象:用矩阵键把分钟一路加到 59 之后再按一下,显示变成 65;日历设到 31 号再往上加,出现 32 号。
原因:校准路径和走时路径用了两套逻辑。走时路径里有CJNE A,#60之类的上限判断和连锁进位,校准路径里只做了简单的加一减一,忘了回绕。减法方向更严重,0 再减会变成 255(字节回绕),显示出来是一堆乱码。
解决:把"修改某个字段"做成统一入口,加一之后立刻走一遍上限判断、减一之后立刻走一遍下限判断,超出范围就回绕到另一端。更彻底的做法是:校准只修改一个"待生效"的副本,退出校准态时整体校验一次,不合法就丢弃。
5.5 闹钟该响的时候不响,不该响的时候一直响
现象:设好的闹钟某天突然不响;或者响了之后停不下来,一直叫到你把电源拔了。
原因:不响的常见原因是比较条件写死了"时相等再比分钟",而如果设置闹钟时把分钟改成了和当前分钟一样的值,那一分钟内主循环一转就响——反过来,如果闹钟时间设在了过去,就得等到第二天。停不下来则是因为比较条件里没有秒、也没有"已触发"标志,只要时分还相等就一直重进蜂鸣函数。
解决:加一个"本分钟已响过"的标志,响完之后置位,分钟一变就清零;或者把触发条件收紧到"时相等、分相等、秒等于 0"这一拍。另外检查闹钟检测的位置,如果在MODE分派之前,校准状态下也会响,需要额外加一个"校准中不触发"的判断。
6. 设计取舍:2 KB 空间里的优先级排序
把整套东西回看一遍,会发现每一个技术选择背后都有一笔明确的账:
| 选择 | 换来的好处 | 付出的代价 |
|---|---|---|
| 50 ms 定时中断 + 软件分频 | 粒度细、误差可补偿 | 中断频繁,重装误差次数多 |
| 595 串行驱动段码 | 省下 8 根 I/O | 每次刷新都要软件移位,时序耦合走线 |
| 动态扫描代替静态驱动 | 位选线只要 4 根,成本低 | 有重影风险,亮度受限于占空比 |
| 中断里做消抖和蜂鸣 | 代码直白,按键响应"即时" | 中断变长,挤占计时精度 |
| 四键复用 24 个动作 | 面板干净,硬件最省 | 隐式状态多,可发现性差 |
| 闹钟放主循环比较时分 | 实现极简,不占中断 | 响一分钟、阻塞显示、校准中误触发 |
这些取舍单独看都合理,叠在一起才显出真实的工程味道:一台用汇编写的电子日历时钟,本质上是在一个只有几 KB 程序空间、128 字节内部 RAM、没有乘除硬件加速(除法指令要四个机器周期)的平台上,把时间精度、显示效果、交互复杂度三件事按优先级排个序。排完之后,任何一处想"再加点功能",都得先问自己从哪儿挪资源出来。
验证这套逻辑有个笨办法但很有效:把MODE、TD、HOUR、MINUTE、SECOND这几个关键变量在调试器里加成观察窗口,然后手动把时间改到 23:59:50,看它自己跨过零点那一秒的行为——年月日的连锁进位、闹钟的边界、秒表是否被误停,一次全暴露。这比逐个功能点测试快得多,也更容易撞出那些只在边界上才出现的 bug。
从那以后我每次碰这种带时基的小系统,都会先把"哪一个中断可以被谁打断"画在纸上,再动笔写第一行代码——因为这类问题一旦埋进去,后面所有的调试都是在猜。希望这份拆解帮到你,源码和完整报告就在资源里,对着看一遍比读十遍原理快。
本文还有配套的精品资源,点击获取