简介:这是一份基于DS1302与1602液晶显示的万年历单片机课程设计资源包,面向电子、自动化等专业正在完成单片机课程设计或毕业设计的同学,可解决时间显示与日期调节的完整实现问题。包内共4个文件,以两个C语言源文件与两个头文件为主要程序代码,并含一张Proteus仿真截图,便于对照硬件连接与运行效果,整体压缩包仅22KB,结构精简,适合直接下载使用。设计以DS1302提供时间基准,1602液晶显示年月日、星期及时分秒,三个按键可分别对日期和时间进行加减调整;程序采用C语言编写,逻辑清晰,附带的仿真截图能帮助理解DS1302读写时序与1602驱动方式,方便初学者调试和二次开发。已有296人学习下载,配合可运行源码与硬件连接示意,可快速搭建实验、验证万年历功能并顺利完成课程设计。
1. 基于 ds1302 的万年历:一个掉电不丢时间的小时钟,到底难在哪
做“基于 ds1302 的万年历”的人,多半是学了一学期单片机之后想做点能拿出手的东西。我一开始也以为难点在显示上,真正动手才发现,最花时间的是 DS1302 那套半 SPI 的三线读写时序,以及月末、闰年这类日期边界。这个项目能解决的核心问题只有一个:掉电之后时间不丢,重新上电不用调。适合刚学会点灯、想过渡到中小型外设驱动的人,也适合给仪表、门禁这类需要离线计时的场景做原型验证。
2. DS1302 读写时序与驱动代码:寄存器地址、BCD 码和三段能直接用的 C 函数
2.1 DS1302 三线接口时序:命令字节怎么拼、地址表从哪查
DS1302 本质上是一个带日历的实时时钟芯片,外部只需要一颗 32.768kHz 晶振。它和单片机的通信很像简版 SPI,但只有一根 IO 线,半双工。每次读写都按同一套节奏走:CE 拉高,写命令字节,然后写或读数据字节,最后 CE 拉低。
命令字节很重要,最高位固定为 1,bit6 用来选时钟区还是 RAM 区。我们做万年历只碰时钟区,所以 bit6 写 0。剩下 bit5 和 bit4 到 bit1 是寄存器地址,bit0 是读写方向:0 写、1 读。刚开始写代码时建议先把地址表列出来,不然每次用裸地址都会懵。
| 寄存器 | 写命令 | 读命令 | 数据范围(BCD) |
|---|---|---|---|
| 秒 | 0x80 | 0x81 | 00~59 |
| 分 | 0x82 | 0x83 | 00~59 |
| 时 | 0x84 | 0x85 | 00~23 |
| 日 | 0x86 | 0x87 | 01~31 |
| 月 | 0x88 | 0x89 | 01~12 |
| 星期 | 0x8A | 0x8B | 01~07 |
| 年 | 0x8C | 0x8D | 00~99 |
| 写保护 | 0x8E | 0x8F | bit7 为 1 时禁止写入 |
这里的“秒寄存器”最高位是时钟停止位 CH,默认可能是 1,写时间前必须把它清成 0,否则芯片不走。写保护寄存器则是很多新人最容易漏的一步,写入前先把 WP 清 0,写完时间再置 1。
2.2 底层读写:CE、SCLK、IO 三个引脚的 C 封装
我一般会把底层读写函数做成一整套,放在一个ds1302_driver.c文件里,之后万年历逻辑和按键调时都只调用上层接口。下面是基于 51 内核的单片机写法,引脚占用 P1.0 到 P1.2。
#include <reg52.h> #include <intrins.h> sbit DS1302_CE = P1^0; sbit DS1302_SCLK = P1^1; sbit DS1302_IO = P1^2; void DS1302_DelayUs(void) { _nop_(); _nop_(); } void DS1302_WriteByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { DS1302_SCLK = 0; DS1302_IO = dat & 0x01; // LSB 先发 _nop_(); DS1302_SCLK = 1; // 上升沿锁存 DS1302_DelayUs(); dat >>= 1; } } unsigned char DS1302_ReadByte(void) { unsigned char i, dat = 0; DS1302_IO = 1; // 准双向口置 1 后才能读 for (i = 0; i < 8; i++) { DS1302_SCLK = 0; // 下降沿后 DS1302 输出当前位 DS1302_DelayUs(); dat >>= 1; if (DS1302_IO) dat |= 0x80; DS1302_SCLK = 1; DS1302_DelayUs(); } return dat; }这里有两个参数值得说明。第一个是“LSB 先发”,DS1302 的串行协议是从最低位开始送数据,很多写成最高位先发的代码要么读出来全是乱码,要么需要把字节反过来才能用。第二个是延时,12MHz 时钟下的两个_nop_()大约对应几百纳秒,满足 DS1302 对时钟高低电平的最小要求;如果换了更快的主频,要把DS1302_DelayUs()里的空指令加多几拍,否则时序会跑飞。
读数据前把DS1302_IO = 1这个动作不能省。51 的 P1 口是准双向口,不置 1 就去读,大概率读到 0。这也是很多第一次跑 DS1302 的人读回全 0 的常见原因。
2.3 用突发模式读写时间:从 BCD 到结构体的完整代码
单个寄存器逐个读写也没问题,但我习惯用突发模式一次性把时间读出来。DS1302 的突发读命令是 0xBF,从秒寄存器开始连续读 8 个字节;突发写命令是 0xBE,连续写 8 个字节。注意写突发必须把 8 个字节写完,最后一个字节是写保护寄存器,否则 DS1302 会把接下来的数据错位放进 RAM。
先定义一个时间结构体,外加两个 BCD 转换函数。
typedef struct { unsigned char second; unsigned char minute; unsigned char hour; unsigned char date; unsigned char month; unsigned char week; unsigned char year; } DS1302_Time; unsigned char DS1302_DecToBcd(unsigned char dec) { return ((dec / 10) << 4) | (dec % 10); } unsigned char DS1302_BcdToDec(unsigned char bcd) { return ((bcd >> 4) * 10 + (bcd & 0x0F)); }写时间前先解锁写保护,然后进突发写,写完最后一个字节顺手上锁。
void DS1302_SetTime(DS1302_Time *t) { DS1302_CE = 1; DS1302_WriteByte(0x8E); // 写保护寄存器命令 DS1302_WriteByte(0x00); // WP=0,解锁 DS1302_CE = 0; DS1302_CE = 1; DS1302_WriteByte(0xBE); // 写突发命令 DS1302_WriteByte(DS1302_DecToBcd(t->second) & 0x7F); DS1302_WriteByte(DS1302_DecToBcd(t->minute) & 0x7F); DS1302_WriteByte(DS1302_DecToBcd(t->hour) & 0x3F); DS1302_WriteByte(DS1302_DecToBcd(t->date) & 0x3F); DS1302_WriteByte(DS1302_DecToBcd(t->month) & 0x1F); DS1302_WriteByte(DS1302_DecToBcd(t->week) & 0x07); DS1302_WriteByte(DS1302_DecToBcd(t->year)); DS1302_WriteByte(0x80); // 最后一个字节:重新开启写保护 DS1302_CE = 0; }读时间用突发读,依次读 7 个字节就够了。读回的是 BCD 码,需要转成十进制再放进结构体。
void DS1302_GetTime(DS1302_Time *t) { unsigned char tmp; DS1302_CE = 1; DS1302_WriteByte(0xBF); // 读突发命令 tmp = DS1302_ReadByte(); t->second = DS1302_BcdToDec(tmp & 0x7F); tmp = DS1302_ReadByte(); t->minute = DS1302_BcdToDec(tmp & 0x7F); tmp = DS1302_ReadByte(); t->hour = DS1302_BcdToDec(tmp & 0x3F); tmp = DS1302_ReadByte(); t->date = DS1302_BcdToDec(tmp & 0x3F); tmp = DS1302_ReadByte(); t->month = DS1302_BcdToDec(tmp & 0x1F); tmp = DS1302_ReadByte(); t->week = DS1302_BcdToDec(tmp & 0x07); tmp = DS1302_ReadByte(); t->year = DS1302_BcdToDec(tmp); DS1302_CE = 0; }读取时的掩码不是随手写的。秒和分的最高位各有 CH 和保留位;时寄存器最高位是 12/24 小时标志,这里我用 24 小时制,所以只要低 6 位;月和星期只用了低 5 位和低 3 位。如果你发现读回来的分钟总是 80 多,八成是没做掩码。
3. 万年历日期逻辑与调时:闰年二月、星期计算和按键边界处理
3.1 日期边界:DS1302 自动进位,但 2100 年会被它误判
很多人以为万年历的难点是“自己判断这个月有几天、下个月怎么进位”,其实 DS1302 内部已经做了这套逻辑。只要初始化时把年、月、日写对,从 2024 年 2 月 28 日 23:59:59 走三秒,它会自动变成 2 月 29 日,因为 2024 年是闰年。真正需要软件参与的是两件事:一是星期寄存器不会自动更新,二是在按键调时间时,日期越界必须人工校验。
我仍然会保留一个日期校验函数,专门给调时逻辑用。它不参与 DS1302 的正常走时,只负责在修改日期时判断最大值。
unsigned char DS1302_IsLeapYear(unsigned char year) { unsigned short y = 2000 + year; // DS1302 年份只存 00~99 if ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)) return 1; return 0; } unsigned char DS1302_DaysInMonth(unsigned char year, unsigned char month) { unsigned char days[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2 && DS1302_IsLeapYear(year)) return 29; return days[month - 1]; }这里有个边界必须知道:DS1302 的年寄存器只有两位,默认代表 2000 到 2099 年。它内部的闰年算法只能按这个纪元判断,所以 2100 年这种不能被 400 整除的“世纪闰年”,会在芯片里被误判成闰年。实际产品如果只打算用到 2099 年,不用管;如果你想做一台“传家钟”,就得在软件层把年份上限卡住,或者改用带世纪位的实时时钟芯片。
3.2 星期从日期算出来:一个 5 行的查表公式
DS1302 的星期寄存器只是一个可以填 1 到 7 的普通寄存器,芯片不会根据年月日自动重算。如果只靠按键去调星期,调到跨年、跨月后就很容易犯错。我的做法是:每次初始化或调完日期,都用日期反算星期,然后写进结构体。
用 Sakamoto 算法最省代码,它比蔡勒公式短,而且从 2000 到 2099 都可靠。
unsigned char DS1302_CalcWeek(unsigned short y, unsigned char m, unsigned char d) { unsigned char t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; unsigned char wk; if (m < 3) y--; wk = (unsigned char)((y + y / 4 - y / 100 + y / 400 + t[m - 1] + d) % 7); return wk; // 0 表示星期日,1 表示星期一,以此类推 }返回值和星期显示的对应关系一定要写清楚。0 对应星期日,1 对应星期一,6 对应星期六。结构体里的 week 字段我习惯存DS1302_CalcWeek() + 1,也就是 1 到 7,这样和 DS1302 寄存器里本身要求的 1 到 7 对齐;显示时碰到 1 就显示“日”,后面数字减 1 得到中文星期。
3.3 按键调时与显示联动:改分钟、改日期时怎么避免溢出
按键调时最容易翻车的地方,是直接对结构体里的十进制数加减,然后马上写入 DS1302,忘记检查日期的最大值。分钟加到 60 会怎么样,日期加到 32 又怎么办,这些都要在写入前处理完。
我一般把调时拆成“调单项”和“日期递增”两个函数。分钟、小时、月份、年份这种固定上下限的,用统一函数;日期这类受月份影响的,单独走递增逻辑。
void DS1302_Adjust(DS1302_Time *t, unsigned char item, signed char delta) { signed short v; switch (item) { case 0: // 秒 v = t->second + delta; t->second = (v < 0) ? 59 : ((v > 59) ? 0 : (unsigned char)v); break; case 1: // 分 v = t->minute + delta; t->minute = (v < 0) ? 59 : ((v > 59) ? 0 : (unsigned char)v); break; case 2: // 时 v = t->hour + delta; t->hour = (v < 0) ? 23 : ((v > 23) ? 0 : (unsigned char)v); break; case 4: // 月 v = t->month + delta; t->month = (v < 1) ? 12 : ((v > 12) ? 1 : (unsigned char)v); break; case 5: // 年 v = t->year + delta; t->year = (v < 0) ? 99 : ((v > 99) ? 0 : (unsigned char)v); break; } } void DS1302_IncDate(DS1302_Time *t) { unsigned char maxDay = DS1302_DaysInMonth(t->year, t->month); t->date++; if (t->date > maxDay) { t->date = 1; t->month++; if (t->month > 12) { t->month = 1; t->year = (t->year + 1) % 100; } } t->week = DS1302_CalcWeek(2000 + t->year, t->month, t->date) + 1; }调完日期后必须重新算星期,这一行千万别忘。我见过太多人写了日期进位,却不更新星期,结果屏幕上周三和日历对不上。调时结束时再统一调一次DS1302_SetTime(t),写入 DS1302,并顺手更新 LCD 显示,这样就不会出现“显示改了但芯片没改”的异步问题。
4. DS1302 硬件搭接与显示选型:晶振、纽扣电池和 LCD1602 接线一次讲完
4.1 DS1302 标准接线与引脚说明:一张表看清七个脚
硬件搭接看起来只有几个引脚,但很多人栽在 VCC1 和 VCC2 上。DS1302 有主电源和备份电源两个输入,接反了要么电池很快耗尽,要么断电后时间丢得更快。
| DS1302 引脚 | 接到哪里 | 关键说明 |
|---|---|---|
| VCC2 | 系统 5V | 主电源,正常工作时由它供电 |
| VCC1 | 纽扣电池正极 | 后备电源,主电源掉电后自动切换 |
| GND | 系统地 | 必须与单片机共地 |
| X1 / X2 | 32.768kHz 晶振 | 晶振要尽量靠近引脚,走线别太长 |
| CE | 单片机 P1.0 | 片选,高电平有效,不用时要拉低 |
| SCLK | 单片机 P1.1 | 串行时钟,空闲时拉低 |
| IO | 单片机 P1.2 | 双向数据线,建议接 10k 上拉 |
常见的 DS1302 模块已经把晶振和电池座集成了,只要把 CE、SCLK、IO、VCC、GND 五个信号引到单片机即可。如果你是从裸芯片开始自己画板,晶振两脚之间不要走其他信号线,焊完之后用示波器量 X1 脚,应该能看到稳定的 32.768kHz 方波或正弦波。
IO 线上加不加 10k 上拉有争议。DS1302 的 IO 是双向的,低功耗模式下如果悬空,遇到干扰容易误触发。我自己的做法是在 IO 和 CE 上各接一个 10k 上拉到 VCC,SCLK 由单片机强驱动,不加。这样能减少时间乱跳的概率。
4.2 备用电池供电:VCC1/VCC2 怎么分配、要不要加二极管
DS1302 内部有电源切换电路,正常时优先使用 VCC2,只有 VCC2 掉电后才切换到 VCC1。所以 VCC2 接系统 5V,VCC1 接 3V 纽扣电池正极,这是标准做法。不要反接,也不要两边都接 5V,否则切换逻辑会出问题。
模块上常见的 CR2032 电池座,正极通常已经串了一个二极管,目的不是防倒灌,而是防止系统上电瞬间给电池充电。自己搭电路时,我会在 VCC1 和电池正极之间串一个 1N4148,再在电池两端并一个 100nF 陶瓷电容。这个电容能滤掉晶振走线串过来的干扰,也能让断电切换瞬间电压掉得没那么陡。
注意 DS1302 的掉电维持电流非常小,典型在 1uA 以下。如果你测出来待机电流有几十微安,不要先怀疑芯片,先查电池座旁边是不是还挂了电源指示灯或分压电阻。很多模块为了指示用 LED 常亮,直接导致纽扣电池几个月就没电。
4.3 LCD1602 显示与 DS1302 的共用 5V:接线和内存占用
万年历显示我用得最多的是 LCD1602,四线模式,只接 D4 到 D7。这样占用 6 个 IO,和 DS1302 的 3 个 IO 加起来也不多,适合 51 内核的小开发板。
| LCD1602 引脚 | 接到哪里 |
|---|---|
| RS | 单片机 P2.0 |
| RW | 单片机 P2.1,接 GND 也行 |
| EN | 单片机 P2.2 |
| D4~D7 | 单片机 P2.3~P2.6 |
| V0 | 10k 可调电阻到 GND |
| A / K | 背光正负,串 220 欧电阻到 5V |
驱动 LCD1602 的标准时序很长,一般都会先写好LCD_WriteCommand和LCD_WriteData两个底层函数。万年历主循环里每 500ms 刷新一次就够了,不需要每毫秒都去读 DS1302,否则按键消抖和显示刷新会互相干扰。
显示内容我习惯放在两行:第一行星期、年、月、日,第二行时分秒。因为 DS1302 读回来的时间是 BCD 转换成十进制,显示前再拆成 ASCII 字符。如果你用数码管,要自己做位选和段选,功耗比 LCD 大;用 OLED 则要注意 DS1302 模块和 OLED 是否都买成 3.3V 版本,混接 5V 和 3.3V 会烧 IO。对于新手,LCD1602 是最好调通的方案。
5. DS1302 万年历常见问题与排查:晶振不振、时间乱跳、电池耗电的 5 个处理记录
5.1 秒不走或停在某一位:多半是时钟停止位被写成 1
现象:LCD 上的秒数几十秒不变化,或者每次上电都停在同一个数。
原因:秒寄存器最高位 CH 被写成了 1,DS1302 的振荡器被关停。很多示例代码写入秒时直接DecToBcd(sec),缺少& 0x7F掩码,而这个寄存器的默认状态又可能已经是 1。
解决:写入秒之前必须做DS1302_DecToBcd(t->second) & 0x7F,写完后用突发读把秒寄存器读回来,确认最高位是 0。我调这类问题最快的方式,是把DS1302_GetTime读到的原始寄存器值用串口打印出来,而不是只看 LCD 上的十进制秒。
5.2 时间偶尔乱跳:CE 引脚在复位期间浮空
现象:正常开机走得好好的,但断电再上电,或者按复位键后,时间变成随机值,甚至年月日变成 00。
原因:单片机复位时 I/O 处于高阻或不确定电平,如果 CE 悬空,DS1302 会把引脚上的毛刺当成一次有效读写操作,把内部数据冲掉。这个现象很随机,十次里可能只出现一次,最容易让人误判成芯片坏了。
解决:CE 引脚加一个 10k 下拉电阻到地,让复位期间保持低电平。程序里初始化 DS1302 时也要先把 CE、SCLK、IO 全部拉低,再延时 10ms 后操作。我通常会在 main 函数一进来就调用DS1302_Init,这个函数什么都不做,只做引脚归零和延时。
5.3 走时明显快慢:晶振负载电容不匹配
现象:时钟一天能快十几秒,或者慢十几秒,调完第二天又偏。
原因:DS1302 推荐的晶振负载电容是 6pF,很多通用 32.768kHz 表晶振负载是 12.5pF,直接换来用,起振没问题,但频偏会明显变大。另外晶振引脚焊得太久,或者 X1/X2 走线过长,也会引入寄生电容导致频偏。
解决:买晶振时看参数,选负载电容 6pF 的圆柱晶振。焊接时温度不要太高,时间不要超过 3 秒。走时每天仍偏差超过 5 秒,可以尝试在 X1 脚对地并联一只 3pF 到 6pF 微调电容,逐次调整。不过对普通万年历来说,更实用的做法是每周用设备内的分钟校准接口手动对齐一次,而不是死磕晶振。
5.4 后备电池几个月就没电:模块上的电阻在“偷电”
现象:CR2032 新电池装上去,两三个月就报低电压,而 DS1302 本身应该能用两三年。
原因:很多 DS1302 模块为了做电源指示,会在 VCC1 和地之间接一个 LED 和限流电阻;有的模块还加了电池电压采样电阻。这些外围电路在断电后直接消耗电池,电流往往是 DS1302 自身功耗的几十倍。
解决:把模块上的电源 LED 拆掉,划断采样电阻。用万用表电流档串在电池回路里量待机电流,理想值小于 1uA,高于 10uA 就要查外围。如果你决定自己做板,VCC1 上除了电池和 100nF 电容,不要再挂任何电阻。
5.5 读出来全是 0xFF 或 0:IO 方向没切换
现象:程序运行后,LCD 上显示的时间全是 255,或者全是 0,有时候拨一下晶振又能恢复正常。
原因:读数据前没有把 IO 置成输入。51 的准双向口如果不先写 1,读操作会一直读到低电平,结果是 0;而全 0xFF 的情况通常出现在 IO 方向切换完成前 DS1302 就已经把数据输出完了,单片机读到的是未定义的悬空电平。
解决:读字节函数第一行先DS1302_IO = 1;,再开始拉 SCLK。同时用示波器量 IO 脚,正常读数据时应该能看到一串约 2us 周期的脉冲。另一个容易忽略的是 CE 在读完整串数据之前不能拉低,否则 DS1302 会直接中断输出。
6. 进阶:加一个串口对时命令,把万年历调到边界日期做验证
如果不想每次调时间都用按键按几十下,我一般会给整机加一个最简单的串口对时命令。协议格式用#YYMMDDHHMMSS,12 个十进制数字,从串口一次性发进来,单片机解析后直接调用DS1302_SetTime。
unsigned char rxBuf[14]; unsigned char rxIndex = 0; void Serial_ParseAndSet(DS1302_Time *t) { unsigned char i; unsigned char d[6]; if (rxBuf[0] != '#') return; for (i = 0; i < 6; i++) { d[i] = (rxBuf[1 + i * 2] - '0') * 10 + (rxBuf[2 + i * 2] - '0'); } t->year = d[0]; t->month = d[1]; t->date = d[2]; t->hour = d[3]; t->minute = d[4]; t->second = d[5]; t->week = DS1302_CalcWeek(2000 + t->year, t->month, t->date) + 1; DS1302_SetTime(t); }解析函数只负责把 ASCII 数字拼成十进制,真正的日期校验还是交给上一章的DS1302_IncDate逻辑。调试时我最喜欢用的测试命令是#160228235958,也就是 2016 年 2 月 28 日 23:59:58。上电后等待三秒,观察日期是否自动变成 2016 年 2 月 29 日;然后再发一条#161231235958,看它是否在跨年时变成 2017 年 1 月 1 日。这两条边界能一次性暴露闰年判断、月末进位和星期重算三个问题。
我还习惯在验证完成后做一次断电测试。正常断电等十秒,重新上电后不按键,看时间是否还在走,星期是否和串口下发的日期保持一致。我自己的调试习惯是把万年历程序当作一个“对时从设备”来用,每次改完日期算法,先发给它一个闰年边界,再发给它一个跨年边界,跑通了才放心交给按键逻辑。希望这个流程也能帮到你。
本文还有配套的精品资源,点击获取