☰
STM32驱动DS1302实时时钟:软件SPI时序与BCD码实战
2026/10/21 1:06:51 网站建设 项目流程

做嵌入式开发的朋友,十有八九都跟实时时钟芯片打过交道。项目里要显示时间、做定时记录,最省事的选择就是DS1302——三线接口、两块钱左右的成本、一颗纽扣电池就能在掉电之后继续走时,在STM32的项目里算是非常经典的搭配了。不过这颗芯片看起来简单,第一次驱动它的人十个有九个会踩时序的坑:读回来的秒值是乱的、时间一上电就归零、写进去的数据第二天又跳走。这篇文章就把我在STM32上从零驱动DS1302的过程完整沉淀下来,重点讲透SPI软件模拟的时序、命令字节结构、BCD码存储细节,以及手册里不会明说的小陷阱。适合准备做课程设计、想给自己的小项目加实时时钟,或者刚开始接触SPI协议的朋友参考。

1. 项目概述:DS1302是什么,为什么这么搭

1.1 一颗芯片解决“时间从哪里来”的问题

很多嵌入式项目一开始是没有“时间”概念的。单片机本身没有独立时钟源,就算用定时器累加,掉电就清零,而且晶振频率漂移会导致每天误差几十秒,根本扛不住长期使用。DS1302之所以受欢迎,是因为它把完整的实时时钟电路都装进了一颗8脚芯片里:内部有振荡电路、分频电路、计时寄存器,外部只需要接一颗32.768kHz晶振,就能在2.0V到5.5V宽电压范围内持续走时。

经典的地附带功能是31字节静态RAM(备用RAM)和涓流充电电路。RAM在一般场景里用不上,但涓流充电可以给超级电容或者可充电电池补电,对需要长期离线运行的数据记录设备来说很实用。芯片功耗也很低,数据手册上典型后备电流在微安级以下,我用普通CR2032纽扣电池做过测试,断电后走一两年基本不成问题。

这里要注意一个概念区分:DS1302常常被归类为SPI设备,但它并不是标准的SPI从机。它只有一根双向数据线I/O,没有独立的MISO和MOSI,时序上也不完全兼容标准SPI模式。所以驱动它的时候,最稳妥的做法还是用GPIO软件模拟。

1.2 为什么我坚持用软件模拟SPI

第一次接触DS1302时,我一度想直接用STM32的硬件SPI外设。研究后发现,硬件SPI需要配置成单线半双工模式,收发方向还要手动切换,代码写起来绕,而且DS1302对时序的要求比较特殊——它在SCLK上升沿写入数据、下降沿输出数据,这跟标准SPI的时序相位并不完全对应。另外,DS1302的CE信号需要在整个通信期间保持高电平,突发模式下一次要传多个字节,用硬件SPI反而要把各种边界情况都处理一遍。

软件模拟就没有这些烦恼。三根线全部由GPIO控制,读和写的时序完全自己把握,每一步做什么清清楚楚,出问题也好排查。更重要的是,这种实现方式基本不依赖具体芯片型号,不管是STM32F1、F4还是国产替代型号,只要把三个引脚宏定义改一改,代码就能直接搬过去用。

从工程角度看,三线软件时序对CPU的占用并不高。DS1302的SCLK频率上限一般是2MHz左右,实际跑几百kHz完全没有问题,一次完整的时间读取也就几十微秒,对主循环来说几乎可以忽略。

提示:如果项目里引脚非常紧张,也可以考虑使用I2C接口的RTC芯片,比如DS3231或者PCF8563,但DS1302的优势是价格便宜、外围元件少。优先考虑成本的话,它依然是很有竞争力的选择。

1.3 硬件连接:几根线的事,别接错

DS1302总共8个引脚,除了电源和地之外,真正要跟MCU连接的就是三根线:CE(片选,也叫RST)、SCLK(串行时钟)、I/O(双向数据)。下面是我在STM32上常用的接线方案:

DS1302引脚STM32引脚电平/说明
VCC23.3V主电源,直接接3.3V
VCC1CR2032电池座正极备用电源
GNDGND共地
SCLKPA5推挽输出,串行时钟
I/OPA6双向数据线
CEPA7推挽输出,片选使能
X1/X232.768kHz晶振两端各接一个6pF电容到地

关于I/O口的电平,必须多说一句。DS1302手册里说它可以在5V下工作,但STM32的GPIO是3.3V电平。如果直接把DS1302的VCC2接到5V,它的输出高电平会接近5V,碰到STM32的普通引脚会有风险。稳妥的做法是:DS1302的VCC2就接3.3V,让I/O和MCU保持同一个电平域,这样双向通信完全没问题。

CE脚物理上加一颗10kΩ下拉电阻到地。这个电阻很多人忽略,但它很重要:MCU刚上电时GPIO处于高阻态,CE引脚悬空,DS1302可能会因为引脚上的杂散电平进入错误状态,导致数据错乱。加上下拉电阻之后,CE默认就是低电平,通信完全由MCU控制,稳定性明显好很多。

晶振的负载电容也要按手册来。DS1302官方推荐X1、X2之间接32768Hz晶振,每端对地接6pF电容。有些模块板子上已经把晶振和电容做好了,直接用就行。如果自己做板子,电容值不要随便加大,否则会改变振荡频率,时间会明显走偏。

2. 核心原理拆解:命令字节、寄存器与BCD码

2.1 命令字节:每一次通信的钥匙

DS1302的所有操作都靠命令字节驱动。一次完整通信分三步:先将CE拉高,然后通过I/O线写入一个命令字节,接下来根据命令的读写方向,写入或读出一个、多个数据字节,最后把CE拉低。命令字节的格式非常规律:

bit位765~10
含义固定为11=读,0=写寄存器地址固定为0

比如要读秒寄存器,命令字节就是0x81(二进制1000 0001),写秒寄存器就是0x80(二进制1000 0000)。看这个规律,你甚至可以不用死记手册里的地址表,只要知道寄存器偏移地址,自己拼出命令字节就行。

这里要特别强调bit6这个读写控制位。新手最容易犯的错误就是把读跟写的命令字节搞混,比如要读时间,结果写进去的是0x80而不是0x81,得到的数据自然不对。我调试时踩过一次这个坑:读出来的秒寄存器一直是0,换了命令字节马上正常。

2.2 寄存器映射:一张表看懂时间数据都存在哪

DS1302的时钟寄存器从地址0x00到0x06,分别对应秒、分、时、日、月、星期、年。每个寄存器还有一个“读地址”和“写地址”,对应关系如下表:

寄存器写命令读命令取值范围说明
秒0x800x8100~59bit7是CH停止位
分0x820x8300~59无特殊位
时0x840x8500~23 / 01~12bit7选择12/24小时制
日0x860x8701~31无特殊位
月0x880x8901~12无特殊位
星期0x8A0x8B01~071表示星期日
年0x8C0x8D00~99只存后两位
写保护0x8E0x8F—bit7是WP位
涓流充电0x900x91—控制充电参数

除了时间寄存器,地址0x00到0x06对应的寄存器背后还有两个重要的控制位。秒寄存器的最高位CH是时钟停止位,写1会停止振荡器,写0才会启动。很多芯片出厂时CH位是1,所以第一次上电时时间不走是正常的。初始化时写秒寄存器之前,一定要把CH位清零,我就是在这里栽过跟头,读了老半天,时间一点没动。

写保护寄存器0x8E的bit7是WP位,置1时所有寄存器都禁止写入。实际操作中我习惯在设置时间前先关闭写保护,全部写完后再开启写保护,这样能防止意外写入导致时间错乱。

时间数据在DS1302内部是以BCD码存放的,这一点非常关键,BcdToDec转换写错一格,读出来的时间就对不上。BCD码的本质就是用一个字节拆成两个十进制位,高4位是十位,低4位是个位。比如十进制的59,BCD码就是0x59。所有时间寄存器,除了12/24小时制切换位,都遵循这个规则。

2.3 读写时序:上升沿写入,下降沿读出

时序是DS1302最核心的难点,也是很多初学者卡住的地方。先把写字节的时序拆开:CE拉高后,MCU先把I/O线上的数据位准备好,然后给SCLK一个上升沿,芯片就会在这个上升沿采样I/O上的电平。每写一个字节,需要重复8次这个过程,先传命令字节,再传数据字节。

读字节的时序稍有不同。命令字节依然是在SCLK上升沿写入,但数据位的输出发生在SCLK下降沿。也就是说,芯片会在SCLK由高变低后,把下一位数据放到I/O线上。MCU需要在这个下降沿之后、下一个上升沿之前,把I/O线读进来。

这里有个很隐蔽的坑:STM32的GPIO翻转速度很快,如果不加一点延时,MCU可能还没来得及读,芯片就切换到了下一位数据。我在代码里每个时钟沿之间做了几百纳秒的延时,实测下来读数据稳定很多。具体延时多少,还要看I/O上下拉电阻和线路电容,通常几百纳秒到1微秒是比较安全的范围。

CE信号在整个通信过程中必须保持高电平,一个字节传完不能把CE拉低,否则芯片会认为通信结束,后面再传的数据就不认了。多字节传输时更要小心,CE直到最后一个字节传完才能拉低。

2.4 涓流充电配置:想开启先确认用法

DS1302的涓流充电功能很有意思,但很多人不了解它的配置规则,或者干脆用错了。涓流充电寄存器的地址是0x08,写命令0x90,读命令0x91。它的bit7到bit4必须写成1010才能允许修改,这相当于一个安全识别码;bit3和bit2用来选择串联二极管数量,bit1和bit0用来选择限流电阻阻值。

只有当电路上接的是充电电池或者超级电容时,才应该开启涓流充电。如果备用电源用的是CR2032这类不可充电的纽扣电池,千万不能开启涓流充电功能。锂锰纽扣电池被充电后会膨胀甚至漏液,轻则损坏电路板,重则腐蚀芯片引脚。

我做过一次测试,用超级电容330F配合DS1302的涓流充电,选择1个二极管加2kΩ电阻,充电电流大概在几百微安量级,白天系统供电时给电容补电,晚上断电后靠电容维持走时,效果很不错。如果你只是普通产品上放一颗CR2032,就别折腾这个功能了。

3. 实操落地:从底层GPIO到时间读写

3.1 底层驱动:用HAL库实现GPIO模拟时序

下面这段代码以STM32的HAL库为例,先把三个引脚用宏定义出来,方便以后改引脚。标准库或者LL库的写法,思路完全一样,只是GPIO操作的函数名不同。

#include "main.h" #define DS1302_CE_PORT GPIOA #define DS1302_CE_PIN GPIO_PIN_7 #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)

GPIO初始化里,CE和SCLK配置为推挽输出,I/O口初始化为开漏输出模式,因为开漏模式下可以直接切换输入和输出方向,不需要重新配置整个GPIO。

void DS1302_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = DS1302_CE_PIN | DS1302_SCLK_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = DS1302_IO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }

底层读写字节是核心中的核心,直接照着时序写就行。

static void DS1302_Delay(void) { for (volatile uint32_t i = 0; i < 50; i++); } static void DS1302_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { DS1302_SCLK_LOW(); if (dat & 0x01) DS1302_IO_HIGH(); else DS1302_IO_LOW(); DS1302_Delay(); DS1302_SCLK_HIGH(); DS1302_Delay(); dat >>= 1; } } static uint8_t DS1302_ReadByte(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(); DS1302_SCLK_LOW(); DS1302_Delay(); } return dat; }

两个函数看起来很像,但方向完全不同。写字节时,数据在SCLK上升沿被芯片采样,所以I/O线设置好之后才拉高SCLK;读字节时,芯片在SCLK下降沿把数据放到I/O线上,所以MCU在下降沿之后、下一次拉高之前去读电平。延时函数我用了简单的循环,如果系统主频很高,可以适当调整循环次数,或者在调试时直接看示波器波形。

3.2 时间设置与读取的完整流程

设置时间的第一步,是关闭写保护。然后按照秒、分、时、日、月、星期、年的顺序,依次写入转换后的BCD码。秒寄存器要注意先把CH位清零,也就是写入的BCD码最大只能到0x7F,或者直接用按位与把最高位关掉。

typedef struct { uint8_t year; // 00~99 uint8_t month; // 01~12 uint8_t date; // 01~31 uint8_t day; // 01~07 uint8_t hour; // 00~23 uint8_t min; // 00~59 uint8_t sec; // 00~59 } DS1302_Time; static uint8_t DS1302_DecToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); } static uint8_t DS1302_BcdToDec(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } void DS1302_SetTime(const DS1302_Time *time) { // 关闭写保护 DS1302_CE_HIGH(); DS1302_WriteByte(0x8E); DS1302_WriteByte(0x00); DS1302_CE_LOW(); // 写秒寄存器,清掉CH停止位 DS1302_CE_HIGH(); DS1302_WriteByte(0x80); DS1302_WriteByte(DS1302_DecToBcd(time->sec) & 0x7F); DS1302_CE_LOW(); // 写分寄存器 DS1302_CE_HIGH(); DS1302_WriteByte(0x82); DS1302_WriteByte(DS1302_DecToBcd(time->min)); DS1302_CE_LOW(); // 写时寄存器,这里默认24小时制 DS1302_CE_HIGH(); DS1302_WriteByte(0x84); DS1302_WriteByte(DS1302_DecToBcd(time->hour)); DS1302_CE_LOW(); // 写日、月、星期、年 DS1302_CE_HIGH(); DS1302_WriteByte(0x86); DS1302_WriteByte(DS1302_DecToBcd(time->date)); DS1302_CE_LOW(); DS1302_CE_HIGH(); DS1302_WriteByte(0x88); DS1302_WriteByte(DS1302_DecToBcd(time->month)); DS1302_CE_LOW(); DS1302_CE_HIGH(); DS1302_WriteByte(0x8A); DS1302_WriteByte(DS1302_DecToBcd(time->day)); DS1302_CE_LOW(); DS1302_CE_HIGH(); DS1302_WriteByte(0x8C); DS1302_WriteByte(DS1302_DecToBcd(time->year)); DS1302_CE_LOW(); // 开启写保护 DS1302_CE_HIGH(); DS1302_WriteByte(0x8E); DS1302_WriteByte(0x80); DS1302_CE_LOW(); }

读取时间更推荐用读突发模式,一条命令把7个寄存器全部读出来。读突发模式的命令是0xBF,芯片会从秒寄存器开始,连续输出7个字节。这样既省去多次拉高CE的时序开销,也能保证读到的数据是同一时刻的快照,不会出现跨秒时分钟和秒数不匹配的情况。

void DS1302_GetTime(DS1302_Time *time) { DS1302_CE_HIGH(); DS1302_WriteByte(0xBF); time->sec = DS1302_BcdToDec(DS1302_ReadByte() & 0x7F); time->min = DS1302_BcdToDec(DS1302_ReadByte()); time->hour = DS1302_BcdToDec(DS1302_ReadByte()); time->date = DS1302_BcdToDec(DS1302_ReadByte()); time->month = DS1302_BcdToDec(DS1302_ReadByte()); time->day = DS1302_BcdToDec(DS1302_ReadByte()); time->year = DS1302_BcdToDec(DS1302_ReadByte()); DS1302_CE_LOW(); }

所有CE拉高的操作都要保证,这个字节之后还有下一个字节在传。写多个寄存器时,每个命令都重新拉高CE、写完再拉低,虽然慢一点,但逻辑清晰。读时间用突发模式之后,整体读取时间不到1毫秒,对主循环几乎没有影响。

3.3 主程序调用:串口打印验证时间

驱动写完之后,验证时间最直接的方式是串口打印。下面这段代码演示了在main函数里初始化、设置一次时间,然后每秒打印一次时间。注意不要在主循环里反复调用设置时间的函数,时间会被不断覆盖,看起来就像时钟不走一样。

int main(void) { DS1302_Time now; HAL_Init(); SystemClock_Config(); DS1302_GPIO_Init(); UART_Init(); now.year = 24; now.month = 1; now.date = 15; now.day = 1; now.hour = 10; now.min = 30; now.sec = 0; DS1302_SetTime(&now); while (1) { DS1302_GetTime(&now); printf("%04d-%02d-%02d %02d:%02d:%02d\r\n", 2000 + now.year, now.month, now.date, now.hour, now.min, now.sec); HAL_Delay(1000); } }

读出来的年份只有两位,打印的时候需要自己加2000。这里还有一个细节:DS1302不保存世纪信息,跨世纪的时候2000年和2100年都一样,产品上如果需要区分,一定要在应用层自己维护世纪变量。

4. 常见问题与排查技巧实录

4.1 时间读出来全零或者数据乱跳

这是DS1302驱动里出现频率最高的问题。如果读秒寄存器一直是0,先检查命令字节是不是写成了0x80而不是0x81;如果所有寄存器都是0,先检查写保护寄存器有没有被意外置1。我调试时发现一个规律:读到的数据是稳定全零,多半是命令字节错了;读到的数据乱跳,多半是I/O口方向切换出了问题。

I/O口方向切换最典型的问题,是在读模式下,I/O口还被配置成推挽输出。开漏输出配合外部上拉,才能实现真正的双向通信。如果你用的是标准外设库,一定要注意GPIO配置里的模式是GPIO_Mode_Out_OD,读之前不需要每次重新初始化,开漏输出模式下直接读引脚电平即可。

时序延时问题也会导致数据乱跳。STM32主频跑到72MHz甚至更高时,GPIO翻转可能只要几十纳秒,而DS1302的数据手册要求的最小SCLK高电平时间、数据建立时间都是百纳秒级别。我建议在主频比较高的系统上,时钟沿之间加一个几百纳秒的软件延时,不要迷信“芯片越快越好”,稳定压倒一切。

4.2 时间一走快、一走慢,晶振电路出问题

如果读出来的时间是稳定递增的,但每天误差很大,问题基本都出在晶振电路上。DS1302必须使用32.768kHz的晶振,这个频率的晶振经过内部15级二分频之后刚好得到1Hz秒脉冲。如果误用了其他频率的晶振,时间走速会完全不对。

晶振两端的负载电容大小会影响振荡频率。6pF的电容换成了20pF,振荡频率会偏低,时间就走得慢;反过来频率偏高,时间就走得快。手上的电容标称值也不一定准确,有条件的话用示波器测一下DS1302的秒脉冲输出,或者通过串口打印时间做对比,误差超过每天几秒就需要考虑调整电容。

另一种奇怪的现象是,刚上电时时间正常,运行一段时间后误差越来越大。这往往不是晶振问题,而是电源纹波或者I/O线串扰导致芯片偶尔误触发。排查方法很简单:把DS1302的三根信号线用短导线直接接到STM32引脚,不要和电机、继电器等大功率负载的导线绑在一起,现象通常会改善很多。

4.3 断电后时间丢失,备用电池接法有问题

正常接法下,掉电后时间应该由VCC1上的备用电池继续供电。如果断电再上电,时间回到初始值,先检查VCC1是否确实接到了电池正极,GND是否共地。一个容易忽略的细节是电池座接触不良,CR2032电池座质量参差不齐,有些簧片力度不够,震动几下就断连。

备用电池不要和主电源之间加二极管。很多网友分享的电路里,VCC2和VCC1之间会串联一个二极管做电源切换,但这个二极管必须选低压降的肖特基管,否则电池电压会被吃掉不少。DS1302内部其实已经有电源切换逻辑,VCC2比VCC1高时用VCC2供电,反之切到VCC1,外部是不需要额外加切换电路的。加了不必要的二极管,反而会增加压降,缩短电池寿命。

最后检查一下程序初始化时是否设置了写保护。如果设置时间后把WP写回1,正常使用时不会丢失。有些程序里设置时间后忘记重新开启写保护,理论上并不会导致时间丢失,但抗干扰能力会下降。工业现场或者强干扰环境下,建议设置完时间后恢复写保护状态。

我在实际使用中的体会是,DS1302这颗芯片的难点从来不在硬件本身,而在于对时序和数据格式的理解。很多同学一上来就复制网上的代码,读时间出来不对,就怀疑芯片坏了,其实静下心对着手册的时序图捋一遍,基本都能找到原因。代码开源的方案在GitHub或各种技术社区里一搜一大把,但真正拿来用之前,最好还是自己重新写一遍底层时序,这个过程对理解SPI类通信协议的帮助非常大。

最后分享一个小技巧:调试时不要每次都串口打印完整时间,直接在秒寄存器变化时翻转一个LED,眼睛能直观看到1秒翻转一次,就知道底层时序没问题了。等这个验证通过,再去处理显示和上层逻辑,能省下一大堆排查时间。DS1302本身的技术不算新,但在很多低成本场景里依然很能打,只要把时序和寄存器搞明白,这颗芯片基本上一次就能调通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询