1. 项目缘起与整体设计思路
1.1 为什么还要折腾 DS1302 这颗老芯片
先说结论:DS1302 是一颗很老的实时时钟芯片,走的是三线串行接口,成本低、资料多、上手门槛不高,但它并不是当下最省心的方案。现在做 RTC,很多人会直接选带 I2C 接口的 DS3231,精度更好,或者干脆用 MCU 内部 RTC 加一颗纽扣电池。那为什么还要专门写一篇 STM32 驱动 DS1302 的笔记?原因很现实——你手上可能正好有一批 DS1302 模块,或者某个老项目、课程设计、维修场景里就是这颗芯片,换不掉,只能把它驱动起来。
我这次的目标很明确:用 STM32 的普通 GPIO,模拟 DS1302 的三线时序,实现时间读写、初始化、走时校准这一整套功能,并且把代码整理成可复用的驱动模块。所谓三线,指的是CE(片选/复位)、SCLK(串行时钟)、I/O(数据线)这三根线。注意它和标准 SPI 不一样,数据线是双向的,读写要切换方向,时序也有自己的脾气,不能直接套用硬件 SPI 外设。
这个项目适合谁?如果你正在学 STM32 的 GPIO 操作、想搞懂软件模拟时序到底是怎么回事,或者手头有个带 DS1302 的板子想把它跑起来,那这篇笔记基本可以照着抄。它不要求你有多深的嵌入式功底,但需要你会用 Keil 或 STM32CubeIDE 建工程、会点 C 语言、能看懂基本的时序图。
1.2 整体方案选型:软件模拟还是硬件外设
DS1302 的通信协议是私有三线协议,STM32 的硬件 SPI 虽然也是同步串行,但它的数据线方向固定、时钟极性相位配置和 DS1302 对不上,硬套会非常别扭。所以主流做法就是用 GPIO 软件模拟时序。这个选择的代价是占用 CPU 时间,但 DS1302 的通信速率本来就不高,一次读写也就几十个微秒级别,对绝大多数应用来说完全无感。
具体到 GPIO 配置,我的做法是:CE 和 SCLK 配成推挽输出,I/O 配成开漏输出或者动态切换输入输出。这里有个细节值得说清楚——DS1302 的 I/O 是双向的,写数据时 MCU 输出,读数据时 MCU 输入。有两种处理方式:一种是把 I/O 脚配置成开漏输出加外部上拉,读的时候直接读引脚电平;另一种是每次读写前动态切换 GPIO 方向。我实测下来,动态切换方向更稳,因为不依赖外部上拉电阻的一致性,尤其是在一些廉价模块上,上拉电阻阻值偏差大,开漏方式容易读错。
整个驱动的分层也很简单:底层是 GPIO 操作和微秒级延时,中间层是字节读写和寄存器读写,上层是时间结构体的转换和 BCD 码处理。这样分层的好处是,换一个 MCU 平台,只需要改底层 GPIO 和延时,上层逻辑原封不动。
2. DS1302 核心细节与时序要点解析
2.1 寄存器结构和 BCD 码这两个坑
DS1302 内部有一片寄存器区,用来存秒、分、时、日、月、周、年,还有写保护寄存器和涓流充电寄存器。这里第一个大坑就是BCD 码。它存的时间不是二进制,而是用十六进制形式表示的十进制数。比如“23 分”,寄存器里存的是 0x23,而不是十进制的 23(也就是 0x17)。所以你从寄存器读出来之后,必须做 BCD 到十进制的转换,写进去之前又要转回 BCD。
我见过不少新手直接把读到的 0x23 当成 23 用,结果时间显示全乱。转换逻辑其实很简单:BCD 转十进制就是(高四位 * 10) + 低四位,十进制转 BCD 就是((值 / 10) << 4) | (值 % 10)。但要注意,小时寄存器还有 12/24 小时制的问题,第 7 位是 12/24 制选择位,第 6 位在 12 小时制下是 AM/PM 标志。如果你不处理这一位,读出来的小时可能莫名其妙多出 0x40 或 0x80。
第二个坑是写保护寄存器。DS1302 上电后,写保护位默认可能是打开的,这时候你写时间根本写不进去,读出来还是老值。所以初始化流程里,第一步必须是关闭写保护(往地址 0x8E 写 0x00),改完时间后再根据需要决定是否重新打开。这个顺序不能反。
2.2 三线时序的读写时机
DS1302 的通信是低位先出(LSB first),这一点和很多 SPI 器件相反,写字节的时候要从 bit0 开始一位一位送。每个 bit 在 SCLK 的上升沿被 DS1302 采样,所以你在拉高 SCLK 之前就要把数据准备好。读的时候则相反,DS1302 在 SCLK 的下降沿输出数据,你需要在下降沿之后去读 I/O 线的电平。
命令字的格式也要记牢:bit0 是读写方向位(1 读 0 写),bit1 到 bit6 是寄存器地址,bit7 固定为 1。比如读秒寄存器,地址是 0x80,读操作就是 0x81;写秒寄存器就是 0x80。这个命令字本身也是低位先发。很多人第一次写驱动,时序怎么都不对,八成是命令字的方向位或者地址搞错了。
还有一点,CE 引脚在每次通信开始时要拉高,通信结束后拉低。CE 拉高的瞬间,如果 SCLK 是高电平,有些批次的芯片会误判,所以稳妥的做法是先把 SCLK 拉低,再拉高 CE。通信结束后,先拉低 CE,再处理 SCLK。这些细节数据手册里画得比较简略,但实际调试时非常关键。
2.3 突发模式与单字节模式的取舍
DS1302 支持单字节读写,也支持时钟突发模式(Burst Mode)。突发模式一次可以把秒到年的 7 个字节连续读出来或者写进去,地址是 0xBE(读)和 0xBE(写,方向位不同)。用突发模式的好处是,读的时候能保证这 7 个字节是同一时刻的,不会出现读到一半时间进位、导致秒和分对不上的情况。
我的建议是:读时间一律用突发模式,写时间可以用单字节逐个写,也可以用突发模式。如果你对时间一致性要求高,比如做数据记录,那读写都用突发模式最省心。单字节模式更适合只改某一个字段,比如只调分钟,不想动其他寄存器。
3. 实操过程与核心代码实现
3.1 GPIO 初始化与微秒延时
先看底层。我用的是 STM32F103 这类常见型号,GPIO 操作直接调 HAL 库。CE 和 SCLK 配成推挽输出,初始电平都拉低。I/O 脚我配成开漏输出,同时使能内部上拉,读的时候直接读输入寄存器。如果你用的是动态切换方向的方式,那就在写之前配成输出,读之前配成输入。
延时这块,DS1302 的时序要求并不苛刻,SCLK 高电平时间和低电平时间最小都在 250ns 左右,所以微秒级延时绰绰有余。我用的是简单的循环延时,或者调 DWT 周期计数器做精确延时。下面是一个典型的微秒延时函数思路:
void ds1302_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }注意:DWT 需要先使能,否则 CYCCNT 不走。如果你不想折腾 DWT,用
for循环空转也行,但要注意编译器优化等级,优化开高了循环可能被优化掉,加个volatile变量或者用__NOP()更保险。
3.2 字节读写函数的实现
写一个字节的流程是:CE 拉高,然后循环 8 次,每次把当前 bit 放到 I/O 线上,拉高 SCLK,延时,拉低 SCLK,延时。注意是低位先发,所以第一次送的是 bit0。读一个字节则是:CE 拉高,循环 8 次,每次拉低 SCLK,延时,读 I/O 线电平存到结果的对应位,再拉高 SCLK。这里读的时机是在 SCLK 低电平期间,因为 DS1302 在下降沿输出数据。
void ds1302_write_byte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { DS1302_IO_OUT(); if (dat & 0x01) DS1302_IO_HIGH(); else DS1302_IO_LOW(); ds1302_delay_us(2); DS1302_SCLK_HIGH(); ds1302_delay_us(2); DS1302_SCLK_LOW(); ds1302_delay_us(2); dat >>= 1; } } uint8_t ds1302_read_byte(void) { uint8_t dat = 0; for (uint8_t i = 0; i < 8; i++) { dat >>= 1; DS1302_IO_IN(); DS1302_SCLK_LOW(); ds1302_delay_us(2); if (DS1302_IO_READ()) dat |= 0x80; DS1302_SCLK_HIGH(); ds1302_delay_us(2); } return dat; }写完一个字节后,如果是写操作,要把 I/O 线释放,避免和 DS1302 的输出冲突。读操作结束后,CE 拉低,通信完成。
3.3 时间读写与 BCD 转换
有了字节读写,寄存器读写就是先发命令字,再读写数据。写寄存器时,先写命令字(方向位为 0),再写数据;读寄存器时,先写命令字(方向位为 1),再读数据。注意命令字和数据的发送之间,CE 要保持高电平。
时间结构体我定义成包含年、月、日、周、时、分、秒的struct。读时间用突发模式,一次读 7 个字节,然后逐个做 BCD 转十进制。写时间则先关写保护,再用突发模式写 7 个字节,最后根据需要恢复写保护。
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; uint8_t bcd_to_dec(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } uint8_t dec_to_bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }实操心得:读出来的小时如果大于 0x23,先检查第 7 位是不是 12 小时制标志。如果是,要先把第 7 位和第 6 位处理掉再转 BCD。我一般初始化时直接写成 24 小时制,省得后面麻烦。
3.4 初始化流程与走时验证
初始化顺序我总结成四步:第一,拉低 CE 和 SCLK,给芯片一个确定的起始状态;第二,关闭写保护;第三,写入初始时间(比如当前编译时间或者固定值);第四,根据需要打开写保护。写完时间后,读回来对比一遍,确认写入成功。
走时验证很简单:初始化一个时间,然后每隔一秒读一次,看秒是不是在正常递增,分和时有没有在进位时出错。我一般会连续观察几分钟,重点看 59 秒到 0 秒、59 分到 0 分这些进位点。如果发现秒跳变异常,多半是 BCD 转换或者突发模式读取的时序有问题。
4. 常见问题与排查技巧实录
4.1 读写全为 0 或全为 0xFF 怎么办
这是最典型的问题。读出来全是 0 或者全是 0xFF,说明通信根本没建立起来。排查顺序我建议这样:先用示波器或者逻辑分析仪看 CE、SCLK、I/O 三根线的波形,确认 CE 有没有正常拉高拉低,SCLK 有没有 8 个脉冲,I/O 线上有没有数据变化。如果没有仪器,就用万用表测静态电平,确认接线没接反、没虚焊。
如果波形正常但数据还是不对,检查命令字。命令字的 bit7 必须是 1,bit0 是方向位,地址位有没有写错。我踩过一次坑,把秒寄存器的地址 0x80 当成了 0x00,结果怎么读都不对。另外,I/O 线的方向切换时机也很关键,写的时候如果没切成输出,数据根本送不出去。
4.2 时间不走或者走时不准
时间不走,先看晶振。DS1302 需要外接 32.768kHz 晶振,如果晶振没起振,时间就停着。用示波器测晶振引脚,看有没有正弦波。如果没有,检查晶振是不是坏了、负载电容是不是匹配。有些廉价模块为了省成本,晶振和电容质量很差,走时一天差几秒很正常。
走时不准还有一个原因是涓流充电寄存器配置不当。如果你用了可充电的纽扣电池或者超级电容,涓流充电寄存器要正确配置,否则电池充不上电,断电后时间就丢了。如果用的是不可充电的 CR2032,那涓流充电必须关闭,否则可能损坏电池。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读出全 0 | CE 没拉高、SCLK 无脉冲 | 查接线、查 GPIO 初始化 |
| 读出全 0xFF | I/O 线一直高、方向切换错误 | 查 I/O 方向、查上拉 |
| 时间不走 | 晶振未起振、电池没电 | 测晶振波形、测电池电压 |
| 写入无效 | 写保护未关闭 | 先写 0x8E 地址写 0x00 |
| 秒进位错误 | BCD 转换错误 | 检查转换函数、检查突发模式 |
| 小时异常 | 12/24 小时制位未处理 | 检查小时寄存器 bit7 |
避坑技巧:调试 DS1302 时,先不要急着读时间,先做一个“写一个字节再读回来”的测试。比如往某个 RAM 寄存器写 0x5A,再读出来看是不是 0x5A。这个测试能快速判断通信链路是否正常,比直接读时间高效得多。
4.4 电源与抗干扰的实战经验
DS1302 对电源比较敏感,尤其是主电源和备份电源切换的瞬间。如果主电源掉电太快,芯片可能来不及切换到备份电源,导致时间丢失。我的做法是在主电源和备份电源之间加一个肖特基二极管做隔离,再并一个 0.1uF 的电容滤波。这样切换更平滑。
另外,三根信号线如果走线比较长,最好串一个 100 欧姆左右的电阻,减少振铃和反射。我在一块板子上遇到过 SCLK 上升沿过冲导致误触发的情况,串了电阻之后就稳了。这些细节在数据手册里不会写,但实际做产品时非常有用。
5. 驱动封装与复用建议
5.1 把驱动做成可移植的模块
如果你想让这份驱动在多个项目里复用,建议把底层 GPIO 操作和延时抽出来,用宏或者函数指针的方式暴露给上层。比如定义DS1302_IO_HIGH()、DS1302_IO_LOW()、DS1302_IO_READ()这些宏,换平台时只改这几个宏和延时函数,上层的时间读写逻辑完全不用动。
我一般会把驱动分成两个文件:ds1302.c和ds1302.h。头文件里放时间结构体、函数声明和可配置的宏,源文件里放具体实现。这样别人拿到你的驱动,看一眼头文件就知道怎么用,不需要去翻源文件。
5.2 时间格式与上层应用的衔接
DS1302 存的是两位年份,比如 24 表示 2024 年。上层应用如果需要完整年份,可以在读取后加上 2000。但要注意,如果你的产品要跨世纪,这个逻辑就得改。我一般会在结构体里直接存完整年份,读取时做转换,这样上层用起来更直观。
另外,星期寄存器 DS1302 只存 1 到 7,具体哪天是星期几由你自己定义。我习惯把星期日定义为 1,星期六定义为 7,和很多日历应用保持一致。这个映射关系要在代码里写清楚,不然后面维护的人会懵。
5.3 低功耗场景下的注意事项
如果你的设备是电池供电,DS1302 的功耗也要考虑。它在工作时的电流大概几百微安,走时电流只有几百纳安级别。但如果你频繁读时间,MCU 和 DS1302 的通信本身也会耗电。我的做法是:不需要显示时间的时候,让 MCU 进入低功耗模式,DS1302 继续走时,需要的时候再唤醒读取。这样整体功耗能压得很低。
还有一点,DS1302 的 CE 引脚在不通信时要保持低电平,否则芯片可能进入不确定状态,增加功耗。这个细节在初始化时就要处理好,别让它悬空。
6. 写在最后的几点个人体会
这个 DS1302 驱动我前前后后改过好几版,最早的一版时序不对,读出来的时间全是乱码,后来对着逻辑分析仪的波形一点点调,才把时序摸清楚。我的体会是,软件模拟时序这类东西,波形比代码更重要。你把波形看懂了,代码自然就写对了;波形没搞明白,代码怎么写都是碰运气。
另外,DS1302 虽然老,但它把 RTC 的基本概念都涵盖了:BCD 码、写保护、突发模式、备份电源。把这颗芯片吃透,再去看 DS3231 或者其他 RTC,会发现很多概念是相通的。所以如果你手头正好有 DS1302,别嫌它老,拿它练手其实挺合适。
最后分享一个小技巧:如果你没有逻辑分析仪,可以用 STM32 的定时器输入捕获功能,把 SCLK 和 I/O 的波形抓下来,通过串口打印出来看。虽然不如专业仪器直观,但调试时序问题足够用了。这个方法我在没有仪器的环境下用过很多次,屡试不爽。