简介:这份LCD1602万年历资源包面向单片机(C51)学习者和电子爱好者,完整演示了DS1302实时时钟、LCD1602液晶显示、I2C/EEPROM存储、温度监测与红外遥控的综合应用。压缩包内含78个文件,约1.41MB,包括C51源文件与配套头文件、编译生成的hex/obj/lst/log等工程文件,以及LCD1602显示时钟接线图jpg,便于对照硬件连接与工程构建。项目涵盖闰年自动处理、温度预警(P1.7口LED闪烁)、红外按键自定义等功能模块,资源目录按模块拆分清晰,适合需要从零搭建多功能时钟或进阶学习单片机外设通信的开发者。该资源已有289人学习,代码结构清晰、模块化程度高,是一套可直接用于实操和二次开发的入门到进阶案例。
1. LCD1602万年历:一块小屏加一颗时钟芯片,为什么值得动手
LCD1602万年历,说白了就是用一块十六字符两行的液晶屏,配一颗实时时钟芯片,在单片机上做一个断电不丢时间、能显示年月至秒的桌面时钟。这个项目常年不冷,不是因为它新鲜,而是它把嵌入式入门最需要的三件事一次凑齐了:IO操作、通信时序、按键交互。做完它,你手里就多了一个能用的桌面摆件,而不是一个烧完程序就往抽屉里扔的点灯板。
适合谁来动手?一类是学过点 C 语言、想从点灯迈向综合项目的新手;另一类是做过一些模块实验、但没试过把三四个模块拼成一个完整产品的入门开发者。这个项目的价值不在电路有多难,而在你能亲眼看懂“屏幕为什么亮、时间为什么走、按键为什么响应”这三件事。做成之后,往后不管做 OLED 菜单还是做温湿度计,这套代码骨架都能直接复用。
2. 显示与计时方案:LCD1602 的工作原理和三种时钟方案怎么选
做 LCD1602 万年历之前,有两个选型问题必须先定下来:屏幕怎么接,时钟芯片用哪个。这两个决定看着不起眼,实际会影响整个代码结构和后期排错难度。尤其是时钟方案,很多人随便抓一个 RTC 模块就焊上去,结果一个月后时间差出十几分钟,再回来改电路,成本比一开始选对高得多。
2.1 LCD1602 能显示什么:字符映射、DDRAM 地址和 4 线模式的取舍
LCD1602 使用的是 HD44780 或兼容控制器,内部有一块 DDRAM,每个地址对应屏幕上的一个字符位置。第一行地址从 0x00 到 0x0F,第二行从 0x40 到 0x4F。往某个地址写一个 ASCII 码,对应位置就显示这个字符。它没有中文字库,只有 ASCII 字符、日文假名和少量希腊字母,所以万年历里的“星期几”一般显示成 SUN、MON 这种英文缩写,或者自己用 CGRAM 造字。
控制这块屏有两种接法:8 线模式一次传一个字节,4 线模式分两次传半个字节。8 线模式代码简单,但要占 8 个数据引脚外加 RS、RW、EN,一共 11 个 IO;4 线模式只占 4 个数据引脚,加控制脚一共 7 个。我一般做项目都选 4 线模式,不是因为省那 4 个引脚,而是后续大概率要在这个板子上再挂 DS18B20、按键、蜂鸣器甚至 OLED,IO 资源能省一点是一点。
4 线模式唯一的代价是初始化和写数据时要把一个字节拆成高低两半,代码多几行,时序上多一次脉冲,但这点开销对单片机完全无感。还有个常见误区是把 RW 引脚直接接低电平固定为写模式,开发阶段没问题,但如果你后面想用“读忙标志位”来判断 LCD 是否准备好,RW 就得接到 IO 上。新手阶段建议直接接低,少一根线少一个故障点。
2.2 时钟方案选型:DS1302、DS3231 还是主控软件计时
万年历对时间精度的要求是“一个月不差出一分钟”,这个底线不算苛刻,但也不是随便能达标的。常见做时钟的方案有三种,我自己按需求这样分:练手课设选 DS1302,成品和追求精度选 DS3231,临时凑合才用主控软件计时。
| 方案 | 接口 | 精度表现 | 掉电续航 | 成本 | 适用场景 |
|---|---|---|---|---|---|
| DS1302 | 三线(CE/SCLK/IO) | 取决于晶振质量,常见日差从几秒到几十秒 | CR2032 可撑数年 | 低 | 练手、课程设计、普通桌面时钟 |
| DS3231 | I2C | 内置温补晶振,年误差约一分钟内 | 电池可维持 | 高,约 DS1302 的数倍 | 成品、需要温度显示、对走时精度有要求的项目 |
| 主控软件计时 | 无 | 受主晶振和中断响应影响,误差大 | 断电即重置 | 几乎为零 | 临时显示、实验演示 |
DS1302 是经典入门选择,三线协议简单,代码只有几十行,掉电后靠一颗 CR2032 电池维持走时。它的精度问题几乎全出在外部 32.768kHz 晶振上,网上买到的模块很多配的是 12.5pF 负载电容晶振,而 DS1302 手册建议的是 6pF 左右,用错晶振会出现一天快几十秒甚至几分钟的情况。这个问题后面避坑章会细说,选型时只要记住一条:买晶振时认准 6pF 负载电容。
DS3231 是另一个极端,自带温补晶振和温度传感器,精度碾压 DS1302,但价格贵几倍,而且模块体积大。如果这个万年历是要长期摆在桌面上用的,我建议直接上 DS3231,多花的钱换来的是不需要反复校时。如果只是练手、过一遍流程,DS1302 完全够。
主控软件计时是很多新手想省芯片时的第一反应,用一个定时器中断累加秒数。这个方案在实验桌上看着能跑,实际没法用:单片机主晶振不是频率基准,误差受温度、电压影响,而且掉电时间直接清零。万年历如果连断电保持都做不到,那还不如用手机看时间。所以我的结论很简单:这个项目至少要用 DS1302 起步,预算允许就 DS3231,软件计时不要碰。
3. 接线与点亮屏幕:引脚对照、最小系统和 LCD 初始化时序
确定方案之后,动手第一步不是写代码,而是把线接对。LCD1602 万年历的硬件说简单也简单,但屏幕 16 个引脚、时钟芯片 8 个引脚,新手第一次对着排针容易眼花。这一章给出一份可以直接照着接的引脚对照表,再把 LCD 初始化时序拆开讲一遍。
3.1 硬件接线表:LCD1602 与 DS1302 怎么接到主控
下面这套接线按最常见的开发板来排,数据线用 P2 口接 LCD,DS1302 用 P1 口。具体引脚宏在代码里都有定义,换成其他型号芯片时只要改头文件里的 sbit 声明即可。
| LCD1602 引脚 | 功能 | 接到哪里 | 备注 |
|---|---|---|---|
| 1 | VSS | GND | 电源地 |
| 2 | VDD | +5V | 逻辑电源 |
| 3 | VO | 10K 电位器滑臂 | 对比度调节,不接基本看不到字 |
| 4 | RS | P2.0 | 0=命令,1=数据 |
| 5 | RW | GND | 固定为写模式,省一个 IO |
| 6 | EN | P2.2 | 使能脉冲,每次都靠它把数据锁存进去 |
| 7~10 | D0~D3 | 悬空 | 4 线模式下不用,可接地 |
| 11 | D4 | P2.4 | 数据线,接反会乱码 |
| 12 | D5 | P2.5 | 数据线 |
| 13 | D6 | P2.6 | 数据线 |
| 14 | D7 | P2.7 | 数据线 |
| 15 | A | 串 100Ω 电阻接 +5V | 背光正极 |
| 16 | K | GND | 背光负极 |
DS1302 那边相对简单,VCC2 接 5V,VCC1 接 CR2032 电池正极,GND 接地,X1 和 X2 接 32.768kHz 晶振,CE、SCLK、IO 分别接 P1.2、P1.1、P1.0。这里有一个细节:DS1302 的 VCC2 和 VCC1 之间有切换电路,主电源有电时用它供电,主电源掉电才切到电池。所以千万不要把电池直接并到 VCC2 上,那样电池会一直在放电。
布线上要注意三点。第一,晶振两根引脚到 DS1302 的走线越短越好,尽量靠近芯片焊,很多时间不走的问题就是晶振引脚上的杂散电容太大导致不起振。第二,DS1302 的 VCC 和 GND 之间放一个 0.1uF 去耦电容,位置靠近芯片。第三,如果用的是普通 CR2032 不可充电电池,绝对不要去配置 DS1302 的涓流充电寄存器,否则电池有漏液风险。市面上也有可充电的 LIR2032,那是配合涓流充电用的,新手不建议碰。
3.2 从 8 位到 4 位模式:LCD1602 初始化代码与调试顺序
LCD1602 上电后默认工作在 8 位模式,想切到 4 位模式,必须在初始化时按手册要求的顺序发特定指令。这个握手过程做错,后面写什么都是乱码。我习惯把初始化拆成两段:先发三次 0x03 并各延时 5ms,告诉屏幕“我要用 8 位方式和你说话”;再发一次 0x02,表示“接下来切到 4 位模式”。这个顺序不能跳,也不能用太短的延时。
/* 引脚定义,按你自己的板子改 */ sbit LCD_RS = P2^0; sbit LCD_RW = P2^1; sbit LCD_EN = P2^2; sbit LCD_D4 = P2^4; sbit LCD_D5 = P2^5; sbit LCD_D6 = P2^6; sbit LCD_D7 = P2^7; void DelayMs(unsigned int ms) { unsigned int i, j; for (i = 0; i < ms; i++) for (j = 0; j < 125; j++); /* 12MHz 下约 1ms,按你的晶振频率微调 */ } /* 向 LCD 发送半个字节(高 4 位或低 4 位) */ void LcdWriteNibble(unsigned char val, unsigned char rs) { LCD_RS = rs; /* rs=0 发命令,rs=1 发数据 */ LCD_D4 = (val >> 0) & 0x01; LCD_D5 = (val >> 1) & 0x01; LCD_D6 = (val >> 2) & 0x01; LCD_D7 = (val >> 3) & 0x01; DelayMs(1); LCD_EN = 1; /* EN 高脉冲,LCD 在上升沿锁存数据 */ DelayMs(1); LCD_EN = 0; } /* 发送一个完整命令,先高 4 位后低 4 位 */ void LcdWriteCmd(unsigned char cmd) { LcdWriteNibble(cmd >> 4, 0); DelayMs(1); LcdWriteNibble(cmd & 0x0F, 0); DelayMs(2); /* 命令执行需要时间,不能省 */ } /* 发送一个数据字节,用于显示字符 */ void LcdWriteData(unsigned char dat) { LcdWriteNibble(dat >> 4, 1); DelayMs(1); LcdWriteNibble(dat & 0x0F, 1); DelayMs(1); } /* 初始化时序:严格按手册的先后顺序来 */ void LcdInit(void) { DelayMs(40); /* 上电等待 >40ms */ LcdWriteNibble(0x03, 0); /* 第一次 8 位模式握手 */ DelayMs(5); LcdWriteNibble(0x03, 0); /* 第二次 */ DelayMs(5); LcdWriteNibble(0x03, 0); /* 第三次 */ DelayMs(5); LcdWriteNibble(0x02, 0); /* 切换到 4 位模式 */ LcdWriteCmd(0x28); /* 4 位、2 行、5x7 点阵 */ LcdWriteCmd(0x0C); /* 显示开、光标关、不闪烁 */ LcdWriteCmd(0x06); /* 写入后地址自动加 1 */ LcdWriteCmd(0x01); /* 清屏,需要较长延时 */ DelayMs(5); LcdWriteCmd(0x80); /* 光标定位到第一行第一列 */ }这段代码的关键点是每一个命令发出后都要有足够延时。尤其是清屏指令 0x01,手册上写的执行时间在 1.6ms 以上,实际使用中我给的延时通常放到 5ms,否则在快速刷新时会偶发花屏。很多人把这里改成延时几十微秒,屏幕能亮,但字符会随机错位,排查起来非常头大。
调试顺序也有讲究。焊完第一件事不是烧程序,而是上电后先拧 VO 电位器,从一端慢慢拧到另一端,正常能看到第二行的方块由深变浅。如果怎么拧都没有方块,先查背光 A 和 K 两个引脚是否接反,再看 VDD 有没有 5V。屏幕能出方块之后再烧初始化程序,这样能快速把“硬件没焊好”和“代码时序不对”分开。从 8 位切到 4 位这个过程,用逻辑分析仪抓 EN 引脚最直观,没有逻辑分析仪就靠眼睛看初始化后屏幕上是否出现干净的两行空格。出现乱码,第一反应检查数据线有没有接错位,而不是反复改延时。
4. 万年历核心代码:读 DS1302 时间、拼显示和按键调时
硬件点亮之后,真正的重头戏是让 DS1302 把时间读出来并显示到 LCD 上。DS1302 的通信协议比 I2C 简单,三根线就能完成读写,但它也有自己的规矩:命令字节的结构、BCD 码的转换、写保护位的处理,每个环节都可能让时间显示成乱码或者干脆不走。这一章从底层读写函数开始,一层层写到主循环。
4.1 用 IO 模拟三线协议:DS1302 读写函数怎么写
DS1302 与单片机之间通过 CE、SCLK、IO 三根线通信,类似 SPI 但又不完全一样。它的命令字节由“固定控制位 + 寄存器地址 + 读写标志”组成,常见的映射关系是:读秒用 0x81,写秒用 0x80;读分用 0x83,写分用 0x82。规律是地址左移一位后,最低位表示读写方向。下面这段是完整的底层函数,所有上层操作都建立在它上面。
sbit RTC_CE = P1^2; sbit RTC_SCK = P1^1; sbit RTC_IO = P1^0; /* 向 DS1302 写一个字节,低位先出 */ void RtcWriteByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { RTC_IO = dat & 0x01; /* 先送最低位 */ dat >>= 1; RTC_SCK = 1; /* SCLK 上升沿,DS1302 采样数据 */ DelayUs(2); RTC_SCK = 0; DelayUs(2); } } /* 从 DS1302 读一个字节 */ unsigned char RtcReadByte(void) { unsigned char i, dat = 0; RTC_IO = 1; /* 引脚置 1,释放总线,准备读外部电平 */ for (i = 0; i < 8; i++) { dat >>= 1; RTC_SCK = 1; DelayUs(2); if (RTC_IO) /* SCLK 高电平期间读 IO 引脚 */ dat |= 0x80; RTC_SCK = 0; DelayUs(2); } return dat; } /* 写寄存器:拉高 CE,发命令,发数据,拉低 CE */ void RtcWriteCmd(unsigned char cmd, unsigned char dat) { RTC_CE = 0; RTC_SCK = 0; RTC_CE = 1; /* CE 拉高后整个读写周期内保持高 */ RtcWriteByte(cmd); /* 先发命令字节 */ RtcWriteByte(dat); /* 再发数据字节 */ RTC_CE = 0; /* 结束,防止误触发写入 */ } /* 读寄存器:拉高 CE,发命令,读数据 */ unsigned char RtcReadCmd(unsigned char cmd) { unsigned char dat; RTC_CE = 0; RTC_SCK = 0; RTC_CE = 1; RtcWriteByte(cmd); dat = RtcReadByte(); RTC_CE = 0; return dat; }几个容易忽略的细节。第一,读数据前要把 IO 引脚置 1,因为常见的 51 单片机端口是准双向口,不写 1 直接读的话,外部拉低能被读到,但外部拉高时可能读成 0,导致读出来的数据全是 0x00 或者 0xFF。第二,CE 要在整个字节传输期间保持高电平,读完立刻拉低,这样能避免 DS1302 在噪声干扰下误判为写操作。第三,SCLK 的延时不需要特别精确,几十微秒级别即可,但不要省略,因为 DS1302 的输入时钟频率有限制,SCLK 太快会采样出错。
4.2 BCD 码不是直读的:时间读取、格式化和首次上电写入
DS1302 内部寄存器存储的时间是 BCD 码,也就是一个字节的高四位存十位、低四位存个位。比如 23 这个数字,BCD 码是 0x23,而不是十进制的 0x17。直接把这个值送去 LCD 显示,会出现“12 34”这类奇怪结果。所以读出来的每个寄存器都要做一个转换,写入前也要把十进制转回 BCD。这个转换是所有 RTC 芯片的通用套路,不只是 DS1302。
typedef struct { unsigned char year; /* 0~99,存储 2000 年后的年份偏移 */ unsigned char month; /* 1~12 */ unsigned char day; /* 1~31 */ unsigned char week; /* 1~7 */ unsigned char hour; /* 0~23 */ unsigned char minute; /* 0~59 */ unsigned char second; /* 0~59 */ } RtcTime; unsigned char DecToBcd(unsigned char d) { return ((d / 10) << 4) | (d % 10); } unsigned char BcdToDec(unsigned char b) { return (b >> 4) * 10 + (b & 0x0F); } /* 一次读全部时间到结构体 */ void RtcGetTime(RtcTime *t) { t->second = BcdToDec(RtcReadCmd(0x81)); t->minute = BcdToDec(RtcReadCmd(0x83)); t->hour = BcdToDec(RtcReadCmd(0x85)); t->day = BcdToDec(RtcReadCmd(0x87)); t->month = BcdToDec(RtcReadCmd(0x89)); t->week = BcdToDec(RtcReadCmd(0x8B)); t->year = BcdToDec(RtcReadCmd(0x8D)); } /* 首次上电设置时间,之后一般只通过按键调时 */ void RtcSetTime(RtcTime *t) { RtcWriteCmd(0x8E, 0x00); /* 0x8E 是写保护寄存器,先关写保护 */ RtcWriteCmd(0x80, DecToBcd(t->second)); /* 先写秒,最低位为 0 表示启动时钟 */ RtcWriteCmd(0x82, DecToBcd(t->minute)); RtcWriteCmd(0x84, DecToBcd(t->hour)); RtcWriteCmd(0x86, DecToBcd(t->day)); RtcWriteCmd(0x88, DecToBcd(t->month)); RtcWriteCmd(0x8A, DecToBcd(t->week)); RtcWriteCmd(0x8C, DecToBcd(t->year)); RtcWriteCmd(0x8E, 0x80); /* 重新打开写保护,防止误写 */ }读时间的代码很简单,但第一次烧录时有一个坑:全新的 DS1302 芯片出厂时时钟是停止的,秒寄存器的最高位 CH 默认为 1。如果不做任何处理直接读时间,秒位永远是 00 或者不动。所以首次上电必须调用一次 RtcSetTime,把秒写成一个 CH 位为 0 的值。上面代码里先写秒再写其他寄存器,就是为了让时钟尽早跑起来。写保护寄存器 0x8E 在每次写寄存器前要关闭,写完立刻打开,这是 DS1302 的防误写机制,很多时间写着写着丢了就是漏了这一步。
4.3 按键调时与边界处理:从修改秒到闰年判断
万年历如果没有调时功能,只能算个时钟,不能算产品。按键调时有一个很关键的工程习惯:先把要改的位调到一个字段,短按加减,长按快速进位,最后再统一写回 DS1302。我一般用三个按键:一个切换调整字段,一个加,一个减。调整项按“年、月、日、时、分、秒”循环,第二个按键每次加 1 并做边界回绕,到 23 加 1 回 0,到 59 加 1 回 0。
/* 按键引脚定义 */ sbit KEY_SET = P3^0; sbit KEY_UP = P3^1; sbit KEY_DOWN = P3^2; unsigned char adjustItem = 0; /* 0=年 1=月 2=日 3=时 4=分 5=秒 */ /* 月份天数表,2 月后面按闰年判断替换 */ code unsigned char MonthDays[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; unsigned char IsLeapYear(unsigned char year) { unsigned int y = 2000 + year; return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); } void AdjustValue(unsigned char dir) { RtcTime t; unsigned char maxVal = 0; RtcGetTime(&t); switch (adjustItem) { case 0: maxVal = 99; break; case 1: maxVal = 12; break; case 2: maxVal = MonthDays[t.month]; if (t.month == 2 && IsLeapYear(t.year)) maxVal = 29; break; case 3: maxVal = 23; break; case 4: maxVal = 59; break; case 5: maxVal = 59; break; } if (dir == 1) { if (adjustItem == 0) t.year++; else if (adjustItem == 1) t.month++; else if (adjustItem == 2) t.day++; else if (adjustItem == 3) t.hour++; else if (adjustItem == 4) t.minute++; else t.second++; } else { if (adjustItem == 5 && t.second == 0) t.second = 59; else { if (adjustItem == 0 && t.year > 0) t.year--; else if (adjustItem == 1 && t.month > 1) t.month--; else if (adjustItem == 2 && t.day > 1) t.day--; else if (adjustItem == 3 && t.hour > 0) t.hour--; else if (adjustItem == 4 && t.minute > 0) t.minute--; else if (adjustItem == 5 && t.second > 0) t.second--; } } /* 超出上限回绕到 0 或 1 */ if (adjustItem == 0 && t.year > maxVal) t.year = 0; if (adjustItem == 1 && t.month > maxVal) t.month = 1; if (adjustItem == 2 && t.day > maxVal) t.day = 1; if (adjustItem == 3 && t.hour > maxVal) t.hour = 0; if (adjustItem == 4 && t.minute > maxVal) t.minute = 0; if (adjustItem == 5 && t.second > maxVal) t.second = 0; RtcSetTime(&t); } void KeyScan(void) { if (!KEY_SET) { /* 按键按下为低电平 */ DelayMs(20); /* 软件消抖 */ if (!KEY_SET) { adjustItem++; if (adjustItem > 5) adjustItem = 0; while (!KEY_SET); /* 等待松手,防止连跳 */ } } if (!KEY_UP) { DelayMs(20); if (!KEY_UP) { AdjustValue(1); while (!KEY_UP); } } if (!KEY_DOWN) { DelayMs(20); if (!KEY_DOWN) { AdjustValue(0); while (!KEY_DOWN); } } }这段代码里最容易翻车的是小时寄存器。DS1302 小时寄存器的最高位是 12/24 小时制标志,写小时时如果忽略了掩码,可能把这一位状态改掉,导致 12 小时制和 24 小时制乱跳。上一章 RtcSetTime 里写的 0x84 地址,在写入前建议加一个掩码DecToBcd(t->hour) & 0x7F,确保最高位永远是 0,强制 24 小时制。调秒也有一个习惯:对时一般把秒清零最简单,不要手动去调秒去追手表,直接在整点整分时按下 SET 切到秒字段,按一下 UP 让秒变成 00 就行。
主循环把这几块串起来只需要十几行。注意不要每次都调用 LcdWriteCmd 清屏,那样屏幕会闪烁得很厉害。我一般 250ms 刷新一次读写时间,只有数值真的变了才重绘对应位置,这样屏幕干净,代码也简单。
void main(void) { RtcTime t; LcdInit(); RtcSetTime(&t); /* 首次烧录需要设置初始时间,之后可注释掉 */ while (1) { RtcGetTime(&t); LcdShowTime(&t); KeyScan(); DelayMs(250); } }5. 常见问题排查:黑屏、乱码、时间不走和不保存
这个项目做到最后,你会发现绝大多数问题出在硬件细节和时序假设上,而不是逻辑。下面这几条都是我反复见过的典型故障,每一条按“现象、原因、解决”的顺序写,方便你对照排查。
5.1 屏幕不亮或只有黑色方块
现象:背光不亮,或者背光亮了但屏幕上只有两行黑色方块,写什么命令都没反应。原因通常有两个:VO 对比度没调对,或者 LCD 根本没有收到有效时序。很多新手把 VO 直接接地或悬空,这会导致屏幕要么全黑要么全白,看起来像坏了。
解决:先把 10K 电位器接上 VO,用万用表确认 VDD 和 VSS 之间确实是 5V。然后一边拧电位器一边观察屏幕,方块从无到有再到消失,说明显示部分正常。如果怎么拧都是深色方块不变,用示波器或逻辑分析仪看 EN 引脚是否有脉冲,没有脉冲就是程序没跑到初始化。还有个小经验:开发板供电不足时,背光一开屏幕就闪,这时给 VDD 和 GND 之间并联一个 100uF 电容能解决大部分电源纹波问题。
5.2 显示乱码与字符错位
现象:上电后第一行是满屏乱码,或者明明写的是数字显示出来却是个别符号。原因一般不是代码逻辑错了,而是 4 线模式握手没完成,或者 D4 到 D7 四根数据线接的顺序有误。屏幕在 8 位模式下收到了半截数据,自然解析出乱码。
解决:先检查数据线。用万用表通断档,从单片机引脚到 LCD 引脚逐根量一遍,很多乱码问题就是两根线在杜邦线端插反了。排除接线后再查初始化时序,重点确认三次 0x03 之间的延时是不是足够 5ms。有一个很容易忽略的点:切换 4 位模式后的第一条命令 0x28 分两次发送,中间要留出至少 1ms 间隔,很多教程代码这里用的延时太短,屏幕偶发乱码。
5.3 时间走得飞快或者干脆不走
现象:刚对好时,半天快了几分钟;或者秒位偶尔跳一下然后停住。原因大概率是 DS1302 的晶振不匹配。DS1302 手册推荐的是负载电容 6pF 的 32.768kHz 晶振,市面上大量模块配的是 12.5pF 的通用晶振,频率偏高,走时会明显偏快。不走的情况则多半是晶振没起振,或者 CE、SCLK、IO 三根线接错。
解决:优先买标称 6pF 的晶振替换。手头只有 12.5pF 也不是不能用,在 X1 和 X2 对地各并一个 6~10pF 的电容做微调,能拉回一部分误差。没有示波器时,用手机秒表对时然后跑 24 小时,算出日偏差值,再决定要不要换晶振。如果时间完全不走,先量 DS1302 的 VCC2 有没有 5V,再用万用表频率档去测 X1 引脚,能测到 32kHz 左右说明起振了。
5.4 断电重启后时间不保存
现象:程序烧好时时间正常,一旦断电再上电就回到初始值。原因有两个方向。第一,CR2032 电池没接对,VCC1 悬空或接反了,DS1302 在主电源掉电后没有后备电源。第二,写保护流程有问题,初始化时根本没执行过 RtcSetTime,芯片里的时间是默认值,上电后读到的自然不是真实时间。
解决:用万用表量 VCC1 对 GND 的电压,应该有 3V 左右。注意电池正极接 VCC1,负极接 GND,接反了不仅不保存,还可能把芯片搞坏。程序上确认首次烧录执行过一次 RtcSetTime,并且里面最后把写保护打开了。有一个需要提醒的点:如果你用带涓流充电的模块,且电池是不可充电的 CR2032,一定要避免写 0x90 寄存器开启涓流充电,否则电池会鼓包甚至漏液。
5.5 调时之后秒被连带改掉
现象:只想把分钟从 05 调到 06,结果秒变成 00,或者小时设置完变成 13 点。原因:按键调速时没有统一先读一次完整时间再改,而是直接对某个寄存器做了写入,导致其他字段被覆盖成上次读到的旧值。另一个原因是写小时寄存器时改了 12/24 小时制标志位。
解决:任何一次调整都先 RtcGetTime 读回整个结构体,改完一个字段再整体 RtcSetTime 写回。写小时时给最高位加掩码& 0x7F。调秒字段的抖动最好也处理一下:按键消抖延时 20ms 只解决机械抖动,如果发现按一下秒跳两到三秒,多半是 while 等待松手的循环没写成死等,加一个超时退出就能解决。
6. 让万年历更可靠:走时精度验证与三个实用改造
屏幕能亮、时间能走、按键能调,这只是开始。真正决定这个项目能不能长期摆在桌面上,是走时精度。我验证精度的方法很简单:用电脑或手机上的时间校准到秒,记下当前差几秒,跑 24 小时后再对一次,算出日差。DS1302 配 6pF 晶振正常日差在 1 秒以内,如果日差超过 5 秒,先检查晶振再考虑软件补偿。软件补偿的做法是每天固定快 0.8 秒的话,在程序中用一个计数器,每 75 秒人为减 1 秒,但这是补救措施,不要一开始就依赖它。
三个改造方向都比较实用。第一是加温度显示,DS3231 自带温度传感器,DS1302 则需要外接 DS18B20 单总线传感器,数据线接一个 IO 加 4.7K 上拉电阻,读取逻辑不复杂,显示在第二行时间后面。第二是加农历显示,这个需要一张公历转农历的表,覆盖 2000 到 2099 年即可,每字节压缩存储月份大小和闰月信息,查表计算后把农历日期显示在 LCD 第一行末尾。第三是加整点报时和闹钟,用定时器中断维护一个秒计数,到整点触发蜂鸣器响一声,比在主循环里判断时间可靠得多,因为主循环里的 DelayMs 会让按键和显示时间互相干扰。
我个人的习惯是每次做完硬件改动,先对时再封壳,跑三天看累计误差。补一句血泪经验:早先贪便宜用过一批杂牌晶振,日差普遍在 20 秒以上,后来换了正规货源才稳定下来。做电子万年历,晶振的钱不能省,这是整个项目里最影响体验的一个原件。希望帮到你。
本文还有配套的精品资源,点击获取