1. 为什么还要折腾 DS1302 这颗老芯片
手头有个小项目需要记录数据的时间戳,主控用的是 STM32F103C8T6 这块经典小板子。选 RTC 方案的时候纠结了一阵:STM32 内部自带 RTC 外设,配个 32.768kHz 晶振就能跑,为什么还要外挂一颗 DS1302?原因很实际——内部 RTC 一旦主电源掉电,VBAT 引脚上的纽扣电池只能维持极低的功耗,而且很多低成本板子压根没焊 VBAT 的电池座。更麻烦的是,内部 RTC 的走时精度受晶振负载电容匹配影响很大,温度一变就飘,校准起来还得反复试。
DS1302 这颗芯片虽然年纪不小了,但它的优势在于:自带涓流充电电路,可以给备用电池缓慢充电;走时寄存器是 BCD 码格式,读出来直接就是年月日时分秒,不用做复杂的进制转换;而且它是三线制串行接口,占用 IO 少,时序简单到用普通 GPIO 就能模拟。最关键的是,它便宜、好买、资料多,对于学习笔记性质的项目来说,拿来练手再合适不过。
这篇文章面向的是刚接触 STM32 外设驱动、想搞明白“怎么用软件模拟时序去驱动一颗芯片”的朋友。我会从硬件连接到 GPIO 配置,再到字节读写时序、寄存器操作,最后封装成可复用的驱动,把整个链路讲透。代码基于标准库风格,但逻辑同样适用于 HAL 库,你照着改就行。
注意:DS1302 是 3.3V 和 5V 兼容的,但 STM32 的 IO 是 3.3V 电平,所以直接连没问题,不需要电平转换。但如果你用的是 5V 单片机,就要确认 IO 耐压。
2. 硬件连接与 GPIO 模式选择背后的门道
2.1 三根线到底怎么接
DS1302 的引脚不多,核心就三根信号线加电源:
| DS1302 引脚 | 功能 | 接到 STM32 | 说明 |
|---|---|---|---|
| VCC2 | 主电源 | 3.3V | 主供电 |
| VCC1 | 备用电源 | 纽扣电池正极 | 掉电保持 |
| GND | 地 | GND | 共地 |
| SCLK | 串行时钟 | 任意 GPIO | 上升沿写,下降沿读 |
| I/O | 数据线 | 任意 GPIO | 双向 |
| CE | 片选/复位 | 任意 GPIO | 高电平有效 |
| X1/X2 | 晶振 | 32.768kHz 晶振 | 走时基准 |
我选的是 PA5 做 SCLK,PA6 做 I/O,PA7 做 CE。这三个脚在 STM32F103 上都是普通 GPIO,没有复用功能冲突,配置起来最省心。晶振一定要用 32.768kHz 的,负载电容选 6pF 的那种,别拿 12pF 的凑合,否则走时一天差好几秒。
2.2 为什么 I/O 口要反复切换输入输出
这是 DS1302 驱动里最容易让人困惑的地方。它的 I/O 引脚是单根双向数据线,写数据的时候 STM32 要输出,读数据的时候 STM32 要输入。所以你不能把它固定配置成推挽输出,也不能固定配置成浮空输入。
我的做法是:初始化时把 I/O 脚配成开漏输出模式,外接一个 4.7kΩ 的上拉电阻到 3.3V。开漏输出的好处是,输出低电平时能拉低,输出高电平时靠外部上拉,这样在切换成输入模式时,引脚不会因为内部推挽结构产生冲突。如果你不想外接上拉电阻,也可以把 I/O 脚在输出和输入模式之间动态切换,但那样代码会稍微啰嗦一点。
具体操作上,我封装了两个宏:
#define DS1302_IO_OUT() { GPIOA->CRL &= ~(0xF << 24); GPIOA->CRL |= (0x3 << 24); } // 推挽输出 50MHz #define DS1302_IO_IN() { GPIOA->CRL &= ~(0xF << 24); GPIOA->CRL |= (0x4 << 24); } // 浮空输入这里直接操作寄存器是因为在时序敏感的场合,库函数的开销会影响波形质量。如果你用 HAL 库,可以用HAL_GPIO_DeInit再HAL_GPIO_Init,但那样切换一次要好几微秒,DS1302 虽然时序慢,但频繁切换还是寄存器操作更稳。
提示:CE 脚在通信期间必须保持高电平,通信结束后拉低。如果你在通信中途拉低 CE,DS1302 会复位内部移位寄存器,之前发的命令就白费了。
3. 字节读写时序:把数据手册的波形图翻译成代码
3.1 写一个字节的完整过程
DS1302 的写时序是:CE 拉高,然后在 SCLK 的上升沿把数据位放到 I/O 线上,低位先发。数据手册上的波形图看着复杂,拆开就是三步:
- 把 I/O 脚设为输出模式。
- 循环 8 次,每次先把数据位的最低位放到 I/O 上,然后 SCLK 拉高再拉低,形成一个时钟脉冲。
- 8 位发完后,如果是写寄存器,还要再发一个字节的数据。
代码实现如下:
void DS1302_WriteByte(uint8_t dat) { DS1302_IO_OUT(); for (uint8_t i = 0; i < 8; i++) { DS1302_IO = dat & 0x01; // 先发最低位 DS1302_SCLK = 1; DS1302_SCLK = 0; dat >>= 1; } }这里有个细节:SCLK 的初始状态应该是低电平。如果你上电后 SCLK 是高,第一个上升沿会提前到来,数据还没准备好就被采走了。所以初始化时先把 SCLK 和 CE 都拉低。
3.2 读一个字节为什么要先释放总线
读操作比写操作多一个坑:在 SCLK 下降沿之后,DS1302 会把数据放到 I/O 线上,这时候 STM32 必须处于输入模式,否则两边同时驱动一根线,轻则读不到数据,重则烧引脚。
正确的读字节流程:
uint8_t DS1302_ReadByte(void) { uint8_t dat = 0; DS1302_IO_IN(); // 先切成输入 for (uint8_t i = 0; i < 8; i++) { dat >>= 1; if (DS1302_IO) dat |= 0x80; // 先读到的放低位,最后移到高位 DS1302_SCLK = 1; DS1302_SCLK = 0; } DS1302_IO_OUT(); // 读完切回输出,方便下次写 return dat; }注意读的时候是“先读位,再给时钟”,因为 DS1302 在时钟下降沿输出数据,你在下降沿之后读就行。我试过在上升沿读,结果读出来全是 0xFF,就是因为采样时机不对。
3.3 命令字节的构造规则
DS1302 的每个操作都要先发一个命令字节,格式是:
| 位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| 含义 | 1 | RAM/CK | A4 | A3 | A2 | A1 | A0 | RD/W |
- 位 7 固定为 1,这是 DS1302 的“起始位”。
- 位 6 选择操作 RAM 还是时钟寄存器,0 表示时钟。
- 位 5~1 是寄存器地址。
- 位 0 是读写方向,0 写,1 读。
比如要写秒寄存器(地址 0x80),命令字节就是 0x80;要读秒寄存器,命令字节就是 0x81。这个规则很简单,但容易记混,我建议在代码里用宏定义:
#define DS1302_SEC_WRITE 0x80 #define DS1302_SEC_READ 0x81 #define DS1302_MIN_WRITE 0x82 #define DS1302_MIN_READ 0x83 // ... 以此类推4. 寄存器操作:BCD 码、写保护与涓流充电
4.1 BCD 码的转换与常见错误
DS1302 的秒、分、时、日、月、年寄存器都是 BCD 码格式。比如秒寄存器里存的是 0x59,表示 59 秒,而不是十进制的 89。所以你在设置时间和读取时间的时候,必须做 BCD 和十进制之间的转换。
转换公式很简单:
uint8_t DecToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); } uint8_t BcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }我踩过的坑是:设置时间的时候忘了转换,直接把十进制 23 写进去,结果读出来是 0x17,也就是 17 点,时间全乱了。所以每次写寄存器之前,一定要确认数据是 BCD 格式。
另外,小时寄存器有一位是 12/24 小时制选择位。如果你用 24 小时制,要把 bit7 清零。我一般直接写DecToBcd(hour) & 0x3F,强制 24 小时制。
4.2 写保护寄存器:不解除它,时间永远改不了
DS1302 的地址 0x8E 是写保护寄存器。上电默认是 0x80,也就是写保护开启,这时候你往任何时间寄存器写数据都会被忽略。所以设置时间之前,必须先往 0x8E 写 0x00 解除保护,设置完再写回 0x80 开启保护。
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(DS1302_SEC_WRITE, DecToBcd(sec) & 0x7F); // bit7 是时钟停止位,要清零 DS1302_WriteReg(DS1302_MIN_WRITE, DecToBcd(min)); DS1302_WriteReg(DS1302_HOUR_WRITE, DecToBcd(hour) & 0x3F); DS1302_WriteReg(DS1302_DATE_WRITE, DecToBcd(day)); DS1302_WriteReg(DS1302_MONTH_WRITE, DecToBcd(month)); DS1302_WriteReg(DS1302_DAY_WRITE, DecToBcd(1)); // 星期几,随便填 DS1302_WriteReg(DS1302_YEAR_WRITE, DecToBcd(year)); DS1302_WriteReg(0x8E, 0x80); // 重新开启写保护 }秒寄存器的 bit7 是 CH 位,为 1 时时钟停止。所以写秒的时候要& 0x7F把它清零,否则时钟不走。
4.3 涓流充电寄存器的配置逻辑
DS1302 的地址 0x90 是涓流充电寄存器。如果你接了可充电的纽扣电池(比如 LIR2032),可以通过这个寄存器配置充电电流。但如果你用的是不可充电的 CR2032,千万别开充电,否则电池会漏液甚至炸裂。
配置涓流充电的公式是:0xA0 | (二极管选择 << 2) | (电阻选择)。比如选两个二极管串联、内阻 2kΩ,就是0xA0 | (0x02 << 2) | 0x01 = 0xA9。我一般直接用 0xA9,实测充电电流在 1mA 左右,对 LIR2032 来说比较安全。
注意:如果你不确定电池类型,最稳妥的做法是往 0x90 写 0x00,关闭涓流充电,让电池只放电不充电。CR2032 在这种低功耗场景下能撑好几年。
5. 完整驱动封装与实测中的意外情况
5.1 把零散操作封装成可复用模块
前面讲的都是单个操作,实际项目里我会把它们封装成一个ds1302.c和ds1302.h,对外只暴露几个接口:
void DS1302_Init(void); void DS1302_SetTime(DS1302_Time *time); void DS1302_GetTime(DS1302_Time *time);DS1302_Time结构体里放年、月、日、时、分、秒、星期,都是十进制格式,调用者不用关心 BCD 转换。这样上层业务代码就干净了,换一颗 RTC 芯片也只需要改底层驱动。
初始化函数里要做几件事:配置 GPIO 为输出、拉低 SCLK 和 CE、解除写保护、如果时钟没在跑就启动时钟。我一般还会在初始化时读一次秒寄存器,如果 CH 位是 1,说明时钟停了,就自动设置一个默认时间。
5.2 读出来全是 0xFF 或 0x00 怎么排查
这是新手最常遇到的问题。我遇到过两次,一次是 I/O 脚模式切换没做对,读的时候还是输出模式,DS1302 拉不动线;另一次是 CE 脚在通信中途被拉低了,命令没发完。
排查步骤可以按这个顺序来:
- 用示波器或者逻辑分析仪看 SCLK 和 CE 的波形。如果没有波形,说明 GPIO 配置有问题。
- 看 I/O 线上有没有数据翻转。如果 SCLK 有波形但 I/O 一直高或一直低,检查模式切换代码。
- 确认 CE 在整个通信期间保持高电平。我习惯在读写函数开头拉高 CE,结尾拉低,中间不做任何切换。
- 检查晶振有没有起振。用示波器测 X1 脚,应该有 32.768kHz 的正弦波。如果没有,换晶振或者检查负载电容。
5.3 走时不准的几种可能原因
DS1302 走时不准,通常不是芯片的问题,而是外围元件或配置的问题。我总结了几种情况:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 每天慢几秒 | 晶振负载电容不匹配 | 换 6pF 晶振,或并联微调电容 |
| 每天快几秒 | 晶振质量差 | 换高精度晶振,误差 20ppm 以内 |
| 偶尔跳秒 | 电源纹波大 | 在 VCC2 加 0.1uF 去耦电容 |
| 掉电后时间归零 | 备用电池没电或没接 | 检查电池电压,确认 VCC1 连接 |
我实测下来,用 6pF 晶振加 0.1uF 去耦电容,DS1302 一天的误差在 1 秒以内,对于大多数记录类应用足够了。如果你要求更高,可以定期用网络时间校准,但那就不只是 DS1302 的事了。
5.4 通信速率能跑多快
DS1302 的 SCLK 最高频率是 2MHz,但实际用的时候没必要跑那么快。我在 STM32F103 上跑 72MHz 主频,用__NOP()做延时,SCLK 频率大概在 500kHz 左右,读写一次时间也就几十微秒。如果你用 HAL 库的HAL_Delay做延时,那频率会降到 1kHz 以下,虽然也能用,但每次读时间要等好几毫秒,在实时性要求高的场合就不合适了。
我的建议是:用__NOP()或者简单的for循环做微秒级延时,别用毫秒级延时函数。DS1302 对时序要求不严,但太慢会影响系统响应。
6. 这个驱动还能怎么扩展
现在这个驱动已经能正常读写时间了,但如果你想让项目更完整,还可以做几件事。一是加一个时间格式化函数,把DS1302_Time转成字符串,方便在 OLED 或串口上显示。二是加一个闹钟功能,DS1302 本身有两组闹钟寄存器,配置好之后可以在指定时间拉低某个引脚,但 STM32 这边要轮询或者用外部中断去读。三是把驱动移植到 HAL 库,用HAL_GPIO_WritePin和HAL_GPIO_ReadPin替换寄存器操作,虽然慢一点,但可移植性更好。
我个人在实际操作中的体会是:DS1302 这种老芯片,时序图看着吓人,但只要你把“先拉 CE、再发命令、低位先出、上升沿写下降沿读”这几条记住,剩下的就是体力活。真正花时间的不是写代码,而是排查硬件连接和确认电池有没有电。我建议你在焊板子之前,先用面包板搭一遍,确认能读到时间了再画 PCB,能省不少返工的时间。