十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 GPIO模拟时序驱动DS1302实时时钟实战笔记

STM32 GPIO模拟时序驱动DS1302实时时钟实战笔记 最近在整理之前做的一个 STM32 小项目用 STM32F103 通过 GPIO 模拟三线时序驱动 DS1302 实时时钟芯片。DS1302 在嵌入式圈子里算是“老熟人”了资料一大堆但真到自己动手接线上容易翻车时序上更容易翻车。这篇文章把我从踩坑到调通的完整过程、驱动代码和排查思路都记录下来了算是一份带注释的实战学习笔记。如果你正在学 STM32或者准备做带时钟功能的毕业设计、小作品可以参考这份笔记快速跑起来如果你已经在用 DS1302也建议看看第 4 节的问题排查里面有几个坑是数据手册里不会直接告诉你的。1. 项目整体设计与思路拆解1.1 为什么是 DS1302 而不是 STM32 内部 RTC很多人在做时钟功能时第一个念头是用 STM32 内部 RTC。STM32F103 的 RTC 确实能用但有几个绕不开的麻烦需要备份域供电VBAT 要接电池或大电容否则主电源一断时间就归零内部 RTC 的校准比较繁琐32.768kHz 外部晶振的起振电路对 PCB 布局要求高芯片内部还容易受复位影响。对初学者来说一旦“莫名其妙时间不走了”排查起来会相当痛苦。DS1302 的优势就在于它是一颗独立 RTC 芯片时间计算、BCD 码转换、闰年判断全部由芯片自己完成MCU 只需要通过三线接口读写寄存器而且芯片自带涓流充电电路可以直接给可充电后备电池充电。它的工作电压范围是 2.0V~5.5V功耗在微安级别主电源断电后靠一颗 CR2032 纽扣电池就能维持时间走时很久。这个项目的目标是做一个“能稳定读写时间、结构清晰、方便移植”的 DS1302 驱动模块所以选型上我特意避开了内部 RTC坚持用外部芯片。道理很简单学习阶段把外部设备和 MCU 之间的通信吃透后面换其他传感器、其他协议思路是通用的。而且用 GPIO 模拟时序不依赖 STM32 的硬件 SPI 外设代码可以轻松移植到任何单片机平台这也是开源项目最看重的一点。1.2 三线接口的本质一套“带使能的 SPI 变种”DS1302 的通信接口有三个引脚CE芯片使能、SCLK串行时钟、IO数据线。很多人一看三线接口本能地以为是 I2C其实它更像一个简化的 SPI而且是“半双工”模式因为 IO 只有一根线读和写共用。CE 是片选信号必须在整个通信过程中保持高电平传输结束时拉低SCLK 提供时钟节拍IO 负责双向传输数据。这里最容易搞混的是时序极性。DS1302 规定主机写入数据时DS1302 在 SCLK 上升沿锁存数据所以主机的 IO 引脚需要在上升沿到来之前把数据准备好主机读取数据时DS1302 在 SCLK 下降沿改变 IO 输出主机在上升沿采样。也就是说SCLK 拉高之前要先准备好数据SCLK 拉高之后再去读数据。很多新手照着网上代码抄不看这层时序关系结果读出来的时间要么全 0要么乱跳大概率就是读写沿搞反了。既然它本质是 SPI 变种用 STM32 的硬件 SPI 外设行不行理论上有方案但 CE 信号控制、半双工切换、寄存器时序细节都要求比较灵活的控制硬件 SPI 用起来反而别扭。所以这个项目果断选择 GPIO 模拟三条线分别接到三个普通 GPIO用软件延时控制时序虽然效率低一点但通信速率本身只要几十 kHz 就足够完全够用。1.3 开源学习笔记的结构设计既然是“开源学习笔记”代码只是一部分更重要的是一份能让人看懂的记录。这个项目的仓库结构我按下面这种方式组织DS1302_STM32_LearnNote/ ├── Docs/ │ ├── DS1302数据手册关键页.pdf │ └── 引脚接线说明.md ├── Core/ │ ├── ds1302.c │ └── ds1302.h ├── User/ │ ├── main.c │ └── retarget.c ├── Hardware/ │ └── bsp_gpio.c ├── README.md └── LICENSE主干只有两个文件ds1302.c和ds1302.h尽量做到驱动与业务逻辑分离。main.c里只负责演示“设置时间”和“读取并打印时间”不掺杂任何业务相关代码。README 里我会写清楚接线表、时钟频率、编译器版本、移植方法以及我在调试时踩过的坑。这样别人拿到仓库后能在一个小时内跑通而不是对着几千行代码发呆。2. 核心细节解析与实操要点2.1 DS1302 的寄存器地图和 BCD 码换算DS1302 内部有 12 个时钟寄存器、31 字节静态 RAM以及一个写保护寄存器和一个涓流充电寄存器。控制时钟部分的寄存器地址从 0x80 开始每两个字节一组一个写地址一个读地址。比如秒寄存器的写地址是 0x80读地址是 0x81分钟是 0x82/0x83小时是 0x84/0x85依此类推。秒寄存器的最低位叫 CHClock Halt它决定时钟是否跑起来CH 为 1 时振荡器停止时间不走CH 为 0 时正常走时。很多人在初始化时只写了分钟、小时、年月日偏偏忘了处理秒寄存器的高位导致芯片上电后秒寄存器可能默认是 0x80 某个值CH 被置 1时钟永远不动。这是掉进坑里最常见的原因之一。DS1302 内部时间数据一律使用 BCD 码存储不是平常的十进制。比如 23 时 59 分 08 秒在寄存器里看到的是 0x23、0x59、0x08而不是 23、59、8。如果是 15 秒BCD 码是 0x15如果是 31 日BCD 码是 0x31。换算公式很简单uint8_t BCD2DEC(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); } uint8_t DEC2BCD(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); }这套换算几乎是所有 DS1302 驱动的基础建议直接封装成函数不要在业务代码里到处散落位运算。2.2 读写时序详解单字节和突发模式单字节操作的命令格式是一个字节bit7 固定为 1bit6 为 0 表示操作时钟寄存器、为 1 表示操作 RAMbit5~bit1 是寄存器地址bit0 是读写方向。写入方向命令字节末尾是 0读取方向末尾是 1。例如向 0x80 写秒实际上是发送命令 0x80然后发送数据如果是读秒先发送命令 0x81然后从 IO 上读一个字节。每次通信流程可以概括为三步CE 拉高开始一次通信发送命令字节发送过程按位进行低位在前根据命令字节的 bit0 决定接下来是写数据还是读数据如果连续读写时间寄存器DS1302 还支持突发模式Burst Mode。时间突发模式的写命令是 0xBE读命令是 0xBF一次可以连续读写 8 个字节顺序分别是秒、分、时、日、月、星期、年、写保护寄存器。我这个项目里读时间用的就是突发模式一次拉取 8 字节效率高代码简洁。还有个细节读时间时建议先读一次 0x81 秒寄存器或者先做一次空读操作然后再发起真正的突发读。原因是时间寄存器在读写过程中可能正好发生秒边界翻转如果没有“预热”就读可能出现跨秒不一致的情况比如秒读到 0x59分钟已经跳到下一位。这个“预热读”操作在官方应用手册里有提到实际调试中确实能减少偶发错乱。2.3 硬件注意事项晶振、电池和上拉电阻DS1302 的走时精度完全取决于外部晶振和负载电容。数据手册要求外接 32.768kHz 无源晶振晶振的两脚分别接 X1、X2负载电容一般在 6pF~12.5pF 之间。我在实际测试中发现使用晶振 两个 6pF 电容的常规接法非常稳定焊好后观察一晚上误差不超过 2 秒。这里要特别提醒DS1302 不能用陶瓷谐振器也不能用有源晶振必须是 32.768kHz 无源石英晶振频率不对时钟根本走不了。后备电池方面如果使用 CR2032 这类一次性锂电池切勿开启涓流充电功能。DS1302 的涓流充电是给可充电电池或超级电容设计的给不可充电池强行充电轻则电池鼓包重则有安全隐患。主电源接 VCC2备用电池接 VCC1GND 共地。平时主电源供电时芯片会自动选择 VCC2 通路主电源断电后自动切换 VCC1 通路。IO 引脚建议加一个 4.7kΩ~10kΩ 上拉电阻。DS1302 在读取模式下IO 是开漏输出如果不加外部上拉高电平会一直拉不起来读出来的数据全是 0。不过有些开发板上的 DS1302 模块已经板载上拉接之前最好用万用表量一下模块上有没有 10k 排阻免得重复上拉增大功耗。3. 实操过程与核心环节实现3.1 硬件接线和开发环境准备我用的是 STM32F103C8T6 最小系统板时钟跑在 72MHz软件用 Keil MDK 配合 HAL 库做演示。DS1302 模块和单片机之间只需要接 4 根线非常简单DS1302 引脚STM32 GPIO说明VCC23.3V主电源GNDGND共地CEPA2片选SCLKPA1串行时钟IOPA0数据线注意 VCC2 接 3.3V 还是 5V 都能工作但 IO 电平要匹配。DS1302 的 IO 输入阈值和 VCC2 相关如果 VCC2 接 5V而 STM32 的 GPIO 是容忍 5V 的理论上也能读但为了稳妥我建议全部统一用 3.3VSTM32 的 GPIO 输出高电平约 3.3VDS1302 完全能识别读回来的电平也一定在 STM32 的逻辑高阈值以上不折腾电平转换。3.2 驱动代码GPIO 模拟时序的核心实现先定义引脚操作宏和延时。ST 的 HAL 库写法下面这套可以直接往工程里套/* ds1302.h 中定义引脚 */ #define DS1302_IO_GPIO_PORT GPIOA #define DS1302_IO_PIN GPIO_PIN_0 #define DS1302_SCLK_GPIO_PORT GPIOA #define DS1302_SCLK_PIN GPIO_PIN_1 #define DS1302_CE_GPIO_PORT GPIOA #define DS1302_CE_PIN GPIO_PIN_2 #define DS1302_IO_H() HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_SET) #define DS1302_IO_L() HAL_GPIO_WritePin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN, GPIO_PIN_RESET) #define DS1302_SCLK_H() HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define DS1302_SCLK_L() HAL_GPIO_WritePin(DS1302_SCLK_GPIO_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define DS1302_CE_H() HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define DS1302_CE_L() HAL_GPIO_WritePin(DS1302_CE_GPIO_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define DS1302_IO_READ() HAL_GPIO_ReadPin(DS1302_IO_GPIO_PORT, DS1302_IO_PIN)IO 口要支持方向切换。写数据之前把 PA0 切到推挽输出读数据之前切到浮空输入。用 HAL 库实现方向切换很直接void DS1302_IO_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, GPIO_InitStruct); DS1302_IO_H(); } void DS1302_IO_SetInput(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_GPIO_PORT, GPIO_InitStruct); }写一个字节的核心代码顺序是“拉低 SCLK - 设置 IO - 拉高 SCLK”保证数据在上升沿前稳定void DS1302_WriteByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { DS1302_SCLK_L(); if (dat 0x01) DS1302_IO_H(); else DS1302_IO_L(); DS1302_SCLK_H(); dat 1; } }读一个字节时先把 IO 切到输入模式然后在每个 SCLK 周期内读数据uint8_t DS1302_ReadByte(void) { uint8_t i, dat 0; DS1302_IO_SetInput(); for (i 0; i 8; i) { dat 1; DS1302_SCLK_L(); DS1302_SCLK_H(); if (DS1302_IO_READ()) dat | 0x80; } DS1302_IO_SetOutput(); return dat; }细心的读者会看到读字节时是先拉低 SCLK 再拉高 SCLK这其实是在制造一个下降沿让 DS1302 更新数据然后通过上升沿让主机采样。实测下来这套时序非常稳不需要额外 delay 也能正常通信。如果单片机主频更高、GPIO 更快建议在 SCLK 切换之间加几条空指令或者 1us 延时给 DS1302 留足响应时间。3.3 读写寄存器和时间结构体寄存器的读写封装成两个函数一个负责发命令一个负责收发数据uint8_t DS1302_ReadReg(uint8_t cmd) { uint8_t dat; DS1302_CE_H(); DS1302_WriteByte(cmd); dat DS1302_ReadByte(); DS1302_CE_L(); return dat; } void DS1302_WriteReg(uint8_t cmd, uint8_t dat) { DS1302_CE_H(); DS1302_WriteByte(cmd); DS1302_WriteByte(dat); DS1302_CE_L(); }这里有个容易忽略的点DS1302_ReadByte 函数内部已经切换了两次 IO 方向所以用 ReadReg 时不需要管 IO 模式但如果是连续读多个字节一定要保证每个字节之间 CE 都保持高电平不能拉低否则 DS1302 会认为通信结束。时间数据用一个结构体表示方便上层业务代码使用typedef struct { uint8_t year; /* 00~99 */ uint8_t month; /* 01~12 */ uint8_t date; /* 01~31 */ uint8_t dow; /* 1Monday, 7Sunday */ uint8_t hour; /* 00~23 */ uint8_t min; /* 00~59 */ uint8_t sec; /* 00~59 */ } RTC_Time_t;3.4 设置时间的完整流程设置时间时操作顺序很关键。第一步必须关闭写保护否则写入操作会被忽略。写保护寄存器的写地址是 0x8E写入 0x00 表示允许写全部写完后再写入 0x80 开启写保护防止程序跑飞误改时间。void DS1302_SetTime(const RTC_Time_t *time) { DS1302_WriteReg(0x8E, 0x00); /* 关闭写保护 */ /* 写秒时清掉 CH 位确保振荡器运行 */ DS1302_WriteReg(0x80, DEC2BCD(time-sec) 0x7F); DS1302_WriteReg(0x82, DEC2BCD(time-min)); DS1302_WriteReg(0x84, DEC2BCD(time-hour)); DS1302_WriteReg(0x86, DEC2BCD(time-date)); DS1302_WriteReg(0x88, DEC2BCD(time-month)); DS1302_WriteReg(0x8A, DEC2BCD(time-dow)); DS1302_WriteReg(0x8C, DEC2BCD(time-year)); DS1302_WriteReg(0x8E, 0x80); /* 开启写保护 */ }小时寄存器的 bit7 是 12/24 小时制标志。示例代码写的是低 5 位意味着最高位始终为 0默认使用 24 小时制。如果你的应用需要 12 小时制那还要处理 bit5 的 AM/PM 标志这里不过多展开。3.5 用突发模式读取时间读取时间我用突发模式命令 0xBF一次读 8 个字节。注意前面提到的“预热读”技巧在真正读之前先做一次空读操作uint8_t DS1302_GetTime(RTC_Time_t *time) { uint8_t buf[8]; uint8_t i; /* 预热读丢弃第一次读取的结果避免跨秒边界 */ DS1302_CE_H(); DS1302_WriteByte(0x81); DS1302_ReadByte(); DS1302_CE_L(); /* 突发读一次读取秒到写保护共8字节 */ DS1302_CE_H(); DS1302_WriteByte(0xBF); for (i 0; i 8; i) buf[i] DS1302_ReadByte(); DS1302_CE_L(); /* 终端状态检查秒寄存器bit7为1表示时钟停止了 */ if (buf[0] 0x80) return 1; time-sec BCD2DEC(buf[0] 0x7F); time-min BCD2DEC(buf[1]); time-hour BCD2DEC(buf[2] 0x3F); time-date BCD2DEC(buf[3]); time-month BCD2DEC(buf[4]); time-dow BCD2DEC(buf[5]); time-year BCD2DEC(buf[6]); return 0; }返回值为 1 的时候表示秒寄存器的 CH 位为 1时钟处于停止状态。这是我在调试过程中专门加的状态检查很有用。上电时如果 DS1302 的备用电池没接好或者用户上一次写入时不小心把 CH 置 1程序能第一时间感知到。4. 常见问题与排查技巧实录4.1 读出来全是 0xFF 的大坑这个问题在新手中出现率极高几乎每周都有同学拿着波形截图来问。全 0xFF 意味着 DS1302 在主机读取时输出了高电平常见原因有三个CE 引脚没有拉高、SCLK 没有正常翻转、IO 方向切换没有生效。第一个原因最常见有人会把 CE 和 SCLK 接反或者代码里 CE 在通信结束后没有正确拉高。第二个原因通常是 GPIO 配置成了模拟模式或者复用功能导致 SCLK 上没有真正的脉冲。第三个原因比较隐蔽如果你用的是开漏模式去读 IO而且模块上没有上拉电阻读出来就可能是 0xFF。我的建议是读引脚之前先把 IO 切回浮空输入再用示波器或逻辑分析仪看一下 SCLK、CE、IO 三根线的实际波形基本一眼就能定位问题。4.2 时间不走或时间清零时间不走首先要检查秒寄存器的 CH 位。如果上电后第一次读取时秒寄存器最高位是 1振荡器就是被暂停的。解决办法就是在初始化时向 0x80 写入一个低位数据把 CH 清 0同时确认晶振两脚焊接可靠。另一个常见现象是第一次设置时间后能走一阵但掉电重启后时间又回到 2000 年 1 月 1 日或者某个固定值。这种一般是 VCC1 电池没接好或者电池电压低于 2V。用万用表直接量 VCC1 引脚和 GND 之间的电压如果低于 2V说明电池已经耗尽或者接触不良。还有一个容易忽略的问题如果 VCC2 一直有主电源供电DS1302 根本不会切换到 VCC1 通路你拔掉电池也看不出异常必须把主电源断开后再测 VCC1 的维持能力。4.3 分钟和秒钟偶发跳变偶发跳变一般和时序时序有关。驱动里如果 GPIO 速度配得过高边沿过于陡峭可能引起振铃数据在上升沿附近不稳定。把 GPIO 速率降到 LOW 或者加长 SCLK 低电平时间可以改善。还有一种情况是 MCU 主循环里频繁调用读取时间的函数而且没有对 IO 做方向管理导致某个瞬间 IO 既不是输入也不是输出。我的建议是读取时间频率不要高于每秒一次并且每次读取用前面给出的带“预热读”的完整函数不要自己拼短路子逻辑。实测这个方法能把偶发跳变基本消除。4.4 涓流充电寄存器要不要写很多二手 DS1302 模块出厂时可能已经写入了涓流充电配置比如 0xA5 或 0xAA。主机可以通过 0x91 命令读取充电寄存器内容。如果模块上装的是 CR2032 不可充电电池而读取结果不是 0x00一定要用 0x90 命令写入 0x00关闭涓流充电防止电池被持续小电流充电。我的开源代码里加了一个DS1302_DisableTrickleCharge()函数就是专门干这件事的。4.5 开源发布小贴士项目发布到 Gitee 或 GitHub 时有几个细节值得注意。许可证选 MIT 或 Apache-2.0 最省心MIT 对读者最友好基本可以随便用如果不选许可证按平台默认规则其实“保留所有权利”别人想参考反而有顾虑。README 里一定要写清楚硬件接线、环境版本和复现步骤最好附一张实物接线图和逻辑分析仪截图这两样东西比一千行注释都有说服力。我个人习惯在工程里放一个 Markdown 格式的CHANGELOG.md记录每次提交改了什么、为什么改。DS1302 驱动这种学习项目后续一定会不断优化比如增加闹钟功能、增加温度补偿有个变更记录能让后来者更快接手。还有一个小技巧把延时函数放在ds1302.c顶部集中定义方便不同主频平台快速适配。5. 还有哪些可以继续扩展的方向DS1302 驱动跑通之后整个系统就有了“时间基准”后面的玩法其实非常多。可以外接一个 OLED 屏把时间直接显示出来做成桌面电子钟可以加一个红外遥控器实现按键设置时间和闹钟如果配合 ESP8266 模块还能把时钟通过串口上报到云平台做一个联网校时的数据采集终端。我自己就是把 DS1302 和 DHT22 温湿度传感器放在一起做了一个带时间戳的环境监测日志仪写数据到 SD 卡时时间戳再也不会乱。如果要往产品方向走建议把驱动层的读写函数加上互斥锁保护防止多任务环境下并发访问。另外可以在驱动里加一个“写时间完成后马上回读校验”的机制写入失败能自动重试这样对上层的可靠性会高很多。这些扩展内容我不打算全部写进这篇笔记但已经建了分支后续会逐步补充到开源仓库里。最后再说一个我自己的体会DS1302 这套三线协议虽然简单但把它彻底理解透对以后操作其他 SPI、I2C 类芯片帮助非常大。很多人觉得 GPIO 模拟时序是“低级操作”现实是项目里经常会遇到某个芯片没有硬件外设支持或者引脚复用冲突这时候用 GPIO 模拟反而成了唯一选择。所以别嫌这个练习简单慢慢把细节抠明白对你后面调试别的芯片绝对有好处。
返回列表