
自己写这套驱动的时候最头疼的不是怎么写代码而是网上资料太多太碎每个人写的时序细节都不一样抄完改完还不知道对不对。这篇文章我把完整的思路、时序拆解、代码实现和排坑过程都整理在一起当一份学习笔记看也好直接当参考模板用也行。1. 内容整体设计与思路拆解1.1 为什么选DS1302而不是DS3231或RX8025这个选择我想多说两句。DS3231精度确实高自带温补晶振I2C接口也简单但价格比DS1302贵了好几倍。如果是做要长期精确计时的产品DS3231没得跑但如果只是做学习项目、小型控制系统或者对时间精度要求没那么苛刻的场景DS1302完全够用而且它最大的优势是——便宜、资料多、容易买。DS1302本身是Dallas现在Maxim出的串行实时时钟芯片支持BCD码格式的秒、分、时、日、月、星期、年还有闰年补偿功能。它的接口叫“三线串行接口”也就是CE片选、SCLK时钟、I/O数据和老式SPI很像但不是标准SPI所以写驱动的时候不能直接套硬件SPI外设更多是用GPIO模拟时序。另外一个比较重要的点是DS1302内部有一个涓流充电电路可以在VCC掉电的时候自动切换到备用电池供电而且能对充电电池或者超级电容进行充电。这个设计对做掉电保存时间的应用非常友好。1.2 驱动方案选型硬件SPI还是GPIO模拟好多人一上来就想着用STM32的硬件SPI去接DS1302我劝你放弃这个想法。DS1302虽然长得像SPI但它的时序有几个和标准SPI不一样的地方读操作是先把命令字节发出去然后切换I/O方向再在SCLK的下降沿读数据中间不能有停顿。它的数据是在SCLK上升沿写入下降沿输出和标准SPI的Mode 0、Mode 1、Mode 2、Mode 3都能对得上但切换I/O方向的时刻不对齐容易出错。所以我最后用的是GPIO模拟时序。这不仅仅是为了省事更重要的是时序可以精确控制也不会占用SPI外设资源。在STM32这种主频几十到上百兆的MCU上GPIO模拟几十kHz的时序完全没有压力。用GPIO模拟还有一个好处代码移植性极强。今天用STM32F103明天换STM32F407或者GD32只要改引脚定义那几行宏就行驱动核心逻辑完全不用动。2. 核心细节解析与实操要点2.1 引脚定义与硬件连接先看一下DS1302的引脚功能它一共8个脚DIP-8封装很常见引脚号名称功能说明1VCC2主电源通常接3.3V或5V2X132.768kHz晶振输入3X232.768kHz晶振输出4GND接地5CE片选使能高电平有效6SCLK串行时钟输入7I/O数据输入/输出双向8VCC1备用电池正极通常接CR2032或超级电容硬件连接我建议这样安排以STM32F103C8T6为例// DS1302引脚宏定义 #define DS1302_CE_GPIO_PORT GPIOA #define DS1302_CE_GPIO_PIN GPIO_PIN_0 #define DS1302_SCLK_GPIO_PORT GPIOA #define DS1302_SCLK_GPIO_PIN GPIO_PIN_1 #define DS1302_IO_GPIO_PORT GPIOA #define DS1302_IO_GPIO_PIN GPIO_PIN_2需要注意的是I/O引脚必须配置成开漏输出模式并且外部接一个10kΩ左右的上拉电阻。为什么因为DS1302的输出结构是开漏的如果不用开漏模式通讯的时候I/O方向切换会产生总线冲突——一个输出高、一个输出低轻则数据错误重则烧芯片。另外32.768kHz晶振的两个引脚要接两个6pF左右的负载电容到地这是晶振正常起振的必要条件。很多人焊完板子发现时间不走大概率就是这里的问题。电源方面VCC2可以直接接3.3VVCC1接CR2032电池或者法拉电容。如果没有备用电池需求VCC1可以直接接地——但我不建议这么做因为留着备用电池位以后产品化的时候随时能用上。2.2 DS1302内部寄存器结构DS1302的寄存器是理解整个驱动的钥匙。它不是一上来就让你读写时间而是通过命令字节来选定要操作的寄存器。核心寄存器就这些寄存器名称命令字节读命令字节写数据范围BCD说明秒寄存器0x810x800x00~0x59bit7是CH标志置1停止振荡分寄存器0x830x820x00~0x59时寄存器0x850x840x00~0x23bit7为12/24小时制标志日寄存器0x870x860x01~0x31月寄存器0x890x880x01~0x12星期寄存器0x8B0x8A0x01~0x07星期天1或7取决于你的设定年寄存器0x8D0x8C0x00~0x99存储的是年份后两位控制寄存器0x8F0x8Ebit7WP写保护置1禁止写所有寄存器充电寄存器0x910x90涓流充电配置默认关闭充电注意DS1302的数据格式是BCD码也就是一个字节的高四位表示十位、低四位表示个位。比如分钟是45分写进去的字节是0x45而不是0x2D。这个转换如果忘了显示出来就是乱码。2.3 时序拆解一次读/写操作到底发生了什么写驱动之前一定要先把时序图看明白。我当初第一次写的时候没细看时序图直接照抄别人的代码结果数据读出来全是0xFF排查了很久才发现是方向切换没做对。一次完整的字节传输分为三个阶段阶段一开始条件把CE引脚从低拉高此时SCLK必须是低电平。CE拉高后芯片被激活准备接收命令字节。阶段二命令字节传输命令字节一共8位在SCLK的上升沿被写入DS1302。也就是说每一个SCLK上升沿到来之前I/O引脚要先准备好对应位的数据。命令字节的格式前面说过了bit0固定为1bit1表示读写方向0为写、1为读bit2到bit4是寄存器地址bit5到bit7固定为0。阶段三数据字节传输如果是写操作在命令字节发送完之后继续在SCLK上升沿发送8位数据如果是读操作命令字节发送完成后I/O引脚立刻从输出模式切换成输入模式然后在SCLK的下降沿读取数据。这里有一个关键细节读数据是在下降沿读写数据是在上升沿写。这两个时序如果搞反读出来的数据就是乱的。3. 实操过程与核心代码实现3.1 驱动初始化GPIO配置初始化部分相对简单就是把三个引脚都配好。I/O引脚注意用开漏输出CE和SCLK用推挽输出即可。void DS1302_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 使能GPIOA时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); // CE和SCLK配置为推挽输出 GPIO_InitStruct.Pin DS1302_CE_GPIO_PIN | DS1302_SCLK_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // I/O配置为开漏输出外部需要上拉电阻 GPIO_InitStruct.Pin DS1302_IO_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // 初始状态CE为低SCLK为低 DS1302_CE_LOW(); DS1302_SCLK_LOW(); }3.2 字节级读写函数有了引脚宏定义和初始化接下来就是最核心的两个函数写一个字节、读一个字节。// 向DS1302写入一字节 void DS1302_WriteByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { // 先拉低SCLK确保是在上升沿写入 DS1302_SCLK_LOW(); // 设置I/O电平先输出低位 if (dat 0x01) DS1302_IO_HIGH(); else DS1302_IO_LOW(); // 产生上升沿DS1302在上升沿采样数据 DS1302_SCLK_HIGH(); dat 1; } } // 从DS1302读取一字节 uint8_t DS1302_ReadByte(void) { uint8_t i, dat 0; for (i 0; i 8; i) { // 拉低SCLK准备下降沿读数据 DS1302_SCLK_LOW(); // 延时等待数据稳定 delay_us(2); // 在SCLK低电平期间读取I/O数据 if (DS1302_IO_READ()) dat | (0x01 i); // 拉高SCLK产生下降沿从高到低 DS1302_SCLK_HIGH(); } return dat; }细心的朋友可能发现了读函数的时序和我前面说的“下降沿读数据”稍有出入。实际我是在SCLK由高拉低之后保持低电平的这段时间去采样I/O数据。这个做法在实际测试中更稳定因为它给了信号足够的时间稳定下来不容易采到跳变沿上的毛刺。3.3 命令字发送与完整读写操作底层字节函数写好了还需要一个“发送命令字读写数据”的封装。这个封装负责管理CE引脚的拉高拉低以及I/O方向切换。// 写一个字节到指定寄存器 void DS1302_WriteReg(uint8_t cmd, uint8_t dat) { // 片选使能 DS1302_CE_HIGH(); // 先发送命令字节 DS1302_WriteByte(cmd); // 再发送数据字节 DS1302_WriteByte(dat); // 片选禁用 DS1302_CE_LOW(); } // 从指定寄存器读取一个字节 uint8_t DS1302_ReadReg(uint8_t cmd) { uint8_t dat 0; // 片选使能 DS1302_CE_HIGH(); // 发送命令字节读命令 DS1302_WriteByte(cmd); // 切换I/O方向从输出切换到输入 DS1302_IO_MODE_INPUT(); // 延时等待方向切换稳定 delay_us(2); // 读取数据字节 dat DS1302_ReadByte(); // 切换I/O方向从输入切回输出 DS1302_IO_MODE_OUTPUT(); // 片选禁用 DS1302_CE_LOW(); return dat; }这里的关键在于DS1302_IO_MODE_INPUT()和DS1302_IO_MODE_OUTPUT()这两个函数。在HAL库下切换方向其实就是重新配置GPIO的模式void DS1302_IO_MODE_INPUT(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, GPIO_InitStruct); } void DS1302_IO_MODE_OUTPUT(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DS1302_IO_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DS1302_IO_GPIO_PORT, GPIO_InitStruct); }这种方式虽然有点笨重每次读操作都要重新初始化GPIO但胜在逻辑清晰。实际测试下来读一次时间也就在几十微秒级别对于RTC这种低频操作完全够用。3.4 BCD码与十进制互转这个转换函数务必独立封装因为你在设置时间和读取时间的时候都会用到而且容易出错的地方就在这。// 十进制转BCD uint8_t DecimalToBCD(uint8_t dec) { return ((dec / 10) 4) | (dec % 10); } // BCD转十进制 uint8_t BCDToDecimal(uint8_t bcd) { return ((bcd 4) * 10) (bcd 0x0F); }为什么要用位运算而不是乘除其实对于编译器来说dec / 10和dec % 10会生成除法指令在Cortex-M3上除法指令大概几个周期不算慢。但用移位和与运算语义更直观也容易看出转换关系。实际上这类转换在RTC驱动里到处都是写成内联函数统一管理后期维护会舒服很多。3.5 时间设置与读取的完整封装有了上面的基础函数设置和读取时间就顺理成章了。// 定义时间结构体 typedef struct { uint8_t year; // 0~99年份后两位 uint8_t month; // 1~12 uint8_t day; // 1~31 uint8_t weekday; // 1~7 uint8_t hour; // 0~23 uint8_t minute; // 0~59 uint8_t second; // 0~59 } DS1302_Time; // 写入时间 void DS1302_SetTime(DS1302_Time *time) { // 解除写保护 DS1302_WriteReg(0x8E, 0x00); // 写入时间数据注意要转成BCD码 DS1302_WriteReg(0x80, DecimalToBCD(time-second)); DS1302_WriteReg(0x82, DecimalToBCD(time-minute)); DS1302_WriteReg(0x84, DecimalToBCD(time-hour)); DS1302_WriteReg(0x86, DecimalToBCD(time-day)); DS1302_WriteReg(0x88, DecimalToBCD(time-month)); DS1302_WriteReg(0x8A, DecimalToBCD(time-weekday)); DS1302_WriteReg(0x8C, DecimalToBCD(time-year)); // 写保护 DS1302_WriteReg(0x8E, 0x80); } // 读取时间 void DS1302_GetTime(DS1302_Time *time) { // 读取时间数据注意要是BCD码转十进制 time-second BCDToDecimal(DS1302_ReadReg(0x81)); time-minute BCDToDecimal(DS1302_ReadReg(0x83)); time-hour BCDToDecimal(DS1302_ReadReg(0x85)); time-day BCDToDecimal(DS1302_ReadReg(0x87)); time-month BCDToDecimal(DS1302_ReadReg(0x89)); time-weekday BCDToDecimal(DS1302_ReadReg(0x8B)); time-year BCDToDecimal(DS1302_ReadReg(0x8D)); }读时间这里有一个容易踩的坑如果刚好在秒寄存器进位的时候连续读取各个寄存器可能读到下一秒的分钟、上一秒的秒导致数据不一致。严格的做法是连续读两遍如果两遍结果一致才认为有效。但对于多数非关键应用比如显示时钟、定时控制单次读取也够用了。我写代码的时候图省事就先用单次读取后面如果需要再做双读校验。4. 常见问题与排查技巧实录4.1 读出来的数据全是0xFF或0x00这个是我见过最多的问题也是最容易让新手一脸懵的问题。排查方向如下先说全是0xFF。这种情况通常是I/O引脚方向切换没做好读数据的时候引脚还是输出模式结果读到的是高电平。检查方式是在DS1302_ReadReg里命令字节发送完之后加一个DS1302_IO_MODE_INPUT()并确保执行到了这一步。如果全是0x00问题大概率在硬件。I/O引脚被强制拉低了检查一下外部上拉电阻是否焊接正常或者芯片是否损坏。4.2 时间不走秒寄存器一直是同一个值这个现象是我调DS1302时遇到的最折磨人的问题。检查步骤如下先检查秒寄存器的bit7CH位。如果CH为1振荡器是停止的时间当然不会走。解决办法很简单写秒寄存器的时候保证写入的值小于0x80。比如写0x00表示秒数为0且启动振荡器。再检查晶振是否起振。DS1302的X1和X2引脚必须接32.768kHz晶振而且负载电容一定要接。有些时候晶振没起振不是晶振坏了而是负载电容虚焊或者容值不对。最后如果上面都没问题用示波器或者逻辑分析仪看一下X2引脚正常应该能测到32.768kHz的方波。没有示波器的话直接换个晶振试试这是最粗暴有效的方法。4.3 设置的时间保存不住一掉电就恢复这个问题要从两个角度排查。首先如果只是断电后时间重置为初始化值检查VCC1引脚是否接了备用电池电池电压是否正常。CR2032一般用3.0V的就行如果电池电压低于2.0VDS1302可能无法维持振荡。其次检查写保护是否被正确配置。部分网友的代码里读操作之前会先取消写保护读完之后又设置写保护这个做法没问题。但如果你用的是突发模式Burst Mode读写所有寄存器就要格外注意命令字节的格式。4.4 使用HAL库时GPIO翻转速度太慢这个问题在新手用STM32CubeMX生成的工程里比较常见。HAL库的HAL_GPIO_WritePin函数内部有不少判断逻辑翻转速度确实比标准库的GPIO_WriteBit慢。但前面也说了DS1302的通讯时钟本来就不需要多高几百kHz完全够用。如果你非要追求极致速度可以直接操作寄存器// 直接操作BSRR寄存器速度更快 #define DS1302_SCLK_HIGH() (DS1302_SCLK_GPIO_PORT-BSRR DS1302_SCLK_GPIO_PIN) #define DS1302_SCLK_LOW() (DS1302_SCLK_GPIO_PORT-BSRR (uint32_t)DS1302_SCLK_GPIO_PIN 16)不过我要提醒一句对于DS1302驱动用普通函数就够了没必要为了省那几百纳秒把代码搞复杂。4.5 一定要加延时吗很多第一次写这个驱动的朋友会纠结要不要在每次电平翻转之间加延时。我的经验是STM32主频72MHz跑GPIO模拟时序不加延时也能正常工作因为GPIO翻转本身就要几十纳秒而DS1302的最小时钟周期是微秒级别的天然满足时序要求。但如果你用的是性能更强的MCU比如STM32H7跑240MHz甚至更高不加延时可能会出问题。稳妥做法是在函数开头加一个简单的循环延时比如void delay_us(uint32_t us) { // 简单的循环延时适合不精确的场景 for (uint32_t i 0; i us * 8; i) { __NOP(); } }这个函数不精确但在这个场景够用。5. 工程化封装从裸机函数到模块化驱动5.1 文件结构规划如果只是学习写几个函数就够了。但如果是做项目建议把驱动模块化方便后续移植。我习惯这样组织文件DS1302/ ├── ds1302.h // 头文件宏定义、结构体声明、API声明 ├── ds1302.c // 源文件驱动实现 └── README.md // 使用说明头文件里的宏定义和函数声明清晰列出来源文件里函数实现内部逻辑。这样做的最大好处是——以后换MCU只需要改头文件里的引脚宏和底层GPIO配置核心时序逻辑完全不用动。5.2 API设计要点对外暴露的API尽量精简我用的是这几个void DS1302_Init(void); // 初始化 void DS1302_SetTime(DS1302_Time *time); // 设置时间 void DS1302_GetTime(DS1302_Time *time); // 获取时间 void DS1302_SetCharge(uint8_t enable); // 设置涓流充电不需要把ReadReg、WriteReg这些底层函数全部暴露出去内部static掉对外只留操作时间的接口这样使用者不容易误操作造成时序错乱。5.3 主函数测试用例写完驱动之后我习惯先跑一个最简单的测试上电初始化设置一次时间然后每秒通过串口打印当前时间。这样能快速验证驱动是否正确。int main(void) { HAL_Init(); SystemClock_Config(); USART1_UART_Init(); DS1302_Init(); DS1302_Time time { .year 25, .month 5, .day 18, .weekday 7, .hour 23, .minute 59, .second 50 }; DS1302_SetTime(time); while (1) { DS1302_Time current_time; DS1302_GetTime(current_time); printf(20%02d-%02d-%02d %02d:%02d:%02d Weekday:%d\r\n, current_time.year, current_time.month, current_time.day, current_time.hour, current_time.minute, current_time.second, current_time.weekday); HAL_Delay(1000); } }注意设置时间的时候秒寄存器不能写0x80以上的值否则芯片会进入振荡停止状态。我上面的测试代码sec写的是50没问题。6. 一些额外的经验与扩展想法6.1 关于读时间的稳定性实际使用中我发现连续多次读DS1302的时间偶尔会出现某一位数据跳变的情况尤其是秒和分钟这两个寄存器。原因大概率是读的时候恰好赶上时间进位或者说读操作过程中I/O方向切换瞬间产生了毛刺。解决办法前面提过就是连续读两次做对比一致才采用。void DS1302_GetTimeWithCheck(DS1302_Time *time) { DS1302_Time t1, t2; do { DS1302_GetTime(t1); DS1302_GetTime(t2); } while (memcmp(t1, t2, sizeof(DS1302_Time)) ! 0); *time t1; }虽然会增加一点耗时但能显著提高容错率适合对时间准确性要求较高的场景。6.2 备用电池用CR2032还是法拉电容这要看应用场景。CR2032容量大大概200mAh以上但不可充电法拉电容可充电但容量小一个1F的法拉电容在DS1302这种微安级电流下能撑几天到几周不等。如果你的设备本身就经常上电断电只是偶尔发生用法拉电容完全够还省钱。如果是长期断电设备比如带RTC的传感器节点用CR2032更合适。DS1302的涓流充电电路建议不用就关掉默认输出是关闭的也尽量不要试图给CR2032充电——那玩意儿不能充充了反而危险。6.3 驱动还能怎么玩如果只是读写时间那这个驱动算是最基础的了。DS1302其实还支持RAM读写内置31字节的静态RAM可以用来存一些掉电保存的配置参数。这个功能在需要存校准参数、设备地址之类的场景很实用。代码实现和读写寄存器类似只是命令字节的寄存器地址段映射到RAM区。另外这套GPIO模拟时序的思想不止适用于DS1302。很多类似的串行接口芯片比如温湿度传感器DHT11、单总线器件DS18B20、各种射频芯片的SPI配置其实都是同一个套路。学会之后你会发现手上所有芯片的驱动写起来都顺手多了。我第一次在论坛公开发这套驱动时有人问我“用模拟IO不嫌慢吗”我的回答是在RTC这种低频设备上GPIO模拟时序的开销根本感知不到反而换来的是极强可移植性。后来在一个项目里同样的驱动代码只需要换个引脚宏定义就从一个国产MCU无缝移植到了另一个国产MCU一分钟都不用那才叫省心。