基于STC15的万年历设计:DS1302驱动、LCD12864界面与日历算法实战
2026/9/9 22:36:14 网站建设 项目流程

简介:面向STC15系列单片机学习者的综合实战资源,以DS1302实时时钟芯片为核心,实现带温湿度监测与超限报警、闹钟提醒功能的万年历电子钟,适用于家庭、办公室等多种环境。项目覆盖C语言编程、单片机原理、实时时钟芯片应用、单总线协议、串行通信等知识点,可作为单片机课程设计或毕业设计的完整参考方案。资源共23个文件,以main.c源码、STC15.h头文件、Keil工程、hex烧录文件为主,另含Proteus仿真工程、Protel电路原理图及PDF原理图文档;编译中间文件便于排查编译细节,包体仅249KB,结构紧凑。已有351人浏览学习。下载后可获得可直接编译运行的万年历程序源码与配套仿真、电路图,便于对照理解STC15如何驱动DS1302读取时间、解析DHT11温湿度数据、设定安全范围并触发蜂鸣器报警,同时支持闹钟功能设置;结合电路图设计需考虑电气隔离、抗干扰措施与功耗优化等因素,能帮助开发者全面掌握此类单片机系统的完整实现路径,适合急需课程设计参考或希望快速上手单片机项目开发的读者。 要说单片机项目里的"国民练手题",万年历绝对是绕不开的那一个。不过网上能找到的教程,十有八九还是STC89C52配DS1302、LCD1602那一套老配方,照着抄能跑,但做出来的体验实在没什么质感。我把这套经典方案换到STC15系列上重新做了一遍,发现同样的功能,换成1T内核、内部EEPROM这些资源之后,整个设计思路都不一样了——不仅能跑带中文字库的大屏界面,还能轻易实现长按连加、参数掉电保存这些实用功能。这篇文章完整记录了我从选型、电路、日历算法到界面交互的整个开发过程,以及调试时踩过的一堆坑。不管你是刚入门51内核的初学者,还是想把手头STC15用起来的老玩家,都能找到参考。

1. STC15到底比89C52强在哪:选型时我考虑的问题

1.1 1T内核带来的设计余量

传统STC89C52是12T架构,一个机器周期吃掉12个时钟,跑12MHz实际只有1MIPS左右。STC15系列是1T架构,一个时钟一个机器周期,同为12MHz大约有12MIPS,性能差了一个数量级。对万年历这种任务,很多人觉得性能没意义,实际上差别很大:LCD12864走串行模式时,每个字节都要软件模拟时序,89C52上刷一屏能感到明显的等待,STC15上几乎无感。更关键的是,性能余量让你可以把按键扫描、显示刷新、蜂鸣器响铃放进同一个主循环时间片里,不用为了时序互相妥协。

选型时我建议选STC15F2K61S2或者STC15W4K32S4这类带2KB以上SRAM的型号。万年历本身不占多少内存,但当你想加农历转换、温度记录或者菜单缓存时,SRAM越大越从容。我第一次用STC15W408AS做,4KB Flash也能跑基础版,但代码稍微加个设置界面就捉襟见肘,最后还是换了大Flash型号才舒服。

1.2 内部EEPROM:不是代替RTC,而是存"用户参数"

STC15系列不少型号都把Flash划出一块IAP区当EEPROM用,掉电不丢。做万年历最典型的用法是存"走时补偿值"和"设置状态":DS1302靠电池保持走时,但用户上次设置到哪一步、亮度开多大这类信息,上电后如果丢了,体验就很差。我把这几个参数每次退出设置界面时写进EEPROM,下次开机直接恢复。

这里有个容易踩的操作细节:STC15的IAP写入需要先关中断再执行,写完恢复,否则写Flash过程中被中断打断很容易把数据写坏。另外,IAP扇区擦除是按块来的,不是按字节,所以不要频繁往里写同一个参数,否则Flash寿命会被快速消耗。我的习惯是只在用户主动退出设置时才保存一次,不在主循环里周期写。

1.3 时钟源选择:万年历必须用外部晶振

STC15默认可以用内部高精度R/C时钟,下载程序时还能分频。但做万年历我强烈建议外部晶振,最好选22.1184MHz。原因有两个:一是DS1302的读写时序依赖SCLK的边沿,内部R/C在不同温度和电压下频率会漂移,边缘状态不够干净;二是调试阶段你要用串口打印日志,22.1184MHz正好能分频出整数波特率,115200、57600都精确。

最小系统搭建很简单:外部晶振加两个20到30pF的负载电容,复位电路直接用STC15内部复位的RC配置,外围几乎不用多余元件。唯一要注意的是STC15不同封装的复位脚配置不一样,部分型号复位脚可复用为IO,千万别一上来就把这个引脚当普通IO调试,否则程序跑飞很难救。

2. 时间基准从哪来:DS1302和DS3231的取舍,以及电池备份电路

2.1 为什么不用定时器当秒源

最偷懒的做法是单片机定时器产生1秒中断,软件数秒。断电清零不说,晶振温漂、定时器重装值的误差都会累积,跑几天就能对不上表。就算部分STC15W系列带了内部RTC外设,断电后同样需要备用电池和外部32.768kHz晶体,绕了一圈还是要处理同样的外围。所以正统做法还是外挂实时时钟芯片。

2.2 DS1302寄存器细节:BCD码和写保护

DS1302的命令字格式是最高位固定1,中间6位是寄存器地址,最低位是读写方向。秒、分、时、日、月、星期、年的读地址分别是0x80到0x8C,控制寄存器是0x8E。时钟数据全部用BCD码存储,比如0x59表示59秒。

两个最容易忽视的位:秒寄存器的最高位CH是时钟暂停位,写1会停振;控制寄存器的最高位WP是写保护,写1后所有寄存器都改不了。初始化顺序必须是先清WP,再清CH,最后把时间写进去,很多程序跑不起来就是少了清WP这一步。

2.3 读写时序:低位在前

DS1302的时序命令字和数据都是低位在前发送,读写函数可以这样写:

sbit DS1302_CE = P2^5; sbit DS1302_IO = P2^6; sbit DS1302_SCLK = P2^7; // 向地址addr写入一个字节dat(addr为写地址,bit7需要置1) void DS1302_WriteByte(unsigned char addr, unsigned char dat) { unsigned char i; DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; for (i = 0; i < 8; i++) { DS1302_IO = addr & 0x01; addr >>= 1; DS1302_SCLK = 1; DS1302_SCLK = 0; } for (i = 0; i < 8; i++) { DS1302_IO = dat & 0x01; dat >>= 1; DS1302_SCLK = 1; DS1302_SCLK = 0; } DS1302_CE = 0; } // 从地址addr读取一个字节(addr为读地址,bit7需要置1) unsigned char DS1302_ReadByte(unsigned char addr) { unsigned char i, dat = 0; DS1302_CE = 0; DS1302_SCLK = 0; DS1302_CE = 1; addr |= 0x01; // 读操作命令 for (i = 0; i < 8; i++) { DS1302_IO = addr & 0x01; addr >>= 1; DS1302_SCLK = 1; DS1302_SCLK = 0; } DS1302_IO = 1; // 释放总线,准备读 for (i = 0; i < 8; i++) { dat >>= 1; if (DS1302_IO) dat |= 0x80; DS1302_SCLK = 1; DS1302_SCLK = 0; } DS1302_CE = 0; return dat; }

读数据之前那条DS1302_IO = 1非常关键。DS1302的IO是双向引脚,写完命令字之后必须先把IO拉高释放总线,否则引脚还处于输出状态,读到的数据永远是最后一位命令字。

2.4 DS3231的升级路线

如果做产品或者对走时精度挑剔,DS3231是更好的选择:内置温度补偿晶振,年误差能到一二十秒级别,接口是I2C。价格大致是DS1302的几倍,但省去了自己选晶振配负载电容的烦恼。

最合理的开发策略是底层封装成RTC_Read/RTC_Write两个函数,界面层只调用这两个接口,以后从DS1302换DS3231只需要改底层十几行代码,日历算法和显示层完全不用动。我开始就是按这个思路写的,后来真把DS3231焊上去验证过一次,改动量比我预想的小得多。

2.5 电池备份电路:一个容易被忽略的安全问题

DS1302有VCC2主电源和VCC1备用电池两个供电引脚,主电正常时自动用VCC2,掉电后切换到VCC1。硬件连接参考下表:

DS1302引脚接法
VCC25V主电源
VCC1CR2032正极
GND与单片机共地
X1 / X232.768kHz晶振,两端各接6pF电容到地
CE接单片机P2.5
IO接单片机P2.6
SCLK接单片机P2.7

这里必须强调:DS1302的寄存器0x90是充电控制寄存器,默认情况下如果主电存在,它会以设定模式向备用电池涓流充电。如果你用的是CR2032这类一次性锂电池,这个充电功能必须显式关闭,否则电池会被充鼓包甚至有安全隐患。在初始化时把0x90写成0x00就行,别留默认值。

3. 日历算法是万年历的"魂":闰年、月末、星期的软件实现

3.1 闰年判断的完整规则

大家常背的"四年一闰,百年不闰,四百年再闰"落实到C语言是这样:

// 闰年返回1,平年返回0 bit isLeapYear(unsigned int year) { if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) return 1; return 0; }

很多人只写了year % 4 == 0,这在2000年、2024年没错,但放到2100年就会出错——2100能被4整除,却不是闰年。既然叫万年历,边界规则必须完整,否则代码跑到2100年2月就崩了。虽然单片机产品大概率用不到100年,但处理这类边界问题的过程,本身就是锻炼程序严谨性的好机会。

3.2 月份天数表:二月的特判

月份天数用查表法最直观:

unsigned char getDaysOfMonth(unsigned int year, unsigned char month) { static const unsigned char daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2 && isLeapYear(year)) return 29; return daysInMonth[month - 1]; }

这个函数在跨月、跨日边界检测时非常好用,判断"当前日期是否合法"可以直接拿它比对。另外要提醒一点:设置界面里用户可能把日期调到2月30号,保存前必须用这个函数校验,否则非法日期写进DS1302,之后星期计算会连锁出错。

3.3 蔡勒公式算星期:1月2月的特殊处理

星期计算我用的蔡勒公式,适用于1582年10月15日之后的公历日期,单片机完全够用:

// 蔡勒公式计算星期 // 返回:0=星期日,1=星期一,...,6=星期六 unsigned char calcWeekDay(unsigned int y, unsigned char m, unsigned char d) { int c, yy, w; if (m < 3) { m += 12; y--; } c = (int)(y / 100); yy = (int)(y % 100); // 兼容C语言负数取模:先求余,为负再加7 w = (int)d + (13 * (m + 1)) / 5 + yy + yy / 4 + c / 4 - 2 * c + 6; w = w % 7; if (w < 0) w += 7; return (unsigned char)w; }

这个公式最经典的坑就是1月、2月必须当成上一年的13月、14月处理。以2024年1月1日为例:月份变成13,年份回退到2023,算出来结果是1,对应星期一,正确。

另一个坑是C语言的负数取模问题。w算出来可能是负数(比如某些年份组合下会出现),直接%7结果还是负数,必须判断一下再加7修正。网上很多版本没这一步,在特定日期就会莫名其妙差一天。

3.4 边界条件自测建议

写完算法不要急着焊板子,先在PC上或者直接用单片机串口把以下节点打出来看:

  • 2024-02-28 23:59:58,跨秒到2024-02-29 00:00:00
  • 2024-02-29 23:59:58,跨日到2024-03-01 00:00:00
  • 2100-02-28 23:59:58,确认2100年2月只到28号
  • 2000-02-28 23:59:58,确认2000年是闰年

这几个节点跑通,日期算法基本就稳了。我实测时候发现,星期计算在1月2月出错的概率最高,十次有八次都是忘了月份调整。

4. 显示层:LCD12864串行接法下的界面与刷新策略

4.1 三线串行省IO

LCD12864带的ST7920控制器支持串行模式,把PSB引脚拉低即可。此时RS、RW、E三个引脚分别当作CS、SID、SCLK用,一共三根IO线,比并口省下五根。V0对比度引脚接一个10K电位器到GND,不接的话屏幕不是全黑就是全白,调整到屏幕底色微微透亮时最合适。

串行模式下初始化要注意上电延时,ST7920需要几十毫秒稳定时间,所以上电后不要立刻发清屏指令,先延时100ms左右。我遇到过不少次白屏,最后发现不是接错线,而是初始化时序太快,芯片还没准备好。

4.2 界面布局:一行日期一行时间,加一行提示

我的界面布局是这样的:

  • 第一行:显示完整日期和星期,如2026-03-14 周六
  • 第二行:显示时间,如08:30:45
  • 第三行:显示当前状态,比如正常模式下显示系统运行中,设置模式下显示设置日期-年
  • 第四行:预留,之后扩展温度、闹钟状态

ST7920文本模式下DDRAM地址0x80是第一行,0x90是第二行,0x88和0x98是第三第四行,每行可显示16个全角字符。中文字库使用很方便,写入GB2312内码即可显示中文,不需要自己取模。

4.3 局部刷新:把每秒整屏刷新改掉

很多初手的写法是每秒清一次屏再重画,结果屏幕闪烁明显。实际上秒只在最后两位变化,分钟在一分钟后变,日期一天才变一次。正确做法是缓存上一次的秒值,检测到秒变化只重写时间字符串,日期用lastDay判断:

void RefreshTimeDisplay(void) { ReadRTC(&rtc); if (rtc.day != lastDay) { LCD_SetPos(0, 0); PrintDate(rtc.year, rtc.month, rtc.day); LCD_SetPos(0, 9); PrintWeek(rtc.week); lastDay = rtc.day; } LCD_SetPos(1, 2); PrintInt(rtc.hour); LCD_SetPos(1, 5); PrintInt(rtc.minute); LCD_SetPos(1, 8); PrintInt(rtc.second); }

这样每秒只有两个字符的位置在变化,肉眼基本看不到闪烁。而且ST7920在串行模式下写数据本身就很慢,局部刷新也让主循环压力小了很多。

5. 交互逻辑与掉电保存:让万年历成为一个完整的"产品"

5.1 三键操作逻辑

硬件上我只用了三个按键:设置、加、减。正常模式下,按设置键进入"设置年"状态,再按依次切换月、日、时、分、秒,最后再按一次回到正常模式。在设置项内,加/减修改当前值,长按自动连加/连减,频率约每秒加10个计数,放开即停。

长按连加的实现思路是:按键扫描返回的不只是"按下"瞬间,而是区分短按和持续按下。加键按下超过600ms后,进入连加模式,主循环每100ms执行一次数值加1,直到检测到释放。

5.2 防抖状态机代替delay

不要用delay(20ms)阻塞式消抖,否则长按期间整个程序卡死,显示也会断。改成简单状态机:检测到按键按下先标记"待确认",20ms后再确认一次按下才算有效,释放也做同样处理。这样可以避免长按时误判为多次短按,也不会阻塞其他任务。

5.3 退出设置时写EEPROM

每次退出设置界面时,把"当前设置到哪一项"以及"走时补偿值"写入EEPROM。STC15的IAP操作需要先读取扇区、修改、擦除扇区、再写入,步骤比较繁琐,所以我把参数打包成一个结构体,整块读写,避免逐个字节操作。

注意:STC15的IAP扇区擦除是按块执行的,写之前必须先擦除。擦除期间程序会短暂阻塞,所以不要在显示刷新的过程中频繁调用,只在退出设置时执行一次就够了。

5.4 可扩展点

这套框架留了不少余量,之后我陆续加了DS18B20温度显示、农历查询(查表法)和一组闹钟。底部两层(DS1302驱动、日历算法)完全没动,只增加了顶层页面和逻辑分支。如果你想把万年历做成桌面摆件的完整产品,还可以加光敏电阻自动调背光、加蜂鸣器报时,IO口都够用。

6. 实测跑了一个多月:遇到的问题和完整定位思路

6.1 32.768kHz晶振不起振

现象:DS1302读出秒一直不变,或者某个寄存器读数忽好忽坏。第一反应是代码问题,反复检查读写函数没毛病。最后用示波器看X1引脚才发现完全没有振荡波形。

原因:X1和X2的负载电容选错了。DS1302的晶振负载电容要求6pF左右,我最初手边只有30pF的,直接焊上去,过大的负载电容会让晶体停振。换成6pF之后,上电立刻起振。如果你手边没有示波器,可以用万用表量X1引脚的直流电压——正常时大约为VCC的一半,如果接近0V或者等于VCC,基本就是没振起来。

6.2 DS1302读写不稳定、读回FF

现象:读回来的秒值偶尔是0xFF,时间跳变。这个问题多半出在时序细节上。一是SCLK在空闲时必须保持低电平,并且每次操作前要有足够低电平时间,不能在CE拉高后立刻发数据;二是读数据前的那句DS1302_IO = 1不能省,我在2.3节强调过;三是MCU的IO口如果是开漏模式,需要在总线上加上拉电阻,否则IO在高电平时被拉到无效电平。

6.3 星期在1月2月算错

这个坑被蔡勒公式坑过两次。第一次忘了把1月2月当成上一年13月14月处理,导致1月和2月的星期全部算错;第二次是负数取模没修正,某些日期差一天。后来我把公式封装成独立函数,输入固定的已知日期做断言测试,比如2024-01-01是星期一、2023-10-01是星期日,稳定通过再集成到主程序。

6.4 LCD12864白屏/花屏

白屏先查三件事:PSB是否确实拉低、V0电位器是否调好、上电延时是否足够。花屏多半是串行数据和时钟线的极性或者初始化指令顺序不对,ST7920的初始化序列里功能设定指令0x30要连续发几次,中间加短延时,不能图省事只发一次。

6.5 按键跳变和显示闪烁

按键跳变基本是按键引脚没有上拉。STC15的IO可以配置内部上拉,我用的是准双向口模式并打开内部上拉,外部就不用再加电阻。显示闪烁则完全是刷新策略的问题,改成局部刷新后彻底解决。

最后再分享一个调试习惯:把系统时间跳到23:59:58,然后盯着秒位看跨天、跨月、跨年,尤其是2月28日到3月1日的边界。每次改日历算法,先把这几个节点跑通再谈其他。实测两个多月下来,整体走时日误差1到2秒,主要误差来自32.768kHz晶振的负载匹配,如果把6pF电容换成可调电容精调还能更好。你要是自己做,建议也保留这个边界测试的习惯,它能帮你省下大量查日期算法的时间。

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

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

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

立即咨询