最近在整理一块老开发板时,翻出一颗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、涓流充电、双电源 | 低成本、掉电保持时间、需要少量非易失RAM |
| DS3231 | I2C | 内置温补晶振 | ±2ppm,非常高 | 温度传感器、报警、方波输出 | 对走时精度要求高的设备 |
| PCF8563 | I2C | 外部32.768kHz晶振 | 与晶振有关 | 超低功耗、定时器、闹钟 | 低功耗便携设备 |
DS1302最大的优势是便宜、接线少、资料多,很多模块直接把晶振和后备电池焊好了,买回来四根线一接就能用。缺点也很明显:精度取决于外部晶振,温度变化大了误差会放大。如果项目只是记录个事件发生的日期时间,不需要精确到秒级长期校准,DS1302非常合适;要是做电表、计时设备这类对时间准确性敏感的产品,建议直接上DS3231,省去后期反复校时的麻烦。
1.3 为什么这个组合适合当学习笔记写
STM32驱动DS1302是一个很好的入门级通信协议实战案例。因为DS1302不是标准SPI,也不像I2C有地址应答机制,你可以完全用GPIO翻转电平来复现时序,这比调库更接近底层原理。阅读DS1302数据手册时,你会被迫去看时序图、理解命令字节、搞清楚BCD编码,这些能力在以后驱动其他串行芯片时都能复用。另外这个驱动规模不大,文件不超过两百行,适合作为开源学习项目整理,别人拿过去也容易改。
2. 硬件连接的第一个关卡:引脚与电源
2.1 DS1302引脚定义与STM32接线表
我手上的DS1302是常见的8脚直插封装,各引脚功能和与STM32的连接建议如下:
| DS1302引脚 | 作用 | 接到STM32 |
|---|---|---|
| VCC2 | 主电源 | 3.3V或5V,建议3.3V |
| X1、X2 | 32.768kHz晶振 | 不接,使用模块自带晶振 |
| GND | 地 | GND |
| CE | 片选/复位使能 | 任选GPIO,示例用PB0 |
| I/O | 串行数据 | 任选GPIO,示例用PB1 |
| SCLK | 串行时钟 | 任选GPIO,示例用PB2 |
| VCC1 | 备份电池 | 接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但不是SPI
DS1302的数据收发依赖三根线: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位区分时钟寄存器还是RAM(0表示时钟寄存器,1表示RAM),中间几位是寄存器地址,最低位是读写标志,0写1读。
对于学习笔记而言,最常用的一组命令是时钟寄存器命令:
| 寄存器 | 地址 | 读命令 | 写命令 | 数据范围 |
|---|---|---|---|---|
| 秒 | 0x00 | 0x81 | 0x80 | 00-59 |
| 分 | 0x01 | 0x83 | 0x82 | 00-59 |
| 时 | 0x02 | 0x85 | 0x84 | 00-23(或12小时制) |
| 日 | 0x03 | 0x87 | 0x86 | 01-31 |
| 月 | 0x04 | 0x89 | 0x88 | 01-12 |
| 星期 | 0x05 | 0x8B | 0x8A | 01-07 |
| 年 | 0x06 | 0x8D | 0x8C | 00-99 |
| 写保护 | 0x07 | 0x8F | 0x8E | 0x00或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准确得多,适合微秒级时序。使用前先使能DWT:
static 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编码、掉电切换电源这些知识点都是嵌入式基本功,值得反复打磨。