STM32与DS1302时钟模块的GPIO模拟通信实现

📅 发布时间:2026/7/6 5:29:42 👁️ 浏览次数:
STM32与DS1302时钟模块的GPIO模拟通信实现
1. 从零开始为什么选择GPIO模拟驱动DS1302大家好我是老张在嵌入式这行摸爬滚打十多年了用过不少实时时钟芯片。今天想和大家聊聊一个经典又有点“个性”的芯片——DS1302以及怎么用我们手头最常见的STM32通过最普通的GPIO口把它给驱动起来。你可能在网上搜过不少资料发现很多例程要么是51单片机的要么直接用了硬件SPI虽然DS1302并不是标准SPI。如果你手头的STM32项目正好SPI口被占满了或者就是想用最精简的硬件资源来实现一个可靠的时钟功能那今天这篇分享就是为你准备的。DS1302这颗芯片年纪不小了但生命力顽强在很多低成本、对精度要求不是极端苛刻的场合依然很常见比如智能插座、温湿度记录仪、老式的考勤机等等。它最大的特点就是接口简单只需要三根线CE、SCLK、IO但麻烦也在这里它的通信协议是厂家自定义的和标准的SPI、I2C都不太一样。这就意味着你没法直接调用HAL库里的HAL_SPI_Transmit这种现成函数得自己动手用GPIO口的高低电平变化去“模仿”出它要求的那套时序规则。这听起来有点麻烦对吧但好处也是显而易见的。首先它不挑MCU哪怕是最基础、没有硬件SPI外设的STM32F0系列也能轻松驾驭。其次GPIO模拟给了我们最大的灵活性时序上的任何细微调整都在代码掌控之中调试起来心里更有底。最后就是成本省下一个硬件SPI外设可能就能多接一个其他传感器。所以掌握GPIO模拟通信是嵌入式开发里一项非常实用的“硬功夫”理解了DS1302以后再遇到其他非标准协议的芯片你也能举一反三。在开始敲代码之前咱们先得把DS1302的脾气摸清楚。它内部有一系列寄存器用来存储秒、分、时、日、月、年、星期。读写这些寄存器就是通过那三根线按照特定的“暗号”时序来进行的。这个“暗号”的核心是单字节、上升沿有效、先低位LSB后高位MSB。简单说就是每传输一个比特bit的数据都需要我们手动控制时钟线SCLK产生一个从低到高的跳变上升沿在这个跳变发生的时刻数据线IO上的电平状态高或低就会被芯片采样。写数据时我们控制IO电平读数据时我们则要去读取IO电平。整个通信过程由片选线CE来开启和关闭。2. 硬件连接与CubeMX基础配置理论说再多不如动手接上线。咱们先来看看硬件怎么连。DS1302最少需要三根信号线CE (Chip Enable) 片选信号也常被称为RST。当这根线被我们拉高时DS1302才“醒过来”准备接收命令。通信结束后要记得把它拉低让芯片“睡觉”省电。SCLK (Serial Clock) 串行时钟线。所有数据的同步都靠它我们需要用GPIO口精确地控制它高低电平的变化来产生时钟脉冲。IO (Data Line) 双向数据线。这根线最特殊它既是输入也是输出。在STM32向DS1302写命令或数据时它需要配置为输出模式当STM32要从DS1302读取数据时它又必须切换为输入模式。这是模拟驱动的一个关键点。另外DS1302还需要接一个32.768kHz的晶振和两个匹配电容通常6-22pF来提供时钟基准以及一个备用电池比如常见的CR2032以保证主电源掉电后时间还能继续走。这些是芯片正常工作的基础焊接的时候可别忘了。接下来我们打开STM32CubeMX来初始化这几个GPIO口。假设我们使用STM32F103C8T6这款经典的“蓝莓派”核心板并选择PB8、PB9、PB10这三个引脚来分别连接CE、SCLK和IO。在CubeMX的图形化界面里找到PB8、PB9、PB10分别将它们设置为GPIO_Output。具体配置可以保持默认输出模式Output Mode推挽输出Output Push Pull上拉/下拉电阻Pull-up/Pull-down选择无No pull-up and no pull-down输出速度Maximum output speed选低速Low就行因为DS1302的通信速率很低高速反而可能引入噪声。这里有个细节PB10我们的IO数据线虽然暂时设为输出但在代码里我们会在读写操作前动态地切换它的模式所以CubeMX里先按输出模式配置没问题。配置好时钟树通常用内部8MHz RC振荡器倍频到72MHz就够用生成工程代码。用Keil或者你喜欢的IDE打开这个工程准备工作就完成了。你会发现HAL库已经帮我们生成了MX_GPIO_Init函数里面初始化了我们刚才配置的三个引脚。但我们驱动DS1302的代码不会直接依赖这个初始化而是会自己封装更精细的控制函数。3. 代码骨架宏定义与数据结构设计好的代码从清晰的定义开始。我们首先在专门的头文件比如ds1302.h里把要用到的引脚操作、寄存器地址和数据结构定义好。这能让主程序看起来非常清爽也便于后期维护和移植。首先我们把三个控制引脚的操作定义成宏这样写代码时就像在直接操作信号线直观又方便// DS1302 控制引脚定义 (根据你的实际连接修改) #define DS1302_CE_PORT GPIOB #define DS1302_CE_PIN GPIO_PIN_8 #define DS1302_SCLK_PORT GPIOB #define DS1302_SCLK_PIN GPIO_PIN_9 #define DS1302_IO_PORT GPIOB #define DS1302_IO_PIN GPIO_PIN_10 // 引脚电平操作宏拉高/拉低 #define CE_HIGH() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define CE_LOW() HAL_GPIO_WritePin(DS1302_CE_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define SCLK_HIGH() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define SCLK_LOW() HAL_GPIO_WritePin(DS1302_SCLK_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define IO_HIGH() HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, GPIO_PIN_SET) #define IO_LOW() HAL_GPIO_WritePin(DS1302_IO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET) // 读取IO引脚电平用于读数据时 #define IO_READ() HAL_GPIO_ReadPin(DS1302_IO_PORT, DS1302_IO_PIN)然后是DS1302内部寄存器的地址。DS1302的每个时间寄存器都有“写地址”和“读地址”之分区别在于地址字节的最高位bit7。写操作时该位为0读操作时该位为1。为了方便我们直接定义好// 写寄存器命令地址 #define DS1302_CMD_WRITE_SECOND 0x80 #define DS1302_CMD_WRITE_MINUTE 0x82 #define DS1302_CMD_WRITE_HOUR 0x84 #define DS1302_CMD_WRITE_DATE 0x86 // 注意DS1302的‘日’寄存器常被称为DATE #define DS1302_CMD_WRITE_MONTH 0x88 #define DS1302_CMD_WRITE_WEEKDAY 0x8A // 星期 #define DS1302_CMD_WRITE_YEAR 0x8C #define DS1302_CMD_WRITE_PROTECT 0x8E // 写保护控制寄存器 // 读寄存器命令地址 #define DS1302_CMD_READ_SECOND 0x81 #define DS1302_CMD_READ_MINUTE 0x83 #define DS1302_CMD_READ_HOUR 0x85 #define DS1302_CMD_READ_DATE 0x87 #define DS1302_CMD_READ_MONTH 0x89 #define DS1302_CMD_READ_WEEKDAY 0x8B #define DS1302_CMD_READ_YEAR 0x8D #define DS1302_CMD_READ_PROTECT 0x8F最后我们定义一个结构体来存放读取到的时间信息这样比用一堆分散的变量要优雅得多typedef struct { uint8_t year; // 年后两位如0x22表示2022年 uint8_t month; // 月 uint8_t date; // 日 uint8_t weekday; // 星期1-7对应周日到周六 uint8_t hour; // 时注意DS1302支持12/24小时制 uint8_t minute; // 分 uint8_t second; // 秒 } DS1302_Time_t;3.1 核心难点双向数据线的模式切换这里要重点说一下IO数据线的模式切换。这是GPIO模拟驱动DS1302与驱动标准SPI设备最大的不同。在标准SPI中MISO主入从出和MOSI主出从入是分开的两根线硬件外设或软件模拟时它们可以同时处于输入和输出状态。但DS1302只有一根双向数据线。所以我们必须手动管理这根线的方向。在STM32要向DS1302发送数据写命令或写数据前需要把对应的GPIO这里是PB10设置为推挽输出模式。当STM32准备从DS1302接收数据读数据前则需要把同一个GPIO设置为浮空输入或上拉输入模式并关闭输出驱动器。我们可以封装两个简单的函数来做这件事static void DS1302_IO_Output_Mode(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; // 低速即可 HAL_GPIO_Init(DS1302_IO_PORT, GPIO_InitStruct); } static void DS1302_IO_Input_Mode(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; // 输入模式 GPIO_InitStruct.Pull GPIO_PULLUP; // 建议使能内部上拉确保空闲时为高电平 HAL_GPIO_Init(DS1302_IO_PORT, GPIO_InitStruct); }这两个函数会在底层的读写函数里被调用。虽然频繁切换GPIO模式会引入一点点开销但对于DS1302这种低速设备来说完全不是问题。4. 时序的舞蹈深入解析读写底层函数现在我们来到了最核心的部分用代码“跳”出DS1302要求的时序舞蹈。一切通信都建立在单字节读写的基础上所以我们先实现最底层的单字节写入和单字节读取函数。单字节写入函数DS1302_WriteByte 这个函数的任务是把一个8位的数据比如一个命令地址或一个时间数据发送给DS1302。根据时序图每个比特都是在时钟SCLK的上升沿被DS1302采样的而数据在时钟上升沿之前就需要稳定在数据线IO上。void DS1302_WriteByte(uint8_t dat) { DS1302_IO_Output_Mode(); // 切换IO为输出模式 for(uint8_t i 0; i 8; i) { SCLK_LOW(); // 先将时钟拉低 // 准备数据位DS1302要求先传输最低位(LSB) if(dat 0x01) { IO_HIGH(); } else { IO_LOW(); } // 短暂延时确保数据稳定。对于72MHz主频几个NOP指令或微秒级延时足够。 // HAL_Delay(1)是毫秒级太长了这里用__NOP()或简单的循环。 for(int j0; j5; j) { __NOP(); } SCLK_HIGH(); // 产生上升沿DS1302在此刻采样数据 for(int j0; j5; j) { __NOP(); } // 保持高电平一段时间 dat 1; // 准备下一个比特次低位右移到最低位 } // 一个字节发送完后时钟线保持低电平 SCLK_LOW(); }我来解释一下这个循环dat 0x01是取出当前要发送的最低位。然后我们根据它是1还是0设置IO线为高或低。接着拉高SCLK产生上升沿DS1302就在这个瞬间把IO线上的电平锁存进去。最后dat 1把数据右移一位原来次低位的值就到了最低位等待下一次循环发送。循环8次一个字节就发出去了。单字节读取函数DS1302_ReadByte 读操作稍微复杂一点。首先STM32需要先发送一个8位的命令读地址这个过程和写一个字节一模一样。发送完命令后DS1302就会把对应寄存器的数据准备好。然后STM32需要在接下来的8个时钟周期内在每个时钟的上升沿之后去读取IO线上的电平状态。注意读的时候IO线的控制权在DS1302那边STM32只是“监听”。uint8_t DS1302_ReadByte(void) { uint8_t dat 0; DS1302_IO_Input_Mode(); // 关键切换IO为输入模式准备读取 for(uint8_t i 0; i 8; i) { dat 1; // 注意先右移因为最先读到的是最低位(LSB) SCLK_HIGH(); // 先产生一个上升沿DS1302会在这个上升沿后更新IO数据 for(int j0; j5; j) { __NOP(); } // 稍作延时等待数据稳定 if(IO_READ() GPIO_PIN_SET) { dat | 0x80; // 如果读到高电平则将当前字节的最高位置1 } SCLK_LOW(); // 拉低时钟为下一个上升沿做准备 for(int j0; j5; j) { __NOP(); } } return dat; }读函数的顺序要特别注意。因为DS1302在时钟上升沿之后才把数据位放到IO线上所以我们先拉高SCLK然后延时一小会儿确保数据稳定了再去读取IO电平。读到的这个比特实际上是当前字节的最低位但因为我们是从第一个时钟周期开始读为了按顺序组装成一个字节我们采用“先右移后置位最高位”的方式。循环结束后dat里就是正确的数据了。4.1 完整的寄存器读写封装有了单字节读写这个“原子操作”我们就可以封装更上层的函数来完成对某个特定寄存器的读写。每次操作都必须以拉高CE开始以拉低CE结束。写寄存器函数void DS1302_WriteRegister(uint8_t regAddr, uint8_t regData) { CE_HIGH(); // 启动传输 DS1302_WriteByte(regAddr); // 发送寄存器地址写命令 DS1302_WriteByte(regData); // 发送要写入的数据 CE_LOW(); // 结束传输 }读寄存器函数uint8_t DS1302_ReadRegister(uint8_t regAddr) { uint8_t regData; CE_HIGH(); // 启动传输 DS1302_WriteByte(regAddr); // 发送寄存器地址读命令 regData DS1302_ReadByte(); // 读取寄存器数据 CE_LOW(); // 结束传输 return regData; }看读寄存器时我们先用DS1302_WriteByte发送读命令地址比如0x81读秒然后紧接着调用DS1302_ReadByte来获取数据。这里DS1302_WriteByte函数内部会把IO切为输出模式发送完地址后DS1302_ReadByte函数内部又会把IO切为输入模式整个过程衔接得非常顺畅。5. 实战应用设置时间与读取显示底层驱动打通了上层应用就水到渠成。我们现在来写两个最常用的函数设置时间和读取时间。设置时间函数 在设置时间之前我们必须先关闭写保护。DS1302的控制寄存器地址0x8E的最高位bit7是写保护位WP。WP1时禁止写入任何时间或控制寄存器WP0时允许写入。所以标准的设置流程是关写保护 - 写各个时间寄存器 - 开写保护。void DS1302_SetTime(DS1302_Time_t *time) { // 1. 关闭写保护 (WP位清0) DS1302_WriteRegister(DS1302_CMD_WRITE_PROTECT, 0x00); // 2. 写入各个时间寄存器 // 注意DS1302寄存器数据格式是BCD码需要将十进制数转换成BCD码 // 例如十进制23 - BCD码 0x23 DS1302_WriteRegister(DS1302_CMD_WRITE_YEAR, DEC_to_BCD(time-year)); DS1302_WriteRegister(DS1302_CMD_WRITE_MONTH, DEC_to_BCD(time-month)); DS1302_WriteRegister(DS1302_CMD_WRITE_DATE, DEC_to_BCD(time-date)); DS1302_WriteRegister(DS1302_CMD_WRITE_WEEKDAY, DEC_to_BCD(time-weekday)); // 小时寄存器要注意bit7是12/24小时制选择位0为24小时制。 // 我们使用24小时制所以直接转换BCD码即可。 DS1302_WriteRegister(DS1302_CMD_WRITE_HOUR, DEC_to_BCD(time-hour)); DS1302_WriteRegister(DS1302_CMD_WRITE_MINUTE, DEC_to_BCD(time-minute)); DS1302_WriteRegister(DS1302_CMD_WRITE_SECOND, DEC_to_BCD(time-second)); // 3. 重新打开写保护防止意外修改 DS1302_WriteRegister(DS1302_CMD_WRITE_PROTECT, 0x80); } // 一个简单的十进制转BCD码函数 static uint8_t DEC_to_BCD(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); }读取时间函数 读取就相对简单了依次读取各个寄存器然后把BCD码转换回十进制数即可。void DS1302_GetTime(DS1302_Time_t *time) { time-second BCD_to_DEC(DS1302_ReadRegister(DS1302_CMD_READ_SECOND) 0x7F); // 秒寄存器最高位是时钟停止位屏蔽掉 time-minute BCD_to_DEC(DS1302_ReadRegister(DS1302_CMD_READ_MINUTE)); uint8_t hour_reg DS1302_ReadRegister(DS1302_CMD_READ_HOUR); // 处理小时判断是12还是24小时制 if (hour_reg 0x80) { // 12小时制 // 12小时制处理略复杂需要判断AM/PM这里假设我们使用24小时制设置所以按24小时制读 // 实际使用时最好统一模式。这里简单处理屏蔽模式位。 time-hour BCD_to_DEC(hour_reg 0x3F); } else { // 24小时制 time-hour BCD_to_DEC(hour_reg 0x3F); } time-date BCD_to_DEC(DS1302_ReadRegister(DS1302_CMD_READ_DATE)); time-month BCD_to_DEC(DS1302_ReadRegister(DS1302_CMD_READ_MONTH)); time-weekday BCD_to_DEC(DS1302_ReadRegister(DS1302_CMD_READ_WEEKDAY)); time-year BCD_to_DEC(DS1302_ReadRegister(DS1302_CMD_READ_YEAR)); } // BCD码转十进制函数 static uint8_t BCD_to_DEC(uint8_t bcd) { return ((bcd 4) * 10) (bcd 0x0F); }现在你可以在main函数里测试了int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 初始化串口用于打印假设已配置... DS1302_Time_t setTime { .year 23, // 2023年 .month 10, // 10月 .date 27, // 27日 .weekday 5, // 星期五 (DS1302规定1周日, ..., 7周六) .hour 14, // 下午2点 .minute 30, .second 0 }; // 第一次上电或需要校准时设置时间 DS1302_SetTime(setTime); DS1302_Time_t currentTime; while (1) { HAL_Delay(1000); // 每秒读取一次 DS1302_GetTime(currentTime); // 通过串口打印格式2023-10-27 Fri 14:30:01 printf(20%02d-%02d-%02d , currentTime.year, currentTime.month, currentTime.date); switch(currentTime.weekday) { case 1: printf(Sun ); break; case 2: printf(Mon ); break; case 3: printf(Tue ); break; case 4: printf(Wed ); break; case 5: printf(Thu ); break; case 6: printf(Fri ); break; case 7: printf(Sat ); break; } printf(%02d:%02d:%02d\r\n, currentTime.hour, currentTime.minute, currentTime.second); } }6. 避坑指南与高级技巧代码跑起来看到串口打印出正确的时间是不是很有成就感别急在实际项目中你可能会遇到一些坑这里我分享几个常见的注意事项和优化技巧。第一坑时序的微妙延时。前面代码里的__NOP()或简单循环延时在72MHz主频下可能刚好但如果换了一款主频不同的MCU比如STM32F0的48MHz或者STM32H7的400MHz通信就可能失败。更稳健的做法是使用一个微秒级的延时函数并针对你的主频进行校准。你可以利用SysTick定时器实现一个delay_us函数然后在SCLK高低电平变化后调用delay_us(1)或delay_us(2)这样代码的移植性会好很多。第二坑BCD码的转换。DS1302所有时间数据都是以BCD码存储的。BCD码就是用16进制的形式来表示十进制。比如十进制数23转换成BCD码就是0x23二进制0010 0011。如果你直接写入十进制数23即十六进制0x17DS1302会把它当成17来显示这就错了。所以前面提供的DEC_to_BCD和BCD_to_DEC转换函数必不可少。第三坑12/24小时制。小时寄存器地址0x84写0x85读的bit7是12/24小时制选择位。1代表12小时制0代表24小时制。在12小时制下bit5是AM/PM标志位1为PM。为了省事我强烈建议在项目里统一使用24小时制。设置时确保写入的小时数据最高位为0读取时也按24小时制解析屏蔽掉bit7和bit5。这样可以避免很多不必要的逻辑判断。第四坑写保护。忘记关闭写保护就直接设置时间是新手常犯的错误。结果就是时间怎么都写不进去排查半天。记住流程写之前关保护写0x00到0x8E写之后开保护写0x80到0x8E。这个保护机制其实很有用可以防止程序跑飞意外修改时间。第五坑时钟停止位。秒寄存器地址0x80写0x81读的最高位bit7是时钟停止位CH。CH1时时钟振荡器停止DS1302进入低功耗状态时间不走CH0时振荡器工作。所以在初始化设置时间时写入秒数据一定要确保这一位是0。通常我们写入DEC_to_BCD(second) 0x7F来清除最高位。读取秒数时也要用reg_data 0x7F来屏蔽这一位。高级技巧突发模式读写。DS1302支持一种“突发模式”Burst Mode可以一次性连续读写所有时间寄存器7个字节加一个写保护寄存器这比单个寄存器读写效率高得多。突发模式的写命令地址是0xBE读命令地址是0xBF。在突发模式下先发送命令字节然后连续写入或读取8个字节的数据DS1302的地址指针会自动递增。这对于需要频繁同步或备份时间的应用非常有用可以大大减少通信时间。实现起来也不难就是在单字节读写的基础上加个循环感兴趣的朋友可以自己尝试实现一下。最后关于精度。DS1302是低速低功耗芯片时间精度受晶振、电容和环境温度影响。如果发现走时有偏差比如一天快慢几秒可以尝试调整匹配电容的容值通常在6-22pF之间微调。对于要求更高的场合可能需要选择温补晶振或精度更高的时钟芯片。GPIO模拟驱动DS1302虽然比调用硬件SPI库要繁琐一些但整个过程会让你对底层时序、通信协议有更深刻的理解。当你用示波器抓取到那规整的、由你自己代码产生的时序波形并成功驱动起这颗小芯片时那种对硬件完全掌控的感觉是直接用库函数无法比拟的。希望这篇长文能帮你扫清障碍顺利把DS1302用起来。如果在实践中遇到其他问题不妨多看看数据手册那才是最好的老师。