51单片机电子时钟课设:定时器中断与DS1302驱动全解析
2026/9/16 11:13:12 网站建设 项目流程

简介:一套基于51单片机的电子时钟课设源码工程,使用Keil开发工具编写,包含DS1302实时时钟驱动与LCD1602液晶显示控制,采用按键调校时间。适用于单片机课程设计、毕业设计或入门学习者,可直接参考整体思路并复用核心代码。压缩包共27个文件,主要涵盖C源文件、头文件、Keil工程配置文件、hex烧录文件和DSN仿真图,整体大小约96KB,小巧、便于快速下载与编译。目前已有2380人浏览学习,项目覆盖时间芯片读取、时间格式转换、液晶屏刷新显示等关键知识点。通过分析源码与仿真工程,可以理解主循环调度、按键消抖、多模块接口交互等常用单片机编程方法,并可直接在此基础上增加闹钟、温度显示等扩展功能,实用性和可读性都不错,对于单片机原理与接口技术课程的综合训练也有较高参考价值。

1. 电子时钟课设的真正难点不在“时钟”,而在“基准与显示”

单片机课设电子时钟,本质上是一个“时间基准 + 显示刷新 + 按键交互”三件事同时跑的实时系统。很多同学在 Keil 里写完代码,编译 0 错误 0 警告,下载到板子上却发现要么秒跳得不均匀,要么数码管闪得厉害,要么调时间时按键怎么按都没反应。问题往往出在同一个地方:你把显示刷新、按键扫描和时间计数全写在了一个死循环里,又用 delay 来拖时间,结果谁都在抢 CPU,谁都没干好。

这个课设的价值也正在于此。它逼你在一个极小的资源范围内(51 单片机的 128 字节片内 RAM、2 个定时器、几个 I/O 口)把任务拆开、把时间片分清楚。本文假设你用的是 Keil uVision5 + C51 编译器 + 51 单片机(STC89C52 或 AT89S52 皆可),显示部分用 8 位数码管,时钟芯片用 DS1302 作为可选扩展。下面这套做法是我自己做课设和帮人改代码时用得最多、也最不容易翻车的一套骨架。

2. 拆开电子时钟的三层结构:驱动、逻辑、任务调度

2.1 先从 Keil 工程的物理分层说起

一个能跑的 Keil 工程,不建议把全部代码堆在一个 main.c 里。常见做法是拆成四个文件:main.c放任务调度和主循环,ds1302.c放时钟芯片驱动(如果你用 DS1302 的话),display.c放数码管段码和扫描函数,key.c放按键扫描与消抖。这样做的好处在你调 bug 时才能体会出来——显示闪了先去 display.c 查刷新频率,时间不对去 ds1302.c 查读写时序,谁的问题一目了然。

一个最小工程的 Keil 配置要点如下表:

配置项推荐值说明
DeviceAT89C52 或 STC89C52RC课设最常见的芯片,8K Flash,够用
Target 晶振频率12.000 MHz后面定时器初值计算都按这个来
Output 勾选 Create HEX File必须勾没生成 hex 文件就没办法烧录
Memory ModelSmall: variables in DATA课设代码量小,Small 模式最省事
Code Rom SizeLarge: 64K program防止编译报 C249 代码越界

这里特别提醒一点:Keil 里 Target 页面的晶振频率只影响软件仿真时的时序,不烧进单片机。如果你实物用的 11.0592MHz 晶振,但这里填 12MHz,仿真出来的串口波特率会不对,定时器仿真时长也会和实物差一点。所以填之前先看一眼你的板子用的到底是多少兆的晶振。

2.2 数码管驱动里最容易踩的段码和极性坑

驱动 8 位数码管显示时间,常见方案是动态扫描——每次只点亮一位,快速轮巡,利用视觉暂留让所有位看起来同时亮。这里的核心是段码表和位选信号。

以共阴数码管为例,段码表长这样:

// 共阴数码管段码:0-9,DP 位在最高位 unsigned char code seg_table[] = { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F // 9 };

但如果你拿到的是共阳数码管,段码要做按位取反,也就是0x3F变成0xC0。这个问题几乎每年课设都能看到有人卡住:现象是显示的数字是乱的,比如显示 0 出来是 8,或者有一横不亮。判断方法是查原理图看公共端接的是 VCC 还是 GND,公共端接 VCC 就是共阳,代码里段码取反,或者直接在段码表里写按位取反后的值。

扫描函数我一般这样写:

// 在 main.c 中,利用定时器中断定时 2ms 调用一次 void display_scan(void) { // 位选:P2 低三位控制 74HC138 译码选通第 0~7 位 static unsigned char pos = 0; unsigned char code *seg_ptr = get_display_buffer(); // 获取显示缓冲区 P0 = 0x00; // 先消隐,防止拖影/鬼影 P2 = (P2 & 0xF8) | pos; // 选中当前位 P0 = seg_ptr[pos]; // 输出段码 pos++; if (pos >= 8) { pos = 0; } }

这段代码的逻辑是:中断每 2ms 触发一次,每次刷新一位,8 位扫完一轮需要 16ms,刷新频率约 62.5Hz。这个频率下看不到闪烁,也不会因为刷新太频繁占用过多 CPU。注意第一行P0 = 0x00是关键,它叫消隐。如果省略,你会看到明显的“拖影”——前一位的残影残留在数码管上,数字边缘发虚。

2.3 按键扫描不能用 delay 等松手

按键部分最常见的翻车写法是这样的:

// 错误示例:按下后 delay 20ms,再等松手 if (key == 0) { delay_ms(20); ... while (key == 0); // 死等松手 }

这个写法在功能上能跑,但没有电子时钟的“实时”概念了。死等松手期间,数码管不再刷新,显示会闪一下,时间计数也停了。更合理的做法是:在主循环里定义按键扫描周期,每 10ms 读一次按键电平,检测到下降沿(从按下到松开)才认为是有效按键,同时用标志位传递给时间调整逻辑。

// key.c:每隔 10ms 调用一次 unsigned char key_scan(void) { static unsigned char last_state = 0xFF; // 上一次按键状态 unsigned char cur_state; unsigned char key_event = 0; // P1 低四位接按键,按下为 0 cur_state = P1 & 0x0F; // 检测下降沿:之前是高电平,现在是低电平 if ((last_state != cur_state) && (cur_state != 0x0F)) { key_event = cur_state; } last_state = cur_state; return key_event; }

这个方案用状态机的思路消抖,不需要 delay。每次读取都同步更新 last_state,只有状态发生变化的那一次才返回有效事件。后续在主循环里根据 key_event 的值来决定是切换调整位还是加一减一。

3. 用定时器中断搭建“时间基准”:初值计算与溢出补偿

3.1 为什么不用 delay 计数

有些同学的第一个版本是用 delay 制造大约 1 秒的延时,然后让秒加一。这个做法在纯软件层面完全无法保证精度——delay 的实际耗时受 for 循环编译优化影响、受中断干扰影响,甚至受温度影响。你想让课设答辩时老师看到秒跳得均匀,就必须用定时器中断来建立时间基准。

定时器中断的原理是:硬件计数器按机器周期计数,计满溢出后自动跳转中断服务函数,我们在中断里对“秒”这个变量做累加。只要定时器初值不变,中断触发的时间间隔就是固定的。

3.2 12MHz 晶振下定时器初值的完整计算

51 单片机的机器周期是 12 个时钟周期。所以 12MHz 晶振下,机器周期 = 12 / 12MHz = 1µs。我们要让定时器中断每 50ms 触发一次,然后累计 20 次得到 1 秒。那么需要计数 50000 个机器周期。

定时器工作在模式 1(16 位)时,最大计数是 65536。初值 = 65536 - 50000 = 15536 = 0x3CB0。这里有个细节:定时器从初值开始向上计数,计到溢出需要 50000 个脉冲,所以初值是 65536 减去计数值,别写反。

// main.c 中定时器 0 的初始化 void timer0_init(void) { TMOD &= 0xF0; // 只修改定时器 0 相关位 TMOD |= 0x01; // 定时器 0,模式 1(16 位) TH0 = 0x3C; // 高 8 位:50000 = 0xC350 TL0 = 0xB0; // 低 8 位:0x3CB0 ET0 = 1; // 开定时器 0 中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器 0 }

TMOD 寄存器低 4 位控制定时器 0,高 4 位控制定时器 1。很多人的代码直接TMOD = 0x01,如果后面还要用定时器 1 做串口波特率,就会把定时器 1 的模式误清掉。所以我习惯先TMOD &= 0xF0再用或运算设置本定时器的模式。

3.3 中断里的时间累加逻辑

// 全局变量,volatile 防止编译器优化 volatile unsigned char timer_count_50ms = 0; volatile unsigned char sec = 0; volatile unsigned char min = 0; volatile unsigned char hour = 0; void timer0_isr(void) interrupt 1 { // 重装初值 TH0 = 0x3C; TL0 = 0xB0; timer_count_50ms++; if (timer_count_50ms >= 20) { // 20 * 50ms = 1s timer_count_50ms = 0; sec++; if (sec >= 60) { sec = 0; min++; if (min >= 60) { min = 0; hour++; if (hour >= 24) { hour = 0; } } } } }

中断里用了 20 次计数累计成 1 秒,而不是直接在中断里判断到了 50000 就秒加一——后者的做法意味着你必须每次重装初值都精确,而多了一层timer_count_50ms缓冲之后,即使有一次中断被高优先级打断导致略微延迟,误差也不会累积到显示层,因为中断本身每次触发都会重装初值,硬件计数是连续的。

定时器中断里做秒累加时,主循环只管读变量去显示,不需要对实时性做过多的等待。注意变量加了volatile关键字。没有这个关键字时,Keil C51 的优化器可能把变量优化进寄存器,你在主循环里读到的值可能是旧的缓存值,表现出来就是时间不走。

4. 让时间掉电不丢:DS1302 读写时序与 Keil 下的对齐策略

4.1 为什么要引入 DS1302

定时器中断做的时间基准,掉电之后时间就清零了。很多课设要求“电子时钟”具备掉电走时的能力,或者要求在开发板上断电后重新上电时间仍能继续走。这就要引入 DS1302——一个带电池备份的实时时钟芯片。它通过三根线(CE、SCLK、I/O)和单片机通信,每次读写 8 位数据,时序是标准的 SPI 变体。

4.2 寄存器地址表和写时序

DS1302 的寄存器地址分成读写两部分。要写入秒寄存器,地址是 0x80(读是 0x81),写分钟是 0x82(读 0x83),写小时是 0x84(读 0x85)。更关键的是控制寄存器:写地址 0x8E,数据最低位 WP 为 0 才允许写,为 1 则是写保护。很多人写完时间后没有再拉 WP 位,结果下次写失败,问题就出在这。

// ds1302.c 中字节写入的时序 void ds1302_write_byte(unsigned char addr, unsigned char dat) { unsigned char i; DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; // 拉高 CE,启动通信 for (i = 0; i < 8; i++) { // 先发地址字节 DS1302_IO = addr & 0x01; // 低位在前 DS1302_SCLK = 1; DS1302_SCLK = 0; addr >>= 1; } for (i = 0; i < 8; i++) { // 再发数据字节 DS1302_IO = dat & 0x01; DS1302_SCLK = 1; DS1302_SCLK = 0; dat >>= 1; } DS1302_CE = 0; // 结束通信 }

这段代码的要点是:地址字节和数据字节都是低位先发,每个 bit 靠 SCLK 上升沿锁存。所以我先拉低 SCLK,设置 IO 电平,再拉高 SCLK,再拉低,完成一个 bit 的传输。DS1302_CE 必须在整个传输过程中保持高电平,如果中途被拉低,通信直接终止。

在实际调的时候,DS1302 对时序要求不高,这反而容易出问题——很多人想当然认为“慢一点没关系”,然后用 delay 函数在两行语句之间插入大量延时。delay 越大越容易在写的过程中出现数据错位,因为 SCLK 的高电平时间过长,芯片内部可能把下一个 bit 提前采样了。我一般只在 CE 拉高之前加一个短延时,其余语句之间不加。

4.3 读 DS1302 与 BCD 码转换

从 DS1302 读出来的数据是 BCD 码,不能直接塞给数码管显示。比如秒寄存器里存的 0x59,代表 59 秒,而不是十进制 59。处理方式有两种:一种是乘除转换,一种是查表。课设代码量小,我倾向直接用移位加乘除,简单直观:

// ds1302.c 读取数据 unsigned char ds1302_read_byte(unsigned char addr) { unsigned char i, dat = 0; DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; for (i = 0; i < 8; i++) { // 发送地址(读命令) DS1302_IO = addr & 0x01; DS1302_SCLK = 1; DS1302_SCLK = 0; addr >>= 1; } DS1302_IO = 1; // 释放 IO 线,准备读数据 for (i = 0; i < 8; i++) { dat >>= 1; // 先收到的位是低位 if (DS1302_IO) { dat |= 0x80; } DS1302_SCLK = 1; DS1302_SCLK = 0; } DS1302_CE = 0; return dat; } // BCD 转十进制 #define BCD_TO_DEC(x) (((x) >> 4) * 10 + ((x) & 0x0F))

读数据时要注意DS1302_IO = 1这一步。IO 是三态口,单片机写地址时 IO 是输出模式,写完地址后如果还保持输出状态,读到的数据必然是错的。先置 1,再把引脚切换成输入状态,才能正确读回数据。

还有一个容易忽略的坑:写 DS1302 之前必须先关闭写保护,即写地址 0x8E、数据 0x00。上电默认 WP 是 1,如果不关保护,后面所有写寄存器操作都无效,但读操作正常,所以现象会很迷惑——显示时间能走到 12:00,但修改时间写不进去。

5. 验证与排错:从 Keil 仿真到实物板的高频问题定位

5.1 用 Keil 的 Logic Analyzer 验证时基是否准确

写完代码先别急着烧录,Keil 的 Debug 模式可以帮你做初步验证。进入 Debug 后,打开 Peripherals -> Timer -> Timer 0,可以看到 TH0/TL0 的实时值和 TF0 标志。单步运行到定时器中断入口,确认重装值确实写入成功。再用 Peripherals -> Interrupt 窗口观察 EA 和 ET0 是否为 1。显示刷新问题则用 Logic Analyzer 把 P0.0 和 P2.0 加进来,观察是否有规律的周期跳变,周期应该在 2ms 左右,换算下来每 16ms 完成 8 位扫描。

软件仿真下有个坑:仿真时晶振频率按 Target 页面的设置来,如果你的代码里用_nop_()这种毫秒级延时函数,仿真时可能因为频率设置不一致而跑出完全不同的时间。所以我在仿真时只验证逻辑波形,不验证时间精度。时间精度要靠实物。

5.2 课设高频报错对照表

报错信息原因处理办法
ERROR C141: syntax error near 'code'段码数组声明写错位置数组加code关键字时确认是全局变量
ERROR C249: 'CODE': too large代码量超出芯片 Flash检查有没有把大数组定义进 code 段,把不用的调试代码注释掉
WARNING C280: unreachable codewhile 后面直接写了代码死循环后别放执行语句,删掉
ERROR L107: ADDRESS SPACE OVERFLOW变量太多,DATA 空间溢出把大数组移到 xdata,或者改用 Small 模型并精简全局变量
MISSING ';' BEFORE '}'位选变量类型写错检查unsigned char是否漏写,常见于 pos 定义处

5.3 一个实用的调试技巧:用“秒闪 LED”验证中断精度

最后分享一个我常用的验证手段。把 P1.0 接一个 LED,在秒变量加一之后翻转一次这个引脚的电平。程序跑起来之后用万用表或逻辑分析仪测 P1.0 的翻转周期。如果两次翻转间隔是 2 秒,说明时基误差在可接受范围;如果明显快或慢超过 0.2 秒,就要回头检查晶振频率和初值计算。这个方法比盯着数码管看“秒跳得准不准”要可靠得多,因为人眼的判断误差本身就超过 0.1 秒。调 DS1302 时也可以把 DS1302 读出来的秒值和系统自走的秒值同时打印到串口,对比两者是否同步。如果你在 Keil 里用了软件仿真看时间,那么记得把sec变量加进 Watch 窗口,中断触发一次就能精确看到它是否只加一。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询