2026/10/18 11:31:17

STM32驱动DS1302实时时钟:从时序模拟到定时开关实战

STM32驱动DS1302实时时钟:从时序模拟到定时开关实战 1. 为什么还要折腾 DS1302 这颗老芯片前阵子帮朋友做一个带定时开关的控制器主控用的是 STM32F103C8T6 最小系统板需求很简单每天固定时间点触发继电器断电重启后时间不能丢。第一反应肯定是上 DS3231温补晶振、I2C 接口、精度高但翻了下手头的元件盒DS3231 模块用完了倒是翻出来几颗当年买错的 DS1302 直插芯片和配套的 32.768kHz 晶振。想着反正精度要求不高一天误差几秒也能接受就干脆用 DS1302 把这事做了。DS1302 这颗芯片玩过 51 单片机的人基本都认识属于那种入门必点的外设。它用的是三线串行接口严格说不是标准 SPI也不是 I2C而是自己的一套时序协议一根 CE有的资料叫 RST、一根 SCLK、一根双向 IO。正因为时序是自己定义的所以移植到 STM32 上不能直接套用硬件 SPI 或 I2C 外设得老老实实用 GPIO 模拟时序。这也是很多人第一次在 STM32 上驱动 DS1302 时踩坑的地方——习惯了 HAL 库一把梭突然要自己数微秒节奏就乱了。这篇笔记就是把我从接线、写时序、调读写、处理 BCD 码到最终跑通定时功能的完整过程整理出来。内容偏向实操代码基于 STM32 HAL 库但时序逻辑是通用的换成标准库或者别的 Cortex-M 芯片改改 GPIO 操作就能用。适合两类人看一类是刚接触 STM32、想找个简单外设练手时序操作的新手另一类是手头正好有 DS1302 库存、想快速把它用起来的老玩家。我会把每个关键决策背后的原因讲清楚比如为什么上拉电阻不能省、为什么写保护要先关、为什么读出来的秒要屏蔽最高位这些细节文档里往往一笔带过但实际调试时全是坑。2. DS1302 到底是个什么东西先把原理捋顺2.1 三线接口的本质不是 SPI 也不是 I2C很多人看到 DS1302 三根线第一反应是这不就是 SPI 吗。其实差得远。标准 SPI 有明确的 CPOL/CPHA 四种模式主机在时钟边沿采样从机在另一个边沿输出而且 SPI 是主机驱动时钟、数据全双工。DS1302 的协议更原始它把一次通信拆成命令字节 数据字节两个阶段命令字节固定从 bit0 开始发数据字节则是 bit0 先发还是 bit7 先发取决于读还是写。具体来说DS1302 的通信规则是这样的CE 拉高之后SCLK 上升沿把数据写进芯片下降沿把数据从芯片读出来。注意这里有个容易搞混的点——写的时候是上升沿采样读的时候是下降沿输出。所以你在写时序函数的时候如果是写操作先准备好 IO 电平再拉高 SCLK如果是读操作先拉低 SCLK再去读 IO 电平。这个顺序反了读出来的数据就是乱的。命令字节的格式也值得说一下。bit7 固定是 1表示使能访问bit6 是 RAM 还是 CLOCK 的选择0 表示时钟寄存器1 表示 RAMbit1 到 bit5 是寄存器地址bit0 是读写位0 写 1 读。举个例子要写秒寄存器地址 0x80命令字节就是 0x80要读秒寄存器命令字节就是 0x81。这个规律记住之后后面所有寄存器的操作都能推出来。2.2 寄存器地图哪些能用哪些别碰DS1302 的时钟寄存器一共 8 个地址从 0x80 到 0x8E全是偶地址写读的时候地址加 1。我整理了一张表把常用寄存器的位定义列出来方便对照寄存器写地址读地址bit7bit6bit5bit4bit3bit2bit1bit0秒0x800x81CH10秒秒个位分0x820x83010分分个位时0x840x85012/2410时/PM时个位日0x860x8700010日日个位月0x880x8900010月月个位周0x8A0x8B00000周年0x8C0x8D10年年个位写保护0x8E0x8FWP0000000这里有几个关键位必须注意。秒寄存器的 bit7 是 CH 位Clock Halt上电默认是 1意思是时钟停止。你如果只写了时间没清这个位会发现时间根本不走读出来永远是那个值。我第一次调的时候就栽在这盯着串口打印的固定时间看了半天以为是晶振没起振换了两个晶振才发现是 CH 位没清。写保护寄存器 0x8E 的 bit7 是 WP 位上电默认是 1表示写保护开启这时候你往任何时钟寄存器写数据都会被忽略。所以标准流程是先写 0x8E 写 0x00 关保护改完时间再写 0x8E 写 0x80 开保护。这个顺序不能省省了就会出现写进去读出来还是旧值的诡异现象。2.3 备用电池和晶振硬件上最容易翻车的两个点DS1302 有个 VCC1 和 VCC2 双电源引脚VCC1 接备用电池VCC2 接主电源。芯片内部有个电源检测电路主电源掉电时自动切到电池保证时钟继续走。这里有个细节电池电压要低于主电源电压否则芯片可能判断不出主电源掉电一直用电池供电反而加速电池消耗。一般用 3V 的 CR2032 纽扣电池主电源 3.3V压差刚好合适。晶振这块DS1302 要求外接 32.768kHz 晶振而且对负载电容有要求。市面上常见的圆柱形晶振负载电容一般是 6pF 或 12.5pF你得配对应的电容。我手头那批晶振是 6pF 的一开始随手焊了两个 22pF 的电容上去结果时间走得明显偏慢一天能差十几秒。后来查了晶振规格书换成两个 6pF 的电容误差就降到一天两三秒了。如果你对精度有要求建议直接上 DS3231省得跟电容较劲。另外DS1302 的 IO 引脚是开漏输出必须接上拉电阻一般 4.7k 到 10k 都行。我习惯用 4.7k上升沿更陡一点时序更稳。有些模块板子上已经集成了上拉电阻如果你是自己画板子千万别忘了这三个电阻否则读出来的数据全是 0xFF。3. 硬件接线与工程搭建3.1 接线方案三根信号线怎么分配我用的 STM32F103C8T6 最小系统板DS1302 模块是那种带电池座的小板子。接线如下DS1302 引脚STM32 引脚说明VCC23.3V主电源VCC1CR2032 正极备用电池GNDGND共地CEPA4片选/复位SCLKPA5串行时钟IOPA6双向数据X1/X232.768kHz 晶振晶振引脚这里 CE、SCLK、IO 我选了 PA4、PA5、PA6其实随便选三个普通 GPIO 都行只要不是那种有特殊功能的引脚比如 JTAG 占用的 PA13/PA14/PA15。选 PA4 到 PA6 纯粹是因为它们挨着接线方便而且不跟串口冲突调试的时候能用串口打印时间。IO 引脚要注意配置成开漏输出加外部上拉或者配置成推挽输出但在读的时候切换成输入模式。我两种都试过开漏加外部上拉更省事不用来回切模式代码也简洁。如果你板子上没有外部上拉那就得在代码里动态切换输入输出方向稍微麻烦一点。3.2 工程配置CubeMX 里怎么点用 CubeMX 配置的话PA4、PA5 配成 GPIO_OutputPA6 配成 GPIO_Output 开漏模式Output Open Drain上拉使能。时钟树按默认的 72MHz 配就行DS1302 的时序对主频不敏感因为我们是靠软件延时控制节奏的。延时函数这块我直接用 HAL 库的HAL_Delay()做毫秒级延时微秒级延时用__NOP()循环凑。DS1302 的时序要求其实很宽松SCLK 最高频率 2MHz也就是周期 500ns我们只要保证每个电平保持个一两微秒就绝对安全。所以微秒延时不用太精确用循环空转就行。void ds1302_delay_us(uint16_t us) { while (us--) { __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); __NOP(); } }这个延时函数在 72MHz 下大概每个循环 1 微秒左右不用精确到纳秒因为 DS1302 对时序的容忍度很高。我实测过就算延时翻倍或者减半读写照样正常。3.3 引脚宏定义方便移植的写法为了让代码好移植我把引脚操作都封装成宏#define DS1302_CE_PORT GPIOA #define DS1302_CE_PIN GPIO_PIN_4 #define DS1302_SCLK_PORT GPIOA #define DS1302_SCLK_PIN GPIO_PIN_5 #define DS1302_IO_PORT GPIOA #define DS1302_IO_PIN GPIO_PIN_6 #define DS1302_CE_HIGH() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define DS1302_CE_LOW() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define DS1302_SCLK_HIGH() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define DS1302_SCLK_LOW() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define DS1302_IO_HIGH() HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, GPIO_PIN_SET) #define DS1302_IO_LOW() HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET) #define DS1302_IO_READ() HAL_GPIO_ReadPin(DS1302_IO_PORT, DS1302_IO_PIN)这样写的好处是换芯片或者换引脚的时候只改这几个宏就行底层时序函数一行都不用动。我后来把这个驱动移植到另一块 GD32 的板子上就花了五分钟改宏定义。4. 时序代码逐行拆解4.1 写一个字节从 bit0 开始上升沿有效DS1302 写字节的规则是从 bit0 开始每次把数据放到 IO 上然后拉高 SCLK再拉低 SCLK循环 8 次。注意命令字节和数据字节都是 bit0 先发这点跟 SPI 的 MSB first 不一样别搞混了。void ds1302_write_byte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x01) DS1302_IO_HIGH(); else DS1302_IO_LOW(); ds1302_delay_us(1); DS1302_SCLK_HIGH(); ds1302_delay_us(1); DS1302_SCLK_LOW(); ds1302_delay_us(1); dat 1; } }这段代码里dat 0x01取最低位然后右移保证下一次取的是新的最低位。延时放在拉高 SCLK 之前和之后是为了满足建立时间和保持时间。DS1302 的建立时间要求是 50ns保持时间也是 50ns我们延时 1 微秒余量足够大。4.2 读一个字节下降沿之后采样读字节的规则稍微绕一点DS1302 在 SCLK 下降沿把数据输出到 IO 上所以我们要先拉低 SCLK等一会儿再去读 IO 电平。注意读的时候也是从 bit0 开始读到的第一位要放到变量的 bit0 位置。uint8_t ds1302_read_byte(void) { uint8_t i, dat 0; for (i 0; i 8; i) { dat 1; if (DS1302_IO_READ()) dat | 0x80; DS1302_SCLK_HIGH(); ds1302_delay_us(1); DS1302_SCLK_LOW(); ds1302_delay_us(1); } return dat; }这里有个细节dat 1放在读之前然后如果读到 1 就dat | 0x80。这样第一次读到的位最终会落在 bit0第二次落在 bit1以此类推。如果你写成先读再移位顺序就反了读出来的数据是 bit 倒序的。我第一次写的时候就是先读后移结果读出来的秒是 0x80 而不是 0x01查了半天才发现是移位顺序错了。4.3 写寄存器命令字节加数据字节写寄存器的流程是CE 拉高发命令字节发数据字节CE 拉低。CE 拉低之后一次通信结束。注意 CE 拉高之后要先延时一小会儿再发第一个字节给芯片一点准备时间。void ds1302_write_reg(uint8_t cmd, uint8_t dat) { DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_CE_HIGH(); ds1302_delay_us(2); ds1302_write_byte(cmd); ds1302_write_byte(dat); DS1302_CE_LOW(); ds1302_delay_us(2); }CE 拉低的时候顺便把 SCLK 也拉低这是个好习惯保证下次通信从已知状态开始。有些资料说 CE 拉低之后 SCLK 要保持低电平其实拉低 CE 之后芯片内部会复位时序SCLK 什么状态无所谓但统一拉低更规范。4.4 读寄存器注意命令字节的 bit0 要置 1读寄存器的命令字节是写命令字节加 1也就是 bit0 从 0 变 1。比如读秒寄存器写命令是 0x80读命令就是 0x81。读的时候发完命令字节之后IO 要切换成输入模式如果用的是开漏加外部上拉就不用切直接读就行。uint8_t ds1302_read_reg(uint8_t cmd) { uint8_t dat; DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_CE_HIGH(); ds1302_delay_us(2); ds1302_write_byte(cmd); dat ds1302_read_byte(); DS1302_CE_LOW(); ds1302_delay_us(2); return dat; }如果你用的是推挽输出模式读之前要把 IO 引脚切成输入模式读完之后再切回输出。我一开始用推挽读出来的数据全是 0xFF就是因为 IO 还在输出模式芯片输出的数据和 MCU 输出的电平打架了。后来改成开漏加外部上拉这个问题就没了。5. BCD 码转换与时间读写5.1 BCD 码为什么 0x23 表示 23DS1302 内部所有时间寄存器都是 BCD 码格式也就是用 4 个二进制位表示一个十进制数字。比如秒寄存器读到 0x23不是十进制的 35而是表示 23 秒。高 4 位是十位低 4 位是个位。这种格式在早期 RTC 芯片里很常见因为用 BCD 码做十进制显示的时候不用做除法。转换公式很简单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 转十进制高 4 位是 2乘以 10 得 20低 4 位是 3加起来 23。反过来 23 转 BCD23 除以 10 得 2左移 4 位变成 0x2023 模 10 得 3或上去就是 0x23。5.2 设置时间先关写保护再清 CH 位设置时间的完整流程是这样的先关写保护然后依次写秒、分、时、日、月、周、年最后开写保护。注意写秒的时候要把 CH 位清零否则时钟不走。void ds1302_set_time(uint8_t year, uint8_t month, uint8_t day, uint8_t week, uint8_t hour, uint8_t min, uint8_t sec) { ds1302_write_reg(0x8E, 0x00); // 关写保护 ds1302_write_reg(0x80, dec_to_bcd(sec) 0x7F); // 秒清CH位 ds1302_write_reg(0x82, dec_to_bcd(min)); // 分 ds1302_write_reg(0x84, dec_to_bcd(hour)); // 时24小时制 ds1302_write_reg(0x86, dec_to_bcd(day)); // 日 ds1302_write_reg(0x88, dec_to_bcd(month)); // 月 ds1302_write_reg(0x8A, dec_to_bcd(week)); // 周 ds1302_write_reg(0x8C, dec_to_bcd(year)); // 年 ds1302_write_reg(0x8E, 0x80); // 开写保护 }这里dec_to_bcd(sec) 0x7F是为了确保 CH 位是 0。如果你写的时间秒数是奇数比如 23 秒dec_to_bcd(23)得到 0x23最高位本来就是 0与 0x7F 之后还是 0x23。但如果之前 CH 位是 1这个与操作就能把它清掉。5.3 读取时间秒寄存器要屏蔽 CH 位读时间的时候秒寄存器的最高位是 CH 位不是时间数据所以要屏蔽掉。其他寄存器直接转 BCD 就行。void ds1302_get_time(uint8_t *year, uint8_t *month, uint8_t *day, uint8_t *week, uint8_t *hour, uint8_t *min, uint8_t *sec) { *sec bcd_to_dec(ds1302_read_reg(0x81) 0x7F); *min bcd_to_dec(ds1302_read_reg(0x83)); *hour bcd_to_dec(ds1302_read_reg(0x85) 0x3F); *day bcd_to_dec(ds1302_read_reg(0x87) 0x3F); *month bcd_to_dec(ds1302_read_reg(0x89) 0x1F); *week bcd_to_dec(ds1302_read_reg(0x8B) 0x07); *year bcd_to_dec(ds1302_read_reg(0x8D)); }小时寄存器我屏蔽了 bit6 和 bit7因为 bit7 是 12/24 小时制选择位bit6 在 12 小时制下是 AM/PM 标志。我们统一用 24 小时制所以把高两位都屏蔽掉。日寄存器屏蔽 bit6 和 bit7月寄存器屏蔽 bit5 到 bit7周寄存器屏蔽 bit3 到 bit7这些都是保留位读出来可能是随机值屏蔽掉更安全。6. 调试过程中踩过的坑和排查方法6.1 读出来全是 0xFF 或者 0x00这是最常见的问题基本可以锁定三个原因。第一上拉电阻没接或者阻值太大IO 线在空闲时处于浮空状态读出来就是随机的。用万用表量一下 IO 线对 VCC 的电阻正常应该是 4.7k 左右。第二CE 引脚没有正确拉高芯片根本没被选中。用示波器看 CE 波形发命令的时候应该是高电平。第三晶振没起振芯片内部时钟停了这时候读寄存器可能返回默认值。拿示波器探头碰一下晶振引脚能看到 32.768kHz 的正弦波就说明起振了。我遇到过一次读出来全是 0xFF查了半天发现是 IO 引脚配置成了推挽输出读的时候没切成输入MCU 和 DS1302 在 IO 线上打架。改成开漏加外部上拉之后问题消失。6.2 时间不走读出来永远是设定值这个基本就是 CH 位没清。DS1302 上电默认 CH1时钟停止。你写时间的时候如果只写了秒数据没清 CH 位芯片就一直在暂停状态。解决办法就是在写秒寄存器的时候确保最高位是 0。我习惯用 0x7F强制清零这样不管之前 CH 是什么状态写完之后一定是走的。还有一种可能是写保护没关。DS1302 上电默认 WP1所有写操作都被忽略。你写进去的数据实际上没生效读出来还是旧值。标准流程是先写 0x8E 写 0x00再写时间最后写 0x8E 写 0x80。这个顺序不能乱。6.3 时间走得快或者走得慢如果一天误差在几秒以内那是晶振精度问题正常。DS1302 配普通晶振一天差个三五秒很常见。如果一天差几十秒甚至几分钟那就要检查负载电容了。晶振规格书上的负载电容值要跟实际焊接的电容匹配6pF 的晶振配 6pF 的电容12.5pF 的晶振配 12.5pF 的电容。电容大了走得慢电容小了走得快。另外晶振的走线要尽量短远离高频信号线。我有一次把晶振线布在了 SPI 时钟线旁边时间走得乱七八糟后来重新布线就好了。如果对精度要求高建议直接换 DS3231内置温补晶振一年误差也就几分钟。6.4 常见问题速查表现象可能原因排查方法解决办法读出全 0xFFIO 无上拉量 IO 对 VCC 电阻加 4.7k 上拉读出全 0x00CE 未拉高示波器看 CE 波形检查 CE 宏定义时间不走CH 位为 1读秒寄存器看 bit7写秒时 0x7F写入无效写保护未关读 0x8E 看 bit7先写 0x00 关保护时间偏差大负载电容不匹配查晶振规格书换匹配电容数据乱跳时序太快加大延时延时加到 2us偶尔读错电源不稳量 VCC 纹波加 0.1uF 去耦7. 从驱动到应用定时开关的完整实现7.1 每秒读一次时间比较触发条件驱动调通之后应用逻辑就简单了。我在主循环里每 500ms 读一次时间然后跟设定的触发时间比较。如果时和分都匹配就触发继电器同时记录一个标志位防止同一分钟内重复触发。uint8_t last_min 0xFF; while (1) { uint8_t year, month, day, week, hour, min, sec; ds1302_get_time(year, month, day, week, hour, min, sec); if (min ! last_min) { last_min min; if (hour target_hour min target_min) { relay_on(); HAL_Delay(1000); relay_off(); } } HAL_Delay(500); }这里用last_min做去重保证一分钟只触发一次。如果你需要触发多个时间点可以搞个数组遍历比较。7.2 掉电恢复电池供电下的时间保持DS1302 接上 CR2032 之后主电源掉电时芯片自动切到电池时钟继续走。我实测过拔掉主电源放了一晚上第二天上电读出来的时间还是准的误差在正常范围内。注意电池正极接 VCC1负极接 GND别接反了。CR2032 的寿命大概两三年如果发现掉电后时间丢失先量一下电池电压低于 2.5V 就该换了。7.3 精度优化软件补偿的思路如果你不想换 DS3231又想让 DS1302 准一点可以在软件里做补偿。具体做法是先让 DS1302 跑一天记录误差比如一天快 5 秒那就在每天固定时间点把秒数减 5。这个补偿逻辑可以放在主循环里每天执行一次。不过这种方法比较粗糙适合对精度要求不高的场景。真要高精度还是建议上温补晶振的方案。8. 代码组织与移植建议8.1 文件划分驱动层和应用层分开我习惯把 DS1302 的驱动单独放在ds1302.c和ds1302.h里只暴露ds1302_init()、ds1302_set_time()、ds1302_get_time()三个接口。应用层的定时逻辑放在main.c里这样换项目的时候驱动直接复制过去就能用。头文件里只放函数声明和必要的宏具体的引脚定义放在源文件里或者用条件编译区分不同板子。我一般会在头文件里留一个DS1302_USE_OPEN_DRAIN的宏用来切换开漏和推挽模式方便适配不同硬件。8.2 移植到其他平台改三个地方就行这套代码移植到别的平台比如 GD32、ESP32 或者 51 单片机只需要改三个地方GPIO 操作宏、微秒延时函数、初始化函数里的 GPIO 配置。时序逻辑本身是纯软件模拟的跟硬件平台无关。我把它移植到 GD32F103 上的时候就改了宏定义和延时函数编译下载直接就跑起来了。如果你用的是 RTOS注意 DS1302 的读写操作不要被打断否则时序会乱。可以在读写前后关中断或者用互斥锁保护。我一般是在ds1302_write_reg和ds1302_read_reg里关中断操作完再开这样最省事。9. 一些零散但有用的经验DS1302 的 IO 引脚在 CE 拉低之后会变成高阻态这时候如果外部上拉电阻太大IO 线上的电平可能会慢慢漂移。我试过用 10k 上拉发现读数据偶尔会出错换成 4.7k 之后就稳了。所以上拉电阻别省4.7k 是个比较稳妥的值。写命令字节的时候bit7 必须是 1这个位叫使能位。如果你不小心写成了 0芯片会忽略整个命令。我一开始看资料没注意这个细节命令字节直接写寄存器地址结果怎么都不对。后来才发现地址要或上 0x80 才是完整的命令字节。DS1302 的 RAM 寄存器地址 0xC0 到 0xFD可以当普通存储器用掉电不丢数据有电池的情况下。我有时候会拿它存一些配置参数比如触发时间、设备编号之类的省得外挂 EEPROM。不过 RAM 容量只有 31 字节存不了太多东西。最后说一个调试技巧如果你手头没有示波器可以用 LED 来粗略判断时序。在ds1302_write_byte里每发一位就翻转一次 LED如果 LED 闪烁频率均匀说明时序在跑如果 LED 常亮或者常灭说明程序卡在某个循环里了。这个方法虽然土但应急的时候挺好用。这个 DS1302 的驱动我从第一次调通到现在已经在好几个小项目里复用了每次都是复制粘贴改引脚十分钟搞定。虽然芯片老但胜在便宜、好买、资料多对于精度要求不高的场景完全够用。如果你也在用这颗芯片希望这篇笔记能帮你少走点弯路。