从买来那块 DS1302 模块到把它在 STM32 上彻底调通,我前后折腾了两天。网上关于 DS1302 的代码确实不少,但很多都是 51 平台直接复制过来的,要么没讲时序,要么对 STM32 的 GPIO 模式切换一笔带过。这篇学习笔记把整个驱动从零写一遍,包括为什么用 GPIO 模拟时序、命令字节的坑、BCD 码换算、写保护和时钟暂停位,把你可能遇到的坑提前踩平。适合正在学 STM32、想搞懂串行时序、或者需要给手头项目加一个掉电不丢时间的实时时钟的开发者参考。
1. 思路先行:为什么用 GPIO 模拟 DS1302,而不是硬件 SPI
1.1 DS1302 是什么,这个项目能学到什么
DS1302 是一颗非常经典的实时时钟芯片,内部自带秒、分、时、日、月、星期、年的寄存器,还有 31 字节的非易失性静态 RAM。它的典型应用就是给嵌入式系统提供“掉电后继续走”的时间基准:主电源 VCC2 正常供电时芯片工作,外部断电后由 VCC1 上的纽扣电池或大电容维持计时。这种机制对很多需要记录事件时间戳、定时任务、电子钟表、数据记录仪之类的项目非常有用。
驱动 DS1302 对你理解 STM32 的 GPIO 操作也很有帮助。它用的是三线串行接口:CE、SCLK、IO,看起来像 SPI,但实际时序有自己的一套。手写这套驱动,你会把“时序图怎么读”“命令字节怎么组织”“双向 IO 怎么处理”“BCD 码怎么换算”这些问题一次性过一遍。这些基本功在以后驱动传感器、存储器、显示屏时都会反复用到。
从我自己的角度看,这个项目特别适合作为“学习笔记”的素材,因为它的逻辑不复杂,但每个环节都能挖出细节。只要认真走一遍,以后再遇到类似的串行器件,上手速度会快很多。
1.2 GPIO 模拟 vs 硬件 SPI,我为什么这样选
DS1302 的三线接口在某些代码里被归类成“类 SPI”,也有人直接用硬件 SPI 去驱动过。但从数据手册的时序来看,它跟标准 SPI 并不是一回事:DS1302 需要 CE 拉高作为一次传输的开始,数据按 LSB 先发,命令字节带有固定的高位标志位,而且 IO 是半双工双向口。用硬件 SPI 不是不行,但你需要额外处理 IO 方向切换、调整时钟极性和相位,还可能被 SPI 外设的字节长度、片选控制等问题困扰。折腾半天,最后发现还是 GPIO 模拟更干净。
GPIO 模拟的好处是每个时钟沿、每个电平变化都在你自己手里。出问题时可以用逻辑分析仪抓波形,一步一步对时序图,定位非常快。DS1302 的时钟频率上限大约是 2MHz,实际应用里我们通常给它 1us 级别的半周期延时,这个速度对 GPIO 来说毫无压力,完全不用担心模拟方式跑不动。
这个选择也符合我自己的习惯:能用简单方案把事情做稳,就不用复杂方案。特别是学习阶段,每行代码都看得懂、每个状态都测得出来,远比“外设配置对但说不清为什么”有价值。
2. 时序与寄存器:最容易翻车的三个关键细节
2.1 命令字节、LSB 和读写时序的关键点
DS1302 的每次操作都是从 CE 拉高开始,然后主机通过 IO 发送一个命令字节,再要么写一个数据字节,要么读一个数据字节,最后 CE 拉低。命令字节的格式是固定的:
bit7 固定为 1,bit6 用于区分操作对象,0 表示操作时钟寄存器,1 表示操作内部 RAM,bit5 到 bit1 是寄存器地址,bit0 表示读或写,0 写、1 读。
另一个容易忽略的地方是数据是 LSB first,也就是低位在前。很多从 SPI 转过来的人习惯性先发高位,结果一上来就读到完全错乱的数据。写一个字节时,每次先把当前 bit 放到 IO 上,然后产生一个 SCLK 上升沿,芯片在这个上升沿采样数据。读一个字节时,芯片会在 SCLK 下降沿之后把数据放到 IO 上,所以主机应该在 SCLK 高电平期间读取 IO,或者下降沿后稍作延时再读。我实测在 SCLK 高电平期间读是稳定的,前提是 IO 方向已经切对。
还有一个细节很关键:读命令发送完最后一个 bit 之后,IO 要立刻从输出转为输入。如果切换慢了,芯片开始输出数据时你的引脚还在驱动总线,两边打架,轻则读到错误数据,重则可能损伤 IO。这也是我为什么在底层函数里把“释放 IO”放在读字节循环前面。
2.2 寄存器地址表与 BCD 码换算
DS1302 的时钟寄存器从秒到年,写地址和读地址相差 1,规律非常明显。我整理了一张常用地址表:
| 寄存器 | 写地址 | 读地址 | 说明 |
|---|---|---|---|
| 秒 | 0x80 | 0x81 | bit7 是时钟暂停位 CH |
| 分 | 0x82 | 0x83 | 无特殊标志位 |
| 时 | 0x84 | 0x85 | bit7 是 12/24 小时模式标志 |
| 日 | 0x86 | 0x87 | 无特殊标志位 |
| 月 | 0x88 | 0x89 | 无特殊标志位 |
| 星期 | 0x8A | 0x8B | 取值范围 1 到 7 |
| 年 | 0x8C | 0x8D | 两位年份,如 24 表示 2024 |
| 写保护 | 0x8E | 0x8F | bit7 置 1 禁止写入 |
| 涓流充电 | 0x90 | 0x91 | 用于给备份电容充电 |
| 时钟突发 | 0xBE | 0xBF | 一次连续读写全部时钟寄存器 |
| RAM 突发 | 0xFE | 0xFF | 一次连续读写 31 字节 RAM |
芯片内部的时间数据全部用 BCD 码保存,也就是说一个字节的高四位表示十位,低四位表示个位。比如秒寄存器里存 0x45,就代表 45 秒,而不是十进制的 45 对应的 0x2D。所以读写时间时必须有十进制到 BCD、BCD 到十进制的转换函数。这个换算本身不难,但漏掉任何一个方向都会让你调半天。
2.3 写保护位、时钟暂停位和 24 小时模式的坑
写保护是新手第一个容易踩的坑。DS1302 上电后写保护位不一定处于关闭状态,如果你不先把写保护位寄存器(0x8E)的 bit7 清零,那么所有写入操作都会被忽略,时间怎么都写不进去。很多人调了很久“写不进去”,最后发现就是少了这一句。
第二个坑是秒寄存器里的 bit7,也就是时钟暂停位。当这个位为 1 时,芯片内部振荡器被关闭,秒数根本不会走。如果你发现模块能读到时间,但秒数一直不变,十有八九是这个位没清。初始化时读秒寄存器,把 bit7 清掉再写回去,时钟才会真正跑起来。
第三个坑比较隐蔽,是小时寄存器的掩码处理。秒、分寄存器读出来时通常用 0x7F 把最高位清掉,小时寄存器却有人用 0x3F。问题在于 24 小时制下,小时十位的 2 存储在 bit6,如果你用 0x3F 去掩码,会把 20 点到 23 点的数据直接变成 0 点到 3 点。正确做法是设置时间时用 0x7F 清除 12/24 模式的 bit7,读取时也用 0x7F 保留 bit6。这个细节不踩一次很难意识到,我在日志里看到 20 点读成 00 点的时候还纳闷了很久。
3. 驱动代码实现:从底层字节读写到时间封装
3.1 引脚定义与 GPIO 初始化:开漏模式的便利
我先说 GPначала引脚分配。以 STM32 的 GPIOB 为例,PB0 接 CE,PB1 接 SCLK,PB2 接 IO。芯片的 VCC2 接 3.3V,GND 接地,IO 线上加一个 4.7k 欧姆上拉电阻到 3.3V,VCC1 我接了一个纽扣电池座。32.768kHz 晶振的两端按模块设计接好,通常模块上已经预留了电容位置。
初始化 GPIO 时,我对三个引脚用了不同策略。CE 和 SCLK 是主机输出,配置成推挽输出。IO 是双向口,我选择了开漏输出模式,并开启内部上拉配合外部 4.7k 上拉电阻。为什么用开漏?因为 STM32 的开漏输出模式下,输出数据寄存器写 1 时引脚处于释放状态,此时可以把这个引脚当输入用,直接读 GPIO 的输入数据寄存器就能拿到外部电平。这样 IO 方向切换不再需要重新配置 GPIO 模式,写数据时写 0 和写 1,读数据时写 1 释放总线然后读,稳定又干净。
如果使用推挽输出,理论上写数据更快,但读取前必须把引脚从输出模式切换到输入模式,切换时机不对很容易产生毛刺。对比下来,开漏加外部上拉更合适,尤其适合 DS1302 这种低速半双工接口。
#include "main.h" #include <stdint.h> #define DS1302_CE_PORT GPIOB #define DS1302_CE_PIN GPIO_PIN_0 #define DS1302_SCLK_PORT GPIOB #define DS1302_SCLK_PIN GPIO_PIN_1 #define DS1302_IO_PORT GPIOB #define DS1302_IO_PIN GPIO_PIN_2 #define DS1302_CE_LOW() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define DS1302_CE_HIGH() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define DS1302_SCLK_LOW() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define DS1302_SCLK_HIGH() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define DS1302_IO_WRITE(v) HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, (v) ? GPIO_PIN_SET : GPIO_PIN_RESET) #define DS1302_IO_READ() HAL_GPIO_ReadPin(DS1302_IO_PORT, DS1302_IO_PIN) static void ds1302_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = DS1302_CE_PIN | DS1302_SCLK_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &gpio); gpio.Pin = DS1302_IO_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &gpio); DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_IO_WRITE(1); }初始化最后那三行不是摆设。CE 低电平让芯片处于待机状态,SCLK 低电平保证没有时钟沿触发误动作,IO 写 1 释放总线,避免复位后引脚状态不确定拖住总线。
3.2 底层读写一个字节:实现和原理
看代码前先明确一下 DS1302 的位时序。写字节时,主机把当前位放到 IO 上,然后 SCLK 拉高形成上升沿,芯片采样,再拉低准备下一位。读字节时,芯片在 SCLK 下降沿之后更新输出,所以主机可以在 SCLK 高电平期间读取 IO。
static void ds1302_delay_us(uint32_t us) { for (uint32_t i = 0; i < us; i++) { for (volatile uint32_t j = 0; j < 16U; j++) { __NOP(); } } } static void ds1302_write_byte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { DS1302_IO_WRITE(dat & 0x01); DS1302_SCLK_HIGH(); ds1302_delay_us(1); DS1302_SCLK_LOW(); ds1302_delay_us(1); dat >>= 1; } } static uint8_t ds1302_read_byte(void) { uint8_t i, dat = 0; DS1302_IO_WRITE(1); for (i = 0; i < 8; i++) { dat >>= 1; DS1302_SCLK_HIGH(); ds1302_delay_us(1); if (DS1302_IO_READ()) { dat |= 0x80; } DS1302_SCLK_LOW(); ds1302_delay_us(1); } return dat; }读函数里每次循环先把 dat 右移一位,再根据 IO 电平决定要不要置最高位,相当于在收集一个反转的移位寄存器。为什么不在循环开始直接读 IO 再移位?也可以,但要保证第 0 位落在 bit0,而不是最后变成反序。我习惯先移位再置位,配合 LSB first 的特性,最后自然得到正确的字节。
延时 1us 是故意的。DS1302 的 SCLK 最快大概到 2MHz,半个周期就是 250ns 左右,不延时也能跑。但实测中如果 STM32 主频较高、代码优化开得激进,没有延时的时钟沿翻转容易超出芯片规格,导致偶发读取错误。加 1us 延时后时序余量很充足,又不影响实际体验,属于“花小钱买个稳”。
3.3 寄存器读写与时间结构封装
底层写好之后,寄存器读写就很直白了。注意我的接口约定:所有寄存器参数统一使用偶数地址,也就是手册里的“写地址”,函数内部把 bit0 改成 0 或 1。
void ds1302_write_reg(uint8_t reg, uint8_t val) { reg &= 0xFE; DS1302_CE_HIGH(); ds1302_write_byte(reg); ds1302_write_byte(val); DS1302_CE_LOW(); } uint8_t ds1302_read_reg(uint8_t reg) { uint8_t val; reg |= 0x01; DS1302_CE_HIGH(); ds1302_write_byte(reg); val = ds1302_read_byte(); DS1302_CE_LOW(); return val; }每次传输 CE 都要经历一次拉低拉高。CE 拉低是固定的结束标志,不要省略。如果没有在读完最后一个 bit 后把 CE 拉低,芯片不会正常结束本次传输,下一次操作时序会乱。这个坑我在调试突发模式时踩过,后面会细说。
接下来是 BCD 转换和时间结构。
typedef struct { uint8_t year; uint8_t month; uint8_t day; uint8_t week; uint8_t hour; uint8_t min; uint8_t sec; } ds1302_time_t; static uint8_t ds1302_dec_to_bcd(uint8_t dec) { return (uint8_t)(((dec / 10) << 4) | (dec % 10)); } static uint8_t ds1302_bcd_to_dec(uint8_t bcd) { return (uint8_t)((bcd >> 4) * 10 + (bcd & 0x0F)); } void ds1302_set_time(const ds1302_time_t *tm) { ds1302_write_reg(0x8E, 0x00); ds1302_write_reg(0x80, ds1302_dec_to_bcd(tm->sec) & 0x7F); ds1302_write_reg(0x82, ds1302_dec_to_bcd(tm->min) & 0x7F); ds1302_write_reg(0x84, ds1302_dec_to_bcd(tm->hour) & 0x7F); ds1302_write_reg(0x86, ds1302_dec_to_bcd(tm->day) & 0x3F); ds1302_write_reg(0x88, ds1302_dec_to_bcd(tm->month) & 0x1F); ds1302_write_reg(0x8A, ds1302_dec_to_bcd(tm->week) & 0x07); ds1302_write_reg(0x8C, ds1302_dec_to_bcd(tm->year)); ds1302_write_reg(0x8E, 0x80); } void ds1302_get_time(ds1302_time_t *tm) { uint8_t b; b = ds1302_read_reg(0x80); tm->sec = ds1302_bcd_to_dec(b & 0x7F); b = ds1302_read_reg(0x82); tm->min = ds1302_bcd_to_dec(b & 0x7F); b = ds1302_read_reg(0x84); tm->hour = ds1302_bcd_to_dec(b & 0x7F); b = ds1302_read_reg(0x86); tm->day = ds1302_bcd_to_dec(b & 0x3F); b = ds1302_read_reg(0x88); tm->month = ds1302_bcd_to_dec(b & 0x1F); b = ds1302_read_reg(0x8A); tm->week = ds1302_bcd_to_dec(b & 0x07); b = ds1302_read_reg(0x8C); tm->year = ds1302_bcd_to_dec(b); }设置时间函数第一行关闭写保护,最后一行为可选的重新开启写保护。如果你打算频繁校时,可以留着写保护打开,防止其他代码误操作时间寄存器。读时间函数里的小时掩码用了 0x7F,而不是常见的 0x3F,这一点我在前文反复强调过:20 点到 23 点的十位信息在 bit6,用错掩码会直接把时间读错。
3.4 main 函数接入示例
接入工程很简单,初始化调用一次 gpio init,如果模块是第一次上电或者需要校时,就调用一次 set_time,之后循环读时间即可。
int main(void) { HAL_Init(); SystemClock_Config(); ds1302_gpio_init(); ds1302_time_t t = { .year = 24, .month = 5, .day = 18, .week = 6, .hour = 10, .min = 30, .sec = 0 }; ds1302_set_time(&t); while (1) { ds1302_get_time(&t); printf("%02u-%02u-%02u %02u:%02u:%02u week=%u\r\n", t.year, t.month, t.day, t.hour, t.min, t.sec, t.week); HAL_Delay(1000); } }注意,演示代码里每次上电都 set_time,实际项目不该这么做,否则每次断电重启时间都会重置。正确做法是只做初始化,清除写保护和时钟暂停位,让芯片跟着备用电池继续走。如果需要判断“是否需要校时”,可以存一个标志位到 DS1302 内置 RAM,或者通过按键、上位机命令主动校时。
4. 调不通怎么办:常见症状排查与避坑实录
4.1 读数全是 0xFF 或 0x00,优先排查这五点
如果你读出来的时间全是 0xFF,或者全是 0x00,不要急着怀疑芯片坏了。按照下面这个顺序查,绝大多数问题都能定位。
第一,查接线。CE、SCLK、IO 三根线有没有接反,VCC2 的电压是否在 2.0V 到 5.5V 之间,GND 是不是和 STM32 共地。很多模块自带电池座,电池极性反了也会导致怪现象。
第二,查 IO 上拉。如果用开漏模式但没有外部上拉,只是依赖 STM32 内部上拉,读数据时总线可能被拉到一个不稳定的中间态,尤其在 IO 方向切换的瞬间。所以外部 4.7k 上拉强烈建议要加。
第三,查 SCLK 延时。代码里的 1us 延时是否真的生效。有些工程开了 O3 优化,空循环可能被编译器优化掉一部分,导致时钟翻转过快。
第四,查 CE 时序。每次读写都必须 CE 高电平开始、低电平结束。如果 CE 一直悬空或者没有正确拉低,芯片的传输状态会混乱。
第五,查命令字节。最简单的方法是买个十几块钱的逻辑分析仪,抓一下 CE、SCLK、IO 三根线,对一下命令字节是不是 0x80 写、0x81 读这样的正确序列。没有逻辑分析仪的话,可以用 GPIO 翻转法,在读写函数前后翻转一个调试引脚,再用示波器看间隔。
我这里遇到过一次比较隐蔽的情况:读出来全是 0xFF,追查发现是 IO 引脚在初始化时被配置成了推挽输出,写数据时正常,但读数据前切换输入模式没有成功。换成开漏模式后问题再没出现过。
4.2 秒数不走、分钟乱跳的处理
秒数完全不动,大概率是时钟暂停位。秒寄存器 bit7 是 CH,为 1 时振荡器关闭。初始化时或者每次 set_time 时把这个位清 0,秒针就开始走了。如果清 0 之后走了几秒又停住,可能是代码某处又把秒寄存器写成带 CH 位的数据,比如没有掩码直接写回了一个含 0x80 的 BCD 值。
分钟乱跳则常发生在连续单字节读取时。因为秒和分是两个独立寄存器,读取过程中如果恰好跨越秒进位边界,你可能读到“新秒 + 旧分”的组合,看起来就是分钟突然变化。避免办法有两个。第一个是连续读两次秒寄存器,如果两次结果不一致说明跨越了进位,重新读一遍完整时间。第二个更优雅,直接使用时钟突发模式,一次读出秒、分、时、日、月、星期、年 7 个字节,减少跨寄存器的不一致窗口。
突发读命令是 0xBF,CE 拉高后发命令,然后连续读 7 个字节,最后 CE 拉低。要注意的是,CE 保持高电平期间芯片会持续输出数据,所以必须按需要的长度读,读完立刻拉低 CE,否则会进入循环输出,时序全部乱掉。
4.3 掉电不保存和涓流充电的配置
掉电后时间丢失,最常见原因是 VCC1 没有接备用电源,或者纽扣电池没电了。DS1302 正常工作时由 VCC2 供电,断电后切换到 VCC1。注意 VCC1 的电压不能高于 VCC2 加 0.3V,否则内部电源切换电路可能出问题。所以 VCC1 接 3V 纽扣电池或 2.4V 到 3.6V 的超级电容是稳妥的。
如果你想用超级电容并希望主电源在的时候自动给它充电,可以配置涓流充电寄存器 0x90。这个寄存器的用法比较讲究:高四位 TCS 必须写成 1010 才会使能充电,中间两位选择二极管数量,低两位选择充电电阻。一个常见配置是 0xA5,代表一个二极管加 2k 欧姆电阻,也有模块出厂配置成 0xA6 或 0xA7。如果这个寄存器的 TCS 位不是 1010,涓流充电是关闭的。
我自己踩过充电相关的一个坑:一开始为了省事直接写 0xA5,结果 0.22F 的超级电容很快就充满了,但芯片时间正常。后来换了大容量电容才发现,如果电容太大,2k 电阻的充电电流可能偏大,需要根据电容容量调整电阻档位。所以没什么特别需求时,不要把涓流充电开得太激进,宁可电容大一点、充得慢一点。
5. 最后再分享三个实用小技巧
5.1 读时间更稳的一个小技巧:突发读模式
前面提到突发模式可以减少跨秒进位的读数不一致问题,这里直接给一个简单的实现。时钟突发读命令是 0xBF,CE 高电平期间连续读 7 个字节,最后 CE 拉低。
void ds1302_get_time_burst(ds1302_time_t *tm) { uint8_t buf[7]; uint8_t i; DS1302_CE_HIGH(); ds1302_write_byte(0xBF); for (i = 0; i < 7; i++) { buf[i] = ds1302_read_byte(); } DS1302_CE_LOW(); tm->sec = ds1302_bcd_to_dec(buf[0] & 0x7F); tm->min = ds1302_bcd_to_dec(buf[1] & 0x7F); tm->hour = ds1302_bcd_to_dec(buf[2] & 0x7F); tm->day = ds1302_bcd_to_dec(buf[3] & 0x3F); tm->month = ds1302_bcd_to_dec(buf[4] & 0x1F); tm->week = ds1302_bcd_to_dec(buf[5] & 0x07); tm->year = ds1302_bcd_to_dec(buf[6]); }这段代码与单字节读取的效果差别在读数的连续性。对精度要求不高的场景,用单字节逐项读取也够,但如果你做的是数据记录仪这类对时间一致性有要求的设备,建议直接用突发读。
5.2 31 字节 RAM 的妙用和扩展玩法
DS1302 除了时间寄存器,还有一块 31 字节的 RAM,地址从 0xC0 到 0xFD,写地址是偶数,读地址对应加 1。这块 RAM 在电池供电时会保持数据,非常适合存放一些掉电不能丢的小参数,比如设备编号、校时标志、上次关机时间。使用方式就是普通的寄存器读写,只是地址范围换一下。
我自己的扩展玩法是做一个“开机判断”:首次上电时读 RAM 里的一个标志字节,如果不是预设魔数,就认为模块从未初始化,自动执行一次 set_time,并把魔数写入 RAM。这样既不会每次开机都重置时间,也能在换电池后自动重新校时。这个思路可以移植到很多带掉电存储需求的场景里。
另外,如果你需要输出更精确的秒信号,可以看 DS1302 的 SQW 引脚功能,不过很多模块没把该引脚引出来。真要高精度长稳计时,还是直接选带温度补偿的实时时钟方案,DS1302 的优势是便宜、简单、入门友好。
这块芯片虽然老,但它把串行时序、双向 IO、寄存器操作这几个嵌入式基本功都串了起来。我第一次调通的时候,看到串口打印出稳定的秒数,心里还是挺有成就感的。如果你也卡在某个读数的怪问题上,试着用逻辑分析仪抓一下波形,很多时候一眼就能看出问题。代码我已经整理到开源仓库里,对照这篇笔记看,应该能把 DS1302 在 STM32 上顺利跑起来。