ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32驱动DS1302时钟芯片:三线时序与BCD码实战

STM32驱动DS1302时钟芯片:三线时序与BCD码实战

1. 为什么还要折腾一颗"过时"的时钟芯片

如果你最近在做一个带时间显示的小项目,比如桌面时钟、数据记录仪或者定时控制器,大概率会面临一个选择:用STM32内部自带的RTC,还是外挂一颗专门的时钟芯片。很多人第一反应是"内部RTC不香吗,省一颗芯片省两根线"。我一开始也是这么想的,直到被内部RTC的低速晶振起振问题折腾了整整两天,才回头老老实实捡起DS1302。

DS1302这颗芯片说起来年纪不小了,但它至今仍然活跃在大量教学项目和中小型产品里,原因很实在:便宜、好买、资料多、接口简单。它是一颗带涓流充电能力的实时时钟芯片,能提供秒、分、时、日、月、周、年信息,并且能自动处理月末日期和闰年调整。它通过一个类似SPI但又不是标准SPI的三线串行接口和主控通信,只需要三根普通IO口就能驱动,对引脚紧张的STM32来说非常友好。

这篇笔记记录的是我用STM32驱动DS1302的完整过程,从硬件接线、时序分析、寄存器操作到实际踩坑。适合两类人看:一类是刚接触STM32、想找一个外设练手时序操作的新手;另一类是项目里需要掉电走时、又不想被内部RTC晶振问题折磨的开发者。我不会只丢一段代码给你,而是把每一步"为什么这么做"讲清楚,这样你换任何一款STM32都能自己移植。

先说结论:DS1302的驱动核心不在于代码有多长,而在于对三线时序的精确控制和BCD码与十进制之间的转换。把这两点吃透,剩下的就是体力活。下面我按实际开发的顺序,一层层拆开讲。

2. DS1302的三线接口到底特殊在哪

2.1 它不是标准SPI,别想着直接套用硬件SPI

很多人看到DS1302有三根线(CE、I/O、SCLK),第一反应是"这不就是SPI吗,我直接上硬件SPI外设"。我试过,能通,但很别扭,而且容易出问题。原因在于DS1302的通信协议和标准SPI有几个本质区别。

标准SPI是四线(CS、SCK、MOSI、MISO),数据输入和输出是分开的两根线,全双工。DS1302只有一根双向数据线I/O,半双工,读写要分时复用。更关键的是,DS1302的数据是在时钟下降沿之后才有效或者被采样的,而且它的命令字节和数据字节是分两个阶段传输的,中间还有CE拉高的时序要求。用硬件SPI去模拟这套时序,你得反复切换引脚方向、控制CE,反而比软件模拟更麻烦。

所以我的建议很明确:用普通GPIO软件模拟时序。这样时序完全可控,移植性也最好,换任何一款STM32只要改几个宏定义就行。软件模拟的代价是占用CPU,但DS1302通信频率本来就不高,读写一次也就几十微秒,对绝大多数应用来说完全可以接受。

2.2 命令字节的位定义,一个字节里藏了所有信息

DS1302的每一次操作,都是先发一个命令字节,再发或收一个数据字节。命令字节的8个位定义如下:

位名称含义
bit71固定为1,命令字节最高位必须是1
bit6RAM/CK1表示操作RAM,0表示操作时钟寄存器
bit5~1A4~A0寄存器地址
bit0RD/W1表示读,0表示写

举个例子,我要写秒寄存器(地址0x80),命令字节就是 1000 0000,也就是0x80。读秒寄存器就是0x81。写RAM第一个字节(地址0xC0)就是 1100 0000,即0xC0。这个规律很规整,记住"最高位恒为1,最低位区分读写,中间是地址"就够了。

这里有个新手特别容易踩的坑:地址是5位,但寄存器地址实际只用到低5位。比如秒寄存器地址是0x80,你把它当成完整地址去理解会晕,其实它的A4~A0是00000,bit7固定1,bit6为0(时钟寄存器),所以拼出来就是0x80。理解了这一点,后面所有寄存器的命令字节你都能自己推出来,不用死记。

2.3 单字节读写的完整时序,一步步拆开看

写一个字节的流程是这样的:

  1. 把CE拉高,启动一次通信。
  2. 把命令字节的8位,从最低位到最高位,依次放到I/O线上。每放一位之前先把SCLK拉低,放好数据后把SCLK拉高,形成一个上升沿,DS1302在上升沿采样数据。
  3. 命令字节发完后,紧接着发数据字节,同样是低位在前,8个时钟周期。
  4. 发完最后一个位,把CE拉低,结束通信。

读一个字节稍微复杂一点:

  1. CE拉高。
  2. 先发命令字节(最低位在前,8个时钟)。
  3. 然后把I/O线切换为输入模式(这一步很关键,很多人忘了切方向,导致读回来全是0xFF或者0x00)。
  4. 在接下来的8个时钟周期里,每个下降沿之后读取I/O线上的数据,同样是最低位在前。
  5. 读完8位,CE拉低。

注意:DS1302的数据是最低位先发(LSB first),这和标准SPI默认的MSB first正好相反。如果你用硬件SPI,一定要配置成LSB first,否则读回来的数据位序是反的,你会得到一堆莫名其妙的数值。

我在第一次写的时候就是栽在LSB first上,读秒寄存器一直返回0x80这种诡异值,查了半天才发现是位序反了。这个坑非常隐蔽,因为数据看起来"像是"有值,只是不对,很容易误判成硬件问题。

3. 寄存器地图与BCD码:数据为什么读出来"不对"

3.1 时钟寄存器一览,每个字节都有讲究

DS1302的时钟寄存器一共7个,加上一个写保护寄存器,地址从0x80到0x8E(偶数地址是写,奇数地址是读)。具体如下:

寄存器写地址读地址bit7bit6bit5bit4bit3bit2bit1bit0
秒0x800x81CH10秒秒秒秒秒秒秒
分0x820x83010分分分分分分分
时0x840x85012/2410时时时时时时
日0x860x870010日日日日日日
月0x880x8900010月月月月月
周0x8A0x8B00000周周周
年0x8C0x8D10年年年年年年年年

秒寄存器的bit7是CH位,也就是Clock Halt。这一位为1时,时钟停止振荡;为0时,时钟正常运行。芯片刚上电时这一位默认是1,所以如果你初始化时不把它清零,时钟根本不会走,你会以为芯片坏了。我第一次就遇到这个,读秒一直是同一个值,差点去换芯片。

小时寄存器的bit7是12/24小时制选择位。为1时是12小时制,为0时是24小时制。bit6在12小时制下表示AM/PM,在24小时制下是10小时的最高位。一般项目里我们都用24小时制,所以初始化时把bit7清零即可。

3.2 BCD码转换,读出来的0x23其实是23

DS1302所有的时间数据都是以BCD码格式存储的。什么叫BCD码?就是用4个二进制位表示一个十进制数字。比如十进制的23,BCD码是0010 0011,也就是0x23。你直接把它当十六进制读出来是35,当十进制读出来是35,都不对,得手动转换。

转换逻辑很简单:

  • BCD转十进制:dec = (bcd >> 4) * 10 + (bcd & 0x0F)
  • 十进制转BCD:bcd = ((dec / 10) << 4) | (dec % 10)

比如读回来0x23,高4位是2,低4位是3,2*10+3=23,正确。写的时候反过来,23除以10得2,余3,拼成0x23。

这个转换看起来简单,但实际写代码时特别容易在"什么时候转、什么时候不转"上出错。我的经验是:在驱动层统一处理,对外只暴露十进制。也就是说,读函数返回给上层的是已经转好的十进制,写函数接收的也是十进制,内部自己转BCD。这样上层业务代码完全不用关心BCD这回事,减少出错概率。

3.3 写保护寄存器:不解除它,你什么都写不进去

地址0x8E是写保护寄存器。它的bit7是WP位,为1时禁止写入任何时钟寄存器,为0时允许写入。芯片上电默认WP为1,也就是默认是写保护的。

这意味着你初始化的时候,必须先写0x8E寄存器把WP清零,才能去设置时间。设置完时间后,建议再把WP置1,防止程序跑飞时误改时间。这个顺序不能乱:解除保护 → 写时间 → 恢复保护。

我见过有人初始化时忘了解除写保护,然后死活写不进时间,读出来永远是默认值,查了半天代码逻辑,最后发现是这一位没处理。这个坑的隐蔽性在于,写操作不会报错,芯片就是默默忽略你的写入,你完全感知不到。

4. 从零搭建驱动:接线、初始化与读写函数

4.1 硬件接线,三根线加电源就够了

DS1302的引脚不多,典型接法如下:

DS1302引脚功能接到STM32
VCC2主电源3.3V
VCC1备用电源纽扣电池正极
GND地GND
CE片选/使能任意GPIO,如PA0
I/O数据线任意GPIO,如PA1
SCLK时钟线任意GPIO,如PA2
X1/X2晶振接32.768kHz晶振

这里有几个硬件上的经验点值得说。第一,VCC1接纽扣电池是为了掉电走时,如果你不需要掉电保持,VCC1可以悬空或者接地,但建议还是接上,成本很低。第二,32.768kHz晶振要尽量靠近芯片,走线短,否则起振可能不稳定。第三,I/O线是双向的,STM32这边配置成开漏输出加上拉电阻会更稳,但实际用推挽输出也能工作,我两种都试过,短距离下没区别。

提示:如果你用的是3.3V的STM32,DS1302工作在3.3V完全没问题,不需要电平转换。但要注意纽扣电池电压一般是3V,和主电源之间有涓流充电电路,如果你用的不是可充电电池,建议把涓流充电相关的寄存器位关掉,避免对不可充电电池充电造成风险。

4.2 GPIO方向切换,读操作的关键一步

前面提到读操作要把I/O线切成输入,这一步在代码里怎么实现?以常见的HAL库为例,你需要动态修改GPIO的模式。写的时候配置成推挽输出,读的时候配置成输入模式(上拉或浮空)。

// 写模式:推挽输出 void DS1302_IO_Output(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DS1302_IO_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DS1302_IO_PORT, &gpio); } // 读模式:输入 void DS1302_IO_Input(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DS1302_IO_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DS1302_IO_PORT, &gpio); }

每次读字节之前调用DS1302_IO_Input(),读完切回DS1302_IO_Output()。这个切换看起来啰嗦,但它是读操作能成功的前提。如果你偷懒不切,读回来的数据线一直被STM32推挽输出驱动着,DS1302根本没法把数据放上来,你读到的就是自己输出的残留值。

4.3 底层读写函数,时序的核心实现

有了GPIO方向控制,就可以写最底层的字节读写函数了。这是整个驱动的核心,时序必须精确。

// 写一个字节,低位在前 void DS1302_WriteByte(uint8_t dat) { DS1302_IO_Output(); for (uint8_t i = 0; i < 8; i++) { DS1302_SCLK_LOW(); if (dat & 0x01) DS1302_IO_HIGH(); else DS1302_IO_LOW(); DS1302_SCLK_HIGH(); dat >>= 1; } } // 读一个字节,低位在前 uint8_t DS1302_ReadByte(void) { uint8_t dat = 0; DS1302_IO_Input(); for (uint8_t i = 0; i < 8; i++) { dat >>= 1; DS1302_SCLK_LOW(); DS1302_SCLK_HIGH(); if (DS1302_IO_READ()) dat |= 0x80; } return dat; }

注意读函数里的顺序:先把dat右移一位,腾出最高位,然后在时钟操作后判断I/O电平,如果为高就把最高位置1。这样经过8次循环,最先读到的位(最低位)最终会落在dat的最低位,位序就对了。这个写法比"读一位存一位再拼"更简洁,也不容易出错。

写函数里,先拉低SCLK,再放数据,再拉高SCLK,这样DS1302在上升沿采样到稳定的数据。这个顺序不能反,如果先放数据再拉低SCLK,可能在拉低的过程中数据还没稳定就被采样了。

4.4 寄存器读写与时间设置

有了字节读写,寄存器读写就是加一层命令字节的封装:

void DS1302_WriteReg(uint8_t cmd, uint8_t dat) { DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_CE_HIGH(); DS1302_WriteByte(cmd); DS1302_WriteByte(dat); DS1302_CE_LOW(); } uint8_t DS1302_ReadReg(uint8_t cmd) { uint8_t dat; DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_CE_HIGH(); DS1302_WriteByte(cmd); dat = DS1302_ReadByte(); DS1302_CE_LOW(); return dat; }

初始化时间的流程是:解除写保护 → 停止时钟(秒寄存器bit7置1)→ 写入年月日时分秒 → 启动时钟(bit7清零)→ 恢复写保护。停止时钟这一步很多人省略,直接写,其实也能工作,但规范做法是先停再写再启,避免写入过程中时钟进位导致数据错乱。

void DS1302_SetTime(uint8_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t min, uint8_t sec) { DS1302_WriteReg(0x8E, 0x00); // 解除写保护 DS1302_WriteReg(0x80, 0x80); // 停止时钟 DS1302_WriteReg(0x8C, DEC2BCD(year)); DS1302_WriteReg(0x88, DEC2BCD(month)); DS1302_WriteReg(0x86, DEC2BCD(day)); DS1302_WriteReg(0x84, DEC2BCD(hour)); DS1302_WriteReg(0x82, DEC2BCD(min)); DS1302_WriteReg(0x80, DEC2BCD(sec)); // 写入秒,同时启动时钟 DS1302_WriteReg(0x8E, 0x80); // 恢复写保护 }

这里最后写秒寄存器时,写入的值bit7是0,等于同时把CH位清零启动了时钟,一举两得。这个技巧比单独再写一次启动要简洁。

5. 实测中那些让人抓狂的坑

5.1 时钟不走:先查CH位,再查晶振

时钟不走是最常见的问题。排查顺序我总结成一条链路:先读秒寄存器,看bit7是不是1。如果是1,说明CH位没清零,时钟处于停止状态,回去检查初始化代码。如果bit7是0但秒值不变,那大概率是晶振问题。

晶振问题又分几种:晶振本身坏了、负载电容不匹配、走线太长、焊接虚焊。DS1302推荐的负载电容是6pF,如果你随便找了个12pF的电容焊上去,可能起振很慢甚至不起振。我遇到过一块板子,晶振焊反了(虽然无极性但引脚应力导致接触不良),换了就好了。还有一次是晶振离芯片太远,飞线过去的,结果时走时停,重新布线后正常。

注意:判断晶振是否起振,最直接的办法是用示波器看X1或X2引脚,正常应该有32.768kHz的正弦波。如果没有示波器,可以写个程序连续读秒寄存器,如果值一直不变,基本就是晶振没起振。

5.2 读回来全是0xFF或0x00:方向切换和上拉

读数据异常,第一个要查的就是I/O方向有没有切换。如果忘了切输入,STM32还在推挽输出,读回来的就是自己最后输出的电平,可能全是高或全是低。第二个要查的是上拉电阻,如果I/O配置成浮空输入,而DS1302输出能力弱,可能读不到稳定的高电平,这时候加上内部或外部上拉就好了。

我个人的习惯是读模式一律配置成上拉输入,这样即使DS1302没驱动,线也不会浮空,读到的至少是确定值,方便判断问题。

5.3 时间乱跳:BCD转换和写保护的双重陷阱

时间乱跳通常有两个原因。一是BCD转换写错了,比如把十进制直接当BCD写进去,读出来再转一次,数值就完全乱了。二是写保护没处理好,导致部分寄存器写进去了、部分没写进去,时间自然对不上。

排查方法:写一个固定值(比如0x12)到某个寄存器,然后读回来,看是不是0x12。如果是,说明读写通路正常,问题在转换逻辑;如果不是,说明写保护或者时序有问题。这个"写固定值回读"的方法是我调试所有存储类芯片的万能起手式,非常有效。

5.4 掉电后时间丢失:电池和涓流充电

如果你接了纽扣电池但掉电后时间还是丢,先量一下电池电压,低于2V基本就没戏了。然后检查VCC1和VCC2的接法,有些模块把两个电源脚设计得比较绕,容易接错。最后看涓流充电寄存器(地址0x90),如果开了充电但电池是不可充电的,长期下来电池会损坏,反而导致掉电丢失。

我的建议是:用不可充电的CR2032时,把涓流充电关掉(0x90写0x00),让电池只放电不充电,一颗电池能用好几年。用可充电的LIR2032时,再开启涓流充电。

6. 让驱动更稳的几个进阶思路

6.1 加一层时间缓存,减少总线访问

DS1302的读写速度虽然不慢,但如果你在主循环里频繁读时间(比如每次刷新显示都读一遍),会占用不少CPU时间,而且增加了出错概率。更好的做法是在RAM里维护一份时间副本,上电时从DS1302读一次,之后靠STM32的SysTick或者定时器每秒累加,每隔一段时间(比如1分钟)再和DS1302同步一次。

这样做的好处是显示刷新完全不碰总线,响应快;同时定期同步又能纠正累计误差。同步的时机建议选在秒值变化后的瞬间读,避免读到进位过程中的中间值。

6.2 写入前先读回校验

对于时间设置这种不常做但很重要的操作,写完可以立即读回来比对,不一致就重写。这个校验逻辑增加不了多少代码,但能极大提升可靠性,尤其是在电源不稳或者总线受干扰的环境下。

6.3 把驱动抽象成统一接口

如果你项目里可能换时钟芯片(比如从DS1302换成DS3231),建议把驱动抽象成统一的接口:RTC_Init、RTC_SetTime、RTC_GetTime。底层具体用哪颗芯片,通过宏或者函数指针切换。这样上层业务代码完全不用改,移植成本几乎为零。我在几个项目里都用了这个思路,后来换芯片时只改了底层一个文件,上层一行没动。

6.4 关于周寄存器的处理

DS1302有一个周寄存器(地址0x8A),值是1到7。它不会自动计算,需要你自己写入。如果你不需要显示星期,可以忽略它,但建议还是写一个合理的值,有些应用会读它。计算星期可以用基姆拉尔森公式,输入年月日算出星期几,再映射到1到7。这个公式网上一搜就有,我就不展开了,注意月份1和2要当成上一年的13和14月来处理。

7. 我个人的几点实操体会

驱动DS1302这件事,代码量不大,但细节密度很高。我前后在好几个项目里用过它,每次都能遇到点新情况。最大的体会是:时序类外设的调试,一定要有"分段验证"的意识。不要一上来就写完整的时间读写然后跑,而是先写单字节读写,用固定值验证通路;通路通了再写寄存器读写,验证命令字节;最后才是时间逻辑。这样每一步都有明确的验证点,出问题能快速定位到是哪一层。

另一个体会是关于延时。DS1302的时序对延时其实不敏感,因为它的最高频率也就几MHz,STM32随便跑都比它快。所以我在软件模拟时没有加额外延时,直接翻转IO就能工作。但如果你发现通信不稳定,可以在SCLK翻转之间加一两个NOP,降低一下速率,往往能解决一些莫名其妙的偶发问题。

最后,别小看那颗32.768kHz晶振。它是整个时钟精度的来源,晶振不好,走时一天差几秒很正常。如果项目对精度要求高,要么选好一点的晶振,要么定期用外部时间源校准。DS1302本身没有温度补偿,精度就是晶振的精度,这一点要有心理预期。

这套驱动我已经在好几个项目里复用,从F1到F4系列都跑过,移植时只需要改GPIO的宏定义和初始化部分,核心时序代码完全不用动。如果你也在用DS1302,希望这些踩坑记录能帮你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进