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个位定义如下:
| 位 | 名称 | 含义 |
|---|---|---|
| bit7 | 1 | 固定为1,命令字节最高位必须是1 |
| bit6 | RAM/CK | 1表示操作RAM,0表示操作时钟寄存器 |
| bit5~1 | A4~A0 | 寄存器地址 |
| bit0 | RD/W | 1表示读,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 单字节读写的完整时序,一步步拆开看
写一个字节的流程是这样的:
- 把CE拉高,启动一次通信。
- 把命令字节的8位,从最低位到最高位,依次放到I/O线上。每放一位之前先把SCLK拉低,放好数据后把SCLK拉高,形成一个上升沿,DS1302在上升沿采样数据。
- 命令字节发完后,紧接着发数据字节,同样是低位在前,8个时钟周期。
- 发完最后一个位,把CE拉低,结束通信。
读一个字节稍微复杂一点:
- CE拉高。
- 先发命令字节(最低位在前,8个时钟)。
- 然后把I/O线切换为输入模式(这一步很关键,很多人忘了切方向,导致读回来全是0xFF或者0x00)。
- 在接下来的8个时钟周期里,每个下降沿之后读取I/O线上的数据,同样是最低位在前。
- 读完8位,CE拉低。
注意:DS1302的数据是最低位先发(LSB first),这和标准SPI默认的MSB first正好相反。如果你用硬件SPI,一定要配置成LSB first,否则读回来的数据位序是反的,你会得到一堆莫名其妙的数值。
我在第一次写的时候就是栽在LSB first上,读秒寄存器一直返回0x80这种诡异值,查了半天才发现是位序反了。这个坑非常隐蔽,因为数据看起来"像是"有值,只是不对,很容易误判成硬件问题。
3. 寄存器地图与BCD码:数据为什么读出来"不对"
3.1 时钟寄存器一览,每个字节都有讲究
DS1302的时钟寄存器一共7个,加上一个写保护寄存器,地址从0x80到0x8E(偶数地址是写,奇数地址是读)。具体如下:
| 寄存器 | 写地址 | 读地址 | bit7 | bit6 | bit5 | bit4 | bit3 | bit2 | bit1 | bit0 |
|---|---|---|---|---|---|---|---|---|---|---|
| 秒 | 0x80 | 0x81 | CH | 10秒 | 秒 | 秒 | 秒 | 秒 | 秒 | 秒 |
| 分 | 0x82 | 0x83 | 0 | 10分 | 分 | 分 | 分 | 分 | 分 | 分 |
| 时 | 0x84 | 0x85 | 0 | 12/24 | 10时 | 时 | 时 | 时 | 时 | 时 |
| 日 | 0x86 | 0x87 | 0 | 0 | 10日 | 日 | 日 | 日 | 日 | 日 |
| 月 | 0x88 | 0x89 | 0 | 0 | 0 | 10月 | 月 | 月 | 月 | 月 |
| 周 | 0x8A | 0x8B | 0 | 0 | 0 | 0 | 0 | 周 | 周 | 周 |
| 年 | 0x8C | 0x8D | 10年 | 年 | 年 | 年 | 年 | 年 | 年 | 年 |
秒寄存器的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,希望这些踩坑记录能帮你少走点弯路。