1. 为什么还要折腾 DS1302 这颗老芯片如果你手头有一块 STM32 的开发板想给它加一个断电还能记住时间的功能大概率会先想到 DS3231 或者 PCF8563。但如果你翻一翻手边的元件盒或者拆过一些早期的工控板、家电控制板会发现 DS1302 出现的频率高得离谱。这颗芯片从上世纪末一直活到现在价格便宜到几乎可以忽略不计很多小批量项目里它就是那个够用就好的走时方案。我这次拿它出来重新写一遍驱动起因很简单帮一个做数据采集的朋友改板子他原来的方案用的是某款内置 RTC 的 MCU后来因为供货问题换成了 STM32F103外挂的时钟芯片就是 DS1302。他之前从网上抄了一份驱动跑是能跑但偶尔会出现读出来的年份变成 2100 年这种离谱数据断电重启之后时间又归零。我帮他排查了一轮发现问题的根子不在芯片本身而在驱动里对时序和寄存器操作的理解有偏差。所以这篇内容不是那种复制粘贴就能用的代码堆砌而是把 DS1302 和 STM32 配合时真正需要搞清楚的几件事讲透三线接口到底怎么模拟才稳、BCD 码和二进制之间怎么转换不出错、写保护位什么时候该开什么时候该关、备用电池那一路电路为什么经常被画错。适合已经会用 STM32 的 GPIO 和基本外设、但没怎么碰过这类准 SPI时序器件的朋友。看完之后你应该能自己写出一份不依赖任何现成库、稳定跑得住的 DS1302 驱动。2. 三线接口的时序本质不是 SPI但可以借 SPI 的思维2.1 为什么 DS1302 不直接叫 SPIDS1302 的通信接口只有三根线CE有的资料叫 RST、I/O、SCLK。很多人第一反应是这不就是 SPI 吗然后直接拿 STM32 的硬件 SPI 去接。接上去发现要么读不出数据要么读出来全是 0xFF。原因在于 DS1302 的时序和标准 SPI 有几个关键差异。标准 SPI 是全双工、有明确的主从模式、时钟极性和相位可以配置。DS1302 则是半双工的单线双向 I/O数据的方向由命令字决定——你先发一个字节的命令然后根据命令是读还是写决定接下来 I/O 线上是输出还是输入。而且它的数据采样发生在 SCLK 的上升沿输出数据在下降沿变化这一点和 SPI 的 Mode 0 有点像但它没有片选之外的多设备概念CE 拉高就开始一次完整的通信周期。我个人的做法是用普通 GPIO 模拟时序不要用硬件 SPI。原因有三个。第一DS1302 的速率很低手册里写的最大 SCLK 频率是 2MHz但实际上你在 72MHz 的 STM32 上用 GPIO 翻转不加延时都能跑加几个 NOP 更稳完全不需要硬件 SPI 的速率优势。第二半双工切换方向用 GPIO 更直观你只需要在读写之间改一下引脚模式就行。第三硬件 SPI 的时钟相位如果配错排查起来很麻烦而 GPIO 模拟你可以精确控制每一个边沿。2.2 命令字的构成与单字节读写的完整流程DS1302 的每一个操作都以一个命令字节开始。这个字节的位定义是这样的bit7 固定为 1bit6 决定访问的是时钟/日历寄存器还是 RAMbit5 到 bit1 是寄存器地址bit0 是读写标志0 写1 读。举个例子要写秒寄存器地址 0x80命令字就是 0x80要读秒寄存器命令字就是 0x81。单字节写的流程是CE 拉高先把命令字节按低位在前的方式逐位送到 I/O 线上每个 bit 在 SCLK 上升沿被 DS1302 采样命令发完之后紧接着发送数据字节同样是低位在前。发完之后 CE 拉低一次写操作结束。单字节读的流程稍微绕一点CE 拉高发送读命令字节然后要把 I/O 引脚从输出模式切换成输入模式再产生 8 个 SCLK 脉冲在每个上升沿之后读取 I/O 线上的电平。这里有个容易踩的坑切换方向之前要先把 I/O 线拉低或者释放否则如果之前输出的是高电平切到输入之后线上可能还残留电荷导致第一个 bit 读错。我的习惯是在发送完读命令的最后一个下降沿之后立刻把 GPIO 配置成浮空输入或上拉输入然后再开始产生读时钟。下面这段代码是我实际用的 GPIO 模拟底层基于 STM32 的 HAL 库风格写的但你去掉 HAL 的宏换成寄存器操作也一样// 假设 CE 接 PA0SCLK 接 PA1IO 接 PA2 #define DS1302_CE_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET) #define DS1302_CE_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET) #define DS1302_SCLK_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET) #define DS1302_SCLK_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET) #define DS1302_IO_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET) #define DS1302_IO_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET) #define DS1302_IO_READ() HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_2) static void DS1302_IO_Out(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_2; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio); } static void DS1302_IO_In(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_2; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, gpio); } static void DS1302_WriteByte(uint8_t dat) { DS1302_IO_Out(); for (uint8_t i 0; i 8; i) { DS1302_SCLK_L(); if (dat 0x01) DS1302_IO_H(); else DS1302_IO_L(); dat 1; DS1302_SCLK_H(); // 上升沿写入 } } static uint8_t DS1302_ReadByte(void) { uint8_t dat 0; DS1302_IO_In(); for (uint8_t i 0; i 8; i) { dat 1; DS1302_SCLK_H(); if (DS1302_IO_READ()) dat | 0x80; DS1302_SCLK_L(); } return dat; }注意读字节里dat 1的位置因为 DS1302 是低位先出所以每读到一个 bit 要放到当前字节的最高位然后整体右移。这个顺序如果搞反了读出来的数据就是位反转的比如 0x01 会变成 0x80。2.3 突发模式到底省了什么DS1302 支持一种叫时钟突发模式的操作命令字是 0xBF写和 0xBE读。它的作用是一次性把秒、分、时、日、月、周、年这七个寄存器全部读出来或者写进去而不需要每个寄存器单独发一次命令。这个模式的价值在于保证时间数据的一致性。如果你用普通模式逐个读寄存器读秒的时候是 59 分 59 秒读完秒进位变成了 00 分 00 秒你接着读分读到的就是新的一分钟这样拼出来的时间就错了一秒。突发模式在内部会把七个寄存器锁存读的过程中不会更新读完再统一释放。对于需要精确时间戳的场景这个模式是必须用的。写突发模式则适合初始化的时候一次性设置完整时间比逐个写要省事也避免了写一半被中断导致时间不完整的情况。3. BCD 码转换那个让年份变成 2100 的元凶3.1 DS1302 的寄存器为什么用 BCD 码DS1302 内部的时间寄存器全部用 BCD 码存储。所谓 BCD就是用四位二进制表示一位十进制数。比如十进制的 25BCD 码是 0010 0101也就是 0x25。而如果用普通二进制表示 25是 0001 1001也就是 0x19。芯片设计成 BCD 码是有历史原因的早期的数码管驱动和显示逻辑直接按十进制处理BCD 码省去了转换电路。但对写代码的人来说这就多了一层转换。你从 RTC 读出来的是 0x25要显示成 25 需要转成十进制你要设置 25 分得先把十进制 25 转成 0x25 再写进去。我见过最多的 bug 就是这里出的。有人读出来 0x19直接当成十进制 19 用结果实际时间是 25 分显示成 19 分。反过来写的时候把十进制 25 直接写进去芯片按 BCD 解析成 0x19 对应的十进制也就是 19 分时间就错了。3.2 两个转换函数的写法与边界处理BCD 转十进制很简单高四位乘以 10 加上低四位。static uint8_t BCD2DEC(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); }十进制转 BCD 也不复杂除以 10 取商作为高四位余数作为低四位。static uint8_t DEC2BCD(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); }看起来没问题但有几个边界要注意。第一年份的处理。DS1302 的年份寄存器只有 8 位BCD 码最大能表示 99所以它只能表示 00 到 99 年。你读出来 0x24代表的是 2024 年还是 1924 年芯片自己不知道需要你的程序约定一个世纪基准。我一般用 2000 年作为基准读出来的年份加上 2000。如果你不做这个加法直接把 0x24 转成 24 显示用户看到的就是24 年虽然也能理解但做时间戳计算的时候会出问题。第二月份的 bit7 是特殊位。DS1302 的月份寄存器 bit7 是一个世纪位有些资料里说它可以用作世纪的标志但实际上大部分应用里这个位是保留的写的时候要确保它是 0。如果你从某个地方抄来的初始化代码把 0x80 写进了月份寄存器读出来的月份就会带上一个高位BCD2DEC 算出来的月份就会多出 8 个月。我朋友那个年份变成 2100的问题根源就是他在写年份的时候没有做 DEC2BCD 转换直接把十进制 24 写进去芯片按 BCD 解析成了 0x18也就是 18 年然后他的显示代码又加了一个偏移最后算出来一个离谱的年份。第三星期的处理。DS1302 的星期寄存器就是一个 1 到 7 的计数器它不会自动根据日期算出星期几。你写进去 1 它就存 1写进去 7 它就存 7具体 1 代表周一还是周日完全由你的程序约定。我一般约定 1 是周一7 是周日这样和 ISO 标准一致做日历计算的时候方便。3.3 一个完整的读写封装示例把上面的东西串起来读时间的函数大概长这样typedef struct { uint8_t year; // 00-99 uint8_t month; // 1-12 uint8_t day; // 1-31 uint8_t week; // 1-7 uint8_t hour; // 0-23 uint8_t min; // 0-59 uint8_t sec; // 0-59 } DS1302_Time; void DS1302_ReadTime(DS1302_Time *t) { uint8_t buf[7]; DS1302_CE_H(); DS1302_WriteByte(0xBF); // 突发读命令 for (int i 0; i 7; i) { buf[i] DS1302_ReadByte(); } DS1302_CE_L(); t-sec BCD2DEC(buf[0] 0x7F); // bit7 是时钟停止位 t-min BCD2DEC(buf[1] 0x7F); t-hour BCD2DEC(buf[2] 0x3F); // 24小时制时 bit6 保留 t-day BCD2DEC(buf[3] 0x3F); t-month BCD2DEC(buf[4] 0x1F); t-week BCD2DEC(buf[5] 0x07); t-year BCD2DEC(buf[6]); }注意秒寄存器的 bit7 是时钟停止位上电默认是 1表示时钟不走。如果你初始化的时候忘了把这个位清零时间就永远停在初始值。这个坑我踩过当时以为是晶振没起振换了两个晶振才发现是寄存器没配置对。4. 写保护、充电电路与备用电池的实战细节4.1 写保护位什么时候开什么时候关DS1302 的地址 0x8E 是写保护寄存器bit7 是保护位。上电默认是 1也就是保护开启这时候你往任何时间寄存器写数据都会被忽略。所以初始化的第一步必须是关掉写保护。void DS1302_WriteProtect(uint8_t enable) { DS1302_CE_H(); DS1302_WriteByte(0x8E); DS1302_WriteByte(enable ? 0x80 : 0x00); DS1302_CE_L(); }我的习惯是初始化的时候关保护写完时间之后立刻开保护。这样做的原因是防止程序跑飞或者电源波动时误写时间寄存器。但如果你需要在运行过程中动态调整时间比如用户通过按键设置时间那就得在设置之前关保护设置完再开。这里有个细节关保护和开保护之间不要插入太长的延时因为保护关闭期间如果发生复位保护位可能停在关闭状态。虽然概率很低但在工业环境里值得注意。4.2 备用电池那一路为什么经常出问题DS1302 有两个电源引脚VCC1 接主电源VCC2 接备用电池。芯片内部有一个电源切换电路当 VCC1 高于 VCC2 一定值时用 VCC1否则用 VCC2。这个设计本身没问题但实际画板子的时候有几个常见错误。第一个错误是备用电池没有加限流电阻。如果你用的是可充电的纽扣电池比如 LIR2032直接接在 VCC2 上主电源在的时候会通过芯片内部的电路给电池充电电流不受控时间长了电池会鼓包甚至漏液。正确的做法是串一个几百欧到几 kΩ 的电阻具体阻值看电池的充电电流要求。如果你用的是不可充电的 CR2032那更要注意绝对不能让它被充电否则有安全风险。这种情况下可以在 VCC2 前面加一个二极管防止电流倒灌。第二个错误是VCC1 和 VCC2 的电压关系没搞清楚。DS1302 的数据手册里写了VCC1 要比 VCC2 高 0.2V 以上才能可靠切换到主电源。如果你用 3.3V 给 VCC1 供电备用电池是 3.0V 的纽扣电池那压差只有 0.3V勉强够用但余量很小。如果电池电压稍微高一点比如新电池 3.2V那芯片可能就一直用电池供电主电源反而没起作用。我一般建议 VCC1 用 3.3VVCC2 用 3.0V 或者 2.8V 的电池保证压差足够。第三个错误是晶振的负载电容没配对。DS1302 用的是 32.768kHz 的晶振手册推荐负载电容是 6pF。但市面上很多 32.768kHz 晶振的负载电容是 12.5pF如果你直接拿来用走时精度会差很多一天可能差几秒甚至几十秒。选晶振的时候一定要看负载电容参数买 6pF 的型号。如果实在买不到可以在晶振两端各并一个几 pF 的电容来微调但效果不如直接选对型号。4.3 初始化流程的完整顺序把上面这些串起来一个可靠的初始化流程是这样的上电后延时至少 100ms等电源稳定和晶振起振。关写保护。检查秒寄存器的 bit7时钟停止位。如果是 1说明时钟没在走需要重新设置时间如果是 0说明之前已经设置过可以跳过设置。如果需要设置时间用突发写模式一次性写入七个寄存器。清除秒寄存器的 bit7启动时钟。开写保护。这个流程里第 3 步很关键。很多人的代码每次上电都重新设置时间结果就是每次断电重启时间都回到初始值备用电池等于白装。正确的做法是判断时钟是否已经在走只有在没走的时候才设置。5. 调试过程中最容易误判的几个现象5.1 读出来全是 0xFF 或 0x00这个现象最常见的原因是接线问题。DS1302 的 I/O 线是双向的如果你在 PCB 上忘了接上拉电阻或者上拉电阻阻值太大比如用了 100kΩ读的时候线拉不高读出来就全是 0。我一般用 4.7kΩ 到 10kΩ 的上拉太小了功耗大太大了上升沿变缓。另一个原因是CE 引脚没有正确拉高。DS1302 的通信必须在 CE 为高的时候进行如果你忘了拉高 CE或者 CE 被其他电路拉低了芯片根本不会响应。用示波器看一下 CE 和 SCLK 的波形一眼就能看出来。还有一个不太常见但很坑的原因SCLK 的初始电平。如果你在初始化 GPIO 的时候把 SCLK 配成了高电平而 DS1302 在 CE 拉高的瞬间会检测 SCLK 的状态某些批次的芯片会因此进入异常状态。我的习惯是初始化的时候把 SCLK 和 I/O 都拉低CE 也拉低确保芯片处于空闲状态。5.2 时间走得快或走得慢如果时间走得不准先排除晶振的问题。用示波器或者频率计测一下 32.768kHz 晶振的实际频率如果偏差超过几十 ppm那就是晶振或者负载电容的问题。如果晶振没问题那可能是电源纹波太大。DS1302 对电源比较敏感如果 VCC1 上有较大的纹波内部振荡电路会受影响。可以在 VCC1 引脚旁边并一个 0.1uF 的陶瓷电容再并一个 10uF 的钽电容。还有一个容易被忽略的点温度。32.768kHz 晶振的频率会随温度变化普通的晶振在 -10°C 到 60°C 范围内可能有几十 ppm 的漂移换算下来一天差几秒是正常的。如果你需要高精度那就得换 DS3231 这种内置温补的芯片DS1302 本身不具备温度补偿能力。5.3 断电后时间丢失如果备用电池装了但断电后时间还是丢先量一下断电瞬间 VCC2 引脚的电压。如果电压正常但时间还是丢那可能是电池供电时芯片进入了低功耗模式但你的程序在断电前做了错误的操作。比如你在检测到主电源掉电的中断里往 DS1302 写了数据而这时候 VCC1 已经低于 VCC2 了写操作可能不完整导致寄存器内容损坏。正确的做法是主电源掉电时不要操作 DS1302让它自己切换到电池供电。你只需要保证在掉电之前已经把写保护打开这样即使有干扰也不会误写。6. 一份可以直接用的驱动代码结构6.1 头文件里应该放什么我习惯把头文件分成三部分引脚定义、数据结构、函数声明。引脚定义用宏方便换板子的时候改一处就行。数据结构就是一个时间结构体加上一个初始化配置结构体。函数声明至少包括初始化、读时间、写时间、写保护控制、突发读写。// ds1302.h #ifndef __DS1302_H #define __DS1302_H #include stdint.h 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; void DS1302_Init(void); void DS1302_ReadTime(DS1302_Time *t); void DS1302_WriteTime(const DS1302_Time *t); void DS1302_WriteProtect(uint8_t enable); #endif6.2 源文件里的初始化与自检逻辑初始化函数里除了配置 GPIO我还会加一个简单的自检往 RAM 的某个地址写一个值再读出来如果读出来一致说明通信正常。DS1302 有 31 字节的 RAM地址从 0xC0 开始用这个来做自检不会影响时间寄存器。void DS1302_Init(void) { // GPIO 初始化代码省略参考前面的宏定义 DS1302_CE_L(); DS1302_SCLK_L(); DS1302_IO_Out(); DS1302_IO_L(); // 自检写 RAM 再读回 DS1302_CE_H(); DS1302_WriteByte(0xC0); // RAM 地址 0 写命令 DS1302_WriteByte(0x5A); DS1302_CE_L(); DS1302_CE_H(); DS1302_WriteByte(0xC1); // RAM 地址 0 读命令 uint8_t val DS1302_ReadByte(); DS1302_CE_L(); if (val ! 0x5A) { // 通信异常可以在这里设置一个错误标志 } }这个自检的好处是如果板子焊接有问题你在初始化阶段就能发现而不是等到读时间的时候才发现数据不对。6.3 时间设置与读取的完整调用示例在主程序里设置时间和读取时间的调用大概是这样DS1302_Time t; DS1302_Init(); // 判断时钟是否在走 DS1302_CE_H(); DS1302_WriteByte(0x81); // 读秒寄存器 uint8_t sec DS1302_ReadByte(); DS1302_CE_L(); if (sec 0x80) { // 时钟没走设置时间 DS1302_Time init {24, 6, 15, 6, 10, 30, 0}; // 2024-06-15 周六 10:30:00 DS1302_WriteTime(init); } // 正常读取 DS1302_ReadTime(t); // 此时 t.year 是 24实际年份是 2000 24 2024注意DS1302_WriteTime内部要先关写保护写完再开。这个顺序不能反否则写不进去。7. 几个我踩过的坑和对应的解法第一个坑是读秒寄存器的时候忘了屏蔽 bit7。DS1302 的秒寄存器 bit7 是时钟停止位如果你直接 BCD2DEC 整个字节当这个位是 1 的时候算出来的秒数会多出 80。我当时的代码是t.sec BCD2DEC(buf[0])结果时钟没启动的时候读出来秒是 80看起来像是芯片坏了。后来改成BCD2DEC(buf[0] 0x7F)就正常了。第二个坑是突发读的时候 CE 拉低的时机。DS1302 的突发读模式在读完第 8 个字节之后如果 CE 还保持高电平它会继续输出数据但那些数据是无效的。如果你在读完 7 个字节之后没有及时拉低 CE第 8 个字节可能会干扰 I/O 线导致下一次通信异常。我的做法是读完最后一个字节的最后一个 bit 之后立刻拉低 CE然后再做数据处理。第三个坑是用 HAL_Delay 做微秒级延时。HAL_Delay 的最小分辨率是 1ms对于 DS1302 这种微秒级时序来说太粗了。如果你在 GPIO 翻转之间插了 HAL_Delay通信速率会慢到离谱而且可能错过采样窗口。正确的做法是用 NOP 或者 DWT 计数器做微秒延时或者干脆不加延时因为 STM32 的 GPIO 翻转速度本身就足够慢DS1302 能跟得上。第四个坑是多个 DS1302 共用 I/O 线。有些项目里为了省引脚把多个 DS1302 的 I/O 和 SCLK 并联只用不同的 CE 来选通。这个方案理论上是可行的但实际用的时候要注意当其中一个芯片的 CE 拉高时其他芯片的 I/O 引脚处于高阻态不会干扰。但如果某个芯片的 CE 引脚悬空它可能会误判为高电平从而干扰总线。所以每个芯片的 CE 都要有明确的下拉电阻确保空闲时是低电平。8. 关于精度和替代方案的几句实在话DS1302 的走时精度在常温下大概能做到每天误差几秒这个水平对于一般的家电控制、数据记录是够用的。但如果你要做需要长时间累积精确时间戳的应用比如某些计量设备那 DS1302 就不太合适了。它的晶振没有温度补偿电源波动也会影响频率长期累积误差可能达到每月几分钟。如果你确实需要更高精度又不想换芯片封装可以考虑两个方向一是换用 DS3231它内置了温补晶振精度能到每年几分钟以内而且引脚和 DS1302 不完全兼容但也不难改二是在软件层面做校准比如定期通过其他时间源同步一次把 DS1302 当作掉电保持的粗计时器上电后再用高精度源校准。不过话说回来DS1302 最大的优势就是便宜和简单。一颗芯片几毛钱三根线就能用不需要额外的驱动电路。在很多成本敏感的项目里这个优势是决定性的。我这次重新写这份驱动也是因为发现网上很多现成的代码要么时序不对要么 BCD 转换有 bug要么初始化流程不完整。与其每次都要改别人的代码不如自己写一份干净的、经过验证的以后直接拿来用。最后分享一个我在调试时常用的小技巧如果怀疑通信有问题可以先不接 DS1302直接用示波器或者逻辑分析仪抓 STM32 发出的波形确认命令字节和数据字节的时序是对的。然后再接上芯片抓 I/O 线上的返回数据。这样可以把STM32 发得对不对和DS1302 回得对不对分开排查比一上来就怀疑芯片坏了要高效得多。