资讯动态

STM32驱动DS1302实战:GPIO模拟三线时序与BCD码转换详解

发布时间:2026/10/19 11:37:35 来源:尧图企业网站定制
1. 为什么还要折腾 DS1302 这颗老芯片现在做嵌入式项目一提 RTC实时时钟大多数人第一反应是 STM32 内部自带的 RTC 外设或者直接上 DS3231 这种高精度 I2C 芯片。DS1302 这颗芯片说实话年纪不小了SPI 三线接口、BCD 码存储、还得外挂 32.768kHz 晶振和备用电池看起来哪哪都不如后来者。但我在实际项目里反复用到它原因很实在便宜、好买、资料多、时序简单到可以用普通 GPIO 模拟不需要占用硬件 SPI 资源。尤其是做一些小批量、成本敏感的板子或者教学实验板DS1302 依然是性价比极高的选择。这篇笔记记录的是我用 STM32 驱动 DS1302 的完整过程。不是那种复制粘贴就能跑的代码堆砌而是把时序逻辑、BCD 码转换、寄存器结构、读写保护机制这些容易踩坑的地方全部拆开讲清楚。适合两类人看一类是刚接触 STM32 和外部芯片通信的初学者想搞明白软件模拟时序到底是怎么回事另一类是有一定经验、但每次写 DS1302 驱动都要翻数据手册、调半天时序的开发者希望能有一套稳定可靠的驱动模板。我用的硬件平台是常见的 STM32F103 系列开发板DS1302 模块是市面上最常见的那种带电池座的小板子。整个驱动基于 GPIO 模拟三线时序实现不依赖硬件 SPI移植到任何 STM32 型号上都只需要改几个引脚定义。下面从芯片本身的通信机制讲起一步步把驱动写出来。2. DS1302 的三线时序到底特殊在哪2.1 三根线各干什么活DS1302 的通信接口只有三根线CE也叫 RST复位/片选、SCLK串行时钟、I/O数据线双向。注意这里没有独立的 MOSI 和 MISO数据收发共用一根线靠方向切换来实现半双工通信。这一点和标准 SPI 的四线制完全不同也是很多人第一次写驱动时最不适应的地方。CE 线的作用是启动和结束一次通信会话。当 CE 拉高时芯片进入通信状态所有读写操作都必须在这个窗口内完成CE 拉低后芯片回到低功耗待机状态。SCLK 提供同步时钟DS1302 最高支持 2MHz 左右的时钟频率但实际用 GPIO 模拟时我一般把半周期控制在 1~2 微秒也就是大概 250kHz 到 500kHz这个速度足够快又留足了时序余量。I/O 线是双向的写数据时由 MCU 驱动读数据时由 DS1302 驱动。关键在于方向切换的时机写命令字节和写数据字节时MCU 输出读数据字节时必须在最后一个命令位的下降沿之后、第一个数据位的上升沿之前把 MCU 的 GPIO 切换成输入模式。这个切换窗口很窄如果切换太慢第一个数据位就可能丢失。2.2 数据是在时钟的哪个边沿采样这是 DS1302 时序里最容易搞错的地方。规则是这样的写数据时数据在 SCLK 的上升沿被 DS1302 采样所以 MCU 应该在 SCLK 低电平期间把数据放到 I/O 线上然后拉高 SCLK再拉低。读数据时DS1302 在 SCLK 的下降沿输出数据所以 MCU 应该在拉低 SCLK 之后去读取 I/O 线的电平。我见过不少人写驱动时把读写边沿搞反结果读出来的数据全是 0xFF 或者 0x00。判断方法很简单如果读出来的秒寄存器永远是某个固定值而且写进去也不变八成就是采样边沿搞错了。另外要注意数据是低位先行LSB first不是像标准 SPI 那样通常高位先行。命令字节和数据字节都是从 bit0 开始传一直传到 bit7。2.3 命令字节的位定义每次通信MCU 首先要发送一个命令字节告诉 DS1302 这次是要读还是写、操作哪个寄存器。命令字节的格式是这样的位名称含义bit0RD/W1 表示读0 表示写bit1~bit5A0~A4寄存器地址bit6RAM/CK1 表示操作 RAM0 表示操作时钟寄存器bit7固定必须为 1举个例子要写秒寄存器地址 0x00命令字节就是 0x80要读秒寄存器命令字节就是 0x81。要写小时寄存器地址 0x04命令字节是 0x84读小时寄存器就是 0x85。这个规律很规整地址左移一位最低位补读写标志最高位固定置 1。注意命令字节的 bit7 必须为 1这是 DS1302 的固定要求。如果忘了置这一位芯片不会响应任何操作而且不会有任何报错表现就是读写全部失败很容易让人以为是硬件接线问题。3. 寄存器结构和 BCD 码转换的坑3.1 时钟寄存器的地址分布DS1302 的时钟寄存器一共 8 个地址从 0x00 到 0x07分别对应秒、分、时、日、月、星期、年还有一个写保护寄存器。每个寄存器都是 8 位但实际有效位数不同。比如秒寄存器 bit0~bit6 存秒的 BCD 码bit7 是 CH 位Clock Halt这一位为 1 时时钟停止振荡为 0 时正常走时。很多人第一次用 DS1302写进去时间后发现不走就是因为忘了把 CH 位清零。小时寄存器稍微复杂一点bit7 是 12/24 小时制选择位。如果 bit7 为 0表示 24 小时制bit0~bit5 存小时的 BCD 码如果 bit7 为 1表示 12 小时制bit5 是 AM/PM 标志bit0~bit4 存小时的 BCD 码。我一般统一用 24 小时制省得处理 AM/PM 转换。写保护寄存器地址是 0x8Ebit7 是 WPWrite Protect位。上电默认这一位是 1也就是写保护开启所有时钟寄存器和 RAM 都不可写。所以初始化流程里第一步必须是先关闭写保护写完时间后再根据需要决定是否重新打开。3.2 BCD 码转换为什么容易出错DS1302 内部所有时间数据都是以 BCD 码格式存储的。所谓 BCD 码就是用 4 个二进制位表示一个十进制数字比如十进制的 25BCD 码是 0010 0101高 4 位是 2低 4 位是 5。而 MCU 内部处理的是普通二进制所以每次读写都要做转换。转换公式看起来很简单BCD 转十进制是(bcd 4) * 10 (bcd 0x0F)十进制转 BCD 是((dec / 10) 4) | (dec % 10)。但实际写代码时有几个地方特别容易翻车。第一个坑是位运算优先级。bcd 4 * 10这种写法是错的因为*的优先级高于实际会先算4 * 10再右移结果完全不对。必须写成(bcd 4) * 10括号不能省。第二个坑是输入范围没有校验。如果传入的十进制数大于 99转换结果会溢出 8 位高位的进位会丢失。虽然正常时间不会超过 99但写代码时最好加个范围检查避免调试阶段传入异常值导致数据错乱。第三个坑是读出来的数据没有屏蔽无效位。比如读秒寄存器bit7 是 CH 位如果不屏蔽掉直接做 BCD 转换CH 位为 1 时就会多出 80转换结果完全错误。正确做法是先 0x7F把 CH 位清掉再做转换。3.3 初始化时到底该写哪些寄存器DS1302 上电后寄存器的值是不确定的尤其是第一次装电池的时候。所以初始化流程必须完整地写一遍所有时间寄存器不能只写秒和分。我的做法是定义一个结构体把年月日时分秒星期都存进去然后一次性全部写入。初始化顺序也很重要先关闭写保护向 0x8E 写 0x00然后依次写入秒、分、时、日、月、星期、年最后根据需求决定是否重新打开写保护。如果项目里时间不需要频繁修改写完就打开写保护可以防止程序跑飞时误改时间。如果需要经常校准时间就保持写保护关闭但要在软件层面做好防护。提示DS1302 的星期寄存器只用了低 3 位取值范围是 1~7具体哪天是星期几由用户自己定义。我一般按 1周一、7周日来设置和国内习惯一致。4. GPIO 模拟时序的完整实现4.1 引脚定义和宏封装先定义三个引脚用宏封装好拉高、拉低、读取和方向切换的操作。这样做的目的是让底层驱动和具体硬件解耦换板子时只需要改宏定义不用动驱动逻辑。#define DS1302_CE_PIN GPIO_Pin_0 #define DS1302_CE_PORT GPIOA #define DS1302_SCLK_PIN GPIO_Pin_1 #define DS1302_SCLK_PORT GPIOA #define DS1302_IO_PIN GPIO_Pin_2 #define DS1302_IO_PORT GPIOA #define DS1302_CE_HIGH() GPIO_SetBits(DS1302_CE_PORT, DS1302_CE_PIN) #define DS1302_CE_LOW() GPIO_ResetBits(DS1302_CE_PORT, DS1302_CE_PIN) #define DS1302_SCLK_HIGH() GPIO_SetBits(DS1302_SCLK_PORT, DS1302_SCLK_PIN) #define DS1302_SCLK_LOW() GPIO_ResetBits(DS1302_SCLK_PORT, DS1302_SCLK_PIN) #define DS1302_IO_HIGH() GPIO_SetBits(DS1302_IO_PORT, DS1302_IO_PIN) #define DS1302_IO_LOW() GPIO_ResetBits(DS1302_IO_PORT, DS1302_IO_PIN) #define DS1302_IO_READ() GPIO_ReadInputDataBit(DS1302_IO_PORT, DS1302_IO_PIN)方向切换需要动态修改 GPIO 模式。STM32 的 GPIO 在初始化时配置为推挽输出读数据前要切换成上拉输入或浮空输入读完再切回输出。我一般用上拉输入因为 DS1302 的 I/O 线在空闲时是高电平上拉可以避免悬空干扰。void DS1302_IO_InputMode(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DS1302_IO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DS1302_IO_PORT, GPIO_InitStructure); } void DS1302_IO_OutputMode(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DS1302_IO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DS1302_IO_PORT, GPIO_InitStructure); }4.2 写一个字节的时序写字节的流程是CE 拉高然后从 bit0 到 bit7 依次把数据放到 I/O 线上每个 bit 在 SCLK 低电平时准备好然后拉高 SCLK再拉低。循环 8 次后一个字节写完。void DS1302_WriteByte(uint8_t dat) { uint8_t i; DS1302_IO_OutputMode(); for (i 0; i 8; i) { DS1302_SCLK_LOW(); if (dat 0x01) DS1302_IO_HIGH(); else DS1302_IO_LOW(); dat 1; DS1302_SCLK_HIGH(); } }这里有个细节SCLK 拉高之后不需要再拉低因为下一次循环开头会先拉低。但为了时序清晰我习惯在每个 bit 结束后把 SCLK 拉低保证每个 bit 的起始状态都是低电平。4.3 读一个字节的时序读字节的流程和写类似但方向相反。CE 拉高后先把 I/O 切换成输入模式然后从 bit0 到 bit7每个 bit 先拉低 SCLK然后读取 I/O 线电平再拉高 SCLK。注意读的时候是在 SCLK 低电平期间读因为 DS1302 在下降沿输出数据。uint8_t DS1302_ReadByte(void) { uint8_t i, dat 0; DS1302_IO_InputMode(); for (i 0; i 8; i) { dat 1; DS1302_SCLK_LOW(); if (DS1302_IO_READ()) dat | 0x80; DS1302_SCLK_HIGH(); } DS1302_IO_OutputMode(); return dat; }注意这里的移位方向因为是低位先行所以每次先把 dat 右移一位然后把读到的 bit 放到最高位。循环 8 次后dat 就是完整的字节。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; }注意每次通信前后都要操作 CE 线通信前拉低再拉高确保一个干净的起始状态通信结束后拉低让芯片回到待机。SCLK 在通信前也要拉低保证第一个时钟脉冲是上升沿。5. 调试过程中遇到的几个典型问题5.1 读出来全是 0xFF 是怎么回事第一次调通之前我读秒寄存器一直返回 0xFF。排查过程是这样的先用示波器看 SCLK 和 I/O 线的波形发现 SCLK 有正常输出但 I/O 线在应该由 DS1302 驱动的时候一直是高电平。这说明 MCU 根本没有切换到输入模式或者切换太慢DS1302 还没来得及输出数据MCU 就已经读完了。后来发现是方向切换函数里 GPIO 初始化结构体没有重新赋值导致模式切换失败。修正后读数据正常。这个问题的教训是GPIO 模式切换一定要重新配置完整的结构体不能只改一个字段因为 STM32 的标准库函数会根据结构体的所有字段来配置寄存器。5.2 时间不走或者走得不准时间不走最常见的原因是 CH 位没有清零。秒寄存器的 bit7 是时钟停止位上电默认可能是 1写时间时必须确保这一位是 0。我的做法是在写秒寄存器之前先把秒值转成 BCD 码然后 0x7F确保 CH 位为 0。时间走得不准一般是晶振问题。DS1302 需要外接 32.768kHz 晶振而且对晶振的负载电容有要求通常是 6pF。如果晶振匹配电容不对或者晶振质量差走时误差会比较大。我实测过几款不同价位的模块便宜的模块一天误差可能到几秒好一点的模块一个月误差在几分钟以内。如果对精度要求高还是建议上 DS3231内置温补晶振精度高一个数量级。5.3 备用电池装上去时间还是丢这个问题我遇到过两次。第一次是电池座接触不良电池根本没供上电。第二次是电池电压不够CR2032 标称 3V但快没电的时候可能只有 2.5V 左右DS1302 在 2.0V 以上就能保持时间但有些劣质电池内阻大一加载就掉压。排查方法很简单用万用表量电池座正负极之间的电压正常应该在 3V 左右。如果低于 2.5V换电池。另外要注意DS1302 的备用电池引脚是 VCC1主电源是 VCC2VCC2 电压高于 VCC1 时由主电源供电主电源掉电后自动切换到 VCC1。如果 VCC1 和 VCC2 接反了备用电池会一直耗电很快就没电了。5.4 写保护忘了关导致写入无效这个坑我踩过不止一次。DS1302 上电后写保护是开启的如果不先向 0x8E 写 0x00 关闭写保护所有写操作都会被忽略而且读回来的数据还是旧值。表现就是写时间后立刻读回来发现还是原来的值好像写入完全没生效。解决办法就是在初始化函数里第一步就关闭写保护写完时间后再根据需要决定是否重新打开。如果项目里时间不需要频繁修改建议写完就打开写保护防止程序异常时误改时间。6. 一套可以直接用的驱动代码结构6.1 头文件里该放什么头文件里主要放引脚定义、函数声明和常用的宏。我习惯把时间结构体也放在头文件里方便其他模块引用。typedef struct { uint8_t year; uint8_t month; uint8_t day; uint8_t week; uint8_t hour; uint8_t minute; uint8_t second; } DS1302_TimeTypeDef; void DS1302_Init(void); void DS1302_SetTime(DS1302_TimeTypeDef *time); void DS1302_GetTime(DS1302_TimeTypeDef *time);6.2 初始化函数的完整流程初始化函数要做三件事配置 GPIO、关闭写保护、写入初始时间。如果不需要设置时间可以只做前两步。void DS1302_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin DS1302_CE_PIN | DS1302_SCLK_PIN | DS1302_IO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); DS1302_CE_LOW(); DS1302_SCLK_LOW(); DS1302_WriteReg(0x8E, 0x00); }6.3 设置时间和读取时间的实现设置时间时把结构体里的十进制值转成 BCD 码然后依次写入各个寄存器。读取时间时反过来先读寄存器屏蔽无效位再转成十进制。void DS1302_SetTime(DS1302_TimeTypeDef *time) { DS1302_WriteReg(0x8E, 0x00); DS1302_WriteReg(0x80, (time-second / 10 4) | (time-second % 10)); DS1302_WriteReg(0x82, (time-minute / 10 4) | (time-minute % 10)); DS1302_WriteReg(0x84, (time-hour / 10 4) | (time-hour % 10)); DS1302_WriteReg(0x86, (time-day / 10 4) | (time-day % 10)); DS1302_WriteReg(0x88, (time-month / 10 4) | (time-month % 10)); DS1302_WriteReg(0x8A, time-week); DS1302_WriteReg(0x8C, (time-year / 10 4) | (time-year % 10)); DS1302_WriteReg(0x8E, 0x80); } void DS1302_GetTime(DS1302_TimeTypeDef *time) { uint8_t temp; temp DS1302_ReadReg(0x81) 0x7F; time-second (temp 4) * 10 (temp 0x0F); temp DS1302_ReadReg(0x83); time-minute (temp 4) * 10 (temp 0x0F); temp DS1302_ReadReg(0x85) 0x3F; time-hour (temp 4) * 10 (temp 0x0F); temp DS1302_ReadReg(0x87); time-day (temp 4) * 10 (temp 0x0F); temp DS1302_ReadReg(0x89); time-month (temp 4) * 10 (temp 0x0F); time-week DS1302_ReadReg(0x8B); temp DS1302_ReadReg(0x8D); time-year (temp 4) * 10 (temp 0x0F); }注意读小时寄存器时屏蔽的是0x3F因为 bit7 是 12/24 小时制选择位bit6 在 24 小时制下无效bit5 在 12 小时制下是 AM/PM 标志。统一用 24 小时制的话屏蔽0x3F就够了。6.4 主函数里怎么调用主函数里先初始化然后设置一个时间再循环读取并打印。打印可以用串口方便观察时间是否正常走动。int main(void) { DS1302_TimeTypeDef time; DS1302_Init(); time.year 25; time.month 1; time.day 15; time.week 3; time.hour 10; time.minute 30; time.second 0; DS1302_SetTime(time); while (1) { DS1302_GetTime(time); printf(20%02d-%02d-%02d %02d:%02d:%02d week:%d\r\n, time.year, time.month, time.day, time.hour, time.minute, time.second, time.week); Delay_ms(1000); } }7. 几个让驱动更稳的实战技巧7.1 时序里加不加延时理论上 DS1302 支持到 2MHz 的时钟STM32 的 GPIO 翻转速度远高于这个频率所以严格来说不加延时也能工作。但实际调试时我发现不加延时在有些板子上会出现偶发读写失败尤其是杜邦线连接、线比较长的时候。后来我在 SCLK 拉高和拉低之后各加了一个微秒级的短延时稳定性明显提升。#define DS1302_DELAY() { volatile uint8_t i 10; while(i--); }这个延时不需要精确大概几百纳秒到一微秒就够了。加延时的代价是读写速度变慢但 DS1302 本来就不需要高速读写一秒读一次时间这点延时完全无感。7.2 读时间时要不要连续读两次如果系统对时间一致性要求高比如要确保读到的年月日时分秒是同一时刻的可以连续读两次比较两次结果是否一致。如果不一致说明读取过程中时间发生了进位再读一次即可。这个方法在秒进位、分进位、时进位、日进位这些边界时刻特别有用。不过大多数应用场景下读一次就够了因为 DS1302 的进位是瞬时的读到的数据最多差一秒对显示类应用没有影响。7.3 备用电池的选择和电路注意事项备用电池一般用 CR2032 纽扣电池标称 3V容量 200mAh 左右。DS1302 在备用模式下的功耗大概是几百纳安到几微安一颗 CR2032 可以撑好几年。但要注意电池座的质量很重要劣质电池座接触电阻大会导致备用模式下电压跌落时间丢失。电路上VCC1 接备用电池正极VCC2 接主电源 3.3VGND 共地。有些模块会在 VCC1 和 GND 之间加一个 0.1uF 电容用来滤除干扰这个电容建议保留。另外32.768kHz 晶振要尽量靠近 DS1302 的 X1 和 X2 引脚走线要短避免引入干扰。7.4 移植到其他 STM32 型号要注意什么这套驱动用的是标准库的 GPIO 操作移植到 HAL 库或者其他 STM32 型号时只需要改 GPIO 初始化和读写宏。如果是 HAL 库把GPIO_SetBits换成HAL_GPIO_WritePin把GPIO_ReadInputDataBit换成HAL_GPIO_ReadPin方向切换用HAL_GPIO_Init重新配置即可。如果是 STM32F4 或 F7 系列GPIO 时钟使能要改成对应的总线比如RCC_AHB1PeriphClockCmd。其他逻辑完全不用动因为时序是纯软件模拟的和硬件 SPI 无关。8. 关于 DS1302 值不值得用的个人看法DS1302 这颗芯片放在今天确实不算先进。精度不如 DS3231接口不如 I2C 省线寄存器结构也有点绕。但它的优势在于简单、便宜、皮实。三根线就能搞定不需要上拉电阻I/O 线内部有弱上拉不需要额外的电平转换3.3V 和 5V 系统都能直接用。对于成本敏感、精度要求不高的场景比如电子钟、定时开关、数据记录仪的时间戳DS1302 完全够用。我在几个小批量项目里用 DS1302批量采购价格不到一块钱配合一颗 CR2032 电池座整体成本比 DS3231 方案低不少。而且驱动代码一旦调通后续项目直接复制粘贴改几个引脚定义就能用开发效率很高。如果你正在选型 RTC 芯片我的建议是精度要求高、预算充足直接上 DS3231成本敏感、精度要求一般、或者只是学习练手DS1302 是很好的选择。关键是搞明白它的时序逻辑和 BCD 码转换这两点通了驱动就稳了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑