
1. 项目缘起与整体设计思路1.1 为什么还要折腾一颗“老掉牙”的时钟芯片DS1302 这颗芯片说实话年纪比很多刚入行的朋友都大。它是一颗带涓流充电能力的实时时钟芯片通过三根线完成通信分别是复位、数据线和串行时钟线。很多人第一次接触它都是在某块入门开发板的例程包里跑通之后往抽屉一扔转头去用自带时钟的芯片了。但真到了实际项目里你会发现它依然有存在的价值主控芯片内部时钟掉电不保存而外挂一颗带纽扣电池的 DS1302断电后时间照样走成本还低得离谱。这个项目的核心目标很明确用 STM32 把 DS1302 驱动起来实现时间的读写、走时保持并且把整套代码整理成可复用的驱动模块。它解决的问题是“主控掉电后时间丢失”这个经典痛点适合正在做数据记录仪、定时开关、电子钟、打卡设备这类需要时间戳的朋友参考。哪怕你用的是别的系列主控只要把底层引脚操作换掉逻辑完全通用。我见过太多人卡在“读出来全是 0xFF”或者“写进去读出来不对”这两个坑上所以这篇笔记不会只给你一份能跑的代码而是把时序、寄存器、BCD 码、读写保护这些容易翻车的地方一个个拆开讲清楚。1.2 三线制通信的取舍与整体架构DS1302 用的是类似三线串行的通信方式但它既不是标准 I2C也不是标准 SPI而是自己的一套时序。为什么选它而不是 I2C 的时钟芯片我的考量是这样的I2C 总线上挂的设备多地址冲突和总线锁死是家常便饭而 DS1302 独占三根普通 IO互不干扰调试起来心里有底。代价就是需要自己写时序不能直接套用硬件外设。整体架构我分成三层来设计。最底层是引脚操作层负责拉高拉低三根线以及读写单比特中间是字节传输层把单比特拼成字节处理“写命令、写数据、读数据”的完整帧最上层是功能接口层对外暴露初始化、设置时间、读取时间、写保护开关这些函数。这样分层的好处是换主控时只需要改最底层的几个宏定义上面两层原封不动。提示DS1302 的数据线是双向的写的时候主控输出读的时候主控要切成输入。很多新手忘记切换方向结果读出来永远是高电平这是第一个高频坑。2. 核心细节解析与实操要点2.1 时序里的三个关键时间参数DS1302 的时序看着简单但对时间有硬性要求。手册里给了几个关键参数我按自己的理解翻译成人话。第一是复位脉冲宽度拉低复位线至少要维持一定时间才能让芯片识别到一次通信开始太短了它当没看见。第二是时钟高电平和低电平的最小持续时间这决定了你翻转时钟线的速度上限太快了芯片反应不过来。第三是数据建立时间和保持时间也就是数据线必须在时钟上升沿之前稳定多久、之后保持多久。在 STM32 上跑主频动辄几十上百兆一个空循环可能就是几十纳秒。如果你不加延时直接翻转 IO时序大概率是超速的。我的做法是在每个电平翻转后插入一个短延时具体时长用示波器或者逻辑分析仪抓一下最稳妥。没有仪器怎么办那就把延时往大了给比如每个翻转之间延时几个微秒先保证能通再逐步往下压。实测下来只要不超过手册上限慢一点完全不影响功能。参数含义实操建议复位脉冲宽度通信起始的复位低电平持续时间给足余量宁长勿短时钟高低电平时间时钟线每个状态的最小保持时间用短延时保证别裸翻转数据建立/保持时间数据相对时钟沿的稳定窗口先写数据再拉时钟读时先拉时钟再采样2.2 BCD 码最容易算错的地方DS1302 内部所有时间寄存器存的都是 BCD 码不是普通二进制。什么叫 BCD就是每个十进制位用四个二进制位表示。比如十进制 25二进制是 11001而 BCD 是 0010 0101高四位表示十位的 2低四位表示个位的 5。这个转换如果搞错你设进去 25 分钟读出来可能是 37 或者别的鬼数字。写的时候要把十进制转成 BCD读的时候要把 BCD 转回十进制。我一般写两个宏或者内联函数来处理。转换公式很简单写的时候(dec / 10 4) | (dec % 10)读的时候(bcd 4) * 10 (bcd 0x0F)。看着简单但边界情况要注意比如小时寄存器最高位还兼作 12/24 小时制标志秒寄存器最高位是时钟停止位这些位在转换时不能当成数据位处理。注意秒寄存器的第 7 位是时钟启动位上电后默认可能是停止状态。如果你发现时间不走先检查这一位有没有清零。2.3 写保护寄存器别让时间被意外改写DS1302 有一个写保护寄存器地址是 0x8E。上电默认是写保护开启的也就是说你直接往时间寄存器写数据是写不进去的。必须先往 0x8E 写 0x00 解除保护改完时间后再写 0x80 重新上锁。这个设计其实很贴心防止程序跑飞时把时间改乱。我踩过的坑是初始化的时候忘了解除保护结果设置时间函数返回“成功”但读出来还是老时间排查了半天才发现是保护位没关。所以我的习惯是在设置时间函数内部自动完成“解保护、写入、上保护”这一整套动作对外只暴露一个接口避免调用者忘记。3. 实操过程与核心环节实现3.1 引脚初始化与底层宏定义先把三根线对应的 GPIO 配好。我习惯用推挽输出模式初始电平都拉高。复位线和时钟线全程都是输出数据线在写的时候是输出读的时候要切成输入。为了切换方便我把数据线的方向切换也封装成宏。// 以某个通用 STM32 系列为例引脚按实际接线替换 #define DS1302_GPIO_PORT GPIOC #define DS1302_RST_PIN GPIO_PIN_0 #define DS1302_DAT_PIN GPIO_PIN_1 #define DS1302_CLK_PIN GPIO_PIN_2 #define RST_HIGH() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_RST_PIN, GPIO_PIN_SET) #define RST_LOW() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_RST_PIN, GPIO_PIN_RESET) #define CLK_HIGH() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_CLK_PIN, GPIO_PIN_SET) #define CLK_LOW() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_CLK_PIN, GPIO_PIN_RESET) #define DAT_HIGH() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_DAT_PIN, GPIO_PIN_SET) #define DAT_LOW() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_DAT_PIN, GPIO_PIN_RESET) #define DAT_READ() HAL_GPIO_ReadPin(DS1302_GPIO_PORT, DS1302_DAT_PIN)数据线方向切换需要动态改寄存器配置我单独写两个函数处理输入输出模式切换。这里要注意切换成输入时最好配上拉电阻因为 DS1302 的数据线在空闲时可能处于高阻态没有上拉会读到随机值。3.2 单字节读写时序的完整落地写一个字节的流程是数据线设为输出从最低位开始把每一位放到数据线上然后拉高时钟再拉低形成一个时钟脉冲。循环八次。读一个字节则相反数据线设为输入先拉高时钟再拉低在时钟高电平期间采样数据线同样循环八次从最低位开始拼。static void ds1302_write_byte(uint8_t dat) { DAT_OUT(); for (uint8_t i 0; i 8; i) { if (dat 0x01) DAT_HIGH(); else DAT_LOW(); delay_short(); CLK_HIGH(); delay_short(); CLK_LOW(); delay_short(); dat 1; } } static uint8_t ds1302_read_byte(void) { uint8_t dat 0; DAT_IN(); for (uint8_t i 0; i 8; i) { dat 1; CLK_HIGH(); delay_short(); if (DAT_READ()) dat | 0x80; CLK_LOW(); delay_short(); } return dat; }注意读的时候是先移位再采样这样第一次采样到的位最终落在最高位八次之后正好拼成一个完整字节。这个顺序如果搞反读出来的字节位序就是颠倒的表现出来就是时间数字乱七八糟。3.3 时间读写与初始化完整流程写时间寄存器的完整帧是拉低复位线拉低时钟线拉高复位线开始通信先发命令字节再发数据字节最后拉低复位线结束。命令字节的格式是bit7 固定为 1bit6 选择寄存器区bit5 到 bit1 是寄存器地址bit0 是读写标志0 表示写1 表示读。void ds1302_write_reg(uint8_t addr, uint8_t dat) { RST_LOW(); CLK_LOW(); RST_HIGH(); ds1302_write_byte((addr 1) | 0x00); // 写命令 ds1302_write_byte(dat); RST_LOW(); } uint8_t ds1302_read_reg(uint8_t addr) { uint8_t dat; RST_LOW(); CLK_LOW(); RST_HIGH(); ds1302_write_byte((addr 1) | 0x01); // 读命令 dat ds1302_read_byte(); RST_LOW(); return dat; }设置时间的流程是先往写保护寄存器写 0x00 解除保护然后依次往秒、分、时、日、月、周、年七个寄存器写入 BCD 码最后往写保护寄存器写 0x80 重新上锁。读取时间就是依次读这七个寄存器再转成十进制。这里有个细节秒寄存器的最高位在写入时要清零否则时钟不启动。寄存器地址说明秒0x80bit7 为时钟启动位写 0 启动分0x82BCD 码范围 00-59时0x84BCD 码注意 12/24 制标志位日0x86BCD 码范围 01-31月0x88BCD 码范围 01-12周0x8ABCD 码范围 01-07年0x8CBCD 码范围 00-994. 常见问题与排查技巧实录4.1 读出来全是 0xFF 或者 0x00这是最高频的问题。如果读出来全是 0xFF大概率是数据线方向没切对主控还在输出模式读到的就是自己输出的高电平。如果全是 0x00可能是复位线没拉高通信根本没启动或者时钟线没动。排查顺序我建议这样先用万用表量三根线的静态电平复位和时钟应该是低数据线看情况然后拿逻辑分析仪抓一次完整通信波形看命令字节有没有正确发出最后检查数据线方向切换函数有没有被真正调用。还有一种情况是芯片本身没供电或者电池没装。DS1302 有主电源和备用电池两个供电脚如果主电源正常但电池没装断电后时间会丢但通电时读写应该正常。如果通电都读不到先量芯片供电脚电压。4.2 时间能读能写但走时不准走时不准通常有两个原因。一是晶振问题DS1302 需要外接一个 32.768kHz 的晶振这个晶振的负载电容要匹配焊盘走线要短否则频率偏移会导致走时快慢。二是涓流充电寄存器配置不当如果电池充电电流设得太大可能影响芯片工作。我一般把涓流充电寄存器设成最小充电电流或者干脆关闭用普通纽扣电池供电走时反而更稳。另外要注意晶振旁边那两个起振电容的容值不是随便选的常见是 6pF 到 12pF具体看晶振手册。如果走时每天差好几秒先换晶振和电容别急着怀疑代码。4.3 断电后时间丢失断电丢时间先确认电池有没有电电池座接触好不好。然后检查涓流充电寄存器有没有把电池过度放电。DS1302 的备用电池一般用 CR2032 这类纽扣电池正常能撑好几年。如果电池有电但时间还是丢可能是写保护没关导致时间根本没写进去或者初始化时没有正确启动时钟。我遇到过一次诡异的情况电池电压正常但时间就是不走。后来发现是晶振的一个脚虚焊补焊之后一切正常。所以硬件问题永远优先于软件排查这是血泪教训。4.4 常见问题速查表现象可能原因排查方向读出全 0xFF数据线方向未切换检查输入输出模式切换读出全 0x00复位线或时钟线无动作量电平、抓波形写入无效写保护未解除先写 0x8E 为 0x00时间不走秒寄存器启动位未清零写秒寄存器时 bit7 置 0走时偏差大晶振或负载电容不匹配换晶振、调电容断电丢时间电池或涓流充电配置问题量电池、查充电寄存器5. 驱动封装与复用的一些个人做法5.1 把时间结构体设计好调用才舒服我不喜欢在业务代码里到处操作寄存器所以定义了一个时间结构体包含年月日时分秒和周几。对外只暴露两个函数一个设置时间一个读取时间。设置时传入结构体指针读取时也传结构体指针。这样业务层完全不关心 BCD 转换和寄存器地址换芯片时只改驱动内部实现。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 min; // 00-59 uint8_t sec; // 00-59 } ds1302_time_t; void ds1302_set_time(const ds1302_time_t *t); void ds1302_get_time(ds1302_time_t *t);结构体里年份只存两位因为 DS1302 本身只支持 00 到 99 年。如果你需要完整年份在业务层加一个世纪偏移就行比如统一加 2000。这样驱动保持简洁业务层灵活处理。5.2 初始化时做一次自检我习惯在初始化函数里做一次简单的自检往某个空闲的 RAM 寄存器写一个固定值再读回来如果一致说明通信正常。DS1302 内部有 31 字节的静态 RAM地址从 0xC0 开始可以用来存一些掉电不丢的小数据顺便当通信自检用。这个技巧在批量生产时特别有用能快速筛出焊接不良的板子。提示RAM 寄存器读写和时钟寄存器读写流程一样只是地址不同。写 RAM 不需要解除写保护这点和时钟寄存器有区别。5.3 延时函数的取舍底层延时我一开始用 HAL 库的微秒延时后来发现它依赖系统滴答定时器在某些低功耗场景下会被影响。后来改成简单的空循环延时用 volatile 变量防止被优化掉。空循环的时长不精确但 DS1302 对时序的容忍度其实挺高只要不太快就行。如果你追求精确可以用定时器做微秒级延时但我觉得没必要杀鸡用牛刀。实测下来空循环延时在几十兆主频下循环几次就够 DS1302 用了。关键是别用编译器优化掉的循环加个 volatile 修饰循环变量或者用内联汇编插入几个空操作。6. 写在最后的一点实操体会这套驱动我在好几个项目里反复用过从简单的电子钟到带数据存储的记录仪稳定性一直不错。最大的体会是DS1302 的难点不在代码而在时序和硬件。代码写对了硬件上晶振和电池没处理好照样出问题。所以每次新板子打样回来我都会先单独测 DS1302 这一路确认走时正常再往下做别的功能。另外读时间的时候建议连续读两次如果两次结果一致再采用能有效避免读到正在进位过程中的临界值。这个技巧在秒跳变的那一瞬间特别有用能防止出现 59 分 60 秒这种非法时间。后续如果要做低功耗可以在不需要时间的时候把 DS1302 的通信引脚拉低减少漏电流需要时再唤醒读取。