1. 写在前面:这篇笔记适合谁
如果你正在跟江科大(江协科技)的 51 单片机课程,学到 DS1302 实时时钟 + LCD1602 显示 + 按键调时 这一节,可能会被一堆 0x80、BCD、interrupt 1 搞得有点懵。
这篇笔记就是给「代码小白」准备的:不跳步、不假设你已经懂了,把整份工程拆成 6 个文件,一行一行讲清楚「这行代码在干嘛、为什么要这么写」。
说明:本文代码来自课程配套工程,笔记中的注释和讲解为个人理解整理,方便复习时对照。
2. 先看全局:这个电子钟到底在做什么
一句话概括:DS1302 芯片负责「记住时间」,LCD1602 负责「显示时间」,按键负责「调时间」,定时器负责「让选中的数字闪烁」。
#mermaid-svg-kBiRTQMhn39EgtQf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kBiRTQMhn39EgtQf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kBiRTQMhn39EgtQf .error-icon{fill:#552222;}#mermaid-svg-kBiRTQMhn39EgtQf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kBiRTQMhn39EgtQf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kBiRTQMhn39EgtQf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kBiRTQMhn39EgtQf .marker.cross{stroke:#333333;}#mermaid-svg-kBiRTQMhn39EgtQf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kBiRTQMhn39EgtQf p{margin:0;}#mermaid-svg-kBiRTQMhn39EgtQf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kBiRTQMhn39EgtQf .cluster-label text{fill:#333;}#mermaid-svg-kBiRTQMhn39EgtQf .cluster-label span{color:#333;}#mermaid-svg-kBiRTQMhn39EgtQf .cluster-label span p{background-color:transparent;}#mermaid-svg-kBiRTQMhn39EgtQf .label text,#mermaid-svg-kBiRTQMhn39EgtQf span{fill:#333;color:#333;}#mermaid-svg-kBiRTQMhn39EgtQf .node rect,#mermaid-svg-kBiRTQMhn39EgtQf .node circle,#mermaid-svg-kBiRTQMhn39EgtQf .node ellipse,#mermaid-svg-kBiRTQMhn39EgtQf .node polygon,#mermaid-svg-kBiRTQMhn39EgtQf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kBiRTQMhn39EgtQf .rough-node .label text,#mermaid-svg-kBiRTQMhn39EgtQf .node .label text,#mermaid-svg-kBiRTQMhn39EgtQf .image-shape .label,#mermaid-svg-kBiRTQMhn39EgtQf .icon-shape .label{text-anchor:middle;}#mermaid-svg-kBiRTQMhn39EgtQf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kBiRTQMhn39EgtQf .rough-node .label,#mermaid-svg-kBiRTQMhn39EgtQf .node .label,#mermaid-svg-kBiRTQMhn39EgtQf .image-shape .label,#mermaid-svg-kBiRTQMhn39EgtQf .icon-shape .label{text-align:center;}#mermaid-svg-kBiRTQMhn39EgtQf .node.clickable{cursor:pointer;}#mermaid-svg-kBiRTQMhn39EgtQf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kBiRTQMhn39EgtQf .arrowheadPath{fill:#333333;}#mermaid-svg-kBiRTQMhn39EgtQf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kBiRTQMhn39EgtQf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kBiRTQMhn39EgtQf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kBiRTQMhn39EgtQf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kBiRTQMhn39EgtQf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kBiRTQMhn39EgtQf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kBiRTQMhn39EgtQf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kBiRTQMhn39EgtQf .cluster text{fill:#333;}#mermaid-svg-kBiRTQMhn39EgtQf .cluster span{color:#333;}#mermaid-svg-kBiRTQMhn39EgtQf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kBiRTQMhn39EgtQf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kBiRTQMhn39EgtQf rect.text{fill:none;stroke-width:0;}#mermaid-svg-kBiRTQMhn39EgtQf .icon-shape,#mermaid-svg-kBiRTQMhn39EgtQf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kBiRTQMhn39EgtQf .icon-shape p,#mermaid-svg-kBiRTQMhn39EgtQf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kBiRTQMhn39EgtQf .icon-shape .label rect,#mermaid-svg-kBiRTQMhn39EgtQf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kBiRTQMhn39EgtQf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kBiRTQMhn39EgtQf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kBiRTQMhn39EgtQf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读时间 ReadTime
键码 1/2/3/4
写时间 SetTime
LCD_ShowNum 显示
翻转闪烁标志
DS1302 时钟芯片
主程序 main
独立按键 Key
LCD1602 屏幕
定时器 T0 中断
整个工程一共 6 个文件:
| 文件 | 作用 | 小白理解 |
|---|---|---|
main.c |
主逻辑:显示、调时、模式切换 | 大脑 |
ds1302.c / ds1302.h |
时钟芯片驱动 | 记忆时间的本子 |
lcd1602.c / lcd1602.h |
屏幕驱动 | 显示器 |
key.c / key.h |
按键扫描 | 遥控器 |
timer0.c / timer0.h |
定时器中断 | 秒表/节拍器 |
delay.c / delay.h |
软件延时 | 等待工具 |
3. 核心概念先扫盲(小白必读)
3.1 BCD 码:为什么时间要「除以 16 乘 10」
DS1302 内部存时间用的是 BCD 码,不是普通二进制。
- 普通二进制:
0x25表示十进制的 37 - BCD 码:
0x25表示十进制的 25(高 4 位是十位,低 4 位是个位)
所以从芯片读出来要转成十进制:
c
十进制 = BCD / 16 * 10 + BCD % 16;
反过来写进芯片要转成 BCD:
c
BCD = 十进制 / 10 * 16 + 十进制 % 10;
记住这两行,整个 DS1302 就通了一半。
3.2 三个引脚:CE、SCLK、IO
DS1302 是「串行」通信,只用 3 根线:
CE(也叫 RST):片选,拉高表示「我要开始通信了」SCLK:时钟线,每来一个上升沿/下降沿就传一位IO:数据线,一位一位地传
c
sbit DS1302_SCLK = P3^6;
sbit DS1302_IO = P3^4;
sbit DS1302_CE = P3^5;
3.3 命令字:0x80 是「写秒」,0x81 是「读秒」
DS1302 的每个寄存器都有一个地址,最低位是「读/写」标志:
- 最低位 = 0 → 写
- 最低位 = 1 → 读
所以 0x80 写秒,0x81 读秒;0x8E 是写保护寄存器。
c
#define DS1302_SECOND 0x80
#define DS1302_MINUTE 0x82
#define DS1302_HOUR 0x84
#define DS1302_DATE 0x86
#define DS1302_MONTH 0x88
#define DS1302_DAY 0x8A
#define DS1302_YEAR 0x8C
#define DS1302_WP 0x8E
4. DS1302 驱动逐行讲解
4.1 初始化:把两根线拉低
c
void DS1302_Init(void)
{
DS1302_CE = 0;
DS1302_SCLK = 0;
}
上电后先把片选和时钟拉低,保证通信从「干净」的状态开始。
4.2 写一个字节:先发命令,再发数据
c
void DS1302_WriteByte(unsigned char Command, unsigned char Data)
{
unsigned char i;
DS1302_CE = 1; // 开始通信
for(i = 0; i < 8; i++) // 先发 8 位命令
{
DS1302_IO = Command & (0x01 << i); // 取出第 i 位
DS1302_SCLK = 1; // 上升沿,芯片采样
DS1302_SCLK = 0;
}
for(i = 0; i < 8; i++) // 再发 8 位数据
{
DS1302_IO = Data & (0x01 << i);
DS1302_SCLK = 1;
DS1302_SCLK = 0;
}
DS1302_CE = 0; // 结束通信
}
小白理解 :0x01 << i 就是「把 1 左移 i 位」,配合 & 就能把第 i 位单独抠出来。循环 8 次,一个字节就发完了。
4.3 读一个字节:命令最低位置 1
c
unsigned char DS1302_ReadByte(unsigned char Command)
{
unsigned char i, Data = 0x00;
DS1302_CE = 1;
Command |= 0x01; // 最低位置 1,表示「读」
for(i = 0; i < 8; i++) // 发命令
{
DS1302_IO = Command & (0x01 << i);
DS1302_SCLK = 0;
DS1302_SCLK = 1;
}
for(i = 0; i < 8; i++) // 收数据
{
DS1302_SCLK = 1;
DS1302_SCLK = 0;
if(DS1302_IO) { Data |= (0x01 << i); } // 读到 1 就把对应位置 1
}
DS1302_IO = 0;
DS1302_CE = 0;
return Data;
}
关键点 :Command |= 0x01 这一句,把「写地址」变成「读地址」,比如 0x80 变 0x81。
4.4 写入时间:先关写保护
c
void DS1302_SetTime(void)
{
DS1302_WriteByte(DS1302_WP, 0x00); // 关闭写保护
DS1302_WriteByte(DS1302_YEAR, DS1302_Time[0]/10*16 + DS1302_Time[0]%10);
DS1302_WriteByte(DS1302_MONTH, DS1302_Time[1]/10*16 + DS1302_Time[1]%10);
DS1302_WriteByte(DS1302_DATE, DS1302_Time[2]/10*16 + DS1302_Time[2]%10);
DS1302_WriteByte(DS1302_HOUR, DS1302_Time[3]/10*16 + DS1302_Time[3]%10);
DS1302_WriteByte(DS1302_MINUTE, DS1302_Time[4]/10*16 + DS1302_Time[4]%10);
DS1302_WriteByte(DS1302_SECOND, DS1302_Time[5]/10*16 + DS1302_Time[5]%10);
DS1302_WriteByte(DS1302_DAY, DS1302_Time[6]/10*16 + DS1302_Time[6]%10);
DS1302_WriteByte(DS1302_WP, 0x80); // 重新打开写保护
}
踩坑提醒:如果忘了关写保护,写进去的时间不会生效,读出来永远是老值。
4.5 读取时间:BCD 转十进制
c
void DS1302_ReadTime(void)
{
unsigned char Temp;
Temp = DS1302_ReadByte(DS1302_YEAR);
DS1302_Time[0] = Temp/16*10 + Temp%16;
// 后面月、日、时、分、秒、周同理
}
5. LCD1602 驱动:只讲你会用到的
LCD1602 的驱动代码比较长,小白不需要全部背下来,只要会用下面几个函数:
c
LCD_Init(); // 初始化,必须最先调用
LCD_ShowString(1, 9, " - - "); // 第 1 行第 9 列开始显示字符串
LCD_ShowNum(1, 9, DS1302_Time[0], 2); // 第 1 行第 9 列显示 2 位数字
参数含义:(行, 列, 内容, 位数),行是 1~2,列是 1~16。
几个底层函数的作用:
LCD_WriteCommand():写命令(比如清屏、设置光标)LCD_WriteData():写数据(真正显示的内容)LCD_SetCursor():设置光标位置LCD_Pow():自己实现的乘方,因为 51 没有现成的
LCD_ShowNum 的核心就一句:
c
LCD_WriteData(Number / LCD_Pow(10, i-1) % 10 + '0');
小白理解 :先除再取余,把某一位数字抠出来,+'0' 是把它变成字符 '0'~'9'。
6. 按键与定时器:调时和闪烁的秘密
6.1 按键扫描:返回 1~4
c
unsigned char Key()
{
unsigned char Keynum = 0;
if(P3_1==0){Delay(20);while(P3_1==0);Delay(20);Keynum=1;}
if(P3_0==0){Delay(20);while(P3_0==0);Delay(20);Keynum=2;}
if(P3_2==0){Delay(20);while(P3_2==0);Delay(20);Keynum=3;}
if(P3_3==0){Delay(20);while(P3_3==0);Delay(20);Keynum=4;}
return Keynum;
}
Delay(20) 是消抖 ,while(P3_1==0) 是等待松手,这样按一次只算一次。
6.2 定时器:每 500ms 翻转一次闪烁标志
c
void Timer0Routine() interrupt 1
{
static unsigned int T0Count;
TL0 = 0x18;
TH0 = 0xFC;
T0Count++;
if(T0Count >= 500)
{
T0Count = 0;
TimeSetFlashFlag = !TimeSetFlashFlag;
}
}
定时器每 1ms 进一次中断,500 次就是 500ms,! 取反让标志位在 0/1 之间跳,从而实现「选中的数字一闪一闪」。
7. 主程序:把上面所有零件拼起来
7.1 显示时间
c
void Timeshow(void)
{
DS1302_ReadTime();
LCD_ShowNum(1, 9, DS1302_Time[0], 2); // 年
LCD_ShowNum(1, 12, DS1302_Time[1], 2); // 月
LCD_ShowNum(1, 15, DS1302_Time[2], 2); // 日
LCD_ShowNum(2, 9, DS1302_Time[3], 2); // 时
LCD_ShowNum(2, 12, DS1302_Time[4], 2); // 分
LCD_ShowNum(2, 15, DS1302_Time[5], 2); // 秒
}
7.2 调时间:加/减都要判断边界
c
if(Keynum == 2) // 切换要调的位
{
TimeSetSelect++;
TimeSetSelect %= 6; // 0~5 循环,等价于 if(>5) =0
}
if(Keynum == 3) // 加
{
DS1302_Time[TimeSetSelect]++;
if(DS1302_Time[0] > 99) { DS1302_Time[0] = 0; }
if(DS1302_Time[1] > 12) { DS1302_Time[1] = 1; }
// 日要按大小月和闰年分别判断......
}
if(Keynum == 4) // 减
{
DS1302_Time[TimeSetSelect]--;
if(DS1302_Time[0] < 0) { DS1302_Time[0] = 99; }
if(DS1302_Time[1] < 1) { DS1302_Time[1] = 12; }
// 同理......
}
小白最容易懵的地方:为什么「加」只判断上界,「减」要判断上下界?
因为加的时候数值只会变大,超过上界就回绕;减的时候数值只会变小,低于下界就回绕。但日比较特殊,因为月份变化会影响日的上限,所以减的时候还要额外判断「日是否超过当前月的最大天数」。
7.3 闪烁显示
c
if(TimeSetFlashFlag == 1 && TimeSetSelect == 0)
LCD_ShowString(1, 9, " "); // 闪烁时显示空格
else
LCD_ShowNum(1, 9, DS1302_Time[0], 2);
6 个位置各写一遍,就是「当前选中的那一位在闪」。
7.4 main 函数
c
void main()
{
LCD_Init();
DS1302_Init();
Timer0Init();
LCD_ShowString(1, 9, " - - ");
LCD_ShowString(2, 9, " : : ");
DS1302_SetTime();
while(1)
{
Keynum = Key();
if(Keynum == 1) // 切换模式
{
if(mode == 0) { mode = 1; }
else if(mode == 1) { mode = 0; DS1302_SetTime(); } // 调完写回芯片
}
switch(mode)
{
case 0: Timeshow(); break;
case 1: TimeSet(); break;
}
}
}
重点 :从「调时模式」切回「显示模式」时,一定要调用 DS1302_SetTime() 把改好的时间写回芯片,否则断电就丢了。
8. 小白常见踩坑清单
- 读出来的时间乱跳:忘了关写保护,或者 BCD 没转换。
- 时间不走 :
DS1302_Init()没调用,或者 CE 一直是高电平。 - 按键按一次跳好几下 :消抖的
Delay(20)漏了。 - 闪烁不生效 :定时器中断没开
EA=1、ET0=1。 - 调完时间又变回去 :切回显示模式时没调用
DS1302_SetTime()。 - LCD 显示乱码 :
LCD_Init()没在DS1302_Init()之前调用,或者数据口接错。
9. 总结
这一节其实是 51 单片机课程的「综合大作业」:把 GPIO、串行通信、定时器中断、按键、LCD 显示 全部串起来了。
复习时建议按这个顺序看:
- 先搞懂 BCD 码转换(两行公式)
- 再看 DS1302 的读写时序(CE → 命令 → 数据)
- 然后看 LCD 的显示函数怎么用
- 最后看 main 里怎么把模式切换和调时逻辑拼起来
把这份笔记对着代码过一遍,再自己动手改改「闪烁频率」「调时范围」,基本就吃透了。