有不少玩 STM32 的朋友最开始做带时间的项目,第一反应都是去翻参考手册用芯片内置 RTC。折腾一圈下来你会发现,内置 RTC 要配备份域、要理清外部低速晶振和校准逻辑,稍不留意读回来的时间就对不上。后来我干脆把目光转向外挂 RTC 芯片,DS1302 就这样进入了我常用的清单。
DS1302 是一颗非常经典的实时时钟芯片,三根线就能通信,写一个驱动大概百来行代码,结构直观、便宜,作为学习 GPIO 模拟时序的入门项目再合适不过。这篇学习笔记会把 DS1302 的驱动从原理到代码完整拆一遍:通信协议为什么这样设计、为什么我用 GPIO 模拟而不是硬件 SPI、寄存器操作和 BCD 码换算怎么写、上电实测会遇到哪些坑,最后整理成一套可以直接抄走或改着玩的开源驱动代码。正在学 STM32、想搞懂外设驱动原理又不想被芯片手册劝退的朋友,应该能从里面找到不少有用的东西。
1. 为什么 DS1302 值得写一遍学习笔记:芯片特性和选型理由
1.1 外挂 DS1302 与内置 RTC、DS3231 之间的取舍
先说结论:DS1302 不是精度最高的 RTC,也不是功能最全的,但它是我见过的驱动逻辑最适合学习的一颗。内置 RTC 省了外设成本,可它通常挂在备份电源域上,涉及 LSE 晶振、校准、备份寄存器乱七八糟一堆配置。调试环境稍有波动,就会出现上电时间归零、走时异常的情况,排查成本一点都不低。
DS3231 那种温补晶振方案精度确实高,但价格差不多是 DS1302 的几倍甚至十倍,而且 I2C 通信虽然简单,对比学习的价值来说,没那么“裸”。DS1302 则完全靠一根双向数据线移位读写,底层时序规则清晰,正好适合拿来理解“为什么芯片手册里要写那么多个上升沿和下降沿”。如果你只是想快速做出一个比别人走得准的时钟,DS3231 可能更省事;如果你想搞懂三线协议、学会从一个字节开始驱动一颗芯片,DS1302 会更适合练手。
1.2 引脚功能与最小系统接线
DS1302 的引脚不算多,但双电源设计是它一个很有特色的点。它有 VCC1 和 VCC2 两个电源脚,VCC2 接主电源,VCC1 可以接备用电池。正常工作电压 2.0V 到 5.5V,所以接 3.3V 或者 5V 都能跑。当主电源掉电后,芯片会自动切换到 VCC1 供电,这样断电后时间还能继续走。常见的 DS1302 模块基本都预留了电池座,或者有一个供你焊接电池的地方。
我平时在 F103 上接的默认引脚是 PA0、PA1、PA2,具体对应关系如下表:
| DS1302 引脚 | 接到 STM32 | 说明 |
|---|---|---|
| VCC2 | 3.3V 或 5V | 主电源 |
| GND | GND | 共地 |
| VCC1 | 纽扣电池正极或悬空 | 备份电源,掉电保持时间 |
| CE | PA0 | 片选使能,高有效 |
| SCLK | PA1 | 串行时钟输入 |
| I/O | PA2 | 双向数据线 |
| X1/X2 | 32.768kHz 晶振 | 模块上一般已焊好 |
X1 和 X2 之间要接一个 32.768kHz 晶振。如果买的是集成模块,晶振已经在板子上焊好了,不需要额外接线。自己搭电路的话还要注意匹配负载电容,对此后面精度校准部分再展开。
1.3 为什么用 GPIO 模拟时序,而不是硬件 SPI
DS1302 的三线接口从外观上看很像 SPI,不少人拿现成的硬件 SPI 模块去驱动,结果发现不好使。原因在于它并不是标准的 SPI。DS1302 要求 CE 在整个通信期间保持高电平,命令字节和数据字节在同一个片选窗口内连续完成传输,而且后面还有 burst 模式这种扩展帧。标准 SPI 的 NSS 和字节边界管理在这里反而碍手碍脚。
再加上 DS1302 的通信速率很低,单个时钟周期只要几百纳秒到几微秒,GPIO 翻转速度完全够用。用 GPIO 模拟可以精确控制每一拍的建立时间、采样点和 CE 拉高拉低的时机,出了问题也容易通过逻辑分析仪定位。所以我在驱动里直接用两个普通 GPIO 加一根双向 IO,效果非常稳定。
2. 通信底层在干什么:CE、SCLK、I/O 三根线的节奏配合
2.1 三根线各自的职责
把 DS1302 通信想象成一次门岗查验:CE 就是门禁,CE 拉低时芯片完全不理会外部数据,CE 拉高才表示“开始干活”;SCLK 是门口保安手里的节拍器,每抬一下手就检查一次;I/O 是通行证递送窗口,递一张证(一个 bit)配合一下节拍。
整个通信过程确实就是一位一位地移数据。在 CE 为高的整个期间,主控通过 SCLK 产生时钟脉冲,同时在 I/O 线上按位发送或接收数据。时钟线没有动作时,I/O 线上的数据不会被芯片关心。这也是为什么有时候你看到代码里先拉低 SCLK、再改 IO、再拉高 SCLK,本质上就是“先把数据放到窗口,再让保安看一眼”。
2.2 命令字节:地址和读写方向是如何打包的
DS1302 每次操作的第一步,都是先发送一个 8 位命令字节。这个字节最高位固定为 1,接下来 6 位是寄存器地址,最低位是读写控制位,0 表示写、1 表示读。所以同样是操作秒寄存器,写秒的地址是 0x80,读秒的地址是 0x81。
按这种规律,每个寄存器都对应一对读写命令。比如写分钟用 0x82,读分钟用 0x83;写小时用 0x84,读小时用 0x85。Burst 模式的命令则是 0xBE 写、0xBF 读,后者一次可连续传输整组时间数据。低位的读写方向位放在命令字节的最后一位,正是前面说的 LSB first 传输顺序。
2.3 写字节的时序:数据先放好,再给上升沿
写数据字节时,主控在 SCLK 的每个上升沿把 I/O 线上的数据锁存进芯片。因此每一拍的正确顺序是:先把 SCLK 拉低,准备好 I/O 电平,保持一小段时间,再把 SCLK 拉高,芯片就采到了这一位。
这里最容易犯的错是数据还没稳定就开始给上升沿。GPIO 翻转本身有延迟,编译器优化程度不同也会影响延迟,所以代码里要在 I/O 赋值之后、SCLK 拉高之前加一点延时。延时不需要很大,实测 1 微秒左右就非常稳了。
2.4 读字节的时序:下降沿输出,低电平期间采样
读数据和写数据的方向相反。命令字节仍然是在 SCLK 上升沿被芯片接收,但读命令发送完毕后,DS1302 会在随后的每一个 SCLK 下降沿把一位数据从 I/O 脚输出。主控可以在 SCLK 低电平期间读取 I/O,因为此时数据已经稳定;也可以等 SCLK 下一次上升沿之前读,只要保证在有效窗口内就行。
所以我的读字节函数里,方向切换成输入之后,每一拍的顺序是:先拉低 SCLK,制造一个下降沿,延时让芯片把数据输出到 I/O,然后读取引脚电平,再把 SCLK 拉高。由于 DS1302 的 I/O 输出不是标准三态高阻模式,STM32 内部上拉并不会干扰它,放心开上拉输入就好了。
3. 寄存器地图与 BCD 编码:驱动代码的核心逻辑
3.1 DS1302 的寄存器分布
DS1302 内部除了一堆控制寄存器之外,还带了 31 字节掉电保持 RAM 可以用来存用户数据,这一点在同类芯片里相对少见。不过学习阶段先关注时间寄存器就够用,常见寄存器映射如下:
| 功能 | 写命令 | 读命令 | 数据范围或说明 |
|---|---|---|---|
| 秒 | 0x80 | 0x81 | 00~59,bit7 为时钟暂停位 CH |
| 分 | 0x82 | 0x83 | 00~59 |
| 时 | 0x84 | 0x85 | 00~23 或 01~12 |
| 日 | 0x86 | 0x87 | 01~31 |
| 月 | 0x88 | 0x89 | 01~12 |
| 星期 | 0x8A | 0x8B | 01~07 |
| 年 | 0x8C | 0x8D | 00~99 |
| 写保护 | 0x8E | 0x8F | bit7 为 WP 写保护位 |
| 充电 | 0x90 | 0x91 | 涓流充电配置 |
| 时钟突发 | 0xBE | 0xBF | 一次连续读写 7 字节时间数据 |
秒寄存器里的 bit7 就是 CH 位。这个位写 1 会把振荡器关掉,写 0 才让时钟正常运行。很多人设置完时间发现秒不走,十有八九是 CH 位没处理对。写保护寄存器里 WP 位写 1 后,芯片会忽略所有写操作,所以在修改时间前先要关掉写保护。
3.2 BCD 码:为什么读回 0x59 而不是 59
DS1302 的时间寄存器内部存的是 BCD 码,每一个字节的高 4 位代表十位,低 4 位代表个位。比如要表示 59 秒,寄存器里存的是 0x59,十六进制显示出来是 59,但这并不等于十进制的 59。如果直接把读数用%d打印,59 秒会显示成 89 这样的怪数字。
换算公式不复杂:十进制转 BCD,等于“十位左移四位再按位或个位”。BCD 转十进制,就是“高位乘以 10 再加低位”。驱动代码里对应两个小函数,后面会写出来。强烈建议把换算封装成固定函数,不要在业务代码里到处手写(bcd >> 4) * 10 + (bcd & 0x0F),容易漏括号。
3.3 特殊控制位逐个说清楚
除了 CH 和 WP,小时寄存器的 bit7 也需要注意。这个位为 1 时芯片进入 12 小时制,bit5 表示上午或下午;为 0 时是 24 小时制。我写驱动时默认强制使用 24 小时制,设置在 0~23 的小时值不会有歧义。如果项目里读出来的时间突然出现“下午 3 点这种偏移”,先检查小时寄存器的高位是不是被写成了 1。
充电寄存器 0x90 是另一个隐蔽的坑。它默认应该保持 0x00,如果需要给备用法拉电容充电,才按手册设置充电电流。某些模块默认配置或误操作会意外开启充电,如果 VCC1 接的是不可充电的纽扣电池,长期过充可能导致电池漏液损坏板子。稳妥做法是初始化时显式写一次 0x00,除非你确实要充电。
3.4 单个寄存器的读写函数
单个寄存器的读写函数是整个驱动的基础,所有操作都逃不开这两个函数。直接看代码:
static void DS1302_Enter(void) { HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET); DS1302_DelayUs(2); HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_SET); DS1302_DelayUs(4); } static void DS1302_Exit(void) { HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_RESET); DS1302_DelayUs(2); HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET); } static uint8_t DS1302_ReadReg(uint8_t addr) { uint8_t dat; DS1302_Enter(); DS1302_WriteByte(addr | 0x01); dat = DS1302_ReadByte(); DS1302_Exit(); return dat; } static void DS1302_WriteReg(uint8_t addr, uint8_t dat) { DS1302_Enter(); DS1302_WriteByte(addr); DS1302_WriteByte(dat); DS1302_Exit(); }注意地址参数传入的是基地址,读的时候在函数内部自动把最低位置 1,这样写驱动的上层代码看起来更干净。
4. 驱动代码逐段解读:从底层时序函数到时间读写接口
4.1 GPIO 宏定义与 IO 方向切换
移植这套代码时,只需要改头文件里的引脚宏。为了让同一根 IO 既能输出命令又能输入数据,驱动里必须提供输入输出方向切换函数。在 HAL 库中可以这样写:
#define DS1302_CE_GPIO_PORT GPIOA #define DS1302_SCLK_GPIO_PORT GPIOA #define DS1302_IO_GPIO_PORT GPIOA #define DS1302_CE_PIN GPIO_PIN_0 #define DS1302_SCLK_PIN GPIO_PIN_1 #define DS1302_IO_PIN GPIO_PIN_2 static void DS1302_IO_AsOutput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DS1302_IO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, &GPIO_InitStruct); } static void DS1302_IO_AsInput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DS1302_IO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, &GPIO_InitStruct); }方向切换不是随便切的。命令字节发完之后,要等最后一个 SCLK 上升沿过去,DS1302 才会接管 IO 线输出数据。所以我在DS1302_ReadByte函数开头才切输入模式,之前保持输出模式发命令,时机正好。
4.2 字节级读写函数
一字节的读写是驱动最底层逻辑,分别实现 8 个 bit 的移位操作。写字节时,每次循环先拉低 SCLK,把对应位放到 IO 上,再拉高 SCLK。读字节时,先切输入模式,每个循环先拉低 SCLK 触发芯片输出下一位,读引脚,再拉高 SCLK。
static void DS1302_WriteByte(uint8_t dat) { uint8_t i; DS1302_IO_AsOutput(); for (i = 0; i < 8; i++) { HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); if (dat & 0x01) HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET); dat >>= 1; DS1302_DelayUs(2); HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET); DS1302_DelayUs(2); } } static uint8_t DS1302_ReadByte(void) { uint8_t i, dat = 0; DS1302_IO_AsInput(); for (i = 0; i < 8; i++) { HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); DS1302_DelayUs(2); dat >>= 1; if (HAL_GPIO_ReadPin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN) == GPIO_PIN_SET) dat |= 0x80; HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET); DS1302_DelayUs(2); } DS1302_IO_AsOutput(); return dat; }DS1302 的数据和命令都是 LSB first,也就是先传最低位。写函数里每次都判断dat & 0x01,然后右移,发完一个字节后自然是从 bit0 到 bit7 的顺序。读函数正好相反,第一次读到的位被放到 bit7,再不断右移,最终还原成一个完整字节。
4.3 时间结构体与设置、读取接口
为了让上层调用更友好,我定义了一个时间结构体,所有时间成员都用十进制表示,内部的 BCD 转换在驱动边界完成。这样业务代码里不用关心芯片内部的编码方式。
typedef struct { uint8_t year; uint8_t month; uint8_t day; uint8_t week; uint8_t hour; uint8_t min; uint8_t sec; } DS1302_Time_t; static uint8_t DS1302_BinToBcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); } static uint8_t DS1302_BcdToBin(uint8_t value) { return (value >> 4) * 10 + (value & 0x0F); }设置时间的流程有一点讲究。直接逐个写时间寄存器而不停振荡器,可能出现写入过程中内部时间跳到下一秒,导致秒和分钟数据不一致。稳妥做法是先把秒寄存器的 CH 位写成 1 停振,再写其他寄存器,最后把 CH 位清零启动振荡器。
void DS1302_SetTime(DS1302_Time_t *time) { DS1302_WriteReg(DS1302_ADDR_CTRL, 0x00); DS1302_WriteReg(DS1302_ADDR_YEAR, DS1302_BinToBcd(time->year)); DS1302_WriteReg(DS1302_ADDR_WEEK, DS1302_BinToBcd(time->week)); DS1302_WriteReg(DS1302_ADDR_MONTH, DS1302_BinToBcd(time->month)); DS1302_WriteReg(DS1302_ADDR_DAY, DS1302_BinToBcd(time->day)); DS1302_WriteReg(DS1302_ADDR_HOUR, DS1302_BinToBcd(time->hour)); DS1302_WriteReg(DS1302_ADDR_MIN, DS1302_BinToBcd(time->min)); DS1302_WriteReg(DS1302_ADDR_SEC, DS1302_BinToBcd(time->sec) | 0x80); DS1302_WriteReg(DS1302_ADDR_SEC, DS1302_BinToBcd(time->sec)); DS1302_WriteReg(DS1302_ADDR_CTRL, 0x00); }注意写法:先写一个带 0x80 的秒值,是停振;再写不带 0x80 的秒值,是启动。年份只支持后两位,比如 2025 年在代码里存 25,读取后再自行加上基准年。
读取时间可以直接挨个读寄存器,但驱动里我更推荐用一个连续读取的小技巧:先读一次,如果秒值跳变,再重读一次。因为读 7 个寄存器需要一段时间,万一读取过程中秒进位,可能读到 59 秒但是分钟已经是下一位,数据就自相矛盾了。
void DS1302_GetTime(DS1302_Time_t *time) { uint8_t sec_tmp; do { time->sec = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_SEC)); time->min = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_MIN)); time->hour = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_HOUR)); time->day = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_DAY)); time->month = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_MONTH)); time->week = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_WEEK)); time->year = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_YEAR)); sec_tmp = DS1302_BcdToBin(DS1302_ReadReg(DS1302_ADDR_SEC)); } while (sec_tmp != time->sec); }这种方案逻辑简单,实测也够用。如果希望一次传输保证整组时间来自同一个通信帧,可以使用时间突发模式。
4.4 Burst 模式:一次读完所有时间寄存器
突发模式用命令 0xBF,在 CE 为高的同一帧内连续读 7 个字节。这样可以减少多次拉高 CE 带来的时间窗口风险,但需要注意,芯片输出的顺序是固定的:秒、分、时、日、月、星期、年。
void DS1302_GetTime_Burst(DS1302_Time_t *time) { uint8_t buf[7]; DS1302_Enter(); DS1302_WriteByte(0xBF); for (uint8_t i = 0; i < 7; i++) buf[i] = DS1302_ReadByte(); DS1302_Exit(); time->sec = DS1302_BcdToBin(buf[0] & 0x7F); time->min = DS1302_BcdToBin(buf[1] & 0x7F); time->hour = DS1302_BcdToBin(buf[2] & 0x3F); time->day = DS1302_BcdToBin(buf[3] & 0x3F); time->month = DS1302_BcdToBin(buf[4] & 0x1F); time->week = DS1302_BcdToBin(buf[5] & 0x07); time->year = DS1302_BcdToBin(buf[6]); }我在这里对每个字节都做了掩码过滤,只保留有效位。秒的 CH 位、小时的 12/24 小时标志位、月份的高位标志都可能干扰显示,提前清掉更安全。突发写模式的地址是 0xBE,写之前同样要先关闭写保护,写完后最好再恢复写保护。
5. 上电实测最容易翻车的几个细节
5.1 读回来的数据全是 0xFF 或 0x00 怎么办
这是 DS1302 驱动最常见的首跑故障。数据全是 0xFF,通常是 IO 引脚悬空,或者方向切换没生效,读成了一个没有驱动的高阻电平。数据全是 0x00,多半是 CE 压根没被芯片认可,芯片一直处于关闭状态,IO 被内部结构拉低,或者接线本身就不对。
我的排查顺序一般是:先量 CE、SCLK、I/O 三根线是不是都在主控和模块之间确实连通;然后把逻辑分析仪接在 SCLK 和 I/O 上,看命令字节发出去之后有没有波形;最后再检查 IO 方向切换时机。很多人在 ReadByte 函数里先切输入没错,但如果不小心切得太早,命令字节还没发完,IO 线上的电平就会被 DS1302 误读。方向切换必须在写命令和读数据边界上严格分开。
5.2 时间设置成功,但秒数就是不走
代码写进去后秒数纹丝不动,第一检查项是秒寄存器的 CH 位。如果你写秒时传入了带 0x80 的值,振荡器就被暂停了。驱动里写完时间后必须再把 CH 位清零,否则时间永远是静止的。第二检查项是晶振。DS1302 的 32.768kHz 晶振如果虚焊或者质量不行,芯片一样不工作。
还有一个容易被忽略的现象:DS1302 刚上电时振荡器启动需要一点时间,可能约 1 秒左右才会有秒跳动。如果你上电后立刻读时间,看到秒不变,不要急着怀疑驱动,稍等一两秒再读。若晶振引脚接触不良导致起了振但幅度不够,秒位还是会隔几秒跳一次或者走时明显偏慢,此时用示波器量一下晶振引脚波形最直观。
5.3 读出的数字乱码,显示成 0x59 样式
乱码问题几乎都是 BCD 换算没做。直接把寄存器里的 0x59 当十进制整数 printf,显示成 89 是必然的。反过来,设置时间时如果把十进制 59 直接写进寄存器,芯片会把它当成 BCD 码 0x59,也就是十进制的 89 秒,时间自然完全不正确。所以我强烈建议所有 BCD 换算都收敛在驱动层,应用层拿到的一定是十进制时间。
5.4 12 小时制和 24 小时制的混乱
小时寄存器在 12 小时制下和 24 小时制下的数据位定义不一样。如果你在设置时间时没管高位,芯片可能处于 12 小时制,下午 3 点读回来就成了 3 这种看起来很正常的数,但实际它代表的是 15 点,后面判断逻辑全错。我在驱动里初始化时固定用 24 小时制,并且读写小时时都对高位做掩码处理。一旦发现时间显示总是和真实时间差 12 小时,优先查这个位。
6. 让驱动真正稳定运行:主循环调度与晶振精度
6.1 主循环中怎么合理调用读取接口
驱动写好之后,调用方式很自由。最简单的主循环逻辑是延时后反复调用DS1302_GetTime,然后通过串口打印。但我不建议在主循环里高频读取,芯片本身每一帧通信要几十微秒,100 次循环读一次和 1 次循环读一次差别不大,反而白白占用 IO 和 CPU。一般 500 毫秒到 1 秒读一次就足够了。
更进阶一点的做法是,用一个秒级定时器或者 RTC 闹钟中断去触发读取,再把时间数据放到一个共享结构体供主循环显示。这样即使主循环里在处理耗时任务,时间采集也不会被拖住。即使不用中断,只要保证读取函数一次性执行完并且加了连续读校验,数据一致性也是有保障的。
6.2 连续读校验的实现细节
前面代码里已经用了连续读校验:先读一组数据,再读一次秒寄存器,如果两次秒值不一致,说明读取过程中发生过秒跳变,整组数据作废重读。这个思路简单可靠,不需要引入突发模式也能保证数据一致性。如果你使用了突发模式,因为 7 个字节是在同一帧内连续读的,本质上已经消除了多次片选之间的时间差,但一次帧读本身也可能跨秒,只不过概率小很多。要求更高时可以突发读两次再比较秒值。
6.3 DS1302 的精度问题与校准思路
坦率说,DS1302 的走时精度非常依赖外部晶振。普通 32.768kHz 晶振在室温下一天误差几秒甚至几十秒都是正常的。如果项目对时间精度有一定要求,我建议从这几个方向入手。
硬件方面,检查晶振的负载电容是否匹配。晶振规格书里通常会写负载电容,比如 12.5pF,PCB 上就要用合适容量的对地电容。DS1302 某些模块设计得比较随意,电容不匹配会造成频率偏差。你也可以用带微调电容的电路,配合一段时间观测去微调。
软件方面,最简单有效的方案是定期用网络时间校时,或者在系统里记录一个漂移系数,计算出每天大约快几秒后,每天固定时间回拨一次。这个补偿值通常需要在真实环境下跑一两天才能测准,不过对于大多数学习项目,能稳定走时且误差在可接受范围内,就已经够用了。如果精度要求真的很高,建议换带温度补偿晶振的 RTC 方案,DS1302 更适合低成本入门和原理学习。
说实话,DS1302 的驱动写到这个程度,整套代码也就两三百行,却把 GPIO 模拟时序、移位寄存器操作、BCD 码、寄存器读写、双电源备份这些嵌入式基础概念全串起来了。我后来写其他外设驱动,遇到 I2C、SPI 芯片时,很多思路都是从这个项目迁移过去的。这套代码从协议到工程实现都足够稳定,如果你需要修改引脚或者移植到其它型号的单片机,只需要替换头文件里的宏定义和 GPIO 初始化函数即可。
最后分享一个小经验:如果你用的是带电池座的 DS1302 模块,首次上电设置完时间之后,最好把写保护寄存器清零或者恢复为关闭状态,然后测量一下 VCC1 和 GND 之间的电压,确认电池确实能给芯片供电。我遇到过很多次“断电后时间丢失”的情况,排查到最后都是电池座氧化或者电池根本没夹紧。这些小细节,比驱动代码本身更容易让人掉头发。