自己写这套驱动的时候,最头疼的不是怎么写代码,而是网上资料太多太碎,每个人写的时序细节都不一样,抄完改完还不知道对不对。这篇文章我把完整的思路、时序拆解、代码实现和排坑过程都整理在一起,当一份学习笔记看也好,直接当参考模板用也行。
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封装很常见:
| 引脚号 | 名称 | 功能说明 |
|---|---|---|
| 1 | VCC2 | 主电源,通常接3.3V或5V |
| 2 | X1 | 32.768kHz晶振输入 |
| 3 | X2 | 32.768kHz晶振输出 |
| 4 | GND | 接地 |
| 5 | CE | 片选使能(高电平有效) |
| 6 | SCLK | 串行时钟输入 |
| 7 | I/O | 数据输入/输出(双向) |
| 8 | VCC1 | 备用电池正极,通常接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.3V,VCC1接CR2032电池或者法拉电容。如果没有备用电池需求,VCC1可以直接接地——但我不建议这么做,因为留着备用电池位,以后产品化的时候随时能用上。
2.2 DS1302内部寄存器结构
DS1302的寄存器是理解整个驱动的钥匙。它不是一上来就让你读写时间,而是通过命令字节来选定要操作的寄存器。
核心寄存器就这些:
| 寄存器名称 | 命令字节(读) | 命令字节(写) | 数据范围(BCD) | 说明 |
|---|---|---|---|---|
| 秒寄存器 | 0x81 | 0x80 | 0x00~0x59 | bit7是CH标志,置1停止振荡 |
| 分寄存器 | 0x83 | 0x82 | 0x00~0x59 | |
| 时寄存器 | 0x85 | 0x84 | 0x00~0x23 | bit7为12/24小时制标志 |
| 日寄存器 | 0x87 | 0x86 | 0x01~0x31 | |
| 月寄存器 | 0x89 | 0x88 | 0x01~0x12 | |
| 星期寄存器 | 0x8B | 0x8A | 0x01~0x07 | 星期天=1或7取决于你的设定 |
| 年寄存器 | 0x8D | 0x8C | 0x00~0x99 | 存储的是年份后两位 |
| 控制寄存器 | 0x8F | 0x8E | bit7=WP写保护 | 置1禁止写所有寄存器 |
| 充电寄存器 | 0x91 | 0x90 | 涓流充电配置 | 默认关闭充电 |
注意,DS1302的数据格式是BCD码,也就是一个字节的高四位表示十位、低四位表示个位。比如分钟是45分,写进去的字节是0x45,而不是0x2D。这个转换如果忘了,显示出来就是乱码。
2.3 时序拆解:一次读/写操作到底发生了什么
写驱动之前,一定要先把时序图看明白。我当初第一次写的时候没细看时序图,直接照抄别人的代码,结果数据读出来全是0xFF,排查了很久才发现是方向切换没做对。
一次完整的字节传输分为三个阶段:
阶段一:开始条件
把CE引脚从低拉高,此时SCLK必须是低电平。CE拉高后芯片被激活,准备接收命令字节。
阶段二:命令字节传输
命令字节一共8位,在SCLK的上升沿被写入DS1302。也就是说,每一个SCLK上升沿到来之前,I/O引脚要先准备好对应位的数据。命令字节的格式前面说过了:bit0固定为1,bit1表示读写方向(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时遇到的最折磨人的问题。检查步骤如下:
先检查秒寄存器的bit7(CH位)。如果CH为1,振荡器是停止的,时间当然不会走。解决办法很简单:写秒寄存器的时候,保证写入的值小于0x80。比如写0x00表示秒数为0且启动振荡器。
再检查晶振是否起振。DS1302的X1和X2引脚必须接32.768kHz晶振,而且负载电容一定要接。有些时候晶振没起振不是晶振坏了,而是负载电容虚焊或者容值不对。
最后,如果上面都没问题,用示波器或者逻辑分析仪看一下X2引脚,正常应该能测到32.768kHz的方波。没有示波器的话,直接换个晶振试试,这是最粗暴有效的方法。
4.3 设置的时间保存不住,一掉电就恢复
这个问题要从两个角度排查。
首先,如果只是断电后时间重置为初始化值,检查VCC1引脚是否接了备用电池,电池电压是否正常。CR2032一般用3.0V的就行,如果电池电压低于2.0V,DS1302可能无法维持振荡。
其次,检查写保护是否被正确配置。部分网友的代码里,读操作之前会先取消写保护,读完之后又设置写保护,这个做法没问题。但如果你用的是突发模式(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(¤t_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,一分钟都不用,那才叫省心。