1. 从一颗老芯片说起整体方案与选型拆解STM32 驱动 DS1302 这个组合在论坛里出现的频率高得离谱但绝大多数帖子只贴一段代码就完事时序为什么这么写、电容要不要焊、电池会不会充爆全都没人讲。我这次把自己在几个量产小设备上反复用过的这套驱动整理出来包含硬件连接、寄存器地图、软件时序、掉电恢复和排查手法代码基于 HAL 库 STM32F103C8T6 实测换成 F4、G0、L4 只需要改引脚宏定义。看完你应该能独立把 DS1302 跑起来并且知道它为什么容易出问题、出问题该往哪儿找。DS1302 是那种便宜到不像话、能活二十年的外置实时时钟芯片三根线就能跟 STM32 通信一根使能 CE有的封装叫 RST、一根双向数据 I/O、一根串行时钟 SCLK。它自己带 32.768kHz 振荡电路、带 31 字节掉电不丢的静态 RAM、带一套涓流充电电路给后备电池慢慢补电。你需要它的时候通常只有一个原因——STM32 主电源断了时间还得继续走。下面这套内容适合几类人刚学完 GPIO 点灯、想找个真实外设练手的入门者做数据记录仪、定时开关、宠物喂食器这类需要时间戳的开发者以及手里已经有一块 DS1302 模块、读出来全是 0xFF 或者 0x80 正在抓头的人。我会把为什么这么写讲透而不是只给一段能跑的代码。1.1 DS1302 这颗芯片到底解决了什么问题先说清楚它存在的意义不然很容易觉得STM32 不是自带 RTC 吗干嘛还外挂一颗。STM32 内部 RTC 本质上是一个计数器加一组备份寄存器主电源掉电后靠 VBAT 引脚上的纽扣电池维持。听起来够了但实际用起来有几个绕不过去的坑第一内部 RTC 依赖 LSE 外部晶振而 LSE 起振不良是 STM32 圈子里出了名的老毛病尤其在湿度大、板子洗不干净、晶振走线过长的情况下起振失败率相当可观第二内部 RTC 的日历寄存器是二进制格式跨月跨年的边界处理得自己写稍不留神二月就变成 30 天第三也是最要命的一点很多同学画板子的时候 VBAT 压根没接电池或者接了电池但没注意 VBAT 和 VDD 之间的隔离结果主电源一断时间直接归零。DS1302 把这三件事都包了晶振电路内置、日历寄存器和闰年补偿内置、双电源引脚自动切换内置。你只需要给它一个 3V 纽扣电池它就能在主控完全断电的情况下继续数秒一年误差在分钟级别。这就是它二十年不倒的原因——不是因为它先进而是因为它把掉电保持时间这件事做成了一个不用思考的模块。成本也值得一提。散买一颗 DS1302 加一个 32.768kHz 晶振总价通常不到两块钱比带温补的 RTC 芯片便宜一个数量级。精度上当然比不过 DS3231 那种带温度补偿的型号但对误差一天几十秒也无所谓的场景它完全够用。1.2 为什么我最终选了软件模拟三线时序DS1302 的通信协议长得很像 SPI但它不是标准 SPI数据是 LSB 先出命令字节和数据字节之间没有片选拉低再拉高的过程读数据时主机还要在时钟下降沿之后去采样。如果你硬要用 STM32 的硬件 SPI 去驱动会遇到几个麻烦事。第一个麻烦是位序。STM32 的 SPI 支持 LSB First这一点倒是能对上。第二个麻烦是读写切换。DS1302 的一次完整操作是先写命令字节再立刻读或写数据字节中间 CE 必须一直保持高电平而 HAL 库的 SPI 收发函数每次调用都会自己控制 NSS时序中间会插入额外的间隙。第三个麻烦是速度。DS1302 的 SCLK 最高只支持 2MHz而且是在 5V 供电下3.3V 供电时数据手册建议再降一档用 STM32 的硬件 SPI 跑起来还得专门分频配置成本比自己写时序还高。所以主流做法是拿三个普通 GPIO 手动翻转也就是常说的软件模拟时序。这样做的好处很直接时序完全可控想加多少延时加多少延时调试的时候拿逻辑分析仪一抓波形一目了然移植的时候只要改三个宏定义任何 STM32 型号甚至 STC51 都能跑。代价是占用 CPU但 DS1302 一次读七个字节也就几百微秒每秒读一次占用率可以忽略。我个人的做法是把 I/O 引脚配置成开漏输出模式GPIO_MODE_OUTPUT_OD而不是在输入和输出之间来回切模式。原因后面第 4 章会细讲简单说就是开漏模式下引脚既能输出低电平、又能直接读回外部电平省掉了一次寄存器重配置的开销在时序紧张的时候非常管用。1.3 这套驱动的分层结构写驱动最忌讳把所有东西塞进一个 main.c。我给这套代码分了四层从下往上依次是引脚宏定义层、微秒延时层、字节读写层、时间结构体层。引脚宏定义层负责把三个引脚的名字抽象出来只改这一处就能换板子。微秒延时层是整套代码的地基DS1302 对延时不像单总线器件那么敏感但延时乱了照样读不出数。字节读写层封装了写命令、写数据、读数据这三个动作是整个驱动的心脏。最上面的时间结构体层把 BCD 码转换、合法性校验、突发模式读写都包起来给应用层一个干净的接口。这么分层还有一个隐藏好处当通信出问题的时候你可以从下往上一层一层验证。先测延时准不准再测写命令字节能不能把 CE 拉高再拉低最后测读回来的字节。定位速度比对着一个两千行的 main.c 找快得多。2. 硬件连接引脚、电容和电池的细节软件写错还能改硬件焊错就得拆板子所以这一章我必须写在代码前面。DS1302 的外围电路看着简单就三个引脚加一个晶振加一个电池但每一处都有讲究尤其是晶振和电池这两块踩过坑的人不少。2.1 三根线的接法与上拉电阻先给一张接线表以 STM32F103C8T6 为例具体引脚可以按自己板子的空闲情况换。信号DS1302 引脚STM32 引脚说明使能CERSTPA4普通推挽输出即可数据I/OPA5开漏输出必须上拉时钟SCLKPA6普通推挽输出即可主电源VCC23.3V系统电源备份电源VCC1纽扣电池正极通常 3V地GNDGND共地关键点在于 I/O 这根线。DS1302 的数据引脚是开漏结构芯片内部只能把它拉低不能主动拉高。也就是说如果没有上拉电阻你写1的时候线上其实是悬空的读回来的值完全随机。很多模块板子上已经焊了 10kΩ 的上拉电阻但如果你是自己画的板子一定记得补上典型值取 4.7kΩ 到 10kΩ。电阻越小上升沿越陡通信速率可以越高但静态功耗也越大用纽扣电池供电的场景宁可取大一点10kΩ 更省电。CE 和 SCLK 这两根线是芯片的输入不需要上拉直接推挽输出就行。不过我在实际布板的时候习惯在 CE 上也加一个 10kΩ 下拉目的不是通信而是防止 MCU 复位期间引脚处于高阻态时 CE 悬空被环境噪声干扰出一些莫名其妙的脉冲。这个下拉不加也能跑但如果你做的设备需要过一些干扰测试加上它成本几乎为零。还有一个容易被忽略的点这三个引脚的走线尽量短尤其别和电机驱动、继电器、开关电源的走线平行。DS1302 的通信速率低、驱动能力弱几厘米的平行走线就足以把通信打乱。我在一个带继电器的项目上就吃过这个亏单独测试一切正常继电器一吸合时间就跳到 2000 年后来把三根线从继电器旁边挪开问题直接消失。2.2 32.768kHz 晶振与负载电容的取舍这是整个硬件部分最容易出问题的地方我把它单独拎出来讲。DS1302 内部已经集成了振荡电路外部只需要接一个 32.768kHz 的晶振。数据手册明确写了这个振荡器是按6pF 负载电容设计的。市面上的 32.768kHz 圆柱晶振绝大多数也是 6pF 规格或者 12.5pF 规格买的时候一定要看清楚。那要不要在晶振两端各接一个电容到地答案是大多数情况下不接或者接了反而更糟。原因是这样算的。晶振的负载电容公式是CL (C1 × C2) / (C1 C2) Cstray其中 C1、C2 是两端对地的电容Cstray 是 PCB 走线和芯片引脚的杂散电容通常在 2pF 到 5pF 之间。如果目标 CL 是 6pF取 Cstray 为 4pF那么 (C1 × C2) / (C1 C2) 只需要 2pF两个电容对称的话每个只有 4pF。这个数值已经接近 PCB 本身的杂散电容了也就是说光靠走线的寄生电容就差不多够了。实际经验是先不焊电容让它跑一天看误差。如果一天快十几秒说明负载电容偏小可以在两端各并一个 3pF 到 6pF 的小电容试试如果一天慢十几秒说明电容偏大或者晶振本身有问题把电容去掉再测。千万别一上来就照着某个教程焊两个 22pF那会让时钟慢得离谱一天慢几分钟都是常有的事。晶振本身的质量也得看。同是 32.768kHz几毛钱的那种温度特性很差夏天和冬天能差出小半分钟。如果项目对精度有要求选带温度补偿的 RTC 芯片更实际DS1302 就不是为高精度场景设计的。2.3 备份电池与涓流充电寄存器的陷阱DS1302 有两个电源引脚VCC1 接备份电池VCC2 接系统主电源。芯片内部有个比较器谁的电压高就用谁。所以正常情况下主电源在的时候用 VCC2主电源掉了自动切到 VCC1。这里有个真实的坑如果主电源是 3.3V备份电池是满电的 CR2032两者电压可能非常接近。CR2032 标称 3.0V但全新开路电压经常能到 3.2V 甚至 3.3V。一旦电池电压等于或超过主电源电压切换逻辑就会变得不可靠可能出现主电源在线时反而在放电、或者切换时抖动导致时间跳变。稳妥的做法是主电源用 5V或者干脆选 2.4V 到 3.0V 的电池把压差拉开到 0.3V 以上。用 3.3V 系统的话我一般会在 VCC2 和 VCC1 之间加一个肖特基二极管做隔离保证电源路径清晰。再来说涓流充电。DS1302 有一个涓流充电寄存器地址是 0x90它允许主电源通过内部的两个可选二极管和可选电阻给备份电池缓慢充电。寄存器各位的含义是这样位名称含义bit7TCS1 表示使能充电0 表示关闭bit6~4DS101 表示一个二极管110 表示两个二极管bit3~2RS01 表示 2kΩ10 表示 4kΩ11 表示 8kΩbit1~0保留必须写 0把使能 一个二极管 2kΩ拼起来就是1_101_01_00即 0xD4。这个值写进去之后主电源就会通过一个二极管和 2kΩ 电阻给电池充电。但请一定注意如果你的备份电池是不可充电的 CR2032绝对不能打开涓流充电。CR2032 被持续充电会鼓包、漏液严重的会炸开把主板腐蚀掉。只有明确标注可充电的 LIR2032 或者镍氢纽扣电池才允许开启充电功能。如果你的板子上焊的是普通 CR2032那么 0x90 这个寄存器要么不写要么写 0x00 明确关闭。我见过太多模块卖家在示例代码里无脑写 0xAB 之类的充电使能值买家配上普通 CR2032 用半年电池就废了。这类细节数据手册里写得清清楚楚但示例代码里从来不说。3. 寄存器地图与协议细节逐个拆开硬件搭好之后就该跟芯片对话了。DS1302 的协议不复杂但有几个位让人抓狂秒寄存器的最高位不是秒数据而是时钟停止位小时寄存器最高位不是小时数据而是 12/24 小时制标志读回来的数是 BCD 码不是二进制。这一章把这些东西一个个拆开。3.1 七个时间寄存器与 BCD 编码规则DS1302 的寄存器地址从 0x80 开始最低位是读写标志位最低位为 0 表示写为 1 表示读。所以同一个时间字段会有两个地址比如秒寄存器写地址是 0x80读地址是 0x81。字段写地址读地址取值范围有效位秒0x800x8100~59bit6~0bit7 是 CH分0x820x8300~59bit6~0时0x840x8500~23bit5~0bit7 是 12/24 标志日0x860x8701~31bit5~0月0x880x8901~12bit4~0周0x8A0x8B01~07bit2~0年0x8C0x8D00~99bit7~0数据格式全部是BCD 码也就是用十六进制的形式表示十进制数。比如十进制 25 分写进寄存器的是 0x25而不是 0x19。这一点是新手最容易搞混的地方你写0x25进寄存器读出来也是0x25但要显示给人看的时候必须转成十进制 25否则显示出来就是 37。转换代码非常简单/* BCD 转十进制 */ static uint8_t BCD2DEC(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); } /* 十进制转 BCD */ static uint8_t DEC2BCD(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); }我在很多项目里干脆不做转换全程用 BCD 存只在最后显示的时候转一次。这样做的好处是省一层数据变换减少出错机会缺点是代码可读性差一点。如果时间要参与运算比如算两个时间点之间的间隔那就老老实实转成十进制再算。周寄存器的取值是 1 到 7代表星期日到星期六具体哪一天是 1 由你自己定义芯片不关心。这个字段纯粹是给你做显示的芯片内部的日期进位只依赖年月日周几不会自动跟着跳。所以如果你用到了周几必须自己在设置时间的时候算对否则读回来永远是错的。3.2 秒寄存器的 CH 位与写保护寄存器秒寄存器的 bit7 有一个专门的名字叫CHClock Halt。这个位是 1 的时候芯片的振荡器被停止时间不走是 0 的时候正常计时。这个设计本来是为了省电但给使用者挖了个大坑新出厂的 DS1302CH 位默认是 1。也就是说如果你只写了秒、分、时这些字段却没有清零 CH 位芯片会一直停在那里不动你会以为是通信坏了其实是时钟根本没启动。所以在设置时间的时候秒寄存器写入的值必须先和 0x7F 做与运算把最高位清掉DS1302_WriteByte(0x80, DEC2BCD(t-second) 0x7F); /* 清 CH 位启动时钟 */读的时候也一样读回来的秒数据要把最高位屏蔽掉再用t-second BCD2DEC(DS1302_ReadByte(0x81) 0x7F);反过来如果你想让时钟停下来比如做运输模式省电把 CH 位置 1 就行。另一个必须处理的是写保护寄存器地址 0x8E读写都是这个地址因为 bit0 在写操作里是 0。这个寄存器的 bit7 是 WP 位为 1 时所有写操作被禁止为 0 时允许写。上电复位后它的默认值是 1。这意味着每次改时间之前你必须先往 0x8E 写 0x00 解锁改完再写 0x80 上锁。解锁和上锁是成对出现的很多人只解锁不上锁结果某次电源波动把寄存器内容冲掉了自己还不知道。上锁的习惯值得养成它能让你的时间在被干扰时多一层保护。3.3 突发模式与 31 字节 RAM 的用法一个一个寄存器读七个字段要发七次命令每次都要 CE 拉高再拉低。虽然也能用但有个致命问题如果读的过程中正好跨越了秒进位你可能会读到 12:59:59 里的一部分和 13:00:00 里的一部分拼出一个不存在的时间。DS1302 提供了**突发模式Burst Mode**来解决这个问题。命令地址是 0xBE写和 0xBF读发出这个命令之后连续读或写 8 个字节芯片会自动按秒、分、时、日、月、周、年的顺序依次收发。这 8 个字节的收发过程中芯片内部的寄存器副本是冻结的不会出现进位竞争。注意突发模式一次读 8 个字节但第 8 个字节是写保护的哑字节读出来是 0x00不要当成有用的数据。写的时候可以顺便把写保护寄存器的值一起写进去。突发模式还有一个实际好处它把 7 次 CE 拉高拉低压缩成了 1 次通信时间缩短到原来的七分之一被中断打断的概率大幅下降。我现在的代码里读时间是清一色走突发模式的只有调试的时候才会单独读某个寄存器。至于那 31 字节 RAM地址从 0xC0 开始每个字节的地址间隔是 20xC0、0xC2、0xC4……一直到 0xFC因为它们的最低 bit 仍然是读写标志位。突发读写整个 RAM 的命令是 0xFE 和 0xFF。这块 RAM 是掉电不丢的主电源断了靠电池维持非常适合存放设备序列号、累计运行时间、用户配置参数这类不能丢但数据量很小的东西。我的一个环境记录仪就在里面存了最近 5 次校准的时间戳省掉了一颗 EEPROM。4. 手写驱动从位操作到时序封装铺垫够多了直接上代码。接下来的代码都是我在实际项目里用过的不是从别处抄来改的逻辑经过多次验证。为了通用性引脚用宏定义延时用 DWT 计数器移植时改两处就行。4.1 微秒级延时为什么不用 HAL_DelayDS1302 的时序对延时有一定要求。数据手册给出的最小脉宽是 SCLK 高电平和低电平各不少于 1μs也就是时钟频率上限大约 500kHz 比较稳妥。如果用HAL_Delay(1)那是毫秒级的一次读时间要花掉几十毫秒太浪费了。更好的选择是用 DWT 里的周期计数器。Cortex-M3 以上的内核都带这个调试组件配置好之后可以精确到 CPU 周期。以 72MHz 主频为例一个周期是 13.9ns做微秒延时绰绰有余。/* 在 main 开头调用一次使能 DWT 周期计数器 */ static void DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } /* 微秒级延时us 建议不超过 1000 */ static void DS1302_DelayUs(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000U); while ((DWT-CYCCNT - start) ticks) { /* 空转等待 */ } }这里的减法运算是无符号的即使 CYCCNT 溢出回绕差值依然正确所以不用担心长时间运行后延时失准。这一点和很多人写的while (DWT-CYCCNT start ticks)有本质区别后者在计数器接近满值时会直接跑飞。如果你用的是没有 DWT 的内核比如某些 M0 芯片退而求其次可以用空循环。简单粗暴的写法是static void DS1302_DelayUs(uint32_t us) { while (us--) { for (volatile uint32_t i 0; i 8; i) { } } }这个循环次数需要根据主频实测调整用示波器或者逻辑分析仪量一下实际的 SCLK 周期最靠谱。我一般不推荐这种方法因为编译器优化等级一变延时就全乱套了。4.2 单字节读写的时序实现先把引脚宏定义和底层位操作写出来。注意 I/O 脚配置成开漏输出#define DS1302_CE_PORT GPIOA #define DS1302_CE_PIN GPIO_PIN_4 #define DS1302_IO_PORT GPIOA #define DS1302_IO_PIN GPIO_PIN_5 #define DS1302_SCLK_PORT GPIOA #define DS1302_SCLK_PIN GPIO_PIN_6 #define DS1302_CE_HIGH() (DS1302_CE_PORT-BSRR DS1302_CE_PIN) #define DS1302_CE_LOW() (DS1302_CE_PORT-BSRR (uint32_t)DS1302_CE_PIN 16) #define DS1302_SCLK_HIGH() (DS1302_SCLK_PORT-BSRR DS1302_SCLK_PIN) #define DS1302_SCLK_LOW() (DS1302_SCLK_PORT-BSRR (uint32_t)DS1302_SCLK_PIN 16) #define DS1302_IO_HIGH() (DS1302_IO_PORT-BSRR DS1302_IO_PIN) #define DS1302_IO_LOW() (DS1302_IO_PORT-BSRR (uint32_t)DS1302_IO_PIN 16) #define DS1302_IO_READ() ((DS1302_IO_PORT-IDR DS1302_IO_PIN) ? 1 : 0)这里用 BSRR 寄存器直接操作而不是调HAL_GPIO_WritePin。原因是 HAL 函数里有断言检查、参数解析、结构体访问一次调用要几十个周期在密集翻转的场景下会成为瓶颈。直接写 BSRR 只要一两个周期时序更干净。BSRR 的低 16 位写 1 表示置位高 16 位写 1 表示复位F1、F4、G0、L4 全系列通用。开漏输出的妙处就在这里DS1302_IO_HIGH()实际上是让引脚释放输出高阻靠外部上拉电阻拉高DS1302_IO_LOW()是主动拉低而DS1302_IO_READ()直接读 IDR 就能拿到线上真实电平。整个过程不需要切换输入输出模式节省了大量时间。写一个字节的时序是这样的CE 拉高然后依次发 8 位命令再依次发 8 位数据最后 CE 拉低。static void DS1302_WriteByte(uint8_t cmd, uint8_t dat) { uint8_t i; DS1302_SCLK_LOW(); DS1302_CE_HIGH(); /* 先发命令字节LSB 先行 */ for (i 0; i 8; i) { if (cmd 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } DS1302_DelayUs(2); DS1302_SCLK_HIGH(); DS1302_DelayUs(2); DS1302_SCLK_LOW(); DS1302_DelayUs(2); cmd 1; } /* 再发数据字节同样 LSB 先行 */ for (i 0; i 8; i) { if (dat 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } DS1302_DelayUs(2); DS1302_SCLK_HIGH(); DS1302_DelayUs(2); DS1302_SCLK_LOW(); DS1302_DelayUs(2); dat 1; } DS1302_CE_LOW(); DS1302_SCLK_LOW(); }这段代码里有两个细节值得说。第一数据必须在 SCLK 上升沿之前准备好因为 DS1302 是在上升沿采样输入数据的。代码里先设 I/O延时再拉高 SCLK顺序是对的。第二循环结束后 CE 拉低标志着一次操作结束如果连续操作同一个寄存器理论上可以让 CE 保持高但为了逻辑清晰我每次都拉低。再来看读字节读的时候命令还是写只是数据部分变成采样static uint8_t DS1302_ReadByte(uint8_t cmd) { uint8_t i; uint8_t dat 0; DS1302_SCLK_LOW(); DS1302_CE_HIGH(); for (i 0; i 8; i) { if (cmd 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } DS1302_DelayUs(2); DS1302_SCLK_HIGH(); DS1302_DelayUs(2); DS1302_SCLK_LOW(); DS1302_DelayUs(2); cmd 1; } for (i 0; i 8; i) { dat 1; if (DS1302_IO_READ()) { dat | 0x80; } DS1302_SCLK_HIGH(); DS1302_DelayUs(2); DS1302_SCLK_LOW(); DS1302_DelayUs(2); } DS1302_CE_LOW(); DS1302_SCLK_LOW(); return dat; }读的部分和写的差别在于先读电平再给时钟。DS1302 在 SCLK 下降沿之后把数据放到线上所以在下一个上升沿到来之前的这段时间里线上的数据是有效的。代码里的顺序是读当前电平 → 拉高 SCLK → 拉低 SCLK其中读当前电平发生在上一轮 SCLK 拉低之后正好落在有效窗口内。提示如果你的系统主频很高2μs 的延时其实偏保守可以把 SCLK 周期压到 1μs 左右通信速度能翻倍。但第一次调通之前我建议保持 2μs 甚至 3μs先把功能跑通稳定之后再逐步压缩。4.3 突发模式函数与时间读写封装有了单字节读写突发模式只是在发完命令之后连续收发多个字节逻辑完全一样static void DS1302_BurstRead(uint8_t cmd, uint8_t *buf, uint8_t len) { uint8_t i, j; DS1302_SCLK_LOW(); DS1302_CE_HIGH(); for (i 0; i 8; i) { if (cmd 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } DS1302_DelayUs(2); DS1302_SCLK_HIGH(); DS1302_DelayUs(2); DS1302_SCLK_LOW(); DS1302_DelayUs(2); cmd 1; } for (j 0; j len; j) { uint8_t d 0; for (i 0; i 8; i) { d 1; if (DS1302_IO_READ()) { d | 0x80; } DS1302_SCLK_HIGH(); DS1302_DelayUs(2); DS1302_SCLK_LOW(); DS1302_DelayUs(2); } buf[j] d; } DS1302_CE_LOW(); DS1302_SCLK_LOW(); }上层的时间结构体和读写函数我这样设计typedef struct { uint8_t year; /* 00 ~ 99 */ uint8_t month; /* 01 ~ 12 */ uint8_t day; /* 01 ~ 31 */ uint8_t week; /* 01 ~ 07 */ uint8_t hour; /* 00 ~ 23 */ uint8_t minute; /* 00 ~ 59 */ uint8_t second; /* 00 ~ 59 */ } DS1302_Time_t; #define DS1302_REG_WP 0x8E #define DS1302_REG_TRICKLE 0x90 #define DS1302_CLK_BURST_R 0xBF #define DS1302_CLK_BURST_W 0xBE void DS1302_WriteTime(const DS1302_Time_t *t) { DS1302_WriteByte(DS1302_REG_WP, 0x00); /* 解锁 */ DS1302_WriteByte(0x80, DEC2BCD(t-second) 0x7F); /* 清 CH 位 */ DS1302_WriteByte(0x82, DEC2BCD(t-minute) 0x7F); DS1302_WriteByte(0x84, DEC2BCD(t-hour) 0x3F); /* 24 小时制 */ DS1302_WriteByte(0x86, DEC2BCD(t-day) 0x3F); DS1302_WriteByte(0x88, DEC2BCD(t-month) 0x1F); DS1302_WriteByte(0x8A, DEC2BCD(t-week) 0x07); DS1302_WriteByte(0x8C, DEC2BCD(t-year)); DS1302_WriteByte(DS1302_REG_WP, 0x80); /* 上锁 */ } uint8_t DS1302_ReadTime(DS1302_Time_t *t) { uint8_t buf[8]; DS1302_BurstRead(DS1302_CLK_BURST_R, buf, 8); t-second BCD2DEC(buf[0] 0x7F); t-minute BCD2DEC(buf[1] 0x7F); t-hour BCD2DEC(buf[2] 0x3F); t-day BCD2DEC(buf[3] 0x3F); t-month BCD2DEC(buf[4] 0x1F); t-week BCD2DEC(buf[5] 0x07); t-year BCD2DEC(buf[6]); /* 合法性校验防止通信失败时把乱码当成时间用 */ if (t-second 59 || t-minute 59 || t-hour 23) { return 1; } if (t-month 1 || t-month 12) { return 1; } if (t-day 1 || t-day 31) { return 1; } return 0; }注意小时寄存器的掩码我用的是 0x3F这会强制把 bit7 清成 0也就是 24 小时制。如果你需要 12 小时制就得处理 bit7 和 bit5 的组合麻烦得多一般没人这么用。合法性校验这段看起来可有可无但它在实际项目里救过我。有一次板子上的 DS1302 接触不良读回来全是 0xFF如果不校验程序会把时间当成 255 年 15 月 45 日显示出来然后拿去算闹钟直接跑飞。加上校验之后读失败就返回错误码上层可以选择沿用上一次的有效时间用户体验好很多。4.4 初始化与掉电恢复策略初始化函数要做的事情有三件配置 GPIO、启动 DWT 延时、检查并配置涓流充电寄存器。void DS1302_Init(void) { DS1302_CE_LOW(); DS1302_SCLK_LOW(); DWT_Init(); /* 如果用的是可充电电池才写这个寄存器普通 CR2032 必须关掉 */ DS1302_WriteByte(DS1302_REG_WP, 0x00); /* 先解锁 */ DS1302_WriteByte(DS1302_REG_TRICKLE, 0x00); /* 关闭涓流充电 */ DS1302_WriteByte(DS1302_REG_WP, 0x80); /* 再上锁 */ }关于掉电恢复有个实用策略值得分享。设备上电时先读一次时间如果合法性校验通过说明后备电池撑着时间还是准的直接沿用如果校验失败说明电池没电或者芯片是第一次上电这时候有两个选择一是用编译时间作为初始时间写进去二是等待用户通过串口或者按键设置。void DS1302_CheckAndInit(void) { DS1302_Time_t t; if (DS1302_ReadTime(t) ! 0) { /* 时间非法说明是首次上电或电池失效写一个默认时间 */ DS1302_Time_t def { 25, 1, 1, 3, 0, 0, 0 }; /* 2025-01-01 周三 00:00:00 */ DS1302_WriteTime(def); } }注意这里写的年份只有两位DS1302 本来就不认识世纪你自己在应用层约定00~99 对应 2000~2099就行。5. 实测调试与问题排查实录代码写完了接下来就是最容易让人怀疑人生的环节。DS1302 的调试有个特点错了不会报错只会给你一堆看起来像数据的垃圾。所以必须有一套系统的排查顺序不能乱试。5.1 第一件事确认通信到底通不通拿到一块新板子别急着读时间先做最基础的通信验证。最直接的办法是写一个字节再读回来。DS1302 的 RAM 是读写自由的可以从 0xC0 开始DS1302_WriteByte(0xC0, 0x5A); uint8_t v DS1302_ReadByte(0xC1); /* 如果 v 0x5A说明通信基本正常 */这个测试的好处是它不依赖振荡器、不依赖电池只要三根线接对、上拉电阻在位就应该成功。如果这一步就失败那问题一定在硬件或者时序基础层跟时间寄存器没关系。如果写读回不来按这个顺序查先用万用表量 I/O 脚在不通信时的静态电平正常应该是接近 VCC 的高电平如果是低电平或者悬空说明上拉电阻没焊或者阻值太大再用示波器或者逻辑分析仪抓 SCLK 波形看有没有正常的方波幅度是不是够如果 SCLK 一直是低电平说明 GPIO 初始化没做对最后把延时从 2μs 加大到 10μs 试试很多时序太快的问题加大延时就能暴露出来。还有一类隐蔽问题引脚配置成了推挽输出。推挽输出意味着 MCU 会主动把 I/O 脚拉高拉低读的时候本来该由 DS1302 控制这根线结果变成两个输出打架读回来的数据当然不对。这个问题在代码上看不出来必须检查 GPIO 初始化的那几行。5.2 时间不走、走快走慢的排查顺序通信通了时间却不走第一个要怀疑的永远是CH 位。读一次秒寄存器看 bit7 是不是 1如果是说明时钟处于停止状态重新写一次时间把它清掉就行。如果 CH 位是 0时间还是不走那就往下查晶振。用示波器探头最好是 10:1 的高阻探头1:1 探头可能把振荡拉停去测晶振两端应该能看到 32.768kHz 的正弦波幅度大概在几百毫伏到 1V 之间。测不到波形说明晶振没起振可能是晶振坏了、焊反了、虚焊或者负载电容配得不对。这时候可以把可能焊上的外部电容先拆掉再测一次。时间走但走得不准这是另一类问题。判断方法很简单设置一个准确时间让它跑 24 小时再读出来对比。误差在几秒以内属于正常范围DS1302 就这个水平一天快十几秒说明负载电容偏小需要加电容一天慢十几秒甚至一分钟以上说明负载电容偏大需要减电容或者换晶振一天误差几分钟基本可以断定晶振本身有问题换一颗正品的 6pF 规格试试。温度影响也别忽略。廉价晶振的频率温度系数大概在 -0.04ppm/℃² 量级从 25℃ 到 0℃ 的偏差能累积到每天好几秒。如果你的设备要在室外用一冬一夏差出几分钟很正常这不是驱动的锅。还有一种情况是时间会跳变比如偶尔跳回 2000 年或者跳到某个奇怪的日期。这类问题的根源通常是电源或者干扰而不是软件。可以查这几个方向VCC1 和 VCC2 的切换是否干净电池电压是不是在临界点附近反复横跳板子上有没有大功率器件在 DS1302 附近开关导致电源瞬间跌落I/O 和 SCLK 走线有没有经过噪声源。5.3 常见问题速查表把上面这些年踩过的坑整理成一张表出问题的时候照着查能省不少时间。现象最可能的原因排查手段处理方式读出来全是 0xFF通信失败I/O 一直高量 I/O 静态电平检查上拉电阻和 GPIO 模式读出来全是 0x00I/O 被拉死或 CE 没动作抓 CE 和 SCLK 波形检查 GPIO 初始化和接线时间不动秒值恒定CH 位为 1读 0x81 看 bit7重写时间清 CH 位时间能读但写不进去写保护未解除读 0x8E 看 bit7写之前先往 0x8E 写 0x00时间跳回默认值电池没电或接触不良量 VCC1 电压换电池检查电池座每天慢几十秒负载电容偏大观察误差方向拆掉外部电容或换小容值每天快十几秒负载电容偏小观察误差方向两端补 3~6pF 小电容偶尔读出错乱时间进位竞争或干扰检查是否用了突发模式改用 0xBF 突发读电池半年就鼓包误开了涓流充电读 0x90 的值写入 0x00 关闭充电附近继电器动作时跳时间电源或布线干扰观察跳变与动作时刻布线远离加强电源滤波这张表里的每一条我都在实际项目里遇到过至少一次其中电池鼓包和秒值恒定这两条最常见也最容易被误判成硬件损坏。5.4 几个踩过才知道的实操心得讲几条数据手册里不会写、教程里也很少提的经验。第一条调试期间把延时调大稳定后再压回去。我一般的做法是调试阶段统一用 5μs 延时功能全通了之后逐步降到 2μs每降一次跑一轮完整测试。这样能把时序临界和逻辑错误这两类问题分开不然两个问题纠缠在一起很难定位。第二条别在中断里读 DS1302。一次突发读要几百微秒如果放在定时器中断里又恰好遇上另一个更高优先级的中断时序就可能被打断读回来的是垃圾。我现在的做法是在主循环里定时读一次把结果存到一个全局结构体里需要时间的地方都从这个结构体取。第三条时间设置好之后立刻回读一次做校验。写完时间不要假设它一定写进去了立刻用突发模式读一遍和写入的值逐字段比对。这个动作只花几百微秒却能挡住绝大多数时间设置失败的现场投诉。第四条给 DS1302 的读操作加超时或者失败重试。通信偶尔失败是正常的尤其是电源刚上电或者附近有大功率设备的时候。读失败就再读一次连续失败三次再报错。我的代码里最多重试两次两次都失败就返回上一次的有效时间设备的显示不会跳。第五条把 31 字节 RAM 用来存在上电次数。这是个很实用的小技巧每次开机读一下 RAM 里的计数器加一写回去就能知道设备的总上电次数。用来判断现场设备的运行情况很有帮助而且不占用任何外部存储。第六条如果项目里同时用了多个 DS1302比如多个独立设备不要把它们的驱动写成宏定义复用同一个引脚组。正确做法是把引脚配置做成结构体参数传进去每个实例一套。否则想在同一个 MCU 上挂两片 DS1302 时代码要重写一遍。6. 这套驱动还能往哪扩展代码跑通只是起点DS1302 的很多能力在实际项目里都能用到这里说两个我实际做过并且觉得性价比很高的方向。6.1 用 RAM 存设备参数与运行记录前面提过那 31 字节 RAM我认真用它做过一件事在一台户外环境监测设备里把最近 8 次传感器校准的时间戳存进 RAM。每次校准后把时间戳写进去循环覆盖最旧的一条。这样即使设备离线几个月拿回来之后也能知道它上次校准是什么时候不需要额外的存储芯片也不需要联网。实现起来很简单RAM 地址从 0xC0 开始每隔 2 一个地址一共 31 个可用字节。可以自己约定一个布局比如前 7 个字节存一个时间戳后面依次存其他参数。写入之前记得解锁写保护写完之后上锁步骤和写时间寄存器完全一样。要注意的是RAM 的读写并不经过振荡器所以即使 CH 位为 1、时间停滞RAM 照样能读写。这个特性在做产线测试的时候挺好用可以往里写一个出厂标记设备上电时读出来判断是否需要进入初始化流程。6.2 低功耗场景下的时间维护如果你做的是电池供电、需要长时间待机的设备DS1302 主电源断电、只靠纽扣电池维持时间是一个很划算的方案。DS1302 在 3V 供电、振荡器工作时的电流大概在几百纳安量级一颗 CR2032 理论能撑好几年实际受自放电和温度影响一两年问题不大。具体的做法是让 MCU 大部分时间处于停止模式或者待机模式只让 DS1302 单独跑着。需要唤醒的时候可以用 DS1302 的闹钟功能吗不行DS1302 没有独立的闹钟输出引脚它只有时间寄存器和 RAM。所以周期性唤醒得靠 MCU 自己的低功耗定时器或者外部中断DS1302 只负责提供现在几点这个信息。这也是 DS1302 相对 DS3231 的一个短板。DS3231 有专门的 INT/SQW 引脚可以输出可编程方波或者闹钟中断能直接把 MCU 从深度睡眠里叫醒。如果你的项目需要每天中午 12 点唤醒一次DS3231 会更方便DS1302 就得自己搭一套定时机制。选型的时候把这一点想清楚能省掉返工。6.3 精度不够时的补救思路最后说个兜底方案。DS1302 精度确实一般但如果你做的是联网设备可以定时用网络时间校准一次把 DS1302 当作两次联网之间的过渡。做法是每次联网拿到时间后用DS1302_WriteTime写进去两次校准之间就以 DS1302 为准。这样即使它一天差十几秒在两次校时的间隔内也不会出问题。校准的时候有个小技巧不要直接写先读一次当前时间算出误差如果误差小于某个阈值比如 2 秒就跳过写入。频繁写时间没有意义还会磨损寄存器。另外写时间会短暂清零 CH 位虽然时间不影响实际计时但如果你用的是 1 秒精度的场合要注意写入瞬间可能丢掉不到一秒的时间。我个人在这类项目里的习惯是设备启动时读一次 DS1302如果值合法就直接用联网成功后再校准一次然后每隔一段时间再对一次。这样一套下来现场时间基本不会出问题DS1302 那颗几毛钱的芯片也就能安稳地干完它该干的活。
