51单片机电子日历时钟的P0口复用与74HC595动态扫描
简介:华南理工大学2020年微机课程设计报告,面向学习微机原理、51单片机与汇编语言的本科生及自学者,围绕电子日历时钟这一完整项目展开。报告从实验目的与功能要求切入,给出硬件与软件两套方案考虑:四位LED数码管静态显示与动态扫描驱动的取舍、矩阵键盘「一键多用」的按键精简、定时中断基准时间与计数器分级计时、起辉与余辉时间对驱动信号的约束,以及月大月小与平年闰年的自动识别。实现部分记录μVision汇编编译、STC-10F08XE开发板与74HC595段码输出,并详述INT0/INT1中断切换年、月日、时分、秒与毫秒、闹钟、秒表六种显示模式、配合矩阵键盘校准时间的具体逻辑,附流程图与操作结果图。压缩包为单个doc文档,约3.89MB,已有1145人学习。读者可获得从需求分析到方案论证、再到代码实现与结果验证的完整课设范式,也能对照复现时钟的定时、显示与闹钟功能。
1. 六个显示模式挤进六个按键:一块 51 板子上的电子日历时钟到底难在哪
多数人第一次看这份微机课设,会把注意力放在“时间走得准不准”上。真正跑起来才发现,更容易把人带偏的是另一件事:51 单片机的 P0 口只有 8 根线,而 4 位数码管的段码、位选,外加 74HC595 的三根控制线,全都得从这 8 根线里挤出来。整份电子日历时钟的设计,绝大部分复杂度都集中在这个资源分配的取舍上:段码走串行移位,位选走并行端口,两者共用同一个 P0,还得保证扫描不闪、小数点按模式亮、按键随时能打断。
这套用汇编语言写成的方案之所以值得拆开看,是因为它把“资源不够时怎么用状态机换硬件”这件事做到了一种很清晰的程度:六个显示模式、六个按键、一个字节的移位函数、一个 TD 变量,互相咬合得非常紧。看懂它,相当于把 51 单片机的中断、定时、串行驱动、动态扫描、按键复用串成一条线。
2. 50ms 中断与分频链:秒是怎么从定时器溢出里滚出来的
电子日历时钟的“时基”不需要精确到微秒,但它必须可预测:每一次溢出之间的间隔要恒定,累积误差要靠分频链下游的规则去消化。这套代码选择的方式是:定时器 0 每 50ms 溢出一次,中断服务程序里用一个寄存器记次数,累计 20 次就是 1 秒。
2.1 0x3CB0 这个初值是怎么算出来的
初始化段落里这两行是整个时钟的“心跳源”:
MOV TMOD,#11H ; T1、T0 都设为方式 1:16 位定时器(不自重装) MOV TL0,#0B0H ; 定时器 0 低 8 位 MOV TH0,#03CH ; 定时器 0 高 8 位 → 合成 0x3CB0 = 15536TMOD = 0x11这个字节的高低位各管一个定时器,低四位0001给 T0,高四位0001给 T1,都落在方式 1 —— 16 位定时器,溢出后初值不会自动重装,得靠软件在中断里重新写回。这是 51 上最常用的模式,因为 16 位计数范围最大。
初值的来历是:65536 - 15536 = 50000,也就是 50000 个机器周期后溢出。按 12MHz 晶振、12 分频计算,一个机器周期正好 1μs,50000 个就是 50ms。20 次溢出拼成 1 秒,这个数字选得很讲究——它既落在 16 位定时器的量程内(16 位最多 65536 个机器周期,约 65.5ms),又把中断频率控制在每秒 20 次,中断开销几乎可以忽略。
提示:这套初值是按“12MHz + 12 分频”算的。很多 51 兼容芯片出厂默认工作在 1T 模式(一个时钟周期就是一个机器周期),同样的 0x3CB0 会变成 4.17ms 溢出一次,秒表会以 12 倍速飞跑。移植这套代码时,先确认芯片的时钟分频配置,再决定要不要重算初值。
选 50ms 而不是更短的基准,还有一层现实约束:定时周期必须大于中断服务程序的执行时间,否则中断还没退出来,下一次溢出就已经到了,主循环会被彻底饿死。这套代码的中断服务里要做进位判断、可能还要改秒、分、时,五十条指令上下,50ms 的时间窗口留得非常宽裕。方案讨论里那句“基准时间越短越有利于精度,但最短不能少于中断服务程序的执行时间”,说的就是这条边界。
2.2 从 R6 到年月日:分频链的每一级都必须整除
报告里只写了“用一个计数器对定时中断的次数进行计数,可实现秒定时”,中断服务体没有完整贴出。按常规写法,中断里大概是这么个结构:
EINT0: PUSH ACC MOV TL0,#0B0H ; 方式 1 不自动重装,必须手动补初值 MOV TH0,#03CH INC R6 ; R6 是 50ms 计数器 CJNE R6,#20,EINT0_EXIT ; 20 × 50ms = 1s MOV R6,#0 INC SECOND ; 进位到秒 MOV A,SECOND CJNE A,#60,EINT0_EXIT MOV SECOND,#0 INC MINUTE ; 秒满 60 进分 ... EINT0_EXIT: POP ACC RETI这段里最关键的不是语法,而是每一级的分频基数:50ms→秒是 20,秒→分是 60,分→时是 60,时→日是 24,日→月是 28/29/30/31,月→年是 12。除了第一级,其余全是“到顶归零再进位”的模运算。
为什么必须是整除关系?因为一旦某一级不能整除,误差就会单向累积。假设秒的进位条件写成“大于 60 才归零”,那每天就会多走几十秒,一个月下来闹钟能差出几分钟。这套代码里每一级都是CJNE A,#上限, 跳过然后清零进位,严格等于上限才进位,一次不多一次不少。
日→月这一级是整个链条里唯一“基数会变”的地方,也是它把简单的模运算变成了查表:
; 月大 31 天,月小 30 天,2 月另算 ; 4、6、9、11 月是 30 天,其余(除 2 月)是 31 天 ; 平年 2 月 28 天,闰年 29 天 ; 闰年判定:能被 4 整除且不能被 100 整除,或能被 400 整除闰年规则里那个“能被 100 整除但要能被 400 整除才算”的例外,是这套逻辑里最容易被简化掉、也最容易翻车的一条。少了它,2100 年 2 月 29 日会凭空出现。方案讨论里专门把这条规则完整写了出来,说明设计时是意识到这个边界的。
2.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 ; 时和分都相等 → 响 MOV R7,#40 LCALL DELAY TIMER_EXIT: MOV A,MODE ; 然后才进入模式分发这个位置选择是对的,理由有两层。第一,蜂鸣器子程序里全是用DELAY堆出来的软件延时,动辄几十毫秒,如果放进定时器中断,会把中断服务时间撑到接近甚至超过 50ms 的溢出周期,直接把时基打乱。第二,闹钟只需要“分钟级”的响应精度,主循环一轮扫描的耗时远小于一秒,放在主循环里完全够用。
但这段代码埋着一个很典型的坑:它是电平判断而不是边沿判断。只要当前时间落在设定的那一分钟里,主循环每转一圈就会完整响一遍“三短一长”,然后DELAY一下,再响一遍。一分钟内会重复几十次。正确的写法是加一个“已触发”标志位,只在秒归零那个瞬间判断一次,触发后置位,等分钟跳走再清除。这个坑在第 5 章会再展开。
3. 74HC595 与动态扫描:把段码一位一位送出去,再把四位数码管点亮
如果说定时器部分决定“时间走得对不对”,那么这一层决定“看上去像不像一个时钟”。整个显示链路是两套完全不同的机制拼起来的:段码走串行(74HC595 移位寄存器),位选走并行(P0 低四位直接驱动)。
3.1 SEND_BYTE 里的移位判决:RL A、ANL A,BYTE 和 JNZ 在干什么
SEND_BYTE是整个显示模块的核心,每次点亮其中一位数字,都要调用它一次:
SEND_BYTE: MOV BYTE,A ; A 里是要发送的段码,先存到 BYTE MOV A,#80H ; 掩码初值 MOV R7,#9 ; 计数值 9:DJNZ 先减后判,正好执行 8 次循环体 JUDGE_R7: DJNZ R7,SEND_LOOP ; 8 次循环体走完,R7 减到 0 落到锁存段 CLR RCK SETB RCK ; 上升沿:把 8 位移位寄存器整体搬到输出锁存 CLR RCK RET SEND_LOOP: RL A ; 掩码左移一位 MOV B,A ; 备份掩码,后面的 ANL 会破坏 A ANL A,BYTE ; 只保留掩码对应的那一位 JNZ SET_SER ; 结果非 0 → 这一位是 1 CLR SER ; 这一位是 0 MOV A,B ; 恢复掩码 SRCK_1: CLR SRCK SETB SRCK ; 上升沿把 SER 上的电平移入移位寄存器 CLR SRCK AJMP JUDGE_R7 SET_SER: SETB SER MOV A,B AJMP SRCK_1第一次读这段最容易困惑:掩码初值是0x80,看着像要从最高位开始送。但循环体第一句就是RL A,0x80左移一次变成0x01,所以实际先送出的是段码的 bit0,是 LSB 先行。之后每次RL把掩码推进到0x02、0x04……第八次到0x80,掩码正好走完一圈。
这样写的好处是判定逻辑统一:不管走到哪一位,都是“掩码与数据相与,看结果是否为零”。如果改用RLC A把数据本身左移,进位标志和累加器会互相干扰,每轮还得额外处理 CF,代码反而更长。MOV B,A这一步纯属无奈——ANL A,BYTE会把 A 里的掩码毁掉,所以借 B 暂存一下,判定完再取回来。
R7赋 9 而不是 8,这是 51 汇编里一个很经典的小聪明:DJNZ是“先减 1 再判非零”,如果写 8,减到 0 时循环体已经执行了 8 次,但第 8 次执行完会直接落到锁存段——看起来对,实际第 8 次的数据位还没来得及建立到 SER 上就被锁存了。写成 9 之后,循环体干净地执行 8 次,第 9 次 DEC 到 0 时跳出,锁存前刚好留出稳定时间。
3.2 RCK 锁存与 P0 口的八根线怎么分工
要理解SEG_DISPLAY里那些ANL P0,#0F0H、ORL P0,#08H,得先把 P0 口的引脚分配摊开看:
| P0 引脚 | 用途 | 代码里的体现 |
|---|---|---|
| P0.0 | 第 1 位数码管位选(最左) | ORL P0,#01H |
| P0.1 | 第 2 位数码管位选 | ORL P0,#02H |
| P0.2 | 第 3 位数码管位选 | ORL P0,#04H |
| P0.3 | 第 4 位数码管位选(最右) | ORL P0,#08H |
| P0.4 | 74HC595 的 SRCK(移位时钟) | SETB SRCK |
| P0.5 | 74HC595 的 RCK(锁存时钟) | SETB RCK |
| P0.6 | 74HC595 的 RST(低电平复位) | 初始化时CLR RST一劳永逸 |
| P0.7 | 74HC595 的 SER(串行数据) | SETB SER/CLR SER |
八个引脚一个不剩。高四位被 595 的控制线吃掉,低四位留给位选,所以ANL P0,#0F0H这个操作的语义就非常清楚了:保留高四位的控制线电平不动,只把低四位的位选全部清零。这是“消影”的实现方式,后面细说。
RCK 那三行CLR / SETB / CLR是 74HC595 最容易搞混的地方。595 内部有两级寄存器:8 位移位寄存器(由 SRCK 驱动,一位一位往里塞)和 8 位输出锁存器(由 RCK 驱动)。数据在移位过程中,输出端完全不变,只有 RCK 出现上升沿,移位寄存器的内容才会整体搬到输出端。这个设计的意义是防抖:8 位数据是一位一位挪进去的,如果输出端跟着实时变,屏幕上会看到每个数字分 8 步逐个段点亮的“动画”。有了锁存,同一时刻点亮的数字永远只经历一次切换。
注意:初始化里那行
CLR RST之后再也没动过 RST。595 的 RST 是低电平有效,只要一直拉低它就一直处于复位状态……所幸 595 的复位只清移位寄存器,不影响输出锁存器,加上每次发送前都重新写满 8 位,所以实际能正常工作。这种“看着危险但结果正确”的写法不建议照搬,正确的做法是把 RST 拉高。
3.3 消影:为什么每次点亮下一位之前必须先清空位选
SEG_DISPLAY的四段结构几乎一模一样,以第一段为例:
DISPLAY1: MOV A,P0 ANL A,#0F0H ; 清掉 P0 低四位 → 所有数码管位选全部关断 MOV P0,A MOV A,GEWEI ; 取个位对应的段码 ACALL SEND_BYTE ; 串行送进 595(此时屏幕上还是黑的,看不见) ORL P0,#08H ; 点亮最右边那一位 MOV R7,#10H LCALL DELAY ; 保持一段时间,让人眼看到 ; 模式 4 在这个位置额外送一个“点”的段码关键在顺序:先灭、再送、后亮。数码管有“余辉”,关断后还会亮一小段时间;同时段码是串行进 595 的,送 8 位需要 8 个时钟周期,这段时间里输出端还保留着上一位的段码。如果先亮再送,那段时间里屏幕上出现的是“这一位的位选 + 上一位的段码”,视觉上就是重影、发灰、数字边缘模糊。
先灭掉位选就没这个问题:位选全断,595 输出什么根本没人看;送完段码再打开对应的位选,屏幕上出现的一定是新内容。
代码里还有另一种写法混在中间:MOV P0,#00H配合DELAY。这一句把整个 P0 口都清零了,高四位的控制线也一并拉低。功能上同样能达到“全灭”的效果,但它顺手把 SER 也清掉了,切换时如果正好赶上电平跳变就可能出问题。两种写法混用说明作者在不同位置写顺手了没统一,读的时候要按“它们的目的都是消影”来理解,不要以为MOV P0,#00H是在发数据。
延时参数R7=0x10是扫描亮度的直接旋钮:值越大,每一位停留越久,整体越亮但刷新率越低;值太小又会因为余辉不够而显得发暗。四位扫描一轮的周期大致是 4 × 16 次内层循环,落在人眼不易察觉闪烁的范围内。
3.4 小数点不是“多画一个点”,而是段码表里的一百零一号
六种显示模式靠什么区分?不是靠屏幕上的字符,而是靠小数点分布。这是整套代码里设计感最强的一处:小数点并没有单独的控制逻辑,而是复用了同一张段码表,只是把索引换成了#10:
; 模式 4(闹钟)时,在第 1 位之后补一个“点” MOV A,MODE CJNE A,#4,LED41 ; 不是模式 4 就跳过 MOV A,#10 ; 段码表索引 10 MOVC A,@A+DPTR ; 取出“只亮小数点”的那个段码 ACALL SEND_BYTE ; 重新送一次,覆盖掉刚才的数字把四段判断串起来,得到的实际效果是这样的:
| 位选线 | 屏幕位置 | 对应变量 | 亮点的模式 |
|---|---|---|---|
| P0.0 | 第 1 位(最左) | QIANWEI | 4、5 |
| P0.1 | 第 2 位 | BAIWEI | 2、3、4 |
| P0.2 | 第 3 位 | SHIWEI | 4、5 |
| P0.3 | 第 4 位(最右) | GEWEI | 4 |
对照一下报告里给的默认值就全对上了:模式 2 是08.30(时分,点在第 2 位后),模式 3 是11.20(秒毫秒),模式 4 是1.6.3.1(两位之间全加点,跟模式 2 明确区分),模式 5 是0.00.0(分 + 秒 + 毫秒,点在第 1 位和第 3 位后)。模式 0 的2020和模式 1 的0826一个点都不加。
秒表那个0.00.0的四位数字怎么拼出来的,是这一层最绕的一段:
SHOW_WATCH: 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 <p> <a href="https://download.csdn.net/download/weixin_44291388/12782302" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>