简介:基于STC15的万年历程序是一套面向单片机初学者的完整实战资料,以STC15系列8051内核MCU为核心,整合DS1302实时时钟、DHT11/DHT22温湿度采集与报警、以及可编程闹钟等常用功能,覆盖从硬件电路到软件逻辑的全流程开发。项目中细致说明了DS1302的串行寄存器读写、传感器单总线协议解析、阈值报警触发方式,并辅以Protel电路图与Proteus仿真文件,便于在焊接实物前进行功能验证。压缩包共23个文件,约249KB,主要包含Keil工程源码(.c/.h/.hex/.uvproj)、原理图(.sch/.DDB/.PDF)和仿真项目(.pdsprj/.pdsbak)等,类型覆盖设计、编码、编译、仿真全环节,适合课程设计、毕业设计或电子爱好者自制时钟时参考。已有351人学习,对于想快速上手STC15与时钟芯片联调的用户,可提供较为直观的参照。 去年冬天办公室那台电子钟第三次因为停电归零之后,我彻底不想再忍了。与其再买一个不知道哪天又“罢工”的成品,不如自己动手做一个。手头正好囤了一批STC15W408AS,所以这个基于STC15的万年历程序就这么立项了。东西不大,功能也不花哨,就是年月日时分秒加星期,按键可以校时,断电之后靠后备电源继续走时,做完之后我最大的感受是:这种“入门级”项目里藏着的坑,真的一点不比复杂项目少,尤其是DS1302这颗RTC芯片的时序细节,第一次玩的人基本都要被它折磨一遍。
STC15W408AS是宏晶STC15系列里很经典的型号,属于1T增强型8051内核,片上Flash和SRAM都足够宽裕,内部还集成了可配置的IRC时钟和EEPROM。万年历这种需求对主控算力要求不高,但资源充足的好处是写代码不用抠抠搜搜,非常适合拿来练手,也可以作为一个给其他设备扩展时钟功能的稳定底座。这篇文章我会从方案选型、硬件电路、软件实现到调试心得完整走一遍。不管你是刚点亮过LED不久的新手,还是想在已有设备上增加时钟功能的单片机爱好者,按我这套思路去改造,基本都能做出来。
1. 整体方案设计:为什么是STC15配DS1302
1.1 控制芯片选型:STC15系列凭什么合适
选STC15不是因为它参数多豪华,而是因为“够用且省事”。STC15W系列是宽电压器件,支持2.4V到5.5V,这意味着用USB的5V、锂电池的3.7V甚至三节干电池的4.5V都能直接工作,前级不需要额外加稳压芯片。对于万年历这种常年插电的桌面设备来说,供电设计被大幅简化了。
另一个很实在的点是下载方式。STC15系列支持串口ISP下载,用一个USB转TTL小板就能烧录程序,不需要专门的仿真器。板子上只要预留一个四针的下载口,写代码、改Bug、重新烧录都非常流畅。再加上它内部有EEPROM,虽然容量不大,但存时间校准参数、界面设置这类小数据完全够用。内部IRC时钟还能让整个板子省掉一颗晶振,对降低BOM成本和焊接难度都有帮助。
我实际用的是STC15W408AS,48KB Flash、4KB SRAM,代码再怎么折腾都装得下。如果你想用更小封装的STC15W204S之类也可以,但要注意引脚数量是否够用。万年历项目需要的IO不算多,算上LCD1602的6根线、DS1302的3根线、3个按键,总共12个IO左右,选个20脚左右的封装比较从容。
1.2 RTC芯片对比:DS1302和DS3231怎么选
万年历的核心是必须有独立于MCU的RTC实时时钟芯片。这里要强调一下,不能用MCU的定时器去数秒,因为一旦主控断电,时间就全没了。RTC芯片自带晶振和计数电路,主电源断开后靠纽扣电池或者法拉电容继续走时,这才是真正意义上的“万年历”。
市面上常用的RTC芯片主要有三款,我做了个对比:
| 芯片 | 接口 | 精度典型值 | 外围复杂度 | 价格区间 | 适用场景 |
|---|---|---|---|---|---|
| DS1302 | 3线串口 | 取决于外部晶振,一般月误差1-2分钟 | 需要外接32.768kHz晶振和备份电池,外围较简单 | 1元左右 | 低成本入门项目、学习用途 |
| DS3231 | I2C | 内置TCXO,年误差1-2分钟 | 几乎不需要外围,自带晶振 | 10元以上 | 对走时精度要求高的设备 |
| PCF8563 | I2C | 同样取决于外部晶振 | 需要外接晶振和电池 | 2元左右 | I2C总线项目、低功耗场景 |
我做这个万年历选了DS1302。理由很直接:万年历对秒级精度没那么敏感,DS1302的月误差完全可以通过软件校准修回来,而且它的驱动代码在51/STC圈子里遍地都是,参考资料多,踩坑经验也好找。对于想学习RTC驱动原理的人来说,DS1302的三线串口时序比I2C更直观,能帮助你把“接口时序”这件事彻底吃透。
1.3 显示与按键方案:信息完整排在第一位
显示方面我选了LCD1602,没有选数码管。万年历要显示年月日时分秒加星期,这是十几二十个字符的信息量。数码管如果用4位或6位,需要反复切换页面才能看全时间日期,体验很差。LCD1602两行16字符,第一行放日期和星期,第二行放时间,一屏就能看到所有信息。
LCD12864也可以,能显示汉字,界面更漂亮,但接线多、驱动复杂,对于万年历这种功能来说有点浪费。LCD1602的4线模式只需要6个IO,对IO资源紧张的方案很友好。按键方面我用了三个:设置、加、减。三个键就能完成时间日期的调整,菜单逻辑做成状态机,后面会在软件章节详细展开。
2. 硬件电路设计要点:别让小细节拖后腿
2.1 引脚分配与最小系统
这个项目的外围电路其实很简单,重点在于引脚分配要顺手。我按自己实际用的SOP20封装做了下面的分配,你可以照着接,也可以根据手头的板型调整:
| 功能 | MCU引脚 | 说明 |
|---|---|---|
| LCD1602 RS | P2.0 | 寄存器选择 |
| LCD1602 RW | P2.1 | 读写选择,直接接GND也行 |
| LCD1602 EN | P2.2 | 使能信号 |
| LCD1602 D4-D7 | P2.3-P2.6 | 4线数据总线 |
| DS1302 CE | P1.0 | 片选信号 |
| DS1302 IO | P1.1 | 数据线,建议外部加上拉 |
| DS1302 SCLK | P1.2 | 时钟线 |
| 按键设置/加/减 | P3.0/P3.1/P3.2 | 按键另一端接GND |
电源部分,VCC和GND之间放了10uF电解电容加0.1uF陶瓷电容的组合,尽量靠近芯片引脚。STC15的宽电压特性让我省掉了LDO,直接USB 5V供电就行。下载口用四针排针引出VCC、GND、RXD、TXD,交叉连接USB转TTL小板,下载时选择对应芯片型号,点“下载/编程”再给板子上电,就能进入ISP模式。这个习惯要养成,否则会出现下载超时的提示。
2.2 DS1302外围电路:晶振、电池和二极管的讲究
DS1302的外围电路是这个项目里最容易翻车的地方,值得单独拿出来说。首先是32.768kHz晶振,负载电容要选6pF的版本,这一点非常重要。我买过一批没有标注负载电容的便宜晶振,焊上去怎么都不起振,换成6pF负载电容的型号之后一次成功。晶振的两个引脚接DS1302的X1和X2,走线尽可能短,旁边不要走高频信号线。有些设计习惯会在X1、X2对地各加一个6pF到12pF的电容,我画板时预留了位置,调试时发现不加也能正常振荡,但留位置的好处是万一遇到不起振可以手工补焊。
然后是备份电池。DS1302的VCC2接主电源5V,VCC1接3V纽扣电池。芯片内部会自动选择电压较高的一侧供电,但为了更稳妥,我会在VCC1支路上串一个1N4148二极管,防止主电源通过芯片内部寄生二极管给电池倒灌充电。需要注意VCC1电压不要超过VCC2加0.3V,否则可能引发芯片闩锁问题。如果你不想用纽扣电池,VCC1接一个0.1F/5.5V的法拉电容也可以,断电后维持几天没问题,只是没有纽扣电池时间长。
数据走线方面,DS1302的IO引脚最好加一个10k上拉电阻到VCC。因为DS1302的数据脚在读取时是输出状态,在写入或空闲时是高阻状态,外部上拉能保证电平稳定,减少误读的概率。CE和SCLK直接接单片机IO即可,不需要额外处理。
2.3 按键和LCD1602的接线细节
按键电路很多人觉得简单,但同样有讲究。STC15内部虽然可以配置上拉电阻,但内部上拉阻值偏大,抗干扰能力弱,我习惯在按键外部加10k上拉到VCC,按键另一端接GND,按下时引脚被拉低,读取为0。这样电平跳变干净利落,后续消抖的压力也小。
LCD1602这边有一个新手必踩的坑是对比度。第三脚VO如果直接接地,绝大多数模块会黑成一团看不清内容。正确做法是用10k电位器从VCC到GND分压,中间抽头接VO,上电后调到屏幕刚好显示两行浅灰方块为止。如果没有电位器,可以先接一个2k电阻到GND,再根据实际效果调整阻值。背光正极串一个100欧限流电阻接VCC,负极接GND,亮度足够也不会过流。1602的RW脚如果只做写入操作可以直接接GND,省掉一个IO口,也少一段初始化逻辑。
3. 软件核心实现:驱动、日历算法与菜单逻辑
3.1 程序整体结构和时间基准
软件部分我采用经典的前后台架构,即“主循环加定时器中断”。Timer0配置为1ms中断,在中断里置一个标志位,主循环检测到标志位后执行按键扫描、显示刷新、时间校准等任务。这种结构代码量小、逻辑直观、便于调试,对万年历这种“非实时性要求不高”的项目完全够用。
实际任务调度我按这个节奏来:每10毫秒扫描一次按键并做消抖处理,每500毫秒读取一次DS1302的时间并刷新LCD显示。显示不用刷太频繁,秒的变化看得清楚就行,频繁读写DS1302反而会增加不必要的总线操作和功耗。主循环里其余时间保持空闲,这样MCU负载很低,也让系统的实时性更稳。
// 主循环简写示意 void main(void) { SystemInit(); while (1) { if (flag_1ms) { KeyProcess(); // 按键扫描与菜单处理 if (flag_500ms) { ReadRtcTime(); // 从DS1302读取时间 LcdDisplay(); // 刷新LCD } } } }这种写法虽然朴素,但通过时间片划分,能让按键响应、显示刷新、数据读取各干各的,互不阻塞。对于学习任务调度来说,这个框架比上RTOS更容易理解底层逻辑。
3.2 DS1302驱动时序与BCD码处理
DS1302的驱动是这个项目最核心的部分,三线串口通信看着简单,时序处理不好就会读出全0或全FF。通信过程大致是先拉高CE片选,然后通过SCLK的上升沿逐位写入命令字节和数据字节,读取时则是在SCLK下降沿之后采样数据线。
#define DS1302_CE P1_0 #define DS1302_IO P1_1 #define DS1302_CLK P1_2 void DS1302_WriteByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { DS1302_IO = dat & 0x01; _nop_(); DS1302_CLK = 1; _nop_(); DS1302_CLK = 0; dat >>= 1; } } unsigned char DS1302_ReadByte(void) { unsigned char i, dat = 0; DS1302_IO = 1; // 释放数据线,让DS1302驱动 for (i = 0; i < 8; i++) { DS1302_CLK = 1; _nop_(); DS1302_CLK = 0; _nop_(); dat >>= 1; if (DS1302_IO) dat |= 0x80; } return dat; } unsigned char DS1302_ReadReg(unsigned char addr) { unsigned char dat; DS1302_CE = 0; DS1302_CLK = 0; DS1302_CE = 1; DS1302_WriteByte(addr); // 读命令,bit0为0 dat = DS1302_ReadByte(); DS1302_CE = 0; return dat; } void DS1302_WriteReg(unsigned char addr, unsigned char dat) { DS1302_CE = 0; DS1302_CLK = 0; DS1302_CE = 1; DS1302_WriteByte(addr); // 写命令,bit0为1 DS1302_WriteByte(dat); DS1302_CE = 0; }这里要特别提一下读时序。DS1302的手册描述是数据在SCLK下降沿之后由从机输出,主机在下降沿后采样比较稳妥。但实际工作中我发现不同的代码写法都能跑,有的先行采样再给时钟,有的给完时钟再采样,差别主要在于时序裕量。如果你写出来的代码读出的时间全是0xFF或者0x00,不要急着怀疑芯片坏了,先把读字节函数改成“给完一个完整时钟脉冲之后在读数据线”的顺序,大概率能解决。这个经验我在调试时踩过,也帮朋友排查过同样的问题。
DS1302的时间寄存器格式是BCD码,也就是一个字节的高四位表示十位、低四位表示个位。比如秒寄存器读出来的0x45代表45秒,而不是十进制45。所以需要做转换:
#define BCD_TO_DEC(x) ((((x) >> 4) * 10) + ((x) & 0x0F)) #define DEC_TO_BCD(x) ((((x) / 10) << 4) | ((x) % 10))另外要注意DS1302的秒寄存器最高位是CH标志位,为1时芯片停止振荡。初始化时一定要把这个位清0,否则时间根本不会走。写保护寄存器是0x8E,写任何时间数据之前要先关写保护,写完再打开,防止误写。还有涓流充电寄存器0x90,如果是普通纽扣电池就不要启用充电功能,否则可能对电池造成损伤,初始化时直接写0x00关掉就好。
3.3 闰年与日期计算
虽然DS1302内部会自己计算月日,但你在做按键校时、日期合法性校验或者未来扩展闹钟功能时,还是必须自己写一套日期算法。很多人的万年历项目会在设置界面允许用户把日期改成2月30日,这就是没有做合法性限制的结果。
const unsigned char MonthDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; unsigned char IsLeapYear(unsigned int year) { if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) return 1; return 0; } unsigned char GetMonthDays(unsigned int year, unsigned char month) { if (month == 2 && IsLeapYear(year)) return 29; return MonthDays[month - 1]; }闰年判断的逻辑是“能被4整除但不能被100整除,或者能被400整除”。这句很多人背得出来,但要注意DS1302的年寄存器只有两位,范围是00到99。也就是它只能直接表示2000年到2099年。我在程序里读出来之后统一加上2000得到实际年份,这样2025年对应25,闰年判断也按2000加实际值来算,避免像2100年这种特殊年份的判断错误。
另外,星期寄存器在DS1302里是1到7,分别对应周日到周六。如果你更习惯“周一是一周的第一天”,显示前要做一次映射转换。我自己是直接按DS1302的返回值显示星期几,只是在打印的时候把1对应成“Sun”,避免额外增加一层换算逻辑。
3.4 按键调整时间的状态机设计
按键菜单用状态机实现,是我在这个项目里觉得最顺手的一部分。整体思路是把界面状态分成正常显示、设置年、设置月、设置日、设置时、设置分这几个状态,按键操作驱动状态切换:
typedef enum { STATE_NORMAL, STATE_SET_YEAR, STATE_SET_MONTH, STATE_SET_DAY, STATE_SET_HOUR, STATE_SET_MIN } MenuState;短按设置键可以从正常显示切换到设置年,之后每按一次进入下一个字段,设置完分钟后再按一次回到正常显示并写入DS1302。加键和减键在当前状态下对对应的字段做加减,同时调用合法性判断,比如当前是2月且年份不是闰年时,日期最大值自动限制为28。
按键处理要特别注意两个问题。一是消抖,我采用10毫秒扫描一次、连续两次读到相同电平才确认状态变化的软件消抖方式,实测下来非常稳定。二是长按连续触发,如果用户想从2025年调整到2030年,一下一下按太痛苦了。我做了长按0.5秒后每150毫秒自动加一的效果,这样既不会误触,又能快速调整。
写入DS1302时,最低位的秒寄存器会触发CH标志位相关操作,建议在校时结束的统一时刻把时间一次性写入所有寄存器,而不是在设置完年就立刻写一次、设置完月又写一次,碎片化写入容易造成数据不一致。我是在“设置分”完成后统一写入,写入前关闭写保护,写完立即打开。
4. 调试记录:那些坑,我替你踩过了
4.1 DS1302晶振不起振,秒不走
这是我遇到最多的问题,也是最多人私信问我的问题。现象很统一:代码逻辑看起来完全没问题,可读出秒数一直不变。排查思路大概分三步。
第一步测硬件,用示波器或者万用表频率档测DS1302的X1脚有没有32.768kHz振荡信号。没示波器的话可以直接读秒寄存器,看个位数有没有变化。第二步检查晶振型号,确认负载电容是6pF而不是12.5pF。第三步检视焊点,X1和X2引脚相邻,助焊剂残留或者锡桥都会导致停振。有一次我怎么查都正常,最后发现是晶振引脚虚焊,补了一烙铁就好了。晶振旁边最好不要铺铜,寄生电容会影响起振。
4.2 掉电之后时间归零
掉电时间丢失,先说硬件原因:VCC1电池没接对。要么是纽扣电池装反,要么是1N4148二极管接反,要么是电池没电了。排除这些之后还有一种很隐蔽的软件原因——初始化代码把时间写死了。很多教程的示例代码会在初始化里直接调用DS1302_WriteReg写入当前时间,如果你照抄了,就会导致每次上电都把你设置的固定时间覆盖掉,电池走时期间更新过的时间全部白搭。
正确做法是上电先读一次时间,判断一下有效性,比如年寄存器读出来是0或者255,才认为是初始上电需要写入默认时间;如果读出来是正常年份,就跳过写入,直接进入显示流程。这样断电重启后时间才能保持在最近一次校时的状态。
4.3 走时误差的软件校准
DS1302的走时精度完全取决于外部那颗32.768kHz晶振,温度变化、晶振本身频率偏差都会带来累积误差,一个月差一两分钟是正常现象。如果不想换更高精度的DS3231,可以像我一样在软件里做补偿。
思路很简单:先解决走快还是走慢的问题。和手机时间对时,让板子连续跑24小时,记录误差秒数。比如实测一天快了5秒,那就在程序里每天固定时刻(比如凌晨0点0分0秒)把每秒数回拨5秒;如果慢了3秒,就每天晚上加3秒。写入秒值时务必保持CH位为0,千万不要往秒寄存器的高位写1,否则时钟会当场停掉。这种补偿是开环的,不能完全消除误差,但能把月误差控制在一分钟内,对桌面万年历来说完全够用。
4.4 LCD1602乱码与按键跳变
LCD1602上电后如果直接初始化,经常会出现满屏黑块或者第一行乱码。原因是1602内部控制器需要在上电后等待一段时间才能稳定,最短也要等40毫秒以上,电压不稳的时候甚至要100毫秒。我习惯在初始化函数最开始加一个至少50毫秒的延时,然后再发初始化指令。对比度电位器也要调整到合适位置,否则即使代码对,显示也会一团糊。
按键跳变的问题多数出在消抖和上拉。我调试时发现如果不做连续两次采样确认,按键按下的一瞬间会触发两三次操作。除了软件消抖,建议在按键引脚加10k上拉电阻,缩短到GND的路径,能显著减少外部干扰引起的误触发。另外,STC15的IO口要配置成准双向口模式,不能是高阻输入,否则按键引脚在悬空时会读到随机电平。
最后分享一点我自己的做事习惯:万年历这类项目做完后别急着拆,把它放在桌面跑一个月。这一个月里你会直观感受到RTC芯片的长期漂移、按键手感和显示效果。很多芯片数据手册上冷冰冰的参数,都会在长时间运行后变成真正属于你的经验。对我个人来说,这个基于STC15的万年历项目最大的收获不是代码本身,而是把“接口时序”“前后台任务调度”“数据有效性判断”这几个基础概念彻底夯实了。如果你也想动手做一个,我建议从一片STC15核心板加一块DS1302模块开始,一个晚上就能点亮第一屏时间,之后想扩展闹钟、温度显示、红外遥控,都有现成的路子可以走。
本文还有配套的精品资源,点击获取