简介这套51单片机实战案例聚焦DS1302时钟芯片的应用适合嵌入式硬件初学者与单片机开发者参考。代码采用C语言编写完整实现实时时钟显示与按键可调时钟功能涵盖DS1302驱动、LCD1602液晶显示、按键控制三大模块可直接编译烧录。资源包共24个文件以.c源文件、.h头文件为主另含.uvproj工程、.hex烧录文件及.lst/.obj等编译中间文件整体仅有60KB结构精简。已有3279人学习下载。项目中main.c可直接运行三个子程序分工明确便于理解模块化设计同时附带引脚配置修改指引与常见问题排查思路可帮助读者适配自己的开发板整体兼具学习参考与工程复用价值。1. 为什么用 DS1302 做 51 单片机的实时时钟而不是一直开定时器51 单片机做电子时钟有不少路线常见的是用内部定时器累加秒数但这类方案在断电、复位、重新烧录程序后时间全部归零课设答辩时最容易露怯。DS1302 这种专用 RTC 芯片则不同它自带 32.768kHz 晶振接口和后备电池引脚主电源掉电后自动切换到电池供电时间继续走。大多数 51 单片机电子时钟课设、Proteus 仿真工程里选的也是它因为三根线就能通信代码量小原理也好讲。“实时时钟”和“可调时钟”对应两个核心动作实时时钟是上电后把芯片里的时间读出来显示可调时钟则是通过按键把校正后的时间写回芯片。本文给出的代码示例就是围绕“读”和“写”这两个动作展开按寄存器时序、读取实现、按键校准、走时误差排查的顺序往下走。2. DS1302 的寄存器映射与读写字节时序先看懂命令字节再写代码2.1 DS1302 为什么用三根线就能完成“实时时钟”的全部功能DS1302 的总线结构和标准 SPI 不完全一样但思路接近CE也常标成 RST拉高表示一次传输开始SCLK 提供时钟I/O 线负责双向数据传输数据低位在前。芯片内部有 7 个时间寄存器和 31 字节的静态 RAM访问哪个寄存器、是读还是写都由第一个命令字节决定。命令字节的高 5 位是寄存器地址bit6 固定为 1bit0 是读写方向标志。先送出命令字节再连续读或写一个字节CE 拉低结束传输。51 单片机硬件设计时DS1302 的 SCLK、I/O、CE 三根线直接接普通 I/O 口即可。P1、P2、P3 口内部有弱上拉可以直接驱动如果图省事把 I/O 接到 P0 口就必须外接 10kΩ 左右的上拉电阻否则读回来的数据会不稳定。很多 Proteus 仿真工程里默认用 P1 口连接实际做板子时也建议优先选 P1 或 P2少踩一个上拉坑。DS1302 常用时间寄存器地址映射如下寄存器写地址读地址数据范围BCD特殊位说明秒0x800x8100~59bit7 CH1 表示时钟暂停分0x820x8300~59bit7 保留写 0时0x840x8500~23 或 01~12bit7 选择 12/24 小时制日0x860x8701~31bit7 保留写 0月0x880x8901~12bit7 保留写 0周0x8A0x8B01~07bit7 保留写 0年0x8C0x8D00~99bit7 保留写 0控制0x8E0x8F0x00 或 0x80bit7 WP 写保护位拿到这张表后读秒寄存器就发命令字节 0x81写秒寄存器就发 0x80。很多人第一次写 DS1302 驱动时把读地址和写地址搞混常见错误是读秒时也发 0x80结果 CE 刚拉高芯片以为你要写数据I/O 方向切换不及时读回来的全是 0xFF。所以后面的代码示例统一用“写地址 1”作为读命令。2.2 字节读写的底层代码示例上升沿写、下降沿读底层字节读写是整个 DS1302 驱动的根基。先看写字节函数sbit DS1302_SCLK P1^0; sbit DS1302_IO P1^1; sbit DS1302_CE P1^2; void ds1302_write_byte(unsigned char dat) { unsigned char i; for (i 0; i 8; i) { DS1302_SCLK 0; DS1302_IO dat 0x01; // LSB first先放最低位 dat 1; DS1302_SCLK 1; // SCLK 上升沿DS1302 锁存数据 } }写字节的逻辑是SCLK 保持低电平时把 I/O 设为当前要发送的位然后拉高 SCLK在上升沿让 DS1302 采样。循环 8 次完成一个字节。这里有个容易被忽略的细节必须先放数据再拉高 SCLK如果顺序反了数据在上升沿到来时还没有稳定写进去的值会随机。读字节函数的方向控制和写不同I/O 线要从输出切换成输入而且采样时机在时钟下降沿附近unsigned char ds1302_read_byte(void) { unsigned char i, dat 0; DS1302_IO 1; // 释放 I/O 线切换为输入 for (i 0; i 8; i) { dat 1; if (DS1302_IO) dat | 0x80; // 先采样当前位 DS1302_SCLK 0; // 下降沿后 DS1302 切换下一位 DS1302_SCLK 1; } return dat; }读字节函数在每轮循环里先读取 I/O 电平再拉低 SCLK 触发芯片输出下一位。51 单片机主频通常在 12MHz 左右这个循环的时序足够满足 DS1302 的速度上限。但需要注意如果主程序开了定时器中断调用这段代码前最好临时关闭中断比如EA 0;。中断响应需要几个微秒如果恰好发生在外围操作中间SCLK 的边沿间隔会被拉长DS1302 对这种延迟有容忍度但读写的结果可能被读取到错误位。Keil C51 的优化等级建议设置在默认或以下不要开 9 级最高优化。部分编译器会把dat 1;和 SCLK 翻转的语句顺序做调整导致产生波形顺序和代码肉眼看到的不一致。2.3 完整读写一条时间的封装命令字节怎么拼有了底层字节函数访问单个寄存器只需要三步CE 拉高发送命令字节读或写一个数据字节CE 拉低。封装成函数时建议把“读命令 写地址 | 0x01”这个规则写进函数里避免上层到处加 1unsigned char ds1302_read_reg(unsigned char addr) { unsigned char dat; DS1302_CE 0; DS1302_SCLK 0; DS1302_CE 1; ds1302_write_byte(addr | 0x01); // 传入写地址自动转成读命令 dat ds1302_read_byte(); DS1302_CE 0; return dat; }这个封装里调用ds1302_read_reg(0x80)读秒寄存器函数内部发送的命令字节是 0x81。参数addr始终使用写地址手册里查地址时不用做额外的位运算减少出错概率。读多个寄存器时就重复调用这个函数每次读写之间 CE 都要完整拉低再拉高不能一次 CE 拉高连续读多个寄存器。如果需要一次性读出秒到年的时间DS1302 也支持突发模式命令地址是 0xBE 读、0xBC 写连续读写 8 个字节但课设代码里很少用逐个读更直观、排错也更方便。3. 读取当前时间的最小代码示例BCD 转十进制与主循环轮询3.1 为什么 DS1302 返回的是 BCD而不是二进制整数DS1302 的时间寄存器内部存储的是 BCD 码不是纯二进制。BCD 码的含义是一个字节的高 4 位表示十位低 4 位表示个位。例如“59 秒”在寄存器里是 0x59也就是二进制 0101 1001。直接把这个字节显示在数码管或 LCD 上反而方便但如果你要拿秒数做运算、比较大小、计算时间差就必须先转成十进制整数。51 单片机课程设计里最典型的一个显示错误是读回 0x23 小时后代码里用整型格式化输出得到 35因为十六进制 0x23 转十进制就是 35。转换公式很简单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); }注意 DS1302 的小时寄存器在 12 小时制下有 AM/PM 标志位如果设置时把小时地址写入 0x12在 12 小时制下会被解释成“12 点 PM”和 24 小时制下的“12 点”含义不同。代码里统一用 24 小时制写入小时时不要置位 bit7。3.2 可抄作业的时间读取代码示例我一般会先定义一个时间结构体保存的还是 BCD 值显示层再决定转不转。这样做的好处是数码管或 LCD 段码可以直接用高 4 位和低 4 位分别取数字效率高而且从芯片读回来的原始值是什么结构体里就是什么调试时对照寄存器值更方便。typedef struct { unsigned char year; // 00~99 unsigned char month; // 01~12 unsigned char day; // 01~31 unsigned char hour; // 00~23 unsigned char minute; // 00~59 unsigned char second; // 00~59 } DS1302_TIME; void ds1302_get_time(DS1302_TIME *t) { t-year ds1302_read_reg(0x8C); t-month ds1302_read_reg(0x88); t-day ds1302_read_reg(0x86); t-hour ds1302_read_reg(0x84); t-minute ds1302_read_reg(0x82); t-second ds1302_read_reg(0x80); }读取函数的核心是连续读 6 个寄存器。这里有一个值得注意的现象读秒时秒寄存器可能刚好从 0x59 跳到 0x00而分钟还是上一秒的旧值导致结构体里出现“00 分 00 秒”这种理论上不存在的组合。工程上如果想避免可以在读秒之前先读控制寄存器但 DS1302 没有寄存器锁存功能严格一致性很难保证。大多数显示场景对一秒内的偏差不敏感连续读两次取第二次是更常用的廉价格局。在代码里也就是在ds1302_get_time末尾再调用一次本函数的第一条读取语句但这样做让代码多了一次总线操作需要自己权衡。3.3 把时间送到串口或 LCD1602printf 示例和轮询策略把时间读出来后显示是最直观的验证方式。51 单片机里跑串口输出通常用printfKeil C51 会把printf的每个字符转发到putchar所以要先实现串口底层发送函数。下面的代码示例把 BCD 值直接以十六进制格式打印读出来的 0x59 会显示成 59正好符合时间阅读习惯void uart_send_byte(unsigned char dat) { SBUF dat; while (!TI); TI 0; } void putchar(unsigned char dat) { uart_send_byte(dat); } void main(void) { DS1302_TIME t; uart_init(); // 9600, 8N1 while (1) { ds1302_get_time(t); printf(20%02x-%02x-%02x %02x:%02x:%02x\r\n, t.year, t.month, t.day, t.hour, t.minute, t.second); delay_ms(500); } }这里的printf格式化参数是%02x输出十六进制正好匹配 BCD 码的显示需求。如果先做了bcd_to_dec转换格式化参数则要用%02d否则 0x59 会被输出成 89。这个差异是很多同学调串口助手时发现输出乱码的真正原因。主循环里每隔 500ms 读一次时间是为了视觉上能看出秒在跳动同时也给串口、LCD 留出刷新时间。实际项目里如果显示刷新频率要求不高也可以改用定时器触发读取但主循环轮询已经足够覆盖大部分 51 单片机电子时钟场景。在 Proteus 仿真中验证时重点观察第二次读回的秒数和第一次是否相差 1如果一直是同一个值说明芯片的时钟没在走优先检查 CH 位是否被置 1。4. 可调时钟的实现按键校准、写保护与写入流程4.1 写入前先看两个容易踩的坑WP 写保护和 CH 暂停位可调时钟的核心是把校正后的时间写回 DS1302。直接调用写寄存器函数通常是不生效的因为控制寄存器 0x8E 的 bit7 是 WP 写保护位。芯片上电后 WP 不一定是 0只要 WP 为 1所有时间寄存器的写操作都会被忽略。所以写时间的标准顺序是void ds1302_write_reg(unsigned char addr, unsigned char dat) { DS1302_CE 0; DS1302_SCLK 0; DS1302_CE 1; ds1302_write_byte(addr); // 写命令bit0 0 ds1302_write_byte(dat); DS1302_CE 0; }每次写时间前先把 WP 清零写完再恢复写保护逻辑上更严谨。否则芯片在正常走时过程中如果受到干扰I/O 线上出现误操作时间寄存器可能被意外改写。另一个坑是秒寄存器的 bit7它叫 CH时钟暂停位。当 CH 1 时DS1302 内部的振荡器停止秒不走但你读其他寄存器时数值仍然存在看起来芯片没有任何反应。很多 Proteus 仿真工程里复位后时间停在同一个数字排查半天硬件结果就是 CH 被初始化成了 1。我在上电初始化代码里通常会对秒寄存器先做一次写操作数据保持当前秒值不变但确保 CH 位被清 0void ds1302_init(void) { unsigned char sec; sec ds1302_read_reg(0x80); sec 0x7F; // 只清除 CH 位 ds1302_write_reg(0x80, sec); }这段代码在第一次上电时会把秒写成 0x00因为读取的sec是一个未定义值清零 CH 后整体写入可能把秒重置。更稳妥的做法是在第一次上电时直接调用写入时间函数把校好的时间写进去。也就是说DS1302 初始化代码最好和调时功能共用同一套写入函数而不是单独走一条初始化分支。4.2 三个按键的时间设置状态机写法和参数设计可调时钟最常见的操作是三个按键设置键进入调时模式、加键、减键。进入调时模式后用数码管或 LCD 指示当前正在调节哪个字段短按加键或减键修改数值修改完按设置键跳到下一个字段最后一个字段再按设置键退出并写入 DS1302。状态机可以用枚举来管理避免散落一堆魔法数字typedef enum { SET_NONE, SET_HOUR, SET_MINUTE, SET_SECOND } SET_MODE; SET_MODE set_mode SET_NONE; void key_scan(void) { if (KEY_SET 0) // 设置键按下 { delay_ms(10); if (KEY_SET 0) { if (set_mode SET_NONE) set_mode SET_HOUR; // 进入调时 else if (set_mode SET_SECOND) set_mode SET_NONE; // 最后一个字段结束 else set_mode; } while (!KEY_SET); // 等待松开 } if (KEY_ADD 0 set_mode ! SET_NONE) { delay_ms(10); if (KEY_ADD 0) { adjust_time(1); while (!KEY_ADD); } } if (KEY_SUB 0 set_mode ! SET_NONE) { delay_ms(10); if (KEY_SUB 0) { adjust_time(-1); while (!KEY_SUB); } } }adjust_time函数根据当前set_mode修改对应的结构体字段并对边界做约束。小时最大值根据 24 小时制是 23分钟和秒最大值都是 59。如果只做加 1 再回绕显示上会出现 24:00:00 这种非法值。参数设为 ±1 而不是绝对数值是为了统一加减逻辑也方便后续改成“长按连续加”。如果按键扫描放在主循环里注意消抖用的delay_ms(10)会让主循环读数暂停 10ms对时间精度没有实质影响因为 DS1302 内部还在走。4.3 写时间函数的可复现代码版本和顺序说明退出调时模式后把结构体里的 BCD 值逐个写回寄存器。关键是写入顺序和写保护闭合时机void ds1302_set_time(DS1302_TIME *t) { ds1302_write_reg(0x8E, 0x00); // 关写保护 WP 0 ds1302_write_reg(0x80, t-second); ds1302_write_reg(0x82, t-minute); ds1302_write_reg(0x84, t-hour); ds1302_write_reg(0x86, t-day); ds1302_write_reg(0x88, t-month); ds1302_write_reg(0x8A, 1); // 周几简单置 1 ds1302_write_reg(0x8C, t-year); ds1302_write_reg(0x8E, 0x80); // 恢复写保护 WP 1 }写入顺序上秒寄存器写在最前是因为秒的 CH 位会被一并清零写完之后芯片立刻开始从新的秒值继续走。如果先写小时再写秒中间会有短暂的时间错位但影响只有几十微秒人眼看不到。注意周寄存器 0x8A 的取值范围是 1~7对应周日到周六课设中如果不关心星期几写 1 即可但不能写 0。调时功能里还要考虑“设置秒为 0”的场景。用户通常在整点报时的那一秒按键校准按设计应该把秒清零而不是把当前秒数加 1。通用的做法是在 SET_SECOND 模式下按下加键或减键都把秒改成 0以便对准整数分钟。这个细节可以让可调时钟的使用体验接近真实电子表。4.4 设置后时间不走或不变化的排查方向写完时间后如果发现秒不走按优先级依次检查三件事首先读秒寄存器看 bit7 是否为 0如果读出 0x80 以上说明 CH 位在你的代码里被重新置位了检查是不是写入秒时用了t-second | 0x80其次确认写保护在写完后被恢复成了 0x80但恢复前要确认所有时间寄存器都已经写完了最后检查 DS1302 的I/O口方向切换。在ds1302_read_byte里如果把DS1302_IO 1;漏掉读回来的数据全部是上次输出的值看起来像是寄存器没变化。如果写入后读回正确、断电重启后时间丢失则是后备电池没接或者电压不足。DS1302 的 VCC1 引脚接 3V 纽扣电池VCC2 接 5V 主电源。电池电压低于 2.0V 时内部寄存器和 RAM 的数据不再保证保持时间就会回到默认值。用 51 单片机开发板做实验时如果板上没有电池座断电后时间丢失是正常现象不代表芯片坏了。5. DS1302 时钟走不准先做一个 24 小时误差记录再改负载电容DS1302 本身的时间基准完全依赖外接的 32.768kHz 晶振。如果一天快几十秒问题基本不在芯片而在晶振电路。最常见的粗暴解决办法是直接换晶振但更有效率的做法是先记录误差再针对性调整负载电容。先把串口输出的时间改成完整日期格式然后找一个固定时刻记录第一次读数比如早上 9:00:00。24 小时后在同一时间读第二次算出快慢秒数。假设实测一天快了 2.5 秒可以估算出频率偏差ppm 误差秒 / 86400 × 1000000 ppm 2.5 / 86400 × 1000000 ≈ 28.928.9ppm 的偏差已经超过普通 32.768kHz 晶振标称的 20ppm 精度。常见原因是晶振的负载电容 CL 与实际匹配不一致。DS1302 电路里晶振两端各接一个电容到地常见取值是 6pF 到 15pF。如果走快说明振荡频率偏高可以稍微加大电容每端并联 2~3pF 的陶瓷电容再观察一天如果走慢说明频率偏低反向减小电容。要注意 DS1302 数据手册里晶振负载电容和引脚分布电容的总和才是有效负载普中、江科大这类开发板上的晶振走线短引脚分布电容小实际并接电容不需要太大。另一个容易忽略的因素是温度。DS1302 内部没有温度补偿晶振频率随温度变化产生的偏差在 -40°C 到 85°C 范围内可能达到几十 ppm。如果项目安装环境是户外或机器内部误差记录要覆盖不同温度时段不能只测白天一段。用“固定节气”的方式每天记录一次偏差连续记录一周比只看两天的数据可靠得多。手边没有示波器时可以用 DS1302 的秒寄存器做低频验证每秒读一次秒值连续读 10 分钟检查是否严格递增。如果出现跳变比如 0x57 之后变成 0x59说明主循环读取频率或总线时序可能存在偶发问题这属于程序问题而非晶振误差。改代码前先分清“硬件走时不准”和“读取结果不对”是两回事。如果想在有限时间里快速收敛误差还有一个省事做法优先换一颗负载电容明确标注为 12.5pF 的 32.768kHz 晶振并在晶振两脚各并 6pF 电容多数开发板在这个配置下一天误差能控制在 1 秒以内。测量精度要求更高的场景才需要引入温度补偿或改用带校准寄存器的 RTC 芯片。同一个开发板上换一颗电容更匹配的晶振往往比改十行代码更有效。本文还有配套的精品资源点击获取
