☰
STM32驱动DS1302实时时钟:GPIO模拟时序与BCD码转换实战
2026/10/21 1:38:30 网站建设 项目流程

1. 项目缘起与整体设计思路

1.1 为什么还要折腾一颗“老掉牙”的时钟芯片

DS1302 这颗芯片,稍微有点年头的嵌入式玩家都不陌生。它是一颗带涓流充电能力的实时时钟芯片,通过三根线(CE、I/O、SCLK)完成串行通信,能输出秒、分、时、日、月、周、年,还附带 31 字节的静态 RAM。很多人第一次接触它,是在某块入门开发板的例程包里,代码一跑,串口打印出时间,任务就算完成了。但真正把它放到实际项目里,问题就来了:掉电走时准不准、读写时序对不对、BCD 码转换有没有坑、和 STM32 的 GPIO 模拟时序怎么配合,这些细节才是决定项目能不能落地的关键。

我这次把 STM32 驱动 DS1302 的完整过程整理出来,核心目的不是复述数据手册,而是把“为什么这么设计”讲透。比如为什么用 GPIO 模拟而不是硬件 SPI,为什么写保护要先关再开,为什么读出来的数据要做 BCD 转换,这些选择背后都有明确的工程理由。适合的读者是已经会点 STM32 基础、能点亮 LED、能配置串口,但还没独立驱动过外设时钟芯片的朋友。如果你正好在做数据记录仪、定时开关、低功耗采集节点这类需要“记住时间”的项目,这篇内容可以直接拿去参考。

1.2 整体方案选型:GPIO 模拟时序 vs 硬件 SPI

DS1302 的通信接口严格来说不是标准 SPI,虽然它也是三线制,但时序细节和 SPI 模式对不上。它的数据在 SCLK 上升沿被写入,下降沿被读出,而且命令字节和数据的传输方向是半双工。STM32 的硬件 SPI 虽然可以配置成类似模式,但灵活性反而成了负担:你需要额外控制 CE 引脚,还要处理单线双向的数据流,配置起来比直接写 GPIO 还麻烦。

我最终选择GPIO 模拟时序,理由有三点。第一,DS1302 的通信速率不高,最高也就 2MHz 左右,STM32 的 GPIO 翻转速度完全够用,甚至用 HAL 库的HAL_GPIO_WritePin都能轻松跑起来。第二,模拟时序可以精确控制每个边沿的延时,方便用逻辑分析仪抓波形调试,出了问题一眼就能看出是哪个沿没对齐。第三,代码可移植性强,换到另一颗 MCU 上,只要改几个宏定义就能跑,不用重新研究硬件外设的配置寄存器。

当然,模拟时序也有代价:占用 CPU 时间。但 DS1302 的读写操作通常只在初始化、设置时间、读取时间时发生,每次也就几十个微秒,对整体系统的影响可以忽略不计。如果你做的是超低功耗项目,可以在读写完成后把 GPIO 配置成模拟输入或关闭时钟,进一步降低功耗。

1.3 硬件连接与引脚分配

我用的是一块常见的 STM32F103C8T6 最小系统板,DS1302 模块是市面上常见的带 32.768kHz 晶振和备用电池座的小板。连接关系如下:

DS1302 引脚STM32 引脚说明
VCC3.3V供电,注意模块是否支持 3.3V
GNDGND共地
CEPA4片选/使能,高电平有效
I/OPA5数据线,双向
SCLKPA6串行时钟,由 MCU 输出

这里有个细节:DS1302 的 I/O 引脚是开漏输出,需要上拉电阻。很多模块板上已经集成了 4.7kΩ 或 10kΩ 的上拉电阻,如果没有,你需要在 STM32 端配置内部上拉,或者外接一个 4.7kΩ 电阻到 3.3V。我实测下来,内部上拉在短距离通信时完全够用,但如果杜邦线较长,建议还是外接上拉,波形会干净很多。

注意:DS1302 的 VCC 范围是 2.0V 到 5.5V,但备用电池电压不能超过 VCC。如果你用 3.3V 供电,备用电池用 3V 纽扣电池没问题;如果用 5V 供电,电池电压也要相应提高,否则涓流充电电路可能无法正常工作。

2. 核心细节解析与实操要点

2.1 DS1302 寄存器结构与 BCD 码转换

DS1302 的所有时间数据都以 BCD 码格式存储。所谓 BCD 码,就是用四位二进制表示一位十进制数。比如秒寄存器地址是 0x80,写进去的 0x59 表示 59 秒,而不是二进制的 89。这个设计在早期芯片中很常见,目的是方便直接驱动数码管显示,省去二进制转十进制的运算。

但对我们写代码来说,就多了一步转换。假设从 DS1302 读出的秒数据是0x59,要转成十进制 59,需要分别取出高四位和低四位:

uint8_t bcd_to_dec(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } uint8_t dec_to_bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }

这两个函数看起来简单,但有个坑:DS1302 的小时寄存器在 12 小时模式下,bit7 是 12/24 小时标志,bit5 是 AM/PM 标志。如果你初始化时没设置成 24 小时模式,读出来的小时数据可能完全不对。我建议在初始化时明确写入 24 小时模式,也就是把小时寄存器的 bit7 清零。

另外,秒寄存器的 bit7 是时钟停止位(CH)。上电后如果 CH 位是 1,时钟不会走。所以初始化时必须先写 0 到秒寄存器,启动时钟。这个细节很多例程里没提,导致新手调试时发现时间不动,以为是晶振坏了,其实是 CH 位没清。

2.2 写保护与涓流充电寄存器

DS1302 有一个写保护寄存器,地址是 0x8E。上电默认是写保护的,任何对时间寄存器的写操作都会被忽略。所以每次写时间之前,必须先写 0x00 到 0x8E 解除保护,写完后再写 0x80 恢复保护。这个顺序不能反,否则时间写不进去。

涓流充电寄存器地址是 0x90,用来配置备用电池的充电方式。如果你用的是不可充电的纽扣电池(比如 CR2032),千万不要开启涓流充电,否则电池会漏液甚至爆炸。我一般直接写 0x00 关闭充电功能,用可充电的 LIR2032 时才根据数据手册配置。这个寄存器很多人忽略,但它是安全相关的,必须重视。

实操心得:我习惯在初始化函数里先关闭写保护,然后依次写入秒、分、时、日、月、周、年,最后开启写保护。写完后立刻读一遍时间,通过串口打印出来核对。如果读出来的数据和写入的不一致,优先检查 BCD 转换和写保护顺序。

2.3 时序图中的关键延时与边沿对齐

DS1302 的数据手册给出了详细的时序参数,其中最关键的是 SCLK 的高低电平时间和数据建立/保持时间。在 3.3V 供电下,SCLK 最小高电平时间是 250ns,最小低电平时间是 250ns。STM32F103 在 72MHz 主频下,一条HAL_GPIO_WritePin执行时间大约几十纳秒,所以直接调用库函数翻转 GPIO 是安全的,不需要额外加delay。

但如果你用的是更快的 MCU,比如 STM32H7 系列,主频几百兆,GPIO 翻转速度可能超过 DS1302 的承受范围。这时候就需要插入几个__NOP()或者简单的空循环来降低速率。我一般会在 SCLK 翻转后加一个__NOP(),确保波形稳定。

数据写入是在 SCLK 上升沿,数据读出是在 SCLK 下降沿。写命令字节时,从 bit0 开始,每个上升沿写入一位。读数据时,也是在下降沿后读取 I/O 引脚的电平。这里有个容易出错的地方:读数据时,MCU 必须先把 I/O 引脚配置成输入模式,读完后再切回输出模式。如果一直保持输出模式,读出来的数据永远是 MCU 最后输出的电平,而不是 DS1302 返回的数据。

// 写一个字节 void ds1302_write_byte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, (dat & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); dat >>= 1; HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET); __NOP(); HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); __NOP(); } } // 读一个字节 uint8_t ds1302_read_byte(void) { uint8_t dat = 0; for (uint8_t i = 0; i < 8; i++) { dat >>= 1; if (HAL_GPIO_ReadPin(DS1302_IO_PORT, DS1302_IO_PIN) == GPIO_PIN_SET) { dat |= 0x80; } HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET); __NOP(); HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); __NOP(); } return dat; }

注意读字节时,我先把dat右移一位,再判断 I/O 电平,然后把结果放到最高位。这样循环 8 次后,最先读到的位在最低位,最后读到的位在最高位,符合 DS1302 的 LSB 优先格式。

3. 完整实操流程与核心代码实现

3.1 工程搭建与 GPIO 初始化

我用的开发环境是 STM32CubeMX + Keil MDK。在 CubeMX 里配置 PA4、PA5、PA6 为 GPIO 输出模式,其中 PA5 在初始化时先配置为开漏输出,内部上拉。为什么用开漏?因为 DS1302 的 I/O 线是双向的,开漏输出可以避免总线冲突。当 MCU 输出高电平时,实际是靠上拉电阻拉高;输出低电平时,MOS 管导通拉低。读数据时,把 PA5 切换成输入模式,上拉电阻仍然有效。

初始化代码如下:

void ds1302_gpio_init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); // CE 和 SCLK 推挽输出 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); // I/O 开漏输出,内部上拉 GPIO_InitStruct.Pin = DS1302_IO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 初始状态:CE 低,SCLK 低 HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET); }

这里有个细节:SCLK 初始状态必须是低电平。如果上电时 SCLK 是高电平,DS1302 可能误判为时钟信号,导致状态机错乱。我在初始化函数里明确把 SCLK 拉低,确保通信从已知状态开始。

3.2 写命令与读写寄存器函数

DS1302 的命令字节格式是:bit7 固定为 1,bit6 为 RAM/时钟选择(0 表示时钟,1 表示 RAM),bit5 到 bit1 是寄存器地址,bit0 是读写标志(0 写,1 读)。比如写秒寄存器,地址是 0x80;读秒寄存器,地址是 0x81。

void ds1302_write_reg(uint8_t addr, uint8_t dat) { HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET); ds1302_write_byte(addr); ds1302_write_byte(dat); HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET); } uint8_t ds1302_read_reg(uint8_t addr) { uint8_t dat; HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET); ds1302_write_byte(addr); dat = ds1302_read_byte(); HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET); return dat; }

注意 CE 引脚在每次通信前后必须拉低再拉高。CE 拉高时,DS1302 进入通信状态;CE 拉低时,通信结束,DS1302 回到低功耗状态。如果你连续读写多个寄存器,可以在第一次拉高 CE 后连续操作,最后再拉低,这样效率更高。但为了代码清晰,我一般每个寄存器操作都独立控制 CE。

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

设置时间的函数需要先解除写保护,然后依次写入秒、分、时、日、月、周、年,最后恢复写保护。这里有个顺序问题:DS1302 的寄存器地址是连续的,秒是 0x80,分是 0x82,时是 0x84,依此类推。所以可以用循环简化代码。

void ds1302_set_time(uint8_t year, uint8_t month, uint8_t day, uint8_t week, uint8_t hour, uint8_t min, uint8_t sec) { ds1302_write_reg(0x8E, 0x00); // 解除写保护 ds1302_write_reg(0x80, dec_to_bcd(sec) & 0x7F); // 秒,清 CH 位 ds1302_write_reg(0x82, dec_to_bcd(min)); ds1302_write_reg(0x84, dec_to_bcd(hour) & 0x3F); // 24 小时模式 ds1302_write_reg(0x86, dec_to_bcd(day)); ds1302_write_reg(0x88, dec_to_bcd(month)); ds1302_write_reg(0x8A, dec_to_bcd(week)); ds1302_write_reg(0x8C, dec_to_bcd(year)); ds1302_write_reg(0x8E, 0x80); // 恢复写保护 }

读取时间时,直接按地址读取并转换即可。注意秒寄存器的 bit7 是 CH 位,读取后要屏蔽掉。

void ds1302_get_time(uint8_t *year, uint8_t *month, uint8_t *day, uint8_t *week, uint8_t *hour, uint8_t *min, uint8_t *sec) { *sec = bcd_to_dec(ds1302_read_reg(0x81) & 0x7F); *min = bcd_to_dec(ds1302_read_reg(0x83)); *hour = bcd_to_dec(ds1302_read_reg(0x85) & 0x3F); *day = bcd_to_dec(ds1302_read_reg(0x87)); *month = bcd_to_dec(ds1302_read_reg(0x89)); *week = bcd_to_dec(ds1302_read_reg(0x8B)); *year = bcd_to_dec(ds1302_read_reg(0x8D)); }

我实测下来,这套代码在 STM32F103 上跑,读写一次时间大约 200 微秒,对主循环几乎没有影响。如果你在中断里调用,注意不要频繁读写,建议每秒读一次就够了。

3.4 串口打印与时间校验

为了验证时间是否准确,我通过串口把读出的时间打印出来。串口配置用 115200 波特率,8 位数据位,无校验,1 位停止位。打印格式用printf重定向,方便调试。

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }

然后在主循环里每秒读取一次时间并打印:

while (1) { ds1302_get_time(&year, &month, &day, &week, &hour, &min, &sec); printf("20%02d-%02d-%02d 周%d %02d:%02d:%02d\r\n", year, month, day, week, hour, min, sec); HAL_Delay(1000); }

这里有个小技巧:HAL_Delay(1000)的精度取决于系统滴答定时器,如果你对时间精度要求高,可以用 DS1302 的秒变化来触发打印,而不是固定延时。比如检测到秒值变化时再打印,这样打印的时间戳更准确。

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

4.1 时间不走或走时不准

这是新手最常遇到的问题。我总结了几种可能的原因和排查方法:

现象可能原因排查方法
时间完全不动秒寄存器 CH 位为 1读秒寄存器,检查 bit7 是否为 0
时间走得慢晶振负载电容不匹配检查晶振是否 6pF 负载,必要时更换
时间走得快晶振质量差或受干扰用示波器看 32.768kHz 波形是否干净
掉电后时间丢失备用电池没电或接触不良测量电池电压,检查电池座焊接
读出的数据乱码BCD 转换错误或时序不对用逻辑分析仪抓波形,核对时序参数

我遇到过一次时间走得慢的问题,排查了半天发现是晶振旁边的两个负载电容焊错了,用了 22pF 而不是 6pF。DS1302 的晶振要求负载电容是 6pF,如果用 12pF 或 22pF,频率会偏移,导致走时不准。这个细节在数据手册里有写,但很容易被忽略。

4.2 读写数据全为 0 或全为 0xFF

如果读出来的数据全是 0 或全是 0xFF,通常说明通信根本没建立。排查步骤:

  1. 用万用表测量 DS1302 的 VCC 和 GND,确认供电正常。
  2. 检查 CE、SCLK、I/O 三根线的连接,有没有虚焊或接反。
  3. 用示波器或逻辑分析仪抓 SCLK 和 I/O 波形,看是否有正确的时钟和数据。
  4. 检查 GPIO 配置,I/O 引脚在读数据时是否切换成了输入模式。

我踩过一次坑:I/O 引脚在初始化时配置成了推挽输出,读数据时忘记切换成输入,结果读出来的永远是 MCU 最后输出的电平。后来改成开漏输出加内部上拉,读之前调用HAL_GPIO_ReadPin前先把引脚模式改成输入,问题就解决了。

实操心得:如果你没有逻辑分析仪,可以用一个简单的办法验证通信是否正常——写一个已知数据到 DS1302 的 RAM 里,再读出来对比。如果读出来的和写进去的一致,说明通信没问题;如果不一致,再查时序。

4.3 备用电池漏液或发热

这个问题比较危险,通常是因为开启了涓流充电但用了不可充电的电池。DS1302 的涓流充电寄存器 0x90 如果配置不当,会给电池持续充电,导致电池发热甚至漏液。我的建议是:如果你不确定电池是否可充电,直接把 0x90 写 0x00,关闭充电功能。CR2032 是不可充电的,LIR2032 是可充电的,两者外观几乎一样,买的时候一定要看清楚型号。

另外,备用电池的电压不能超过 VCC。如果 VCC 是 3.3V,电池用 3V 没问题;如果 VCC 是 5V,电池用 3V 也可以,但涓流充电电路可能无法正常工作。我一般统一用 3.3V 供电,电池用 CR2032,关闭充电,这样最安全。

4.4 多字节读写时的 CE 控制

DS1302 支持突发模式(Burst Mode),可以一次性读写所有时间寄存器。突发模式的好处是避免在读的过程中时间变化导致数据不一致。比如你读秒的时候是 59 秒,读分的时候已经变成下一分钟了,这样读出来的时间就是错的。突发模式会在一个 CE 周期内连续读出所有寄存器,保证数据的一致性。

突发读的命令是 0xBF,突发写的命令是 0xBE。使用突发模式时,CE 拉高后先写命令字节,然后连续读写 8 个字节(秒、分、时、日、月、周、年、写保护),最后拉低 CE。我一般在需要高精度时间戳的场景下用突发模式,平时单字节读写也够用。

void ds1302_burst_read(uint8_t *buf) { HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET); ds1302_write_byte(0xBF); for (uint8_t i = 0; i < 8; i++) { buf[i] = ds1302_read_byte(); } HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET); }

注意突发读出来的第一个字节是秒,第二个是分,依此类推,最后一个字节是写保护寄存器。写保护寄存器的值在突发读时也会被读出,但一般忽略即可。

4.5 低功耗场景下的优化建议

如果你的项目是电池供电,需要长时间运行,DS1302 的功耗也需要考虑。DS1302 在 3.3V 下的工作电流大约 300nA(时钟运行,通信关闭),备用电池模式下大约 1μA。这个功耗已经很低了,但 STM32 的 GPIO 如果配置不当,可能会通过上拉电阻持续漏电。

我的做法是:在不需要通信的时候,把 CE、SCLK、I/O 三个引脚都配置成模拟输入模式,关闭上拉。这样 GPIO 不会对外输出电流,也不会通过上拉电阻漏电。需要通信时再临时切换回输出模式。这个切换过程很快,对通信没有影响。

另外,DS1302 的晶振在低温下可能起振困难。如果你在户外使用,建议选择工业级温度的 DS1302 和晶振,或者在软件里增加起振检测:上电后读秒寄存器,如果 CH 位一直是 1,说明晶振没起振,可以尝试重新初始化。

5. 项目扩展与个人体会

5.1 可以继续扩展的方向

这套驱动代码跑通之后,可以往几个方向扩展。第一,加一个 OLED 或 LCD 显示屏,把时间直接显示出来,做成一个桌面时钟。第二,结合 STM32 的 RTC 功能,用 DS1302 做备份,主 RTC 走时,DS1302 在掉电时维持时间,上电后自动校准。第三,加一个 SD 卡模块,把时间戳和传感器数据一起记录到文件里,做成数据记录仪。第四,用 DS1302 的 31 字节 RAM 存储一些配置参数,比如报警阈值、设备 ID 等,掉电不丢失。

我个人觉得最有意思的是结合低功耗模式:STM32 进入 STOP 模式,DS1302 继续走时,通过 DS1302 的报警功能或者定时中断唤醒 MCU。这样整体功耗可以做到微安级别,用一节纽扣电池就能跑几个月。

5.2 我踩过的坑和最后的小技巧

最后分享几个我在实际项目中踩过的坑。第一个是 BCD 转换函数写反了,把dec_to_bcd和bcd_to_dec搞混,结果写进去的时间全是乱的。后来我养成了一个习惯:在函数名里明确标注输入输出格式,比如dec_to_bcd表示十进制转 BCD,bcd_to_dec表示 BCD 转十进制,这样不容易搞错。

第二个是 CE 引脚的初始状态。有一次我忘记在初始化时把 CE 拉低,结果上电后 DS1302 一直处于通信状态,功耗比正常高了好几倍。后来我在初始化函数里第一件事就是把 CE 和 SCLK 都拉低,确保 DS1302 进入低功耗状态。

第三个是晶振的焊接。DS1302 的 32.768kHz 晶振是圆柱形的,焊接时如果温度太高或时间太长,晶振内部可能会损坏。我一般用 300°C 左右的烙铁,焊接时间不超过 3 秒,焊完后用万用表测一下晶振引脚是否短路。

最后再分享一个小技巧:如果你怀疑 DS1302 的通信有问题,可以先写一个最简单的测试程序,只读写秒寄存器,用串口打印出来。如果秒寄存器能正常读写,说明硬件和时序都没问题,再逐步加上其他寄存器。这样排查问题比一上来就跑完整代码要快得多。

这套代码我已经在几个小项目里实际用过,稳定性没问题。DS1302 虽然老,但便宜、好用、资料多,对于不需要高精度时间的场景来说,依然是个靠谱的选择。如果你也在用这颗芯片,希望这篇内容能帮你少走点弯路。

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

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

立即咨询