51单片机万年历设计:DS1302时序、LCD1602显示与掉电保持全解析
2026/9/16 2:54:22 网站建设 项目流程

简介:一份面向51单片机初学者的万年历项目完整源码与设计文档包,涵盖计时、1602 LCD显示、温度采集(支持LM35/DS18B20等)、按键控制及闰年日期管理等核心模块,适合正在学习定时器、中断、ADC与LCD驱动开发的读者。压缩包共11个文件,包含4个C语言源程序、1个头文件、课程设计报告(docx/pdf格式)、README/说明文档及License等,整体仅约973KB,结构清晰便于学习查阅。已有181人学习下载。通过该项目可掌握基于51单片机的多功能系统设计思路,了解ENDclock与ENDmain等程序对时间管理函数、按键扫描和LCD刷新机制的封装方式,同时也能从课程设计报告中学习到完整的硬件方案与文档写作方法,帮助读者快速上手类似的嵌入式系统项目。

1. 从 51 单片机万年历说起,这个项目的价值不只是报时间

万年历是 51 单片机学习中少有的能一次性涵盖纯逻辑、时序和低功耗考量的项目。DS1302 负责走时、LCD1602 负责显示、按键负责调时、蜂鸣器负责闹钟,四者组合正好覆盖单片机开发的完整链路。网上流传的 "51单片机万年历.zip" 大多就是围绕这条链路展开的,里面一般包含 Keil 工程、Proteus 仿真文件和原理图 PDF。真正值得关注的不是把源码编译通过,而是搞清楚 DS1302 那根数据线为什么需要上拉电阻、为什么写一个字节要和读一个字节共用同一套时序,以及掉电后靠什么维持走时。这些点吃透了,再去写基于 51 单片机电磁炉程序、倒车雷达或者模拟 PTC2262 发射都能顺手很多。

这个项目适合已经开始学定时器和中断的人,如果能把 DS1302 的 SPI 类时序看清楚,再回头看矩阵键盘和 IIC 库就很轻松。文章会按硬件选型、DS1302 驱动、LCD 显示和按键调时、掉电保持与功耗优化、最后是调试技巧的顺序展开,每段都有可复现的代码和实际踩过的坑。

2. 万年历的器件选型和电路设计,先想清楚再打开 Keil

万年历的电路并不复杂,但选型直接决定代码难度和显示效果。常见做法是 STC89C52RC 或 AT89C52 配 DS1302、LCD1602、DS18B20 温度传感器,外加 3 个按键和 1 个蜂鸣器。下面先讲清楚每个器件的角色,再给出完整的接线表和 Proteus 中的注意事项。

2.1 DS1302 为什么是万年历的默认选择,而不是 DS12C887 或 PCF8563

DS1302 是 DALLAS 公司(现 Maxim)的涓流充电时钟芯片,用三线接口(SCLK、I/O、CE)和主控通信,内置 31 字节静态 RAM,支持年、月、日、时、分、秒和星期自动调整,闰年自动识别。它的优势在海量教程和库函数,因为接口逻辑简单,用普通 IO 口加延时就能模拟时序,不需要硬件 SPI 外设,这对 51 来说非常合适。

DS12C887 内部带有锂电池和完整的时钟寄存器,断电后不需要外部电路,但它需要占用 0x70-0x7F 的地址空间,在 51 上要用 P2 口做地址译码,接线复杂且芯片体积大。PCF8563 走 IIC 接口,地址线和中断引脚更省,但 IIC 时序对 51 的软件模拟容错较低,新手的调试成本偏高。

所以,DS1302 是课程设计和竞赛中万年历的标准答案。它需要在外部加 32.768kHz 晶振和两个 6pF 负载电容,晶振两端跨接在 DS1302 的 X1 和 X2 引脚。要注意的是,DS1302 的 VCC2 接主电源,VCC1 接电池,芯片内部会自动切换到电压较高的一侧,这样主电掉电后由 3V 纽扣电池维持走时,时间不丢。

2.2 完整接线表和 Proteus 仿真环境的两个坑

以下是万年历在 Proteus 和实物中通用的接线表,所有信号都走 51 的普通 IO 口,不占用串口和中断资源,方便后续扩展红外遥控或矩阵键盘功能。

器件引脚接 51 引脚备注
DS1302CEP3.5片选,拉高开始通信
DS1302SCLKP3.6时钟线,上升沿采样
DS1302I/OP3.7数据线,双向
LCD1602RSP2.6寄存器选择
LCD1602RWP2.5读写选择,直接接地也可
LCD1602ENP2.7使能信号,下降沿锁存
LCD1602D0-D7P0.0-P0.7数据口,需接 10k 排阻上拉
DS18B20DQP3.4单总线数据
按键KEY1P1.0调时/确认键
按键KEY2P1.1加 1 键
按键KEY3P1.2切换设定项键
蜂鸣器BEEPP1.3高电平驱动需加三极管

Proteus 仿真中有两个常见的坑。第一,DS1302 晶振在仿真里不需要接,仿真模型内部已经有时钟源,但你接了也不会报错,只是和实物行为略有差异;第二,LCD1602 在 Proteus 中 P0 口不接上拉电阻也能正常显示,但烧到实物上就花屏或乱码,原因是 P0 口内部没有上拉电阻,输出高电平时带不动液晶的数据线。所以仿真和实物之间,P0 口上拉电阻是必须保留的差异点。

仿真的另一个坑是 DS1302 的时间初始值。Proteus 启动后 DS1302 的寄存器往往是全 0 或随机值,显示出来可能是 00:00:00 或者一个奇怪的年份。在代码初始化中应当检测年份寄存器是否为 0xFF(芯片首次上电的标志),是就写入一次初始时间,否则就直接读当前时间。这里用到一个关键字节,即 DS1302 的写保护寄存器地址 0x8E,当最高位 WP 为 1 时芯片只读不可写,所以每次写时间前要清写保护,写完后再开启写保护,避免后续程序误改时间。

void DS1302_WriteByte(unsigned char addr, unsigned char dat) { unsigned char i; CE = 0; SCLK = 0; // 初始状态 CE = 1; // 拉高片选,开始通信 for (i = 0; i < 8; i++) { // 先发送地址字节 IO = addr & 0x01; addr >>= 1; SCLK = 1; SCLK = 0; } for (i = 0; i < 8; i++) { // 再发送数据字节 IO = dat & 0x01; dat >>= 1; SCLK = 1; SCLK = 0; } CE = 0; // 拉低片选,结束通信 }

这段代码是最基础的 DS1302 写操作。地址字节使用 LSB(最低位在前)顺序发送,这是与很多 SPI 外设的最大区别,SPI 通常 MSB 在前,如果惯性思维按高位先发,读回来的时间会是乱的。而 CE 线在整个通信过程中必须保持高电平,不能在中间抖动,否则 DS1302 直接取消本次通信,这是硬件手册里明确写的时序要求。

2.3 晶振电路和上拉电阻的实际选型原则

DS1302 的 32.768kHz 晶振负载电容选 6pF 到 12.5pF 均可,常见做法是配两个 6pF 电容接地。晶振频率的准确性直接影响日差,普通晶振日误差在 ±1 秒到 ±5 秒之间,一个月累计误差可能到几分钟。如果项目要求高精度,可以用 DS3231 替代 DS1302,走 IIC 接口且内置温度补偿,年误差不超过 2 分钟。但在学习项目中,DS1302 加普通晶振够用,误差靠软件校准法来弥补。

软件校准的思路是:先记录 24 小时内走快的秒数,然后每隔 1 小时在程序里减去相应的秒数。比如实测一天快 3 秒,那么每 8 小时减 1 秒。更细的方法是用 DS1302 的涓流充电寄存器 0x90 控制充电电流,通过调整充电电流间接影响晶振振荡幅度,但这属于硬件级校准,一般不推荐初学者动。

上拉电阻方面,DS1302 的 I/O 线和 SCLK 线都建议接 10k 上拉到 VCC。因为 I/O 是双向口,在读数据时 51 的 P3.7 是准双向口,本身有弱上拉,但在 Proteus 和长线传输情况下容易受干扰,外加 10k 上拉能明显提高信号稳定性。LCD1602 的数据口 P0 用 10k 排阻上拉到 VCC,注意是排阻不是独立电阻,排阻的公共端接 VCC,另一端分别接 P0.0-P0.7。实物焊接时还要在 LCD1602 的 V0 引脚(对比度调节)接一个 10k 电位器,调出清晰显示,Proteus 里不接电位器也能正常显示,但实物不接的话字迹可能非常淡。

3. DS1302 驱动代码的实现,从单字节读写到时间和日历的结构体操作

DS1302 的驱动是整个万年历软件的核心,它并不难,但细节非常多。这章从时序图的理解开始,给出完整的读函数、写函数、时间转换函数和初始化流程,并解释 BCD 码与十进制互转的必要性。

3.1 时序图的本质:为什么一个反向读操作要那么多条 NOP

DS1302 的读时序比写时序多一个步骤:写地址字节之后,I/O 线要切换为输入方向,然后时钟线再产生脉冲,芯片在上升沿和下降沿之间把数据放到 I/O 线上。51 单片机是准双向口,切换方向不需要像 STM32 那样配置寄存器,只需要在读取前执行一条IO = 1把引脚拉高,之后才能正确读入外部信号。

时序图中的另一个关键参数是 tCKC(时钟下降沿到数据有效的时间),典型值为 250ns。51 单片机的晶振 12MHz 时,一个机器周期是 1μs,直接给个空操作延时就能满足要求。但很多人用 STC 单片机跑 12MHz 和 24MHz 时会发现读取的数据不稳定,原因是 STC 的 IO 口翻转速度快,必须在时钟线翻转后至少延迟 4 个空操作再读取数据线。

unsigned char DS1302_ReadByte(unsigned char addr) { unsigned char i, dat = 0; CE = 0; SCLK = 0; CE = 1; for (i = 0; i < 8; i++) { // 发送地址字节,仍为 LSB 低位在前 IO = addr & 0x01; addr >>= 1; SCLK = 1; SCLK = 0; } IO = 1; // 数据线释放,准备读取 for (i = 0; i < 8; i++) { dat >>= 1; // 先移位再读,保证数据按位正确 SCLK = 1; _nop_(); _nop_(); // 等待数据稳定 if (IO) dat |= 0x80; SCLK = 0; } CE = 0; return dat; }

读函数的移位顺序和写函数对称,数据从低位到高位依次进入。注意dat >>= 1放在SCLK = 1之前,否则第一次循环进来的数据会出现在最高位而不是最低位,导致读回来的时间完全错乱。

参数说明:addr 是分两个部分拼接的,高 6 位是寄存器地址,第 7 位固定为 1(这是控制字内的 RAM/CK 位用于区分时钟寄存器和 RAM 单元),最低位是读写方向位,读时置 1。例如读秒寄存器,地址是 0x80(秒寄存器的控制字),读秒就调用DS1302_ReadByte(0x81)。其中 0x80 是秒寄存器写地址,0x81 是其读地址,两者相差 1。

时序参数方面,DS1302 对 CE 建立时间要求是 tCES(CE 向高电平建立时间)最小 1μs,所以代码中CE = 1之后不要立刻拉 SCLK,至少加一个_nop_()或一个简单延时。而对 SCLK 高电平脉冲宽度的最低要求是 250ns,在 12MHz 晶振下 1 个机器周期为 1μs,天然满足要求,但为了代码可移植性,还是建议在SCLK = 1后加至少 2 个_nop_()

3.2 用结构体管理时间数据,BCD 转十进制的代码最容易被忽略

DS1302 内部寄存器中时间数据以 BCD 码存储,也就是一个字节的高 4 位表示十位,低 4 位表示个位。例如十进制 23 在 DS1302 中存储为 0x23,而不是 0x17。LC D1602 显示时要把它拆成两个 ASCII 字符,直接(time / 10) + '0'(time % 10) + '0'就可以了,不需要先转成真正的十进制数值再拆分。

但如果你要把时间拿去计算(比如加 1、减 1、或做闹钟比较),就必须先把 BCD 转成普通数值。以下是用 C 语言实现的结构体时间管理和转换代码。

typedef struct { unsigned char year; // BCD 码,如 0x25 代表 2025 年 unsigned char month; // BCD 码,如 0x12 代表 12 月 unsigned char date; // BCD 码,日期 unsigned char week; // 星期,1-7 或 1-6 不同芯片定义略有差异,本代码用 1-7 unsigned char hour; // BCD 码,小时 unsigned char minute; // BCD 码,分钟 unsigned char second; // BCD 码,秒 } RTC_Time; 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); } void DS1302_GetTime(RTC_Time *t) { t->year = DS1302_ReadByte(0x8D); // 读年 t->month = DS1302_ReadByte(0x89); // 读月 t->date = DS1302_ReadByte(0x87); // 读日 t->week = DS1302_ReadByte(0x8B); // 读星期 t->hour = DS1302_ReadByte(0x85); // 读时 t->minute = DS1302_ReadByte(0x83); // 读分 t->second = DS1302_ReadByte(0x81); // 读秒 }

读取顺序是有讲究的,DS1302 在读秒时会把所有内部寄存器的值锁定到缓存区,秒寄存器被读取时,其他时间寄存器的值都被锁存住,保证时钟在跨秒边界时读出来的时间是一致的。如果你只读分钟不读秒,那么跨秒边界时分钟可能读到偏大或偏小的值。工程上正确的做法是:先读秒,再用if (t->second == 0) 复位的方式在整秒对齐点读取所有寄存器,或者直接接受DS1302_GetTime按序读秒在读其他,只会有最多一秒的误差,不影响显示。

BCD_to_DECDEC_to_BCD两个函数是万年历的万能小工具。在调时界面,按一下 KEY2 要对分钟加 1,操作对象是十进制数值,修改完后写回 DS1302 前必须用DEC_to_BCD转换一次,否则显示无所谓,但重新上电后时间恢复乱码。此外,年的读取要特别处理,DS1302 的年份寄存器只有后两位,世纪信息是隐含的,常见的做法是在代码里定义基准年 2000,显示时2000 + BCD_to_DEC(t->year)输出给屏幕。

3.3 DS1302 的初始化流程,顺序错了时间写不进去

初始化流程看似简单,但顺序非常关键。如果先开写保护再写时间,所有写操作都会被丢弃,但代码不会有任何报错,你只会发现时间怎么都调不进去。正确顺序如下:

void DS1302_Init(void) { // 先关闭写保护 DS1302_WriteByte(0x8E, 0x00); // 写初始时间:2025-01-01 星期三 00:00:00 DS1302_WriteByte(0x80, 0x00); // 秒,0x80 是秒寄存器写地址 DS1302_WriteByte(0x82, 0x00); // 分 DS1302_WriteByte(0x84, 0x12); // 时,这里是 12 点(BCD 0x12) DS1302_WriteByte(0x86, 0x01); // 日,1 号 DS1302_WriteByte(0x88, 0x01); // 月,1 月 DS1302_WriteByte(0x8A, 0x03); // 星期,星期三 DS1302_WriteByte(0x8C, 0x25); // 年,0x25 即 25,表示 2025 年 // 再打开写保护 DS1302_WriteByte(0x8E, 0x80); }

初始化完成后再调用DS1302_GetTime,主循环里每 200ms 刷新一次显示,就能看到从 2025-01-01 00:00:00 开始走动的时钟。注意写时间时的小时寄存器不能用 24 小时制的 25 这种值,因为 DS1302 默认是 12 小时制还是 24 小时制取决于小时寄存器 bit7(24/12 位),bit7 为 0 是 24 小时制,为 1 是 12 小时制且有 AM/PM 位。如果初始化小时时误写入0x85(代表 PM 5 点,实际是 12 小时制下午 5 点),显示会异常,写入0x12才是 24 小时制的 12 点。

关于初始时间的判定,在main函数开始加上:

unsigned char flag = DS1302_ReadByte(0x81); // 读秒寄存器,同时作为初始检测 if (flag == 0xFF || flag == 0x00) { // 芯片从未初始化或电池耗尽,写入默认时间 } else { // 直接进入正常计时 }

但不是必须的,因为正常上电后 DS1302 电压由 VCC2 提供,读回来不会是 0xFF,只有电池完全耗尽且主电刚断开的极端情况才会是 0xFF。实际调试中,读回来 0x00 的情况更常见,特别是新芯片第一次上电,秒寄存器是 0,但这时芯片已经走时。最稳妥的判断是读年寄存器DS1302_ReadByte(0x8D)若大于 0x50 或等于 0xFF,则认为是非法值重写初始时间。

4. LCD1602 显示万年历信息,与 DS18B20 温度采集同时驱动

万年历的显示部分有两个数据来源:DS1302 的时间和 DS18B20 的温度。LCD1602 驱动没有太多难点,但它有 11 条控制命令需要掌握,关键是区分指令码和数据码的发送。同时,DS18B20 单总线时序比 DS1302 更苛刻,延时以微秒计,必须用 NOP 函数或延时函数精确控制。

4.1 LCD1602 的 4 线驱动模式和 8 线驱动模式,省引脚还是省代码

LCD1602 支持 8 位和 4 位两种数据接口模式。8 位模式占 P0 全部 8 根线,4 位模式只用高 4 位(D4-D7),能省出 4 个引脚。对于万年历项目,DS1302 占用 3 个引脚,DS18B20 占用 1 个,按键占用 3 个,蜂鸣器 1 个,P1 口和 P3 口已经用得差不多。所以使用 4 线模式是万年历项目的标准做法,接线资源更宽裕,后面要加红外遥控接收头或者矩阵键盘都放得下。

4 线驱动模式下,初始化时序比 8 线复杂,必须按 HD44780 数据手册的时序要求,分两次发送功能设置命令 0x28。以下是在 4 线模式下初始化 LCD1602 的代码:

#define LCD_DATA P0 #define LCD_RS P2_6 #define LCD_RW P2_5 #define LCD_EN P2_7 void LCD_WriteCmd(unsigned char cmd) { LCD_RS = 0; // 命令模式 LCD_RW = 0; // 写模式 LCD_DATA = (cmd & 0xF0); // 先送高 4 位 LCD_EN = 1; _nop_(); _nop_(); LCD_EN = 0; LCD_DATA = ((cmd << 4) & 0xF0); // 再送低 4 位 LCD_EN = 1; _nop_(); _nop_(); LCD_EN = 0; delay_ms(2); // 命令执行时间 2ms } void LCD_WriteData(unsigned char dat) { LCD_RS = 1; // 数据模式 LCD_RW = 0; LCD_DATA = (dat & 0xF0); LCD_EN = 1; _nop_(); _nop_(); LCD_EN = 0; LCD_DATA = ((dat << 4) & 0xF0); LCD_EN = 1; _nop_(); _nop_(); LCD_EN = 0; delay_ms(1); } void LCD_Init(void) { delay_ms(15); // 上电等待,至少 15ms LCD_WriteCmd(0x30); // 先切换到 8 位模式(模拟) delay_ms(5); LCD_WriteCmd(0x30); delay_ms(1); LCD_WriteCmd(0x30); LCD_WriteCmd(0x20); // 切换为 4 位模式 LCD_WriteCmd(0x28); // 4 位模式,2 行,5x7 点阵 LCD_WriteCmd(0x0C); // 显示开,光标关,闪烁关 LCD_WriteCmd(0x01); // 清屏 LCD_WriteCmd(0x06); // 光标右移,整屏不移动 }

这段代码中的LCD_WriteCmd(0x30)是刻意模拟 8 位模式下的功能设置命令,这样做是为了让 LCD1602 在 4 位模式下也能正确识别后续的0x20切换指令。因为 LCD1602 上电默认是 8 位模式,如果你直接发送0x28(4 位模式命令)的高 4 位,芯片会把它当成 8 位指令,结果不可预期。这是 4 线模式初始化最容易出错的地方。

参数说明:0x28的二进制是0010 1000,其中 bit5 为 1 表示 4 位数据长度,bit4 为 1 表示 2 行显示,bit3 为 0 表示 5x7 点阵。0x0C是显示控制命令,bit3 为 1 开显示,bit2 为 0 关光标,bit1 为 0 关闪烁。0x01清屏命令执行时间约 1.64ms,所以延时 2ms 更保险。

4.2 在 LCD1602 上显示动态时间与日期,注意光标定位

万年历的显示布局一般分为两行:第一行显示时间和星期,第二行显示日期和温度。示例布局如下:

第一行: 12:30:45 WED 第二行: 2025-01-01 25.5C

对应的 LCD 光标定位命令是0x80 | 地址,第一行首位地址是 0x00,第二行首位地址是 0x40。使用LCD_WriteCmd(0x80 + 0x00)定位到第一行第 1 列,LCD_WriteCmd(0x80 + 0x40)定位到第二行第 1 列。

显示时间的函数需要注意数字与 ASCII 码的转换,不能直接把 BCD 码发送给 LCD,要先把 BCD 码右移 4 位取出十位,再与0x0F相与取出个位,最后分别加上0x30转成 ASCII 字符。

void LCD_DisplayTime(RTC_Time *t) { unsigned char bcd_h = t->hour; unsigned char bcd_m = t->minute; unsigned char bcd_s = t->second; LCD_WriteCmd(0x80); // 第一行首位 LCD_WriteData((bcd_h >> 4) + '0'); // 十位 LCD_WriteData((bcd_h & 0x0F) + '0'); // 个位 LCD_WriteData(':'); LCD_WriteData((bcd_m >> 4) + '0'); LCD_WriteData((bcd_m & 0x0F) + '0'); LCD_WriteData(':'); LCD_WriteData((bcd_s >> 4) + '0'); LCD_WriteData((bcd_s & 0x0F) + '0'); LCD_WriteData(' '); LCD_WriteData('W'); LCD_WriteData('E'); LCD_WriteData('D'); }

LCD_WriteDataLCD_WriteCmd的区别只在 RS 引脚电平,前者置 1 后把后续字节当作显示数据发送,后者置 0 当作指令处理。写数据时不要用LCD_DATA = dat直接赋值整个 P0 口,因为 4 线模式下 P0 口的低 4 位用作数据线,高 4 位可能被其他外设占用(比如接矩阵键盘),直接整口赋值会干扰其他引脚的状态。

4.3 DS18B20 温度读取与显示,单总线时序比 DS1302 更严格

DS18B20 是单总线器件,协议上与 DS1302 的三线时序有本质区别:DS18B20 的所有指令和数据都通过一根线传输,而且是严格的时序窗口。它的初始化时序是主机拉低 480μs 以上,释放总线,等待 DS18B20 拉低 60-240μs 作为存在脉冲,然后释放总线拉高。如果你看到的 51 单片机万年历源码带有温度显示功能,那么这块代码基本就是照搬 DS18B20 的标准驱动,但要注意指令顺序。

读取一次温度的标准流程是:复位 -> 跳过 ROM(0xCC)-> 启动温度转换(0x44)-> 等待 750ms -> 复位 -> 跳过 ROM -> 读暂存器(0xBE)-> 读取低位字节和 9 位分辨率的数据。750ms 的等待时间非常长,如果直接阻塞主循环会导致按键不响应,常见优化是启动转换后先做别的事,等 750ms 后再回来读温度。但对于万年历项目,1 秒刷新一次显示,750ms 的等待完全可以接受。

unsigned char DS18B20_Reset(void) { unsigned char presence; DQ = 0; delay_us(480); // 拉低 480μs DQ = 1; delay_us(60); // 释放总线 60μs presence = DQ; // 读取存在脉冲 delay_us(420); return presence; // 返回 0 表示存在,1 表示无设备 } void DS18B20_WriteByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { DQ = 0; _nop_(); _nop_(); // 拉低 2μs 作为起始信号 DQ = dat & 0x01; delay_us(60); // 维持 60μs 写入时隙 DQ = 1; _nop_(); // 释放总线,时隙间隙 1μs dat >>= 1; } } unsigned char DS18B20_ReadByte(void) { unsigned char i, dat = 0; for (i = 0; i < 8; i++) { dat >>= 1; DQ = 0; // 主机拉低起始信号 _nop_(); _nop_(); // 保持 2μs DQ = 1; // 释放总线,准备读 _nop_(); _nop_(); // 等待数据稳定 if (DQ) dat |= 0x80; delay_us(60); // 完成时隙 } return dat; }

DS18B20 返回的温度数据是一个 16 位有符号数,低 8 位和高 8 位分别存储在暂存器的第 0 字节和第 1 字节。要转换成实际温度,需要判断正负并拼接后乘以 0.0625。以下是转换代码:

unsigned int DS18B20_GetTemp(void) { unsigned char low, high; int temp; DS18B20_Reset(); DS18B20_WriteByte(0xCC); // 跳过 ROM DS18B20_WriteByte(0x44); // 启动温度转换 delay_ms(750); DS18B20_Reset(); DS18B20_WriteByte(0xCC); // 跳过 ROM DS18B20_WriteByte(0xBE); // 读暂存器 low = DS18B20_ReadByte(); high = DS18B20_ReadByte(); temp = (high << 8) | low; // 温度 = 原始值 * 0.0625 // 实际显示时保留 1 位小数:temp * 625 再除以 10000 return (unsigned int)((temp * 625) / 10000); // 返回小数部分格式化值 }

temp * 625 / 10000这个技巧非常实用,它避免了浮点运算。最终显示时,整数部分 = 结果 / 10,小数部分 = 结果 % 10,分别加上0x30后发送给 LCD。如果你的万年历没有温度功能,完全可以跳过这一节,但保留 DS18B20 的接口能让项目更完整,在课程设计答辩时也是一个加分项。

温度显示的刷新频率要注意:不要每次刷新时间都重新读温度,因为 DS18B20 转换需要 750ms,频繁读取会导致主循环卡顿。正确做法是每 2 秒读一次温度,其他时间只刷时间。

5. 按键调时与菜单状态机,让万年历从纯显示变成可以交互的设备

万年历仅有显示功能是不够的,用户必须能调整时间和设置闹钟。这一章的核心不是按键扫描本身,而是如何用状态机优雅地管理「正常运行 -> 调时模式 -> 调分模式 -> 调秒模式 -> 退出」的切换逻辑。矩阵键盘是 51 单片机进阶的必备技能,但万年历用 3 个独立按键就够,重点在于状态切换的代码结构。

5.1 按键消抖与长按/短按的区分,独立按键扫描的标准写法

独立按键的抖动持续时间一般在 5-10ms,扫描时必须在检测到按下后延时 10-20ms 再读取一次,确认仍是低电平才算有效按下。如果直接读取就响应,多次触发是必然的,特别是 Proteus 仿真中按一下键会连续触发好几次,这是因为仿真器也模拟了按键抖动。

以下是经过消抖处理的按键扫描代码:

unsigned char KEY_Scan(void) { static unsigned char key_up = 1; // 按键释放标志 if (key_up && (KEY1 == 0 || KEY2 == 0 || KEY3 == 0)) { delay_ms(20); // 消抖延时 key_up = 0; // 标记按键按下 if (KEY1 == 0) return 1; if (KEY2 == 0) return 2; if (KEY3 == 0) return 3; } if (KEY1 == 1 && KEY2 == 1 && KEY3 == 1) { key_up = 1; // 已释放,允许下次触发 } return 0; }

key_up是静态变量,每次调用都会保留上次的值。它保证了一次按键只返回一个键值,不会因为主循环 200ms 刷新时间过长而重复触发。如果你需要一个按键实现长按连续加时间(比如长按 KEY2 快速加分钟),在这种结构上改造即可:检测到 KEY2 按下超过 1 秒后,每 200ms 自动加 1,松手停止。

在 Proteus 调试中要注意,按键按下时需要的消抖延时不能用delay_ms(20)挂在主循环里,因为 LCD 刷新会等待这段时间,画面会有明显闪烁。改进方法是使用定时器扫描:每 10ms 中断一次读取按键状态,用软件计数代替阻塞延时。

void Timer0_ISR() interrupt 1 { static unsigned char cnt = 0; TH0 = 0xFC; // 1ms 定时初值 TL0 = 0x18; cnt++; if (cnt >= 20) { // 20ms 扫描一次 cnt = 0; key_value = KEY_Scan(); // 全局变量供主循环使用 } }

定时器扫描法避免了阻塞,但要注意KEY_Scan内部不能再调delay_ms(20)消抖,否则中断被卡住其余逻辑全停。该函数的消抖逻辑本身包含 20ms 延时,在中断里要改为直接读取输入,由主循环的读取频率自然完成消抖,或者用递增计数实现非阻塞消抖。

5.2 用状态机管理调时界面,3 个按键完成全部操作

万年历的按键通常定义为:KEY1 进入调时模式并切换设置项(年、月、日、时、分、秒),KEY2 对当前项加 1,KEY3 退出设置并写回 DS1302。为了同时做到位置记忆和时间调节,可以用一个状态变量menu_index表示当前正在修改的是哪一项,0 表示正常运行,1-6 分别对应上述六个字段。

unsigned char menu_index = 0; RTC_Time current_time; while (1) { key = KEY_Scan(); if (key == 1) { if (menu_index == 0) { menu_index = 1; // 进入调时模式 DS1302_GetTime(&current_time); // 先把 BCD 转成十进制方便加减 current_time.year = BCD_to_DEC(current_time.year); // ... 其余字段也转十进制 } else { // 切换到下一项,保存当前项并进入下一项编辑 // 若当前是最后一项(秒),恢复正常运行 } } if (key == 2 && menu_index > 0) { // 对当前 menu_index 对应的字段加 1 // 例如 menu_index==1 时 current_time.hour++,且如果 hour>23 则 hour=0 } if (key == 3 && menu_index > 0) { // 写回 DS1302,恢复 menu_index=0 // 注意所有字段先 DEC_to_BCD 再写回 } // 根据 menu_index 决定显示逻辑 if (menu_index == 0) { DS1302_GetTime(&current_time); LCD_DisplayTime(&current_time); } else { // 显示被编辑项并闪烁光标,提示当前方位 } }

这段伪代码展示了状态机的核心思想:主循环在三个键的值之间切换,每个分支只做一件事。在调时模式中,被编辑的那个字段应以闪烁形式显示,指示用户当前修改的是哪个位置。实现方式是:定时器中断 500ms 翻转一次显示标志,在显示函数中如果当前字段与menu_index对应且闪烁标志为 1,则发送0x20(空格)代替数字。

5.3 调时模式下的边界检查和 BCD 回写细节

调时加 1 操作不是简单的current_time.hour++,需要边界判断。小时在 0-23 之间循环,分钟和秒在 0-59 之间循环,月份要根据大小月判断天数,2 月要判断闰年。以下是几个关键边界判断:

void Adjust_Time_AddOne(void) { switch (menu_index) { case 1: // 小时 current_time.hour++; if (current_time.hour > 23) current_time.hour = 0; break; case 2: // 分钟 current_time.minute++; if (current_time.minute > 59) current_time.minute = 0; break; case 3: // 秒 current_time.second++; if (current_time.second > 59) current_time.second = 0; break; case 4: // 日期 current_time.date++; if (current_time.date > DaysInMonth(current_time.year, current_time.month)) current_time.date = 1; break; case 5: // 月份 current_time.month++; if (current_time.month > 12) current_time.month = 1; break; case 6: // 年份 current_time.year++; if (current_time.year > 99) current_time.year = 0; break; } }

DaysInMonth函数需要处理闰年:能被 4 整除且不能被 100 整除,或能被 400 整除的年份为闰年。由于 DS1302 只有后两位年份,所以判断时用2000 + current_time.year作为完整年份。

调时模式退出后写回 DS1302 时,要注意秒寄存器是否清零。如果在进入调时模式时秒值为 30,在调整分钟时秒继续走,写回时会把停止的秒值覆盖,导致时间跳了几十秒。常见做法是将秒固定为 0,退出时重置。

void DS1302_SaveTime(void) { current_time.second = 0; // 调时精确到分,秒清零 DS1302_WriteByte(0x8E, 0x00); // 关闭写保护 DS1302_WriteByte(0x84, DEC_to_BCD(current_time.hour)); DS1302_WriteByte(0x82, DEC_to_BCD(current_time.minute)); DS1302_WriteByte(0x80, DEC_to_BCD(current_time.second)); DS1302_WriteByte(0x8A, DEC_to_BCD(current_time.week)); DS1302_WriteByte(0x8E, 0x80); // 开启写保护 }

这段代码省略了年月日的写回,实际使用时补全即可。注意写回顺序不重要,但关闭写保护后必须一次性写完所有字段,中途若有指令被 CE 低电平等因素打断,后续写入会失败。

6. 掉电数据保持与低功耗设计,DIY 万年历抗干扰的进阶玩法

万年历挂在市电上时电源问题不大,但如果做成便携版或要用电池长期供电,就必须考虑 DS1302 的掉电保持机制和整个系统的功耗控制。这一章讲清楚掉电电路设计与实测数据,再深入一个日常调试中最让人头疼的技巧:如何在断电后仍保持走时正确。

6.1 DS1302 的 VCC1/VCC2 电源切换与电池电路设计

DS1302 的数据手册明确写了 VCC1 可以接一个 3V 纽扣电池(CR2032 或 CR1220),VCC2 接主电源 5V。芯片内部比较 VCC1 和 VCC2 的电压,哪个高就用哪个供电。主电掉电后 VCC2 降至 3V 以下,芯片自动切换到 VCC1 的电池供电,走时和 RAM 数据全部保留,恢复到主电后再切回 VCC2。

但要注意的是,DS1302 的充电能力非常弱,涓流充电电流典型值在 0.25-1mA 之间,只适合给可充电的 LIR2032 电池充电。CR2032 是一次性锂锰电池,不能被充电。很多人直接把 CR2032 挂在 VCC1 上之后发现电池很快鼓包漏液,就是这个原因。正确做法有两种:使用可充电的 LIR2032 配 DS1302 的涓流充电功能,或者使用不可充电电池但把涓流充电寄存器关闭。

涓流充电配置寄存器的地址是 0x90,它的控制逻辑是:bit7 使能充电控制,bit6-bit4 选择二极管数量,bit3-bit2 选择限流电阻,bit1-bit0 保留。常用配置代码如下:

// 关闭涓流充电(适用于 CR2032 一次性电池) DS1302_WriteByte(0x90, 0x00);

要用0x90写控制字,说明地址本身已经固定,但要开启充电时需要置0xA5表示 1 个二极管加 2k 电阻充电,或0xAA表示 1 个二极管加 4k 电阻充电,充电电流分别是 1mA 左右和 0.5mA 左右。不要贪图充电电流大,因为电池满电后继续过充容易损坏电池。对 LIR2032 常用0xA50xAA,根据自己需求选择。

6.2 实测功耗数据:主电掉电后电池能撑多久

用一个正常工作的万年历板子实测,DS1302 在电池供电模式下电流约 0.3μA-1μA(数据手册典型值 0.3μA,实际项目中因为有上拉电阻和晶振漏电流,实测约 1.2μA)。一颗 CR2032 的标称容量是 220mAh,假设电池自放电每年约 1-2%,纯理论待机时间在 20 年以上。

但这只是芯片本身的耗电,若你的电路把 LCD1602 和 51 单片机直接并到电池上,那电池撑不到一个月。因为 LCD1602 背光典型工作电流 20-50mA,51 单片机运行电流约 10-20mA,掉电状态下如果没做电源切换,整个电路都会从电池取电。所以掉电设计必须加一个电源切换电路,最常见的是两个二极管隔离。

VCC5V -->| IN5819 |--> VCC_SYS --+--> LCD, MCU, DS1302 VCC2 BAT3V -->| IN5819 |--> VCC_BAT --+--> DS1302 VCC1

用两个肖特基二极管(如 1N5819)分别隔离 5V 和电池,当 5V 存在时 VCC_SYS 被抬高,DS1302 VCC2 电压高于 VCC1,内部自动选择主电源;掉电后 VCC2 跟着 VCC_SYS 跌落,VCC1 的电池电压相对更高,芯片自动切换。肖特基二极管的正向压降约 0.3V,所以 5V 经二极管后约 4.7V,给 LCD 和单片机供电没有问题。

6.3 掉电检测与数据保存机制,时间不准的根源排查

DS1302 自身在掉电后走时不丢,但如果单片机复位或重新上电时初始化代码有问题,时间会被重置。最常见的问题是:工程师在初始化函数中无条件写了初始时间,导致每次上电时间都被覆盖为写死的值,电池反而无用。解决办法是加一个首次上电标志位,利用 DS1302 内置 RAM 中的一个字节存储标志。

DS1302 的 31 字节静态 RAM 地址从 0xC0 到 0xFE,但读写地址要加控制位。例如 RAM 第 0 字节的写地址是 0xC0,读地址是 0xC1。初始化时先读该 RAM 地址,如果值不等于预设魔数(比如 0x5A),就写入初始时间和魔数,然后继续正常流程;如果等于魔数,跳过初始时间写入,只检查写保护状态。

#define MAGIC_NUM 0x5A void System_Init(void) { unsigned char magic = DS1302_ReadByte(0xC1); // 读 RAM 第 0 字节 if (magic != MAGIC_NUM) { // 首次上电,写入初始时间 DS1302_WriteByte(0x8E, 0x00); // 关写保护 // 写入初始时间... DS1302_WriteByte(0x8E, 0x80); // 开写保护 DS1302_WriteByte(0xC0, MAGIC_NUM); // 写魔数标志 } else { // 非首次上电,直接从 DS1302 读取时间 DS1302_WriteByte(0x8E, 0x00); // 确保写保护关闭,仅是打开状态 DS1302_WriteByte(0x8E, 0x80); // 再开启写保护,保持只读模式 } }

上电后初始时间只写一次,后续掉电再上电时间不会重置,这是万年历产品化的核心逻辑。如果发现断电后时间归零或乱走,排查顺序是:先量 DS1302 VCC1 是否还有电压(2.4-3.3V),再量晶振是否起振(用示波器探头 X1 挡,正常应看到 32.768kHz 正弦波),最后检查 RAM 魔数是否被意外写入覆盖,比如调试时反复调用了DS1302_Init就可能破坏魔数。

7. 万年历综合调试技巧:时序波形验证和常见故障一览

项目做到这里,硬件电路、驱动代码、显示、按键、掉电保持都已经齐全,最后落在调试环节。一块板子焊好上电后,可能遇到的故障比想象中多,而每个故障背后对应的都是某个模块的时序或接线问题。这一章直接给排查思路和验证工具的使用方法。

7.1 用逻辑分析仪验证 DS1302 时序波形,而不是靠眼睛猜

DS1302 的通信时序中,SCLK 最高允许频率是 2MHz,而你用 51 的普通 IO 口模拟,速度远低于这个上限,所以逻辑分析仪采样 1MHz 或 2MHz 就不会漏数据。接线时把逻辑分析仪的通道 0、通道 1、通道 2 分别接 DS1302 的 CE、SCLK、I/O,地线接板子地。然后用逻辑分析仪的协议解析功能选择 SPI 或自定义协议,观察发送的地址字节和数据字节。

在 DS1302 驱动中,地址字节 0x81(读秒)的二进制是1000 0001,分析仪解码后应显示0x81,随后 I/O 线上应出现 8 位数据(秒的 BCD 码)。如果解码结果总是0x010x80,说明发送顺序有误,最常见的是 IO 口方向切换时机不对,导致数据线在发送地址时仍处于输出状态,读回的数据被自身引脚电平短路。

没有逻辑分析仪时,用示波器也可以验证,但逐位抓取太慢。更高效的方法是在代码里加入回读验证:将写入 DS1302 的秒值读回来与期望值比较,若不一致则点亮一个 LED 作为错误指示。这种方法在 Proteus 中尤其好用,因为 Proteus 的虚拟示波器可以直接抓到 SCLK 和 I/O 波形,比实物调试更加方便。

7.2 万年历项目的 5 个常见故障和对应的检查点

以下是做万年历最常遇到的 5 个问题和检查思路:

故障现象可能原因检查点
LCD 显示乱码P0 口未加上拉电阻检查排阻是否焊好,10k 排阻公共端接 VCC
LCD 显示但无背光背光引脚悬空或接错LCD15 接 VCC 串 10Ω 电阻,LCD16 接地
时间不走DS1302 晶振未起振或 VCC2 未接用示波器测 X1/X2,正常有 32.768kHz 波形
时间乱跳写保护未正确开启/关闭,或初始化被重复调用检查初始化函数是否放在 while(1) 之外
按键调时无反应按键消抖代码阻塞在主循环,或 key_up 标志未复位检查 KEY_Scan 中的 key_up 标志是否被正确清除

其中「时间乱跳」在实际中还有一种隐蔽可能:DS1302 的 CE 引脚悬空。CE 是片选输入,悬空时可能随机为高或低,导致芯片在不该通信的时候接收无效指令。解决方法是 CE 引脚接一个 10k 下拉电阻到地,或者代码初始化前先将 CE 拉低。

「LCD 显示但无背光」在 Proteus 和实物中的表现不同。Proteus 中背光引脚不接也会强制显示,但实物中若不接背光,屏幕字迹能看到但暗得难以阅读。注意背光电流较大,如果直接用 51 引脚驱动背光会导致引脚电压拉低,显示变暗。正确方案是背光串电阻后直接接 VCC,不要经过单片机引脚。

7.3 用定时器 0 实现非阻塞的秒信号抖动处理

万年历的显示刷新频率太高或太低都会影响观感。200ms 刷新一次时间,看起来数字跳动非常流畅,但这样做会有两个问题:一是频繁读取 DS1302 会增加通信错误概率(因为 I/O 线在传输过程中如果受干扰,最坏情况是写坏时间),二是 LCD 频繁写入占用了 CPU 大量时间,按键扫描的响应变慢。常见做法是把 DS1302 读取频率降为 1 秒一次,在非整秒时刻只显示当前内存中的时间值,这样刷屏压力小很多,按键扫描更及时。

使用定时器 0 产生 50ms 中断,在中断内用计数器累加,计数到 20(即 1 秒)时置位标志second_flag。主循环检测到标志后读取 DS1302 并刷新时间,然后清标志。这种方法保证读 DS1302 的间隔精确为 1 秒,且主循环的空闲时间足够多,按键扫描不会卡顿。

void Timer0_ISR() interrupt 1 { static unsigned int cnt = 0; TH0 = 0x4C; // 50ms 定时(12MHz 晶振) TL0 = 0x00; cnt++; if (cnt >= 20) { cnt = 0; second_flag = 1; } } void main() { // 初始化... while (1) { if (second_flag) { second_flag = 0; DS1302_GetTime(&current_time); LCD_DisplayTime(&current_time); // 每 10 次刷新(即 10 秒)读一次温度 temp_cnt++; if (temp_cnt >= 10) { temp_cnt = 0; temp = DS18B20_GetTemp(); LCD_DisplayTemp(temp); } } key = KEY_Scan(); // 按键处理... } }

这里的定时初值0x4C00对应 50ms 中断一次,前提是定时器工作在方式 1(16 位)且晶振 12MHz,12 个时钟周期为一个机器周期。如果换成 11.0592MHz 晶振,初值会略微不同,最准确的做法是用 STC-ISP 软件自动生成定时初值,避免手算误差导致时间累计偏差。

注意:DS1302 本身走时精度取决于 32.768kHz 晶振,与 51 主晶振无关。定时器中断只是用来控制刷新频率,即使 51 主晶振频率有误差,也不会导致 DS1302 走时不准。

7.4 Proteus 仿真与实物调试的差异映射

Proteus 仿真万年历是大多数课程设计的完成验证环境,但仿真结果与实物有 5 个显著差异,提前知道能少走弯路:

第一,Proteus 中的 DS1302 走时速度与真实时间完全一致,但仿真时间轴是可以加速的,按 F12 调高仿真速度后可以看到走时加快,便于检查时钟逻辑是否正确。加速时显示刷新会跟不上,这是正常现象。

第二,Proteus 默认不模拟 DS1302 的掉电行为,如果你在仿真中直接断开 VCC2,时间不会像实物那样由电池保持,DS1302 内部寄存器会保持不变,重新上电后时间还是断开前的值。实物的掉电保持要靠 VCC1 电池电路验证。

第三,实物上 STC89C52RC 的 P0 口必须加上拉电阻,否则 LCD 显示异常。但在 Proteus 中不加也能工作,容易让你疏忽。另外 Proteus 的 LCD1602 模型默认对比度电压已经调好,不需要电位器,实物则必须加电位器或固定分压。

第四,Proteus 中按键扫描不用真实消抖也可能正常工作,这是因为仿真按键没有物理抖动,实物则必须消抖。很多人在仿真中不加消抖直接通过,烧到实物后按键乱跳,就是这个原因造成的。

第五,DS18B20 在 Proteus 中的延时要求更宽松,实物对延时更敏感,特别是读时序中_nop_()的个数不足时仿真相安无事、实物读取失败。用 STC 芯片时,_nop_()的周期长度与晶振和倍频系数有关,建议统一用delay_us函数而不是_nop_()保证可移植性。

这些差异本质上不是工具的问题,而是仿真模型简化了真实器件的电气特性。将 Proteus 当作纯逻辑验证工具,把实物调试当作时序验证工具,两者配合,就是 51 单片机万年历最快的落地路径。等这个项目的 DS1302 时序、LCD 4 线初始化、单总线温度读取三块都能默写出来,再去看矩阵键盘做电子琴、用 PWM 驱动 WS2811、或移植 Modbus 主站都会快很多。

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

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

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

立即咨询