网上找 51 万年历的资料,翻来覆去就那几种:1602 只显示个时间、没有农历、代码贴一半就没了下文,或者干脆只给个原理图不给代码。找得挺烦,干脆自己写了一套完整的。
这块屏是 LCD12864B(ST7920 自带中文字库),时钟用 DS1302,温湿度是 DHT11,另外做了农历换算、闹钟,闹钟时刻还能掉电保存。Proteus 里从接线到点亮完整跑通,9 个模块 1500 行左右
这篇先把效果和功能全部演示一遍,后面再按模块逐个拆开讲。
一、先上效果图
整机跑起来之后的状态:屏幕四行都有内容,DS1302 持续走时,DHT11 间隔回传湿度。连续跑了十几分钟没有卡死或者花屏。

农历是这个项目里我觉得最值钱的部分 ------ 它不是硬编码进去的几个日期,是 1901--2100 年查表算出来的,闰月也处理了。网上带农历的 51 万年历,成体系的很少。
二、主要功能
能跑的、有瑕疵的、没做的,三档全列 ------ 尤其最后一档,别人的教程一般不写。
已跑通,能演示
-
公历年月日、时分秒(DS1302)
-
中文星期一到星期日
-
农历月日(1901--2100 查表,含闰月)
-
湿度
%RH(DHT11 单总线) -
闹钟掉电记忆(存在 DS1302 的 NV RAM,断电再来还是原来那个点)
-
闹钟到点蜂鸣器响(P2.6)
-
12/24 小时制一键切换(K4,直接改 DS1302 小时寄存器的标志位)
-
全中文界面,字库 ST7920 自带,不用自己取模
三、硬件清单与接线
| 模块 | 型号 | 接法 | | --- | --- | --- | | 主控 | AT89C52(仿真)/ STC89C52(实物) | 12MHz | | 显示 | LCD12864B(ST7920) | 数据 P1,RS=P2.3 RW=P2.4 E=P2.5 PSB=P2.0 | | 时钟 | DS1302 | SCLK=P3.6 IO=P3.5 CE=P3.4 | | 温湿度 | DHT11 | P2.7 | | 按键 | 4 路独立按键 | P3.1 P3.0 P3.2 P3.3 | | 蜂鸣器 | 无源 | P2. |
四、屏幕布局与 DDRAM 地址
这张表建议直接收藏。ST7920 的 DDRAM 地址是跳着排的,搞不清就会一头撞进"字跑到别的行去"。
| 行 | 地址范围 | 内容 | | --- | --- | --- | | 第一行 | 0x80--0x87 | 20 + 年 月 日 | | 第二行 | 0x90--0x97 | 时 : 分 : 秒 + 星期 | | 第三行 | 0x88--0x8B | 农历 月 日 | | 第三行(后半) | 0x8C--0x8F | 钟 + 闹钟 时 : 分 | | 第四行 | 0x98--0x99 | 温度 + 单位 | | 第四行(后半) | 0x9D--0x9E | 湿度 + %RH |
按键功能
| 按键 | 引脚 | 运行模式 | 调时模式 | | --- | --- | --- | --- | | K1 | P3.1 | 进入调时 | 保存并退出(写回 DS1302 + NV RAM) | | K2 | P3.0 | 摄氏 / 华氏切换 | 切换选中位(0→8 循环) | | K3 | P3.2 | 无 | 当前位 +1(带大小月 / 闰年越界处理) | | K4 | P3.3 | 12/24 小时制切换 | 当前位 −1 |
调时模式下,选中的那一位 500ms 闪一次,靠 Timer0 的 1ms 定时 + 计数翻转实现。只有按 K1 退出时才写回 DS1302 和 NV RAM ------ 调的过程中改的只是数组,绝不碰芯片。
五、核心代码:主循环
bash
```c
void main()
{
unsigned int dly;
PSB = 1; // 并口模式,串行模式下屏不亮
for(dly = 0; dly < 30000; dly++); // 上电延时,等液晶内部复位完成
write_com(0x0C);
write_com(0x30); // 8位并口基本指令集
write_com(0x06);
Timer0Init();
DS1302_SetTime();
Alarm_Load(); // 上电从 NV RAM 读回闹钟时刻
while(1)
{
conversion_(DS1302_Time[0], DS1302_Time[1], DS1302_Time[2],
&n_year, &n_yue, &n_ri); // 公历转农历
KeyNum = Key();
if(KeyNum == 1) // K1:模式切换
{
if(MODE == 0){ MODE = 1; TimeSetSelect = 0; }
else { MODE = 0; DS1302_SetTime(); Alarm_Save(); }
}
switch(MODE)
{
case 0: TimeShow(); break; // 正常运行:显示 + 闹钟比对
case 1: TimeSet(); break; // 调时:选中位闪烁
}
}
}
```
这三行,每一行都是一个能单独写一篇的坑:
① PSB = 1 必须拉高。 LCD12864B 并口串口都支持,PSB 拉低就进串口模式。这时候你按并口时序往里写,屏幕一点反应都没有 ------ 不报错、不提示,就是一片黄。我第一次翻车就在这。
② 上电那 30000 次空循环,一个都不能省。 液晶上电后内部复位要时间,主控跑得比它快,不等它就发指令,第一条直接丢。表现是偶尔点得亮、偶尔不亮,属于最难查的那种"时好时坏"。
③ DS1302_SetTime() 每次上电都会把初始时间写进去。 初始值就是数组里那个 2026-10-13 12:30:00。想让它真正掉电走时,正确姿势是:先烧一次带 SetTime() 的版本把时间写进去,再把这行注释掉、重新烧一次。 DS1302 的常规操作,别看见时间不对就以为芯片坏了。
六、闹钟掉电保存:别浪费 DS1302 那 31 字节 NV RAM
大部分 DS1302 教程只讲到时钟寄存器就收手了。其实这颗芯片还白送你 31 字节 NV RAM,后备电池供电,掉电不丢 ------ 不用白不用,拿来存闹钟时刻刚好。
地址从 0xC0 起,写命令是偶数地址(0xC0 0xC2...),读命令是奇数地址(0xC1 0xC3...)。我这的 DS1302_ReadByte() 内部已经做了 Command |= 0x01,所以读的时候传同一个地址就行
cpp
```c
#define DS1302_NV_ALARM_HOUR 0xC0 // NV RAM 第 0 字节:闹钟时
#define DS1302_NV_ALARM_MIN 0xC2 // NV RAM 第 1 字节:闹钟分
void Alarm_Save(void)
{
DS1302_WriteByte(DS1302_NV_ALARM_HOUR, DS1302_Time[7]);
DS1302_WriteByte(DS1302_NV_ALARM_MIN, DS1302_Time[8]);
}
void Alarm_Load(void)
{
unsigned char h, m;
h = DS1302_ReadByte(DS1302_NV_ALARM_HOUR);
m = DS1302_ReadByte(DS1302_NV_ALARM_MIN);
// 首次上电或后备电池耗尽时读出来是 0xFF,做合法性检查
if(h <= 23 && m <= 59)
{
DS1302_Time[7] = h;
DS1302_Time[8] = m;
}
}
```
调用就两处:上电 DS1302_SetTime() 之后 Alarm_Load(),退出调时时 Alarm_Save()。
七、12/24 小时制:bit7 那个坑
DS1302 小时寄存器(0x84)的 bit7 是 12/24 标志 ,12 小时制下 bit5 是 AM/PM 位:
| bit7 | 模式 | 小时值怎么读 | | --- | --- | --- | | 0 | 24 小时制 | bit5--bit0 就是小时的 BCD 码 | | 1 | 12 小时制 | bit4 是十位,bit5 是 AM/PM(0=AM,1=PM) |
坑就坑在:你直接把读出来的值做 BCD 转换,bit7 和 bit5 会被一起算进去。 我第一次切到 12 小时制,屏幕上小时直接显示成 90 多 ------ 就是标志位没剥。
cpp
```c
Temp = DS1302_ReadByte(DS1302_HOUR);
if(Temp & 0x80) // 12小时制:先剥掉 bit7 与 bit5 再转 BCD
DS1302_Time[3] = (Temp & 0x1F) / 16 * 10 + (Temp & 0x1F) % 16;
else // 24小时制
DS1302_Time[3] = (Temp & 0x3F) / 16 * 10 + (Temp & 0x3F) % 16;
```
切换本身只是改 bit7:
cpp
```c
void Set12HourMode()
{
unsigned char Temp;
Temp = DS1302_ReadByte(DS1302_HOUR); // 读出原始寄存器值(含标志位)
Temp &= 0x9F; // 清 bit5(AM/PM) 与 bit6
DS1302_WriteByte(DS1302_WP, 0x00); // 关写保护
DS1302_WriteByte(DS1302_HOUR, Temp | 0x80); // bit7=1 -> 12小时制
DS1302_WriteByte(DS1302_WP, 0x80); // 开写保护
jinzhiMODE = 0;
}
```
还有一处最容易漏:DS1302_SetTime() 写小时时必须把标志位一起写回去,否则每次调时都会把模式冲回 24 小时制 ------ 你会以为切换功能坏了,其实是写时间的时候被抹了。
cpp
```c
DS1302_WriteByte(DS1302_HOUR,
(DS1302_Time[3] / 10 * 16 + DS1302_Time[3] % 10) | (jinzhiMODE ? 0x00 : 0x80));
```
八、温度为什么不自动刷新
TimeShow() 最后那三行写温度的代码,是被我注释掉的:
cpp
DHT11_receive();
write_com(0x9d);
Num(humidity);
write_com(0x9e);
hzkdis("%RH "); // 补了空格:ST7920 连续 ASCII 必须偶数个,否则会错位
// write_com(0x98);
// Num(temperature);
// write_com(0x99);
// hzkdis("摄氏度");
原因很实在:DHT11_receive() 末尾有一句 DHT11_delay_ms(1000),调用一次要阻塞整整一秒。把它塞进主循环,整个刷新节奏当场就垮,秒的跳动会变得很难看。
现在改成常态只刷湿度,按一次 K2 才读一次温度。这是个妥协,不是设计 ------ V1.0 的遗留问题,整合篇会改掉。
九、代码结构
cpp
```
main.c 384行 主循环、按键分发、界面显示、闹钟判断
12864bin.c 69行 ST7920 并口底层(写指令/写数据/忙检测/显字符串/显数字)
DS1302.c 143行 三线时序读写、BCD 转换、写保护、12/24制、NV RAM 闹钟存取
DHT11.c 107行 单总线启动、收 40bit、校验
ds1302change.c 228行 公历转农历算法
ds1302change.h 352行 year_code[597] 农历数据表(1901-2100)
Timer0.c 34行 1ms 定时,500ms 翻转闪烁标志
Key.c 13行 4 路按键查询 + 延时消抖
Clock.c 114行 音阶频率定义(闹钟铃声,本版未接入)
Delay.c 15行 软件延时
```
十、Proteus 工程和源码
工程文件 12864b.pdsprj 和编译好的 hex 都在,下载下来直接能跑,不用重新搭原理图。
完整源码和 Proteus 工程我一并打包好了,需要的在评论区留言,我看到就发。
写在最后
这篇只是把成果摆出来。接下来这个系列会把每个模块拆开讲,下一篇最值得看:
这个项目里卡我最久的问题 ------ Proteus 里 LCD12864B 一片黄、什么都不显示、还不报错。查了一圈资料才定位到是模型、PSB 引脚、忙检测三件事叠在一起坑我。如果你仿真时也撞上黄屏,直接看下一篇
后面还有:LCD12864B 驱动写法、ST7920 的 ASCII 偶数对齐、DS1302 时序与 BCD 码、DHT11 单总线、农历查表算法、整合篇(按键 / 闹钟 / IAP 那些坑)。
本文是「51 万年历」系列第 1 篇,完整源码和 Proteus 工程见上文。
有问题评论区问,我看到就回。