最近在整理一块老开发板时翻出一颗DS1302这芯片年纪不小了可我手头一个需要断电继续记时间的记录仪项目第一反应还是把它和STM32凑成一对。STM32的IO口富裕用GPIO模拟三线时序完全可行顺带还能把读时序、写时序、寄存器映射这些底子再完整捋一遍。本篇学习笔记就围绕“STM32驱动DS1302”展开从硬件接线、协议拆解到代码实现和调试验证把这次完整跑通的过程记录下来也把中间踩过的坑写清楚。如果你正打算在自己项目里加一个断电也能走时的实时时钟或者想通过一个小驱动深入理解通信协议这篇笔记应该能直接拿来参考。1. 先聊清楚为什么这块老芯片还在被使用1.1 DS1302到底是一颗什么样的芯片DS1302是一颗串行实时时钟芯片内置了秒、分、时、日、月、星期、年这些日历寄存器还额外带31字节的静态RAM可以用来存一些掉电后不想丢的小数据。接口上只引出了CE、SCLK、I/O三根线不需要数据总线和地址总线和单片机之间只要三个GPIO就能跑起来。我最早接触这颗芯片时觉得它像是“微型SPI设备”但仔细看时序后会发现它和标准SPI其实有很多差别这也是为什么很多学习教程喜欢拿它来演示GPIO模拟时序。它的供电范围很宽主电源VCC2可以支持大约2.0V到5.5V同时还有一个专门的备份电源引脚VCC1通常接电池。这种双电源设计非常适合记录仪、仪表、嵌入式控制器这类设备系统上电时由主电源供电突然掉电后自动切换到VCC1时间信息不丢失。单凭这一点它在很多低成本项目里就一直没被淘汰。1.2 与DS3231、PCF8563的选型对比很多人在选RTC芯片时会纠结这里我给自己做方案时常用的对比表也放出来供参考芯片接口精度来源典型精度额外特性适用场景DS1302三线串行外部32.768kHz晶振与晶振有关常用晶体约20ppm月误差几十秒到1分钟31字节RAM、涓流充电、双电源低成本、掉电保持时间、需要少量非易失RAMDS3231I2C内置温补晶振±2ppm非常高温度传感器、报警、方波输出对走时精度要求高的设备PCF8563I2C外部32.768kHz晶振与晶振有关超低功耗、定时器、闹钟低功耗便携设备DS1302最大的优势是便宜、接线少、资料多很多模块直接把晶振和后备电池焊好了买回来四根线一接就能用。缺点也很明显精度取决于外部晶振温度变化大了误差会放大。如果项目只是记录个事件发生的日期时间不需要精确到秒级长期校准DS1302非常合适要是做电表、计时设备这类对时间准确性敏感的产品建议直接上DS3231省去后期反复校时的麻烦。1.3 为什么这个组合适合当学习笔记写STM32驱动DS1302是一个很好的入门级通信协议实战案例。因为DS1302不是标准SPI也不像I2C有地址应答机制你可以完全用GPIO翻转电平来复现时序这比调库更接近底层原理。阅读DS1302数据手册时你会被迫去看时序图、理解命令字节、搞清楚BCD编码这些能力在以后驱动其他串行芯片时都能复用。另外这个驱动规模不大文件不超过两百行适合作为开源学习项目整理别人拿过去也容易改。2. 硬件连接的第一个关卡引脚与电源2.1 DS1302引脚定义与STM32接线表我手上的DS1302是常见的8脚直插封装各引脚功能和与STM32的连接建议如下DS1302引脚作用接到STM32VCC2主电源3.3V或5V建议3.3VX1、X232.768kHz晶振不接使用模块自带晶振GND地GNDCE片选/复位使能任选GPIO示例用PB0I/O串行数据任选GPIO示例用PB1SCLK串行时钟任选GPIO示例用PB2VCC1备份电池接CR2032正极这里要说一个很多人踩过的坑DS1302的VCC2到底接3.3V还是5V虽然数据手册说电源范围很宽但STM32F103这类芯片的GPIO输出高电平是3.3V如果DS1302的VCC2接5V它的逻辑高电平阈值会接近3.5V左右3.3V驱动起来余量很小。我最初图省事把模块VCC和单片机一样接5V结果SCLK线上的高电平勉强够到阈值偶尔出现读写错乱。换成3.3V供电后所有问题都消失了。逻辑电平兼容性是最容易被忽略的硬件细节。2.2 上拉电阻、去耦电容和晶振处理CE、SCLK、I/O三根线没有特殊的上拉要求因为通信时由STM32主动驱动。不过为了抗干扰尤其是SCLK高速翻转时不要留长飞线我习惯在靠近DS1302芯片的地方放一个100nF去耦电容跨接VCC2和GND。如果用的是自己焊的裸片而不是模块还要注意X1和X2之间的32.768kHz晶振。DS1302内部并没有集成晶振必须外接晶体负载电容通常在6pF左右具体以数据手册为准。我遇到的很多“时间不走”案例最后检查下来都是晶振没起振或晶体脚虚焊。用模块可以省掉这个麻烦买那种自带晶振和电池座的模块回来只接四根线。2.3 备份电池的正确接法DS1302的VCC1一般接CR2032纽扣电池正极负极接GND。由于DS1302内部有涓流充电电路可以通过寄存器配置对可充电电池进行充电。这里必须注意如果VCC1接的是不可充电的一次性锂电一定不要开启涓流充电否则轻则鼓包重则有安全隐患。我自己的习惯是默认不启用涓流充电保持寄存器默认关闭状态。另外当单片机完全断电而DS1302还在靠电池供电时CE、SCLK、I/O三根线会出现悬空可能造成额外耗电。解决思路有两种关机时把这些控制线通过电阻接GND或者外部加隔离二极管确保掉电后不会从VCC1倒灌电流到单片机的IO。3. 时序才是真正的核心三线协议深入拆解3.1 通信本质类SPI但不是SPIDS1302的数据收发依赖三根线CE作为使能信号SCLK提供时钟I/O传输数据。基本姿态是CE从低变高表示一次通信开始SCLK上有稳定的脉冲序列I/O在对应时钟沿输出或输入数据位最后CE拉低一次操作结束。关键点在于数据位顺序和采样沿。DS1302传输数据时是低位在前也就是LSB first和很多SPI设备默认的MSB first相反。写数据时主机在SCLK上升沿之前先把数据放到I/O上SCLK上升沿时DS1302锁存该位读数据时DS1302在SCLK下降沿把数据放到I/O上主机在下降沿之后去读电平。这个区别直接决定了代码里GPIO切换方向和读电平的时机。用一个生活化的类比写数据相当于对方先伸出左手你把手里的牌放上去等他捏紧才算给到读数据相当于他把牌亮出来你来抄。放牌要早抄牌要等手落定时机反了就会抄错。3.2 命令字节一次通信的“单据”DS1302允许对单个寄存器单独读写命令字节决定了你要操作哪一个寄存器。命令字节共8位组成结构大致是最高位固定为1第6位区分时钟寄存器还是RAM0表示时钟寄存器1表示RAM中间几位是寄存器地址最低位是读写标志0写1读。对于学习笔记而言最常用的一组命令是时钟寄存器命令寄存器地址读命令写命令数据范围秒0x000x810x8000-59分0x010x830x8200-59时0x020x850x8400-23或12小时制日0x030x870x8601-31月0x040x890x8801-12星期0x050x8B0x8A01-07年0x060x8D0x8C00-99写保护0x070x8F0x8E0x00或0x80从这个表能看出一个规律读命令比写命令大1。比如秒寄存器写命令是0x80读命令就是0x81。如果不想记表也可以通过计算构造命令时钟寄存器地址addr对应的读命令等于((addr * 2 1) | 0x80)写命令等于((addr * 2) | 0x80)。这套命令字节结构在代码注释里讲清楚后面维护起来会省很多事。3.3 一次完整读操作的过程读秒寄存器为例完整过程如下CE拉高开始通信。I/O配置为输出发送命令字节0x81低位先发送。每一位的流程是设置I/O电平SCLK拉高产生上升沿SCLK拉低。命令字节发送完成立刻把I/O切换为输入模式。产生8个SCLK脉冲。每个脉冲的下降沿之后读取I/O电平低位先读拼成完整数据。CE拉低结束通信。第3步是代码里最容易漏的。如果I/O始终保持推挽输出读数据时就会出现总线冲突轻则读到0xFF或0x00重则可能损坏DS1302的IO驱动级。我见过好几个初学者写的驱动读出来全是对的仔细一看是用了开漏模式加外部上拉虽然也能工作但理解上绕了一大圈。最直接的做法就是在代码里动态切换GPIO方向。3.4 为什么寄存器里的时间是BCD码DS1302的寄存器不以二进制存储时间而是BCD码。BCD码就是每个十进制数用四位二进制表示比如秒值为45寄存器里存的是0x45而不是十进制45对应的0x2D。这种编码的好处是可以直接按位解析出十位和个位显示时更方便。但如果你直接用二进制方式去运算拿到的数字会完全不对。工程上经常要写两个小函数来回转换一个把DEC转成BCD一个把BCD转成DEC。转换公式很简单DEC转BCD就是((dec / 10) 4) | (dec % 10)BCD转DEC就是(bcd 4) * 10 (bcd 0x0F)。这点如果不提前想清楚后面写驱动时很容易卡住。4. 基于HAL库的STM32驱动实现4.1 引脚初始化和微秒延时我用的是STM32CubeMX生成的基础工程时钟配置为72MHz。DS1302三个引脚直接定义为GPIOB的PB0、PB1、PB2具体引脚可以根据自己板子随便改因为全部是软件模拟对GPIO没有硬性复用要求。初始化时三个引脚先都配置为推挽输出默认CE和SCLK输出低电平#include ds1302.h #include stm32f1xx_hal.h void DS1302_Init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin DS1302_CE_PIN | DS1302_SCLK_PIN | DS1302_IO_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_PORT, gpio); DS1302_CE_LOW(); DS1302_SCLK_LOW(); }延时函数我推荐用DWT计数器比HAL_Delay准确得多适合微秒级时序。使用前先使能DWTstatic void DS1302_DelayInit(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static void DS1302_DelayUs(uint32_t us) { DWT-CYCCNT 0; while (DWT-CYCCNT us * 72); }72MHz下1微秒对应72个时钟周期所以延时循环里乘72。如果换其他主频的MCU只要改这个系数就可以。4.2 I/O方向切换和底层字节读写函数DS1302的数据线I/O是双向的发送命令/数据时要输出接收数据时要输入。因此驱动里需要两个函数来切换GPIO方向static void DS1302_IO_Output(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin DS1302_IO_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_PORT, gpio); } static void DS1302_IO_Input(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin DS1302_IO_PIN; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_NOPULL; HAL_GPIO_Init(DS1302_PORT, gpio); }底层写字节函数要遵循LSB first每个数据位在SCLK上升沿之前放到I/O上static void DS1302_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } DS1302_SCLK_HIGH(); DS1302_DelayUs(1); DS1302_SCLK_LOW(); DS1302_DelayUs(1); data 1; } }读字节函数在命令字节发送完成后由主机切换I/O方向然后在SCLK下降沿之后读取电平static uint8_t DS1302_ReadByte(void) { uint8_t data 0; DS1302_IO_Input(); for (uint8_t i 0; i 8; i) { DS1302_SCLK_HIGH(); DS1302_DelayUs(1); DS1302_SCLK_LOW(); DS1302_DelayUs(1); data 1; if (DS1302_IO_READ()) { data | 0x80; } } DS1302_IO_Output(); return data; }这段代码里data 1配合最高位置位就是为了把先收到的最低位在8次循环后“落”到正确的位置。我最初写成左移怎么读都是乱的后来对着时序图推了一遍才明白。4.3 BCD转换和时间数据结构定义一个时间结构体把年、月、日、周、时、分、秒都放进去typedef struct { uint8_t year; uint8_t month; uint8_t day; uint8_t week; uint8_t hour; uint8_t minute; uint8_t second; } DS1302_Time; static uint8_t DS1302_DecToBcd(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); } static uint8_t DS1302_BcdToDec(uint8_t bcd) { return (bcd 4) * 10 (bcd 0x0F); }这里建议把BCD转换单独做成静态函数不要在读取函数里到处写(buf[0] 4) * 10 (buf[0] 0x0F)可读性会差很多。4.4 时间读取与写入API读取时间最好用突发模式一次把7个时间寄存器全部读出来。时钟突发读命令是0xBF数据顺序是秒、分、时、日、月、星期、年void DS1302_GetTime(DS1302_Time *time) { uint8_t raw[7]; DS1302_CE_HIGH(); DS1302_WriteByte(0xBF); for (uint8_t i 0; i 7; i) { raw[i] DS1302_ReadByte(); } DS1302_CE_LOW(); time-second DS1302_BcdToDec(raw[0] 0x7F); time-minute DS1302_BcdToDec(raw[1] 0x7F); time-hour DS1302_BcdToDec(raw[2] 0x3F); time-day DS1302_BcdToDec(raw[3] 0x3F); time-month DS1302_BcdToDec(raw[4] 0x1F); time-week DS1302_BcdToDec(raw[5] 0x07); time-year DS1302_BcdToDec(raw[6]); }这里对秒寄存器做了 0x7F是为了把最高位的时钟暂停标志去掉。小时寄存器做 0x3F是为了兼容24小时制模式。如果发现读出来的时间偶尔变成0点很可能是12/24小时制标志位被干扰这属于寄存器标志位没屏蔽好的问题。写入时间时需要注意两点秒寄存器的CH标志位要写成0否则振荡器不启动时间不会走写保护寄存器要临时关闭写完后再恢复。下面是完整实现void DS1302_SetTime(const DS1302_Time *time) { uint8_t raw[7]; raw[0] DS1302_DecToBcd(time-second) 0x7F; raw[1] DS1302_DecToBcd(time-minute) 0x7F; raw[2] DS1302_DecToBcd(time-hour) 0x3F; raw[3] DS1302_DecToBcd(time-day) 0x3F; raw[4] DS1302_DecToBcd(time-month) 0x1F; raw[5] DS1302_DecToBcd(time-week) 0x07; raw[6] DS1302_DecToBcd(time-year); DS1302_WriteReg(0x8E, 0x00); DS1302_CE_HIGH(); DS1302_WriteByte(0xBE); for (uint8_t i 0; i 7; i) { DS1302_WriteByte(raw[i]); } DS1302_CE_LOW(); DS1302_WriteReg(0x8E, 0x80); }其中DS1302_WriteReg是封装好的单字节寄存器写函数void DS1302_WriteReg(uint8_t cmd, uint8_t data) { DS1302_CE_HIGH(); DS1302_WriteByte(cmd); DS1302_WriteByte(data); DS1302_CE_LOW(); }虽然突发写模式下CE从高到低中间会连续传输7个字节但写保护关闭和恢复操作是分开的两个CE周期这个细节让时序层次很清楚。4.5 开源驱动的文件分层和注释习惯如果这份驱动要作为开源学习项目发布我建议把文件分成两层一层是纯逻辑的DS1302驱动只包含寄存器操作、时间转换这类接口另一层是板级适配文件专门放GPIO宏、延时时基和引脚定义。这样别人换一块板子时只需要改适配层不用动协议层。开源项目最重要的不是代码本身而是说明文件。README里至少要有硬件连接表、引脚宏定义位置、测试用的MCU型号和主频、以及一个简单的调用示例。调用示例可以直接这样写DS1302_Init(); DS1302_Time now { .year 25, .month 4, .day 1, .week 2, .hour 10, .minute 30, .second 0 }; DS1302_SetTime(now); while (1) { DS1302_Time t; DS1302_GetTime(t); printf(20%02d-%02d-%02d %02d:%02d:%02d\n, t.year, t.month, t.day, t.hour, t.minute, t.second); HAL_Delay(1000); }5. 调试中的实战经验与排错思路5.1 读取全是0xFF或0x00先检查IO方向这是驱动调试里遇到最多的情况。如果串口打印出来的时间全是0xFF多半是DS1302的I/O始终被配置为输出模式读数据时两个输出源打架。如果打印出来全是0x00则可能DS1302根本没能输出原因可能是CE没有正确拉高或者I/O引脚焊接有问题。排查步骤很简单先用万用表测CE、SCLK、I/O到VCC2和GND的通断排除引脚接反然后在代码里用固定电平分别测三个宏确保GPIO操作有效最后再把DS1302_IO_Input()和DS1302_IO_Output()前后各打一个断点观察切换是否生效。5.2 时间不走或总是停在某一秒如果时间写入后不走第一个嫌疑是秒寄存器最高位CH被置1了。CH是时钟暂停标志为1时振荡器关闭秒值不变。很多模块出厂时寄存器是随机状态也可能默认把CH写了1。解决办法就是写入时间时显式把CH位清零也就是我在上面raw[0] ... 0x7F做的事情。第二个嫌疑是晶振没起振。这种情况用示波器看X1引脚有无32.768kHz波形没有示波器可以连续读秒寄存器若秒值隔一会儿才跳一次甚至长时间不动多半和晶振有关。常见的硬件原因是晶体虚焊、使用了过大负载电容的晶体、或者X1/X2走线太长引入过多寄生电容。直接用模块会省心很多。5.3 写入无效重新上电后时间还是旧值写入无效通常不是时序问题而是写保护寄存器WP没有关闭。DS1302的写保护标志在寄存器0x07的最高位默认可能是1。只要WP为1任何对时钟寄存器的写入都会被忽略。我早期的代码就是这样设置时间时只发命令不写WP结果看起来函数执行得很顺利但重新上电后时间还是模块出厂值。解决方式就是像前面那样在写入前把0x8E寄存器写0写入结束后写0x80重新打开保护。5.4 走时误差偏大怎么评估和补偿DS1302走时精度完全依赖外部晶振。普通32.768kHz晶振的误差可能在±20ppm左右算下来一天误差约1.7秒一个月大约50秒。如果发现误差远超这个范围更可能是晶振负载电容不匹配或者供电电压低于3V。软件层面可以做的补偿很有限常见思路是定期在整点通过串口或网络校时或者做一个简单的软件微调把每天误差量换算成每秒补偿值累加。我自己的经验是如果设备需要长期准确走时老老实实上DS3231这类温补芯片那点差价不值得在后期维护上反复折腾如果项目对时间精度宽限到分钟级DS1302完全够用还很便宜。5.5 进一步扩展31字节RAM和闹钟DS1302的RAM区可以当作一个掉电不丢失的小规模存储命令字节中把第6位置1地址范围从0x00到0x1F。比如RAM地址0x00的读写命令分别是0xC1和0xC0。RAM的操作时序和时钟寄存器一样区别只在于命令字节和后续要传的数据个数。因为RAM空间很小我一般用来存设备编号、校准系数这类几个字节的配置存大数组就明显不合适了。至于闹钟DS1302本身没有完整的中断闹钟逻辑更多是靠外部比较寄存器实现。相比DS3231的闹钟输出DS1302做闹钟需要自己轮询时间这会让主控频繁醒来反而不如直接选带中断输出引脚的芯片。学习驱动时了解就行实际产品里不推荐用DS1302做实时报警。6. 把驱动整理成开源学习项目的一些心得6.1 学习笔记和开源代码的定位标题写“开源|学习笔记”意味着这个项目不只是丢一份能用的代码更重要的是把思路讲清楚。我整理这类项目时会刻意保留两部分内容一部分是完整可运行的驱动文件另一部分是README里的“设计笔记”记录为什么CE要在数据前先拉高、为什么读写沿要分开、为什么LSB先传。这些内容比代码本身更有价值因为代码看两天就忘理解了协议结构才能举一反三。6.2 让其他人“三分钟上手”的关键开源驱动最怕别人下载后改半天才能跑。我的做法是在代码头部直接放一张硬件连接表引脚宏统一集中在头文件前几行主频和DWT延时的换算系数单独写成宏示例程序单独放一个example.c里面包含初始化和读写时间的全部调用。这样别人拿到手只需要改引脚宏最多再改一下DWT延时的系数就能编译运行。6.3 这类小项目值得持续维护吗值得但维护重点不是加功能而是持续校对文档和减少特殊平台依赖。我最初写的版本只针对手头那块板子后来发现有人用GD32、AT32、CH32这些国产MCU移植驱动只要遵守HAL库API基本都能跑。为了让更多人用起来我把GPIO操作收敛成一组宏并明确写了“如果使用标准库请把宏替换为对应的寄存器操作”。这种小项目的成就感往往来自被别人借鉴后的反馈反过来也会促使自己把注释写得更准确。DS1302本身不复杂但串行时序、BCD编码、掉电切换电源这些知识点都是嵌入式基本功值得反复打磨。
阅读完成 · 觉得有帮助?