资讯动态

STM32驱动DS1302时钟芯片:三线协议时序与HAL库实现

发布时间:2026/10/13 1:19:57 来源:尧图企业网站定制
1. 为什么一个老掉牙的时钟芯片还值得单独写一篇笔记DS1302 这颗芯片的年纪比很多刚入行的朋友都大它最早是达拉斯半导体推出的涓流充电时钟芯片后来产线几经辗转如今市面上流通的模块大多是国产兼容版本。很多人第一次接触它是在某宝几块钱包邮的迷你模块上背面焊着 32.768kHz 晶振和一颗纽扣电池座正面引出 VCC、GND、CLK、DAT、RST 五根针。看起来简单到不能再简单但真正把它接到 STM32 上跑通、并且做到断电走时准确、读写稳定中间踩的坑一点都不比驱动一块 OLED 少。我之所以愿意花时间整理这篇笔记是因为在实际项目里DS1302 出现的频率远比想象中高。工业控制面板需要记录故障发生的时间戳家用电器需要定时开关机数据采集终端需要在每条记录上打上日期这些场景对时间精度的要求通常只到秒级成本敏感度却极高。DS1302 配合一颗 CR2032 纽扣电池整体物料成本可以压到两块钱以内而且它走的是三线串行接口占用 MCU 引脚极少对于引脚资源紧张的 STM32F0、STM32G0 这类小容量芯片来说非常友好。但友好归友好DS1302 的时序要求相当讲究。它的通信协议不是标准 SPI也不是 I2C而是自己的一套三线同步串行协议数据在 CLK 的上升沿被写入、下降沿被读出命令字节和数据的传输顺序都是 LSB 优先。更麻烦的是它对时序的容忍度比较低CLK 高低电平的持续时间、RST 拉高到第一个 CLK 之间的建立时间这些参数如果处理不好读出来的秒数会乱跳或者干脆全是 0xFF。很多网上的例程直接拿软件延时凑合在 72MHz 的 F1 上能跑换到 144MHz 的 H7 上就歇菜了原因就在这里。这篇笔记面向的是已经能点亮 LED、会用 CubeMX 配置 GPIO 的 STM32 初学者以及那些曾经用过 DS1302 但被时序问题折磨过的朋友。我会从芯片的通信协议底层讲起把命令字节的每一位拆开揉碎然后给出基于 HAL 库的完整驱动实现包括 GPIO 初始化、字节读写、寄存器操作、时间格式转换最后重点聊一聊我在实际调试中遇到的几个典型问题和解决办法。代码全部开源可以直接拿去用但更重要的是理解每一行背后的意图这样换到任何一款 STM32 上都能自己调整。2. 三线协议的命令字节里藏着哪些容易忽略的细节2.1 命令字节的位定义与读写方向DS1302 的每一次通信都由一个命令字节开始这个字节决定了接下来是读还是写、操作的是哪个寄存器。命令字节的 bit0 是读写选择位1 表示读0 表示写bit1 到 bit6 是寄存器地址对应芯片内部的 RAM 和时钟寄存器bit7 固定为 1这是协议规定的起始标志。很多人写驱动时直接抄别人的宏定义比如#define DS1302_SECOND_WRITE 0x80能用但不知道 0x80 是怎么来的。实际上 0x80 展开成二进制是 1000 0000bit7 为 1bit6 到 bit1 全 0bit0 为 0 表示写对应的就是秒寄存器的写命令。这里有一个非常容易踩的坑DS1302 的数据传输是 LSB 优先也就是说命令字节的最低位先被送出。如果你用硬件 SPI 的 MSB 优先模式去模拟命令字节的位序就全反了芯片根本不会响应。我见过有朋友用 STM32 的 SPI 外设去驱动 DS1302调了两天没通最后发现是位序问题。所以最稳妥的做法还是用普通 GPIO 软件模拟时序虽然占用一点 CPU 时间但时序完全可控移植性也最好。命令字节发送完毕后如果是写操作紧接着发送数据字节同样是 LSB 优先如果是读操作DS1302 会在接下来的 8 个 CLK 周期内把数据逐位输出到 DAT 线上STM32 在 CLK 下降沿采样即可。整个通信过程中RST 必须保持高电平通信结束后拉低 RST一次操作才算完整。如果 RST 在中途被拉低芯片会复位串行接口之前发送的命令字节全部作废。2.2 寄存器地址映射与 BCD 码格式DS1302 的时钟寄存器一共 7 个分别对应秒、分、时、日、月、周、年地址从 0x80 到 0x8C 依次递增步长为 2。为什么步长是 2 而不是 1因为 bit0 被读写位占用了所以地址实际上是在 bit1 到 bit6 之间变化反映到十六进制上就是每次加 2。比如秒寄存器写命令是 0x80分寄存器写命令就是 0x82时寄存器是 0x84以此类推。读命令则是在写命令的基础上把 bit0 置 1比如秒寄存器读命令是 0x81分寄存器是 0x83。这些寄存器里存储的数据不是普通的二进制而是 BCD 码。BCD 码用四个二进制位表示一个十进制数字比如十进制的 23BCD 码是 0010 0011高四位是 2低四位是 3。这样做的好处是芯片内部不需要复杂的二进制转十进制电路直接按位显示即可。但对 STM32 来说读写时需要做一次转换写入前把二进制数转成 BCD 码读出后再把 BCD 码转回二进制数。转换逻辑很简单写的时候bcd (bin / 10 4) | (bin % 10)读的时候bin (bcd 4) * 10 (bcd 0x0F)。秒寄存器的 bit7 是时钟停止位上电后默认是 1表示时钟停止。很多朋友发现 DS1302 不走时读出来的秒数一直是 0x80就是因为没有把这个位清零。初始化时必须先写秒寄存器把 bit7 置 0时钟才会开始运行。小时寄存器稍微复杂一点bit7 是 12/24 小时制选择位bit6 在 12 小时制下表示上午下午bit5 和 bit4 是小时的十位低四位是个位。如果要用 24 小时制就把 bit7 置 0然后按 BCD 码写入小时值即可。2.3 写保护寄存器与涓流充电寄存器除了 7 个时钟寄存器DS1302 还有两个特殊寄存器需要关注。一个是写保护寄存器地址 0x8Ebit7 是写保护位。上电后这个位默认是 1所有对时钟寄存器的写操作都会被忽略。所以在初始化时必须先写 0x8E 寄存器把 bit7 清零解除写保护写完时间后再把它置回 1防止意外修改。我见过有人在循环里反复写时间结果发现只有第一次生效就是因为写保护没处理。另一个是涓流充电寄存器地址 0x90。这个寄存器控制内部涓流充电电路如果模块上焊了可充电的纽扣电池可以通过它设置充电电流。但市面上大多数模块配的是不可充电的 CR2032这时候涓流充电寄存器必须保持默认值 0x00否则可能损坏电池。我个人的习惯是初始化时显式写一次 0x90 寄存器写入 0x00确保充电电路关闭。这个细节在数据手册里写得很清楚但很多例程直接忽略了。3. 基于 HAL 库的 GPIO 模拟时序完整实现3.1 引脚配置与宏定义封装我用的测试平台是 STM32F103C8T6 最小系统板DS1302 模块的 VCC 接 3.3VGND 接 GNDCLK 接 PA5DAT 接 PA6RST 接 PA7。这三个引脚都配置为推挽输出模式初始电平为低。DAT 引脚在写操作时是输出在读操作时需要切换为输入所以代码里要能动态改变它的方向。HAL 库提供了HAL_GPIO_WritePin和HAL_GPIO_ReadPin函数配合GPIO_InitTypeDef结构体可以方便地切换。为了代码可读性和移植性我把引脚操作封装成宏#define DS1302_CLK_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET) #define DS1302_CLK_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET) #define DS1302_DAT_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_SET) #define DS1302_DAT_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_RESET) #define DS1302_DAT_R() HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6) #define DS1302_RST_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET) #define DS1302_RST_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET)DAT 引脚的方向切换需要单独写两个函数一个把 PA6 配置为输出一个配置为输入。这里有个细节切换方向后最好加一个微秒级的延时让引脚电平稳定下来尤其是在长导线连接的情况下。我一般用delay_us(1)就够了但如果你用的是 SysTick 做延时注意不要在中断里调用否则会死锁。3.2 字节写入与读取的时序拆解写一个字节到 DS1302 的流程是这样的先把 RST 拉高然后循环 8 次每次把数据字节的最低位放到 DAT 线上然后 CLK 拉高再拉低形成一个时钟脉冲。注意数据必须在 CLK 上升沿之前就准备好DS1302 在上升沿采样 DAT 线。循环结束后如果是最后一个字节把 RST 拉低通信结束。代码实现如下void DS1302_WriteByte(uint8_t dat) { DS1302_DAT_Output(); for (uint8_t i 0; i 8; i) { DS1302_CLK_L(); if (dat 0x01) DS1302_DAT_H(); else DS1302_DAT_L(); dat 1; DS1302_CLK_H(); } DS1302_CLK_L(); }读字节的流程稍微不同先把 DAT 引脚切换为输入模式然后循环 8 次每次先把 CLK 拉低延时一小会儿再拉高在上升沿之后读取 DAT 线的电平把读到的位拼接到结果变量的对应位置。注意 DS1302 是在 CLK 下降沿输出数据所以我们在拉低 CLK 之后稍等片刻再拉高拉高后读取即可。代码uint8_t DS1302_ReadByte(void) { uint8_t dat 0; DS1302_DAT_Input(); for (uint8_t i 0; i 8; i) { dat 1; DS1302_CLK_L(); delay_us(2); DS1302_CLK_H(); delay_us(2); if (DS1302_DAT_R()) dat | 0x80; } DS1302_CLK_L(); return dat; }这里我加了 2 微秒的延时是为了适配不同主频的 STM32。在 72MHz 下不加延时也能跑但换到 144MHz 或 168MHz 的芯片上GPIO 翻转速度太快DS1302 可能来不及响应。数据手册里给出的 CLK 高电平最小持续时间是 250ns低电平也是 250ns2 微秒远远大于这个要求虽然牺牲了一点速度但换来了稳定性。如果你追求极致速度可以把延时改成__NOP()循环根据主频计算循环次数。3.3 寄存器读写函数与时间结构体设计有了字节读写函数寄存器操作就水到渠成了。写寄存器时先发命令字节再发数据字节读寄存器时先发命令字节再读一个字节。注意每次操作前后都要正确控制 RST 引脚void DS1302_WriteReg(uint8_t cmd, uint8_t dat) { DS1302_RST_L(); DS1302_CLK_L(); DS1302_RST_H(); DS1302_WriteByte(cmd); DS1302_WriteByte(dat); DS1302_RST_L(); } uint8_t DS1302_ReadReg(uint8_t cmd) { uint8_t dat; DS1302_RST_L(); DS1302_CLK_L(); DS1302_RST_H(); DS1302_WriteByte(cmd); dat DS1302_ReadByte(); DS1302_RST_L(); return dat; }时间结构体我定义成包含年、月、日、时、分、秒、周七个字段年份只存后两位比如 2025 年存 25。这样做的好处是和 DS1302 的寄存器一一对应读写时不需要额外的映射。初始化函数里先解除写保护然后依次写入秒、分、时、日、月、周、年最后恢复写保护。注意秒寄存器的 bit7 要清零否则时钟不走。typedef struct { uint8_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute; uint8_t second; uint8_t week; } DS1302_Time;读写时间的时候BCD 码转换是绕不开的。我写了两个内联函数BinToBcd和BcdToBin放在头文件里编译器会自动优化。这里有一个小技巧转换时先对 10 取模和整除不要用浮点数整数运算在 Cortex-M 上只要几个周期比浮点快得多。4. 调试过程中遇到的三个典型问题与排查思路4.1 读出来的数据全是 0xFF 或 0x00这是最常见的问题现象是调用读函数返回的字节要么全是高电平要么全是低电平。遇到这种情况我一般按以下顺序排查。第一步用示波器或者逻辑分析仪抓 CLK、DAT、RST 三根线的波形看 RST 是否在通信期间保持高电平CLK 是否有 8 个完整的脉冲DAT 线上有没有数据翻转。如果没有逻辑分析仪可以用 LED 接在 DAT 线上写一个循环发送 0xAA 的程序肉眼观察 LED 是否闪烁。如果 LED 不闪说明 GPIO 配置有问题检查引脚是否被复用为其他功能或者时钟使能是否遗漏。第二步检查命令字节是否正确。我曾经把秒寄存器的写命令写成 0x81结果写进去的数据读出来不对后来发现 0x81 是读命令写命令应该是 0x80。这种错误很隐蔽因为芯片不会报错只是行为不符合预期。建议把常用的命令字节定义成枚举或宏写代码时直接引用避免手写出错。第三步检查 DAT 引脚的方向切换。写操作时 DAT 是输出读操作时 DAT 是输入。如果读的时候忘记切换方向STM32 的引脚还处于推挽输出状态DS1302 输出的数据会和 STM32 的输出打架读出来的值自然不对。我的做法是在DS1302_ReadByte函数开头调用DS1302_DAT_Input()在DS1302_WriteByte开头调用DS1302_DAT_Output()确保每次操作前方向都是正确的。4.2 时间走时偏快或偏慢如果读出来的时间能正常递增但和标准时间对比发现每天快几秒或慢几秒问题通常出在晶振上。DS1302 需要外接一个 32.768kHz 的晶振这个频率是 2 的 15 次方经过 15 级分频后得到 1Hz 的秒信号。晶振的负载电容匹配不好或者晶振本身精度不够都会导致走时误差。市面上几块钱的模块配的晶振精度通常在 20ppm 左右换算下来每天误差大约 1.7 秒一个月就是 50 秒这个量级是可以接受的。如果误差远大于这个值比如一天差几分钟那就要检查晶振是否起振。用示波器探头搭在晶振引脚上应该能看到 32.768kHz 的正弦波幅度大约 1V 峰峰值。如果看不到波形可能是晶振坏了或者负载电容不匹配。DS1302 数据手册推荐使用 6pF 的负载电容但很多模块上焊的是 12pF这会导致频率偏低。我的经验是如果走时偏慢可以把负载电容换成小一点的比如 6pF 或 8pF如果偏快就换大一点的。当然最彻底的办法是用频率计校准但对于大多数应用来说没必要这么较真。还有一个容易被忽略的点DS1302 的晶振引脚对布线很敏感如果模块上的晶振离芯片太远或者走线太长寄生电容会增加导致频率偏移。这种情况下即使用高精度的晶振也白搭。所以如果你是自己画板子尽量把晶振靠近 DS1302 放置走线尽量短底下不要走其他信号线。4.3 断电后时间丢失或复位断电后时间丢失首先检查纽扣电池有没有电。用万用表量一下电池电压CR2032 满电是 3.3V低于 2.5V 就基本没用了。如果电池有电但时间还是丢检查电池座的正负极是否接反或者电池座弹片是否接触不良。我遇到过一批模块电池座的正极弹片氧化了导致接触电阻很大电池电压加不到 DS1302 的 VCC1 引脚上换了电池座就好了。另一个可能的原因是涓流充电寄存器被意外使能了。如果模块上装的是不可充电的 CR2032而涓流充电电路又在工作电池会被持续放电很快就没电了。所以初始化时一定要写 0x90 寄存器写入 0x00关闭充电功能。这个操作只需要做一次但很多例程里没有导致用户用了一段时间后发现电池没电了还以为是电池质量问题。还有一种情况是软件问题每次上电初始化时无条件地把时间重置为编译时的固定值。这样即使电池有电时间也会被覆盖。正确的做法是判断秒寄存器的 bit7如果为 1 说明时钟停过需要重新初始化如果为 0 说明时钟一直在走直接读取即可。这个逻辑在DS1302_Init函数里实现可以避免不必要的重置。5. 从驱动到应用时间戳记录与低功耗唤醒的衔接5.1 在数据采集终端中打时间戳DS1302 最典型的应用场景就是在数据采集终端里给每条记录打时间戳。假设你有一个温湿度采集节点每隔十分钟采集一次数据通过串口发送到上位机。上位机收到的数据格式可能是2025-03-15 14:30:00, 25.6, 60.2前面的时间就是从 DS1302 读出来的。实现上每次采集前调用DS1302_ReadTime获取当前时间格式化成字符串再和传感器数据拼接。这里有一个细节DS1302 的读操作不是原子性的如果你在读取秒寄存器之后、读取分寄存器之前时钟刚好进位了读出来的时间可能是不一致的。比如秒从 59 跳到 00分从 30 跳到 31如果你先读秒得到 59再读分得到 31组合起来就是 31 分 59 秒实际上这个时间点已经不存在了。解决办法是连续读两次如果两次结果一致就采用否则重读。或者更简单粗暴先读秒延时一小会儿再读其他寄存器如果秒变了就重读。对于秒级精度要求不高的场景连续读两次的方法足够用了。格式化字符串的时候注意年份要加上 2000因为 DS1302 只存后两位。月份、日期、小时、分钟、秒都要补零保证输出宽度一致。我一般用snprintf函数比手动拼接字符串安全不会溢出缓冲区。如果你用的是 Keil MDK记得在工程设置里勾选 Use MicroLIB否则snprintf可能不支持浮点数格式化。5.2 利用闹钟寄存器实现定时唤醒DS1302 内部有两组闹钟寄存器一组是闹钟 1地址 0x87 到 0x8D另一组是闹钟 2地址 0x8F 到 0x95。每组闹钟可以设置日、时、分、秒的匹配值当实时时钟和闹钟寄存器匹配时DS1302 的 IRQ 引脚会输出低电平。不过很多模块并没有引出 IRQ 引脚所以这个功能在实际中用得不多。如果你的模块引出了 IRQ可以把它接到 STM32 的外部中断引脚上配置成下降沿触发在中断里唤醒 MCU。但更常见的做法是用 STM32 自己的 RTC 或者定时器来做低功耗唤醒DS1302 只负责记录时间。因为 STM32 的 RTC 在低功耗模式下也能运行而且精度可以做到很高配合 DS1302 做时间备份两者互补。具体来说STM32 的 RTC 负责短周期的唤醒比如每秒钟唤醒一次采集数据DS1302 负责在系统断电后继续走时下次上电时把时间同步给 RTC。这样既保证了低功耗又保证了断电后时间不丢失。同步的逻辑是这样的上电后先读 DS1302 的时间如果秒寄存器的 bit7 为 0说明 DS1302 一直在走把读出来的时间转换成 Unix 时间戳写入 STM32 的 RTC 计数器如果 bit7 为 1说明 DS1302 也停过那就需要重新设置时间可以通过串口接收上位机下发的时间或者用编译时的固定值。这个流程在main函数开头执行一次即可。5.3 驱动代码的移植与适配不同 STM32 系列我写的这套驱动代码核心逻辑和硬件无关只依赖 GPIO 的读写和微秒延时。移植到其他 STM32 系列时只需要修改三个地方引脚宏定义、GPIO 初始化函数、微秒延时函数。引脚宏定义改成对应系列的 GPIO 端口和引脚号GPIO 初始化用 CubeMX 生成或者手动调用HAL_GPIO_Init微秒延时可以用定时器实现也可以用__NOP循环。对于 STM32G0、STM32L4 这类低功耗系列GPIO 的翻转速度可能比 F1 慢一些但 DS1302 的时序要求本来就不高所以不需要特别调整。我实测在 STM32L431 上不加延时也能正常读写但为了保险起见还是保留了 2 微秒的延时。如果你用的是 STM32H7 这种 480MHz 的芯片GPIO 翻转极快不加延时几乎肯定失败这时候延时要加到 5 微秒以上。还有一个移植时容易忽略的点HAL 库的HAL_GPIO_WritePin函数内部有断言检查在 Release 模式下会被优化掉但在 Debug 模式下会消耗不少时间。如果你发现 Debug 模式下能跑、Release 模式下反而出问题可能是时序太紧导致的。解决办法是直接用寄存器操作 GPIO比如GPIOA-BSRR GPIO_PIN_5这样速度最快但可读性差一些。我一般用宏封装在宏里直接操作寄存器兼顾速度和可读性。6. 写在最后几个让我印象深刻的调试瞬间调试 DS1302 的过程中有几个瞬间让我印象特别深。一次是读出来的秒数每隔几秒就跳变一次排查了半天发现是 DAT 引脚的输入模式没有配置上拉电阻引脚浮空导致读到的电平不稳定。加上内部上拉后问题消失。另一次是写时间后读出来不对最后发现是写保护寄存器没有解除写操作全部被忽略了。还有一次是电池供电时走时正常但一接上 USB 串口就乱跳原因是 USB 的 5V 和电池的 3V 通过某种路径耦合了导致 DS1302 的 VCC 波动。后来在 VCC 引脚旁边加了一个 0.1uF 的陶瓷电容问题解决。这些经历让我意识到DS1302 虽然简单但它的稳定性依赖于每一个细节的正确处理。GPIO 的配置、时序的把握、电源的干净程度、电池的接触可靠性任何一个环节出问题都会导致异常。所以如果你正在调试 DS1302遇到问题不要慌按照从硬件到软件、从电源到信号的顺序逐一排查总能找到原因。代码我已经整理成独立的ds1302.c和ds1302.h文件不依赖任何特定的 HAL 版本只需要提供 GPIO 读写和微秒延时函数即可。如果你需要移植到其他平台比如 STM32 标准库或者 LL 库只需要替换底层的宏定义。后续我还会把这套驱动和 STM32 的 RTC 结合起来做一个断电后时间自动恢复的完整方案到时候再整理成笔记分享出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑