简介:基于DS1302实时时钟芯片的Proteus可调闹钟仿真工程,专为嵌入式初学者和单片机课程设计准备。工程支持通过独立按键调整时间与闹钟,并在LCD上实时显示,完整演示了RTC初始化、时间读写、按键消抖和显示刷新的典型流程。压缩包共20个文件,体积仅78KB,以C语言源程序、头文件、Proteus仿真图(DSN)、Keil工程文件和hex烧录文件为主,附带闹钟设定说明文档及工程备份,结构简洁,便于直接打开学习或修改复用。已有263人学习使用。通过该项目可掌握DS1302三线串行通信协议、闰年自动校正、12/24小时制切换等核心知识点,同时熟悉Proteus与Keil联合仿真的操作流程,并参考代码中的错误处理与用户交互设计思路,是实践嵌入式开发、加深RTC芯片理解的实用案例。
1. 用 DS1302 在 Proteus 里跑通可调闹钟仿真,先理解这三个关键词
这个标题拆开看,其实是一道典型的单片机综合题:用 DS1302 做时钟芯片,用 Proteus 做电路仿真,把闹钟做“可调”。很多人在 DS1302 上折腾一个晚上,调出来的时间不走、读回全是 0xFF、闹钟到点不响,问题大多不在代码逻辑,而在时序边沿、BCD 码换算和 Proteus 的元件参数。这篇文章不讲大而全的工程开发流程,只围绕 DS1302 可调闹钟仿真这一件事,从数据手册里的读写时序讲起,到 Proteus 电路搭建,再到 C51 代码和排错验证,全程可复现。适合正在做课程设计、准备电子竞赛,或者刚把 Keil 和 Proteus 装好、想拿真实项目练手的读者。
2. DS1302 时序与寄存器:闹钟可调的前提是时钟可读写
2.1 三线接口的读写沿,和 SPI 不完全一样
DS1302 是 DALLAS 的串行实时时钟芯片,接口只有三根线:CE(片选)、SCLK(串行时钟)、IO(数据)。很多人习惯把这三根线直接理解成 SPI,但 DS1302 并不是标准 SPI 器件,它不区分主从模式,也没有片选之外的应答机制,全靠主机按照固定时序往 IO 上推位。
先记住结论:数据是低位先走,也就是 LSB first。写操作时,CE 拉高后,主机在 SCLK 上升沿把 IO 上的数据锁存进 DS1302;读操作时,命令字节同样按上升沿写入,最后一个命令位送入后,DS1302 会在接下来的 SCLK 下降沿开始把数据放到 IO 上,主机应当在 SCLK 低电平期间读取。
这个边沿差异是仿真里最容易出错的地方。如果代码里读写都用上升沿,读出来的数据会整体错位,典型表现是分钟值和秒值对不上、寄存器地址错乱。
下面是最小可用的字节读写代码,直接对应 DS1302 的时序要求:
#include <reg51.h> #include <intrins.h> sbit DS1302_CE = P2^0; sbit DS1302_SCLK = P2^1; sbit DS1302_IO = P2^2; void ds1302_delay(void) { _nop_(); _nop_(); } void ds1302_write_byte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { DS1302_SCLK = 0; DS1302_IO = dat & 0x01; // 低位先发送 DS1302_SCLK = 1; // 上升沿锁存 dat >>= 1; } }ds1302_delay()的作用不是摆设。Proteus 的 DS1302 模型虽然不会像真实芯片那样对极短的脉冲完全没响应,但仿真步长过小时,信号竞争容易导致数据位丢失。SCLK 低电平和高电平各自保持几个微秒比较稳妥,实测中把延时放大到 10µs 级别,读回数据的稳定性会明显提升。
读字节代码和写字节不同,关键在方向切换后要先让 IO 引脚置 1:
unsigned char ds1302_read_byte(void) { unsigned char i, dat = 0; DS1302_IO = 1; // 准双向口读之前先置 1 for (i = 0; i < 8; i++) { DS1302_SCLK = 0; // 下降沿后数据出现在 IO 上 if (DS1302_IO) { dat |= (0x01 << i); // 低位先到达 } DS1302_SCLK = 1; } return dat; }51 单片机的 P2 口是准双向口,读外部引脚之前必须先把端口写 1,否则读回来的永远是 0。这个细节在 Keil 里不会报错,在 Proteus 里表现为“时间全是 0”,排查起来非常隐蔽。读字节循环里用的是dat |= (0x01 << i),对应低位先出的规则。
读写单字节都准备好之后,操作寄存器的 API 就是在前面加上地址字节,再处理 CE 拉低结束通信:
void ds1302_write_reg(unsigned char addr, unsigned char dat) { DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; ds1302_write_byte(addr); // 写命令,例如 0x80 ds1302_write_byte(dat); // 写入数据 DS1302_CE = 0; } unsigned char ds1302_read_reg(unsigned char addr) { unsigned char dat; DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; ds1302_write_byte(addr | 0x01); // 读命令,写地址最低位置 1 dat = ds1302_read_byte(); DS1302_CE = 0; return dat; }这里注意地址参数传入的是写地址。例如读秒寄存器时调用ds1302_read_reg(0x80),函数内部会自动把最低位改成 1,变成读命令 0x81。如果调用时直接传入 0x81,命令字节会变成 0x81 | 0x01 = 0x81 不变,看起来没问题,但换成其他寄存器就可能错位。统一约定传写地址更不容易乱。
2.2 寄存器表和 BCD 码,直接决定“可调”怎么写
DS1302 的时间寄存器按秒、分、时、日、月、星期、年排列,每个寄存器都有独立读写地址。仿真里用到最多的是前四个,控制寄存器 0x8E 和涓流充电寄存器 0x90 也值得了解。
| 功能 | 写地址 | 读地址 | 数据范围 | 备注 |
|---|---|---|---|---|
| 秒寄存器 | 0x80 | 0x81 | 00~59 BCD | bit7 是 CH,1 表示停振 |
| 分寄存器 | 0x82 | 0x83 | 00~59 BCD | 无特殊标志位 |
| 时寄存器 | 0x84 | 0x85 | 00~23 BCD | bit7 选择 12/24 小时制 |
| 日寄存器 | 0x86 | 0x87 | 01~31 BCD | 低 6 位有效 |
| 控制寄存器 | 0x8E | 0x8F | 0x00 | bit7 是写保护 WP |
寄存器内部存放的是 BCD 码,而不是十进制数。比如 9 点 45 分,小时寄存器里存的是 0x09,分钟寄存器里是 0x45。如果直接把 45 塞进寄存器,芯片会把它当成 0x45,也就是十位是 4、个位是 5,这在显示上恰好还是 45,但一旦进行加法操作,问题就会暴露。
加减 1 分钟的正确做法是:先读寄存器,把 BCD 转成十进制,加减运算后再把十进制转回 BCD,写回寄存器。转换函数如下:
unsigned char bcd_to_dec(unsigned char bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } unsigned char dec_to_bcd(unsigned char dec) { return ((dec / 10) << 4) | (dec % 10); }仿真中常见的“按一下加 1,9 变成 0x10”、“分钟从 09 跳到 0A”这一类现象,都是省略了 BCD 到十进制的换算。在做可调闹钟时,这个转换函数就是修改时间的最底层依赖。
2.3 第一次上电必须做的三件事:清写保护、清 CH、写初值
DS1302 默认状态容易让新手误判。芯片上电后,控制寄存器 0x8E 的写保护位可能是 1,此时所有写入操作都被忽略。另外秒寄存器的 bit7(CH 位)如果为 1,振荡器停振,秒值不跳动。还有一点:Proteus 仿真启动时 DS1302 模型内部的时间和 PC 时间不一定同步,甚至可能是 00:00:00 起步。
初次初始化建议直接写成固定时间,方便观察秒动作:
void ds1302_init(unsigned char hour, unsigned char minute, unsigned char second) { ds1302_write_reg(0x8E, 0x00); // 取消写保护 ds1302_write_reg(0x80, dec_to_bcd(second) & 0x7F); // 清 CH 位 ds1302_write_reg(0x82, dec_to_bcd(minute)); ds1302_write_reg(0x84, dec_to_bcd(hour) & 0xBF); // 24 小时制 ds1302_write_reg(0x8E, 0x80); // 恢复写保护,防止误写 }写秒寄存器时& 0x7F的作用不是必须的,但如果传入的 second 大于 59,BCD 结果会污染 CH 位,保险起见保留。小时寄存器& 0xBF是把 12/24 小时制标志位置 0,强制 24 小时制,这样后续判断“几点”不必关心 AM/PM。
完全不必在初始化里写入日、月、年这些字段,因为闹钟只用到“时”和“分”,日期不参与比较。保持寄存器状态简单,后面读回来的数据也好排查。
3. Proteus 搭建 DS1302 闹钟电路:元件清单、接线与固件加载
3.1 先列元件清单,DS1302 是核心但不是全部
在 Proteus 8 Professional 里新建工程后,直接在元件搜索框输入名称选择即可。下面这张清单是经过验证可用的最小配置,去掉任何一个都可能导致现象不在预期范围。
| 元件 | Proteus 搜索关键词 | 数量 | 说明 |
|---|---|---|---|
| 单片机 | AT89C51 | 1 | 经典 51 内核,12MHz 晶振 |
| RTC 芯片 | DS1302 | 1 | 仿真模型包含在标准库中 |
| 晶振 | CRYSTAL | 2 | 单片机用 12MHz,DS1302 用 32.768kHz |
| 电容 | CAP | 4 | 晶振负载电容,仿真中可省略 |
| 排阻 | RESPACK-8 | 1 | P0 口上拉,10kΩ |
| 电阻 | RES | 4 | 按键下拉或三极管基极限流 |
| 按键 | BUTTON | 3 | 模式切换、加、减 |
| 数码管 | 7SEG-MPX4-CA-BLUE | 1 | 四位共阳数码管,显示时:分 |
| PNP 三极管 | 2N2907 | 4 | 数码管位选驱动,低电平导通 |
| 蜂鸣器 | BUZZER | 1 | 5V 有源蜂鸣器,闹钟输出 |
| NPN 三极管 | 2N2222 | 1 | 蜂鸣器驱动 |
DS1302 的晶振频率必须选 32.768kHz,这是 RTC 芯片的标准低频晶振。Proteus 提供的 CRYSTAL 元件属性里有 Frequency 参数,双击晶振改成 32.768kHz。如果误填成 12MHz,DS1302 秒值会异常跳动,而且误差没有任何规律。
3.2 接线参数与 sbit 定义,先统一再连线
Proteus 仿真是先连电路再写代码,但引脚分配必须提前定好,否则电路和程序对不上。下面这组分配合适,段选数据从 P0 走,控制线集中在 P2,按键独占 P1,互不干扰。
sbit DS1302_CE = P2^0; sbit DS1302_SCLK = P2^1; sbit DS1302_IO = P2^2; #define SEG_PORT P0 // 数码管段选,a~g 分别接 P0.0~P0.6 #define DIG_PORT P2 // 位选复用 P2.3~P2.6,低电平点亮 sbit KEY_MODE = P1^0; // 模式切换 sbit KEY_UP = P1^1; // 加 sbit KEY_DOWN = P1^2; // 减 sbit BUZZER = P1^7; // 蜂鸣器接线的关键有三处。第一,P0 口必须接排阻上拉,否则高电平驱动能力不足,数码管亮度不匀甚至不亮。第二,DS1302 的 CE、SCLK、IO 三根线建议各接一个 10kΩ 上拉电阻,Proteus 内部模型不强依赖上拉,但这是真实电路的常规做法。第三,共阳数码管的公共端需要通过 PNP 三极管接 VCC,位选引脚为低电平时三极管导通,对应位才会点亮。
蜂鸣器驱动也不建议直接把 IO 口接蜂鸣器。用 P1.7 经 1kΩ 电阻接到 2N2222 的基极,发射极接地,集电极接有源蜂鸣器负极,蜂鸣器正极接 VCC。这样仿真效果和真实电路一致,代码里只要给 P1.7 高电平就能响。
3.3 Keil 编译出 hex,Proteus 双击芯片加载固件
电路画完,代码在 Keil C51 工程里编译生成 hex 文件。注意 Proteus 仿真依赖的外部时钟频率要和 Keil 里的晶振设置一致,如果代码里用了_nop_()做延时,那延时实际时长由 Proteus 里 AT89C51 的 Clock Frequency 决定,默认 12MHz 即可。
加载固件的操作路径是:在 Proteus 原理图上双击 AT89C51 芯片,在弹出的 Edit Component 对话框里找到 Program File 一栏,点击文件夹图标选中编译好的 .hex 文件。Crystal Frequency 保持 12MHz,其他参数不需要改。加载完成后点击左下角运行按钮,仿真立即开始。
如果此时数码管没有任何显示,先检查 P0 排阻是否接好、位选三极管方向是否正确。更快的验证手段是用 Proteus 的 Virtual Terminal(虚拟终端)接到单片机串口,在代码里打印 DS1302 读回的寄存器值。这个方法比盯数码管盲排快得多,后面排错还会用到。
4. DS1302 可调闹钟的 C 语言实现:按键改时间、BCD 换算与闹铃触发
4.1 底层驱动补齐 RAM 读写,闹钟值存进 DS1302 片内 RAM
上一章的读写函数只覆盖了时间寄存器。DS1302 内部除了时钟寄存器,还有 31 字节的通用 RAM,地址从 0xC0 开始,写地址是偶数,读地址是奇数。把闹钟的“时”和“分”存在这里,比放在单片机普通变量里更贴合标题中“可调仿真”的含义,断电后数据也能保留。
void ds1302_write_ram(unsigned char addr, unsigned char dat) { DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; ds1302_write_byte(addr); // 0xC0 起始,写入 RAM 地址 ds1302_write_byte(dat); DS1302_CE = 0; } unsigned char ds1302_read_ram(unsigned char addr) { unsigned char dat; DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; ds1302_write_byte(addr | 0x01); // 转成读命令 dat = ds1302_read_byte(); DS1302_CE = 0; return dat; }写 RAM 之前同样需要清除写保护位,否则写入被忽略。闹钟存储方案定为:RAM 地址 0xC0 存闹钟小时,0xC2 存闹钟分钟。选 0xC0 和 0xC2 而不是 0xC0 和 0xC1,是为了让读地址与写地址错开时更直观,两个地址之间留出余量,避免 RAM 地址混用时互相覆盖。
改用这两个 API 之后,初始化代码里多做一步:把闹钟默认值写入 RAM,比如早上 7 点 30 分:
ds1302_write_reg(0x8E, 0x00); ds1302_write_ram(0xC0, dec_to_bcd(7)); ds1302_write_ram(0xC2, dec_to_bcd(30)); ds1302_write_reg(0x8E, 0x80);4.2 按键状态机:模式切换、加减、写回
“可调”的本质是一个状态机。用三个按键实现四档切换,比用矩阵键盘简洁,也够展示 DS1302 的完整操作路径。
| 状态 | 显示内容 | K1 按下后进入下一状态 | K2 / K3 效果 |
|---|---|---|---|
| MODE_TIME | 当前时间 | MODE_SET_HOUR | 无操作 |
| MODE_SET_HOUR | 当前小时 | MODE_SET_MINUTE | 小时加减并写回 DS1302 |
| MODE_SET_MINUTE | 当前分钟 | MODE_SET_ALARM_HOUR | 分钟加减并写回 DS1302 |
| MODE_SET_ALARM_HOUR | 闹钟小时 | MODE_SET_ALARM_MINUTE | 闹钟小时加减并写入 RAM |
| MODE_SET_ALARM_MINUTE | 闹钟分钟 | MODE_TIME | 闹钟分钟加减并写入 RAM |
按键扫描需要处理抖动。Proteus 里按键抖动现象不如真实硬件明显,但代码层面最好加上 10ms 延时消抖,否则一次按下被识别成多次,调时间根本停不住。
#define MODE_TIME 0 #define MODE_SET_HOUR 1 #define MODE_SET_MINUTE 2 #define MODE_SET_ALARM_HOUR 3 #define MODE_SET_ALARM_MINUTE 4 unsigned char mode = MODE_TIME; void scan_keys(void) { if (KEY_MODE == 0) { delay_ms(10); if (KEY_MODE == 0) { mode = (mode + 1) % 5; while (KEY_MODE == 0); // 等待释放 } } if (KEY_UP == 0) { delay_ms(10); if (KEY_UP == 0) { if (mode == MODE_SET_HOUR || mode == MODE_SET_ALARM_HOUR) { adjust_hour(1); } else if (mode == MODE_SET_MINUTE || mode == MODE_SET_ALARM_MINUTE) { adjust_minute(1); } while (KEY_UP == 0); } } }adjust_hour和adjust_minute内部做读、转十进制、加减、越界回绕、转 BCD、写回整套动作。越界回绕要注意 24 小时制和 60 分钟制的模数不同,小时数对 24 取余,分钟对 60 取余。
下面给出调整小时的实现,分钟的逻辑几乎一样:
void adjust_hour(unsigned char delta) { unsigned char hour; if (mode == MODE_SET_HOUR) { hour = bcd_to_dec(ds1302_read_reg(0x84)); hour = (hour + delta) % 24; ds1302_write_reg(0x8E, 0x00); ds1302_write_reg(0x84, dec_to_bcd(hour)); ds1302_write_reg(0x8E, 0x80); } else { hour = bcd_to_dec(ds1302_read_ram(0xC0)); hour = (hour + delta) % 24; ds1302_write_reg(0x8E, 0x00); ds1302_write_ram(0xC0, dec_to_bcd(hour)); ds1302_write_reg(0x8E, 0x80); } }这段代码展示了可调操作中最重要的顺序:每次写入前都清写保护,写完立即恢复。写保护恢复后,DS1302 继续正常走时,只是禁止后续写入,这一层保护在真实产品里有意义,仿真里至少能让时序状态更可控。
4.3 闹钟比较逻辑与响铃控制
闹钟触发条件有两个层面:时间匹配、响铃去重复。每秒读一次时间,如果时、分、秒恰好等于闹钟设定值且秒为 0,表示这一分钟刚到来,只响一次,避免同一分钟内反复触发。
#define ALARM_RING_TICKS 50 unsigned char alarm_seconds = 0; void check_alarm(unsigned char hour, unsigned char minute, unsigned char second) { unsigned char alarm_h, alarm_m; if (second != 0) { return; } alarm_h = bcd_to_dec(ds1302_read_ram(0xC0)); alarm_m = bcd_to_dec(ds1302_read_ram(0xC2)); if (hour == alarm_h && minute == alarm_m) { if (alarm_seconds == 0) { alarm_seconds = ALARM_RING_TICKS; } } } void ring_task(void) { if (alarm_seconds > 0) { BUZZER = 1; delay_ms(50); alarm_seconds--; } else { BUZZER = 0; } }ALARM_RING_TICKS是响铃时长控制。每次主循环调用一次ring_task,响铃持续约 2.5 秒。有源蜂鸣器在 Proteus 里只要给高电平就会发声,这一点和真实器件行为一致。
秒值比较放在check_alarm的最前面,是个实用技巧。DS1302 的秒寄存器每秒变化一次,主循环如果在同一秒内多次调用check_alarm,靠second != 0拦截后,只有秒为 0 的那一次才有机会进入比较,天然避免了重复触发。
主循环把读取、显示、按键、闹钟四件事串起来:
void main(void) { unsigned char h, m, s; ds1302_init(23, 59, 50); ds1302_write_reg(0x8E, 0x00); ds1302_write_ram(0xC0, dec_to_bcd(0)); ds1302_write_ram(0xC2, dec_to_bcd(0)); ds1302_write_reg(0x8E, 0x80); while (1) { h = bcd_to_dec(ds1302_read_reg(0x84)); m = bcd_to_dec(ds1302_read_reg(0x82)); s = bcd_to_dec(ds1302_read_reg(0x80)); display_time(h, m); scan_keys(); check_alarm(h, m, s); ring_task(); } }初始化时间设在 23:59:50,闹钟默认值设为 00:00,这样仿真启动 10 秒后就能看到蜂鸣器响应,不用长时间等待。
5. DS1302 闹钟仿真排错与验证:先看时序、再查上拉、最后核对 BCD
5.1 用 Proteus 虚拟示波器验证时序是否正确
时间读不出来时,最有价值的手段不是反复看代码,而是直接看波形。Proteus 8 Professional 左侧工具栏里有 Virtual Instruments,选择 Oscilloscope 添加到电路图中,把通道 A 接到 DS1302 的 SCLK,通道 B 接到 IO,CE 接通道 C 或直接用逻辑分析仪替代。
正常读一次秒寄存器的波形应该具备两个特征:SCLK 上出现连续 16 个脉冲,前 8 个对应命令字节 0x81,后 8 个对应数据字节;IO 上的第一位出现在第 8 个 SCLK 下降沿之后,而不是从第一个脉冲就开始输出。
如果只看到 8 个脉冲,说明代码在发送命令之后没有继续产生时钟,检查ds1302_read_byte是否被调用。如果 IO 波形一直是低电平,先查 P2.2 有没有被外部电路强行拉低,再查读之前DS1302_IO = 1是否执行。
5.2 常见仿真故障对照表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 读回全为 0xFF | IO 未上拉或方向切换失败 | 三根控制线接 10kΩ 上拉,读前先写 1 |
| 读回全为 0x00 | IO 被拉死,或读循环边沿错误 | 检查 SCLK 低电平期间读 IO 的时序 |
| 秒不走,秒值不变 | CH 位为 1 或晶振频率不对 | 写秒寄存器时清 bit7,确认晶振 32.768kHz |
| 分钟 09 加 1 变 0A | BCD 码直接做十进制运算 | 先 bcd_to_dec,计算后 dec_to_bcd 写回 |
| 调小时不生效 | 写保护未取消 | 写寄存器前先写 0x8E = 0x00 |
| 数码管亮度低 | P0 无上拉 | 接 RESPACK-8 排阻到 VCC |
其中“秒不走”在 Proteus 里还有一个容易忽略的原因:仿真暂停时 DS1302 模型不走时间,但代码里读秒的值不会变。点击运行按钮后如果秒值仍不变,才需要往寄存器方向排查。
5.3 仿真提速技巧:临时改晶振频率验证闹钟触发
验证闹钟逻辑时,等待真实时间触发太慢。DS1302 的外部晶振是 32.768kHz,Proteus 里双击 DS1302 旁边的晶振元件,把 Frequency 临时改成 327.68kHz,时钟走速就变成原来的 10 倍,10 分钟的时间流逝约 1 分钟就能观察到。
这个办法比在代码里强行跳过时间判断更可靠,因为 DS1302 芯片本身的状态没有被修改,只是仿真环境的晶振频率改变了。验证完成后把晶振改回 32.768kHz,代码不用动,后续要以真实速率验证时,一切恢复正常。测试时把闹钟时间设在当前时间后 2 分钟,晶振放大 10 倍后大约 12 秒就能看到蜂鸣器动作。
验证完整路径建议按三步走:第一步,初始化时间设到 23:59:50,闹钟设 00:00,确认秒走、分走、到点响铃;第二步,按 K1 进入调小时模式,按 K2 加 1,退出后显示值变化;第三步,进入调闹钟模式,把闹钟值调整到当前分钟,验证可调后仍能正常触发。三步全部通过,这个 DS1302 可调闹钟仿真才算真正闭环。
本文还有配套的精品资源,点击获取