
写文章之前先说点实在的为什么我对这个组合挺有感情因为“STM32 DS1302”几乎是每个嵌入式学习者都绕不开的组合拳。DS1302是用三线串口访问的实时时钟芯片STM32则是目前学习资料最多、生态最成熟的MCU系列之一两者搭在一起既能练手底层时序又能实现一个真正有用的功能——掉电不丢时间。这篇笔记适合正在学STM32的同学也适合做低成本时钟项目、需要给板子加一个可靠本地时间的开发者。做嵌入式这些年我最大的感受是芯片选型往往不是越贵越新越好而是要在成本、学习价值和实际需求之间找到那个平衡点。DS1302就是这样一块芯片它不算新甚至有点“老古董”但恰恰因为接口简单、寄存器结构清晰用来理解时序、理解芯片通信协议的底层逻辑非常合适。这篇笔记我会从选型原因开始把硬件设计、驱动代码、踩坑记录完整过一遍你可以直接照着复现也可以把它当成一个入门级驱动框架去改造成其他三线协议芯片。1. 方案选型解析为什么偏偏是DS13021.1 核心特性与它的定位DS1302是Dallas Semiconductor现在归在Maxim旗下推出的实时时钟芯片核心功能就是计时间能到秒支持年月日时分秒和星期内置31字节静态RAM可以用来存一些掉电不丢的数据。它最大的一个特点就是接口简单只有三根线CE片选、SCLK时钟、IO数据任何单片机都能用GPIO模拟时序驱动不需要专门的硬件外设。工作电压方面DS1302支持2.0V到5.5V宽范围给3.3V的STM32供电系统用完全没问题。功耗比较低加上备用电池后在主电源断电的情况下可以靠纽扣电池继续走时这也是它相比单片机内部RTC最明显的优势——单片机一掉电内部RTC的时间就丢了而DS1302外接电池就能保住时间。这块芯片还带一个涓流充电功能可以在电池电压低的时候用主电源给锂电池或者超级电容慢速充电这一点在小功耗产品里比较实用。1.2 与同类型时钟芯片对比选型不纠结我在选型的时候其实是做过一轮对比的因为市面上实时时钟芯片不少光常用的就有DS3231、PCF8563、RX8025这些。简单说说我当时横向比较的结果芯片接口精度价格区间参考特点DS1302三线自制时序外部晶振决定普通32.768kHz晶振走时会有偏差1-3元接口简单适合学习带RAM与涓流充电DS3231I2C内置温补晶振年误差通常很小5-15元精度极高但贵不少常用在需要高精度时间的终端设备PCF8563I2C外部晶振决定2-5元超低功耗I2C接口寄存器操作不算难RX8025I2C内置晶振精度中等偏上6-10元原装价格偏高需要关注货源这里要说明一点我选择DS1302并不是因为它性能最好恰恰是因为它在几个维度上更适合做学习项目和低成本应用。其一三线接口要比I2C更容易让人理解通信的本质——一根时钟线控制节奏一根数据线传数据片选线决定芯片是否参与通信这种直观性是I2C那种主从寻址机制比不了的。其二芯片价格低几块钱甚至一两块钱就能买到学习和批量使用都不心疼。其三寄存器结构很清晰读手册半小时就能明白。如果你的项目对时间精度要求很高比如做一个数据采集终端每天误差都要控制在几百毫秒以内那建议直接选DS3231。但如果只是做一个电子时钟、一个定时器或者你只是想通过一个具体项目把GPIO模拟时序彻底搞明白DS1302是一个非常合适的起点。2. 硬件设计要点与连接细节2.1 引脚功能与最小系统电路DS1302的封装常见的是DIP-8和SOP-8引脚功能不复杂引脚名称功能说明1VCC2主电源正常工作时接3.3V或5V2X132.768kHz晶振输入3X232.768kHz晶振输出4GND地5CE片选使能高电平有效读写前必须拉高6SCLK串行时钟输入7IO数据输入/输出8VCC1备用电池正极接纽扣电池或超级电容典型电路里要注意的有这么几个点晶振必须用32.768kHz的这个频率对应2的15次方分频后正好是1Hz用来产生秒信号。晶振两端各接一个6pF左右的负载电容到地。有些朋友图省事直接买那种带电容的晶振模块插上去发现时间不准甚至不走原因多半是负载电容不匹配。DS1302内部已经集成了部分振荡电路对负载电容比较敏感最好按手册推荐的参数来。CE、SCLK、IO三根线与STM32连接时根据STM32型号不同选择的引脚也不同。比如用STM32F103C8T6这种常见型号可以把PA0、PA1、PA2分别接CE、SCLK、IO。习惯上我会在IO线上加一个10kΩ上拉电阻因为IO线是双向的在读数据时STM32要把这个引脚配置成输入模式如果没有上拉电阻读到的电平可能会不稳定。2.2 电源切换与备用电池的连接方法DS1302的电源设计是它一个比较有意思的地方它有两个电源引脚VCC1和VCC2。芯片内部会自动选择电压较高的那一路供电不需要你手动切换。这个机制在典型应用里就是VCC2接系统主电源VCC1接纽扣电池系统上电时用主电源系统断电时自动切到电池。我自己测试过用CR2032纽扣电池的情况在常温下DS1302的走时电流在微安级别一颗新的CR2032撑两三年问题不大。如果你的应用环境温度变化大建议在电池两端并联一个0.1μF的陶瓷电容和10μF左右的电解电容用来滤除电源噪声防止因为电压跌落导致时间跳变。备用电池不一定要用电池也可以用超级电容。超级电容的好处是可充电次数多、寿命长但自放电率偏高断电后能撑多久取决于电容容量和芯片功耗。用1F左右的超级电容实测在室温下大约能维持DS1302走时一周到两周这个方案适合那些只是短暂断电、不需要长时间保持时间的设备。还有一点要留意CE引脚不可以在上电之前拉高。如果CE提前拉高芯片可能进入一种未定义状态表现为读写无响应或时间错乱。实际接线中如果CE接了MCU的GPIO默认情况下MCU上电时GPIO通常是浮空或低电平注意不要在初始化时把这个引脚先拉高再初始化其他外设。3. 驱动代码实现手把手完整走一遍3.1 三线时序的核心原理DS1302的三线接口本质上和SPI很像但它是自己的一套时序规则不能用STM32的硬件SPI直接对接因为它的数据是在时钟的上升沿写入、下降沿读出的。所以最稳妥的方式就是用GPIO模拟时序这也是我推荐大家手写一遍的原因。核心规则整理出来就三条写数据SCLK上升沿到来前IO引脚先设置好高低电平上升沿时DS1302采样锁存。读数据SCLK下降沿到来后DS1302会从IO引脚输出一位数据主控此时去读。每次通信都从CE拉高开始CE拉低结束。一次完整的读写操作可以拆成两段先发一个命令字节告诉芯片要操作哪个寄存器、是读还是写然后才是真正的数据字节。命令字节的结构是最高位固定为1第6位选择访问时钟寄存器还是RAM区中间几位是寄存器地址最低位是读写控制位。需要特别注意的是DS1302的数据传输方向是LSB first也就是最低位在前。很多人在这个地方吃过亏写成MSB first结果发现读出来的全是乱码。这一点建议直接标在代码注释里避免过几天回来看自己都忘了。3.2 时钟寄存器与BCD码转换DS1302的时间寄存器是分开编址的每个时间项都有自己的地址而且读地址和写地址相差1。秒、分、时、日、月、星期、年分别对应一组地址比如秒寄存器的写地址是0x80读地址是0x81分的写地址是0x82读地址是0x83以此类推。芯片内部的时间数据是用BCD码存储的。BCD码用4位二进制表示一个十进制位比如十进制的59在BCD码里就是0x59高4位是5低4位是9。所以在驱动代码里一定要有二进制和BCD码互转的函数否则你写进去一个59芯片可能理解成别的值。时寄存器还有个细节DS1302支持12小时制和24小时制默认我用的是24小时制。12小时制时第7位是AM/PM标志这个位会影响小时值的解析如果不需要12小时制初始化时确保把这一位清零。另外秒寄存器的最高位是CH位Clock Halt这一位为1时振荡器停止工作时间就不走了。很多人初始化完发现时间不动十有八九是这个位没清零。写保护寄存器地址是0x8E它的最高位是WP位为1时禁止写入时间寄存器。所以在写时间之前要先把写保护关掉写完后再打开防止误操作改乱时间。3.3 完整驱动代码与逐段说明下面是我整理并测试过的驱动代码基于STM32 HAL库GPIO模拟三线时序把关键部分分块说明。首先是GPIO初始化和宏定义#define DS1302_GPIO_PORT GPIOA #define DS1302_CE_PIN GPIO_PIN_0 #define DS1302_SCLK_PIN GPIO_PIN_1 #define DS1302_IO_PIN GPIO_PIN_2 #define DS1302_CE_HIGH() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define DS1302_CE_LOW() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define DS1302_SCLK_HIGH() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define DS1302_SCLK_LOW() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define DS1302_IO_HIGH() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_SET) #define DS1302_IO_LOW() HAL_GPIO_WritePin(DS1302_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET) #define DS1302_IO_READ() HAL_GPIO_ReadPin(DS1302_GPIO_PORT, DS1302_IO_PIN) static void DS1302_IO_Output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_GPIO_PORT, GPIO_InitStruct); } static void DS1302_IO_Input(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DS1302_GPIO_PORT, GPIO_InitStruct); } void DS1302_GPIO_Init(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_CE_PIN | DS1302_SCLK_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_GPIO_PORT, GPIO_InitStruct); DS1302_IO_Output(); DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_IO_LOW(); }然后是底层字节读写函数。这里我把延时写成了delay_us你可以根据自己用的平台替换成实际的微秒延时函数。时序上要求SCLK高电平保持时间和低电平保持时间都不小于手册规定的最小值实际用1到5微秒都比较稳。static void DS1302_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { DS1302_SCLK_LOW(); if (data 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } delay_us(2); DS1302_SCLK_HIGH(); delay_us(2); data 1; } } static uint8_t DS1302_ReadByte(void) { uint8_t data 0; DS1302_IO_Input(); for (uint8_t i 0; i 8; i) { data 1; DS1302_SCLK_LOW(); delay_us(2); if (DS1302_IO_READ()) { data | 0x80; } DS1302_SCLK_HIGH(); delay_us(2); } DS1302_IO_Output(); return data; }读字节函数里有个容易被忽略的细节在读取数据期间IO方向要从输出切换成输入读完之后再切回输出。如果忘了切换IO引脚一直处于推挽输出状态读回来的电平就会一直是你写出去的那个值典型表现就是无论怎么读都读到同一个固定数值。有了字节读写寄存器读写就很容易了void DS1302_WriteReg(uint8_t addr, uint8_t data) { DS1302_CE_HIGH(); DS1302_WriteByte(addr 0xFE); // 最低位为0表示写操作 DS1302_WriteByte(data); DS1302_CE_LOW(); } uint8_t DS1302_ReadReg(uint8_t addr) { uint8_t data 0; DS1302_CE_HIGH(); DS1302_WriteByte(addr | 0x01); // 最低位为1表示读操作 data DS1302_ReadByte(); DS1302_CE_LOW(); return data; }每次读写都要有完整的CE拉高到拉低过程。有些朋友想省事把CE一直拉高只靠SCLK来访问在DS1302上这样是不允许的。手册里明确要求每次访问必须在CE上升沿之后开始CE下降沿之后结束。没有完整的CE脉冲读写数据会产生错位。3.4 BCD转换和时间设置读取时间设置与读取函数配合BCD转换函数static uint8_t BinToBcd(uint8_t bin) { return ((bin / 10) 4) | (bin % 10); } static uint8_t BcdToBin(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); } void DS1302_SetTime(uint8_t year, uint8_t month, uint8_t day, uint8_t week, uint8_t hour, uint8_t minute, uint8_t second) { DS1302_WriteReg(0x8E, 0x00); // 关闭写保护 DS1302_WriteReg(0x80, BinToBcd(second) 0x7F); // 秒并确保CH位为0 DS1302_WriteReg(0x82, BinToBcd(minute)); DS1302_WriteReg(0x84, BinToBcd(hour)); DS1302_WriteReg(0x86, BinToBcd(day)); DS1302_WriteReg(0x88, BinToBcd(month)); DS1302_WriteReg(0x8A, BinToBcd(week)); DS1302_WriteReg(0x8C, BinToBcd(year)); DS1302_WriteReg(0x8E, 0x80); // 打开写保护 } void DS1302_GetTime(DS1302_Time_t *time) { if (time NULL) { return; } time-second BcdToBin(DS1302_ReadReg(0x81) 0x7F); time-minute BcdToBin(DS1302_ReadReg(0x83)); time-hour BcdToBin(DS1302_ReadReg(0x85)); time-day BcdToBin(DS1302_ReadReg(0x87)); time-month BcdToBin(DS1302_ReadReg(0x89)); time-week BcdToBin(DS1302_ReadReg(0x8B)); time-year BcdToBin(DS1302_ReadReg(0x8D)); }时间结构体我习惯这样定义typedef struct { uint8_t year; // 00-99表示2000-2099 uint8_t month; // 01-12 uint8_t day; // 01-31 uint8_t week; // 1-7周一为1 uint8_t hour; // 00-23 uint8_t minute; // 00-59 uint8_t second; // 00-59 } DS1302_Time_t;初始化时有一个很容易犯的错只调用时间设置函数但没有先关闭写保护。因为DS1302上电后写保护默认是打开的直接写寄存器不会生效。如果你的程序里设置了时间但读取回来的时间还是初始值先检查写保护有没有按顺序关开。还有一点经验读时间的时候建议连续读两次对两次的结果做一次校验。因为读取过程不是原子的如果刚好跨到秒边界你可能会读到“59分59秒”变成“59分60秒”这种异常组合。连续读两次如果结果一致就采纳不一致就再读一次能有效避免边界错误。很多产品代码里都会用这个思路做软件容错。4. 实操过程中的踩坑记录4.1 读出来的数据全是0xFF或全是0x00这个问题我在调试时第一个遇到相信很多人也遇到过。排查方向有这么几个第一确认IO方向切换是否正确。读数据前一定要把IO引脚切到输入模式而且要有上拉电阻。如果IO一直是推挽输出模式读回来的电平只会是你上一次写出的电平表现就是数据固定不变。第二检查CE的时序。CE拉高后要稍微等一下再开始传时钟CE拉低也要等SCLK完全结束后再去操作其他引脚。用示波器或逻辑分析仪抓一下CE和SCLK的关系可以看得非常清楚。第三检查IO引脚有没有和其他外设冲突。有些开发板的PA2默认接了板载的其他器件比如USART2的TX如果你用过串口2要确认功能复用是不是已经解除。这种情况很隐蔽单独测引脚电平都正常但一跑驱动就是读不到有效数据。4.2 时间设置成功但秒钟不走这个现象很有意思时间能写进去芯片也能读出来但就是不走或者走一会儿就停住。最常见的元凶是秒寄存器的CH位没有清零。CH位是秒寄存器的最高位值为1时振荡器停止。如果你在写秒的时候没有对数据进行掩码处理直接用BinToBcd(second)赋值那么秒为0到59时BCD码最高位恰好是0正常情况下没问题但如果你不小心把写保护关闭后又写入了一个最高位为1的值振荡器就会停掉。另外也要检查晶振是否真正起振了。用示波器探头夹在X1引脚上如果能看到32.768kHz的正弦波说明振荡器在工作。如果看不到波形优先检查晶振有没有虚焊、负载电容是否匹配。市面上的32.768kHz晶振分为很多种负载电容规格常见的有6pF、7pF、12.5pFDS1302手册推荐的是6pF买的时候尽量选负载电容小的。还有一个容易被忽略的因素是SCLK引脚上的毛刺。如果SCLK上电瞬间出现高频抖动DS1302可能误判为有效时钟把寄存器改乱了。解决方法是SCLK引脚上串联一个几百欧的电阻或者在软件初始化阶段先确保SCLK为低电平再对CE进行拉高操作。4.3 掉电后时间丢失或走时大幅变快如果设备断电后重新上电时间回到了默认的初始值问题基本出在备用电池电路上。先检查电池是否接反、是否没电、VCC1引脚电压是否正常。另一个比较隐蔽的问题是电池正极直接和VCC2连在一起了这样主电源断电后电池会给整个系统供电导致电池很快耗尽。走时变快往往不是DS1302本身的问题而是晶振频率偏了。如果晶振的负载电容和芯片要求的不匹配振荡频率会偏离32.768kHz频率偏高等效于时间走快。这个偏差很难靠软件修正最好的办法是换一颗匹配的晶振。非要软件补偿的话可以通过定期校时或者写一个频率补偿值来近似解决但精度有限。4.4 一个容易被忽略的问题读时间时跨秒边界我在4.2节提到过连续读两次校验这里展开说一下。曾经在一个项目里我用DS1302驱动一个数字时钟显示上偶尔出现“秒60”这种离谱值。排查到最后发现是因为主循环里读时间的那条代码恰好卡在秒值从59跳转到00的那一刻。读出来高4位已经是新的值低4位还是旧的值拼在一起就是一个不存在的秒数。解决方式很简单void DS1302_GetTimeSafe(DS1302_Time_t *time) { DS1302_Time_t t1, t2; do { DS1302_GetTime(t1); DS1302_GetTime(t2); } while (memcmp(t1, t2, sizeof(DS1302_Time_t)) ! 0); *time t1; }代码逻辑就是连续读两次直到两次结果完全一致。因为两次读取的时间间隔极短如果结果不同一定是卡在了边界重试就能读到完整一致的数据。这种做法虽然多花了几个微秒但换来的是数据可靠性在时钟对精度要求不高的场景里非常推荐。5. 从一个时钟走向一个完整项目5.1 让时间“可见”搭配OLED显示DS1302本身只是提供了一个时间源要成为一个用户能看的产品得给它加个显示设备。SSD1306驱动的0.96寸OLED是性价比很高的选择接口是I2C或者SPI代码量也不大。我建议在驱动里把时间和显示彻底解耦DS1302驱动只负责读写时间OLED驱动只负责显示中间用一个公共结构体传数据。这样以后如果要把OLED换成LCD1602或者TFT屏幕只需要改显示层不用动时间层。这也是嵌入式开发里很基础但很重要的分层思想。5.2 联网自动校时解决DS1302走时偏差DS1302用普通晶振走时精度实在一般一天下来快慢几秒都很正常。要彻底解决这个问题最佳方案就是联网校时。比如在项目里加一个ESP8266或者ESP32模块开机时从网络获取标准时间然后一次性写入DS1302。之后即便断网设备依然有稳定的本地时间源而且每天可以定时校准一次。我在实际项目里用过这种方式设备每次联网成功后就调用一次校时接口把DS1302的时间重新写一遍。实测下来只要校时频率不低于每天一次设备显示的时间就能始终保持在一个可接受的精度范围内。这也是低成本设备常用的“网络校时RTC保持”方案。5.3 粘手就来的开源整理习惯既然标题里带了“开源”最后分享一点整理开源代码的经验。代码结构建议至少分成三个关注点底层驱动、应用层调用和示例。底层驱动不要和具体的业务逻辑混在一起最好做到换一个单片机型号、只要改引脚宏定义和延时函数就能跑起来。示例文件则应该有完整的main函数和注释让别人能直接编译运行看到效果。开源许可证我一般选MIT一行字就能说明白随便用出了事别找我保留版权声明就行。给代码写README的时候开头放一张实物接线图比写一千字都好使。如果你暂时不知道怎么写README把引脚连接表、芯片手册链接、编译环境版本这三样放上去就已经比很多仓库专业了。6. 写在最后的一点体会DS1302这个芯片本身不是什么高精尖的东西但它陪我走过了最开始那段学STM32的时光。前前后后踩过IO方向没切换的坑、吃过晶振负载电容不匹配的亏也体验过第一次断电再上电时间依然准确走时的成就感。这些经验书本上都有但不亲手做一遍永远只是“知道”而不是“掌握”。如果你正在学STM32我强烈建议不要只跑一个GPIO点灯就换项目花两三天时间把一个DS1302驱动彻底调通弄懂每一行代码和每一个时序图的对应关系你会对MCU的GPIO操作、时序概念、寄存器读写有完全不一样的理解。之后再去看SPI、I2C都会觉得轻松很多。驱动代码我是按开源MIT许可放到仓库里的功能和结构都在上面你直接拿来用没问题。如果调不通先查时序再查硬件八成问题都出在这两处。希望这篇笔记能帮你少走一点弯路。