STM32驱动DS1302实时时钟:标准库与HAL库双版本实现与串口显示
2026/8/31 13:18:42 网站建设 项目流程

简介:本资源是面向STM32初学者与嵌入式开发者的DS1302实时时钟驱动实战项目,聚焦STM32F103系列MCU在标准库与HAL库双框架下对DS1302芯片的完整驱动实现与串口时间显示功能。资源提供可直接编译运行的工程模板(含IAR、Keil MDK双平台支持),涵盖底层时序模拟、寄存器读写、时间校准、BCD码转换及USART实时打印等核心环节,适用于课程设计、毕业设计及模块化开发参考。压缩包共1162个文件,以589个C源文件和273个头文件构成主体逻辑,辅以编译中间文件(.o/.d/.crf)、链接脚本(.sct)、工程配置(.uvprojx/.ioc)及数学库(.a/.lib),总大小29.37MB,结构清晰、模块解耦,便于理解驱动分层设计。目前已有3794人学习下载,配套代码注释详尽,含多版本工程对比、关键时序调试要点及常见通信异常处理提示,助读者快速掌握外设驱动移植与跨库适配方法。 做这个项目最磨人的地方,往往不是DS1302本身,而是你同时要用标准库和HAL库各写一遍,还要保证串口输出不乱码、时间不走丢。我去年把一套仓库管理系统里的时钟模块从标准库迁移到HAL库时,就被这个“两套代码、同一个芯片”的组合折腾得不轻。这篇文章把完整思路、时序细节、两套库的代码写法、串口打印的坑全部拆开讲,尤其适合正在做课程设计、毕业设计,或者想把老项目快速翻新到HAL库的朋友。

1. 项目整体设计与方案选型

1.1 为什么要用DS1302而不是其他时钟芯片

STM32F103内部是有RTC的,很多人会问:既然单片机自带RTC,为什么还要外挂DS1302?答案是:STM32F103的RTC依赖VBAT引脚供电,而很多最小系统板压根没给VBAT接电池。一旦主电源断电,内部RTC时间直接归零。DS1302的优势就在这——它的VCC1引脚可以接一颗CR2032纽扣电池,主电断了它还在走,而且芯片本身带31字节的静态RAM,可以存一些掉电不丢的标志位数据。价格也就是一两块钱,比DS3231便宜不少。

DS1302的接口虽然经常被叫SPI,但严格来说是一个三线串行接口:CE(片选使能)、SCLK(串行时钟)、I/O(双向数据)。注意,它没有标准的MOSI和MISO分开的双向数据线,而是用一根I/O线分时收发。这就决定了一个事情:你不能直接拿STM32的硬件SPI外设去怼它,得用GPIO软件模拟时序。这也是这个项目里最核心的技术点——软件时序模拟。

1.2 标准库和HAL库怎么选

很多新手在标准库和HAL库之间纠结,我的建议很简单:如果你是用CubeMX配置工程、喜欢用图形化界面生成初始化代码,那就用HAL库;如果你习惯了KEIL里全手写寄存器操作风格、项目又不需要跨芯片移植,标准库其实很顺手。但现实是,现在很多公司的新项目都在用HAL库,老的维护项目还在跑标准库,所以最好两个都会。

从代码量来看,标准库的GPIO初始化需要写好几行结构体配置,HAL库也一样要往结构体里填参数,两者差别不大。真正的差别在函数命名风格上:标准库是GPIO_SetBitsGPIO_ResetBitsGPIO_ReadInputDataBit,HAL库变成了HAL_GPIO_WritePinHAL_GPIO_ReadPin。功能完全相同,就是封装层级和命名习惯不一样。而且HAL库在串口、定时器、中断这些外设上封装得更狠,比如HAL_UART_Transmit直接帮你处理了发送超时机制,标准库就要自己轮询USART_FLAG_TXE标志位。

1.3 把“串口显示”这个需求拆开看

既然标题里带了“串口显示”,就说明时间数据最终要从串口打印出来给人看,而不是只在OLED屏上显示。串口输出的优势是调试方便,打开串口助手就能看到时间日志,不用额外接显示设备。整个项目的功能链路是:DS1302把时间数据以BCD码形式存到寄存器里,STM32通过GPIO模拟时序读出这些BCD码,再换算成十进制数,最后通过USART1发到PC串口助手显示。

所以这个项目虽然看起来简单,实际上覆盖了几个基本功:GPIO模拟通信时序、BCD码与十进制互转、串口底层驱动、printf重定向。无论你以后做传感器采集,还是做数据上报,这几个技能都是通用的。特别是DS1302这种软件模拟时序的办法,在I2C、单总线(比如DS18B20)设备上同样适用,学会一套思路,以后遇到其他芯片也能快速上手。

2. 硬件连接与DS1302协议原理

2.1 最小系统与引脚接线

STM32F103最小系统板(也就是常说的“蓝板”或“红板”)和DS1302时钟模块之间,只需要接4根线:VCC、GND、SCLK、I/O、CE——严格来说加电源是5根。DS1302模块上一般自带上拉电阻和晶振,买成品模块的话接线很简单。

DS1302引脚STM32F103引脚说明
VCC23.3V或5V主电源,模块自带稳压就接5V,否则接3.3V
VCC1CR2032正极备用电池,掉电保时间
GNDGND共地
CEPA1片选使能,也叫RST
SCLKPA0串行时钟,由STM32产生
I/OPA2双向数据线

我用的是PA0/PA1/PA2这组引脚,原因是这组引脚在最小系统板上引出来方便,而且不占用USART1的PA9/PA10。另外要提醒一下,I/O引脚建议加上拉电阻到VCC,虽然STM32推挽输出时内部上拉不太重要,但切换到输入模式读数据时,如果外部线路有干扰,上拉电阻能让电平更稳定。DS1302模块上一般已经焊了4.7k或10k上拉,成品模块就不用额外处理。

这里还有一个容易忽略的点:DS1302的VCC1接的备用电池,如果你用可充电的LIR2032,需要配合充电电路;如果用一次性CR2032,就直接正极接VCC1、负极接GND。我的做法是在电池座和VCC1之间串一个1N4148二极管,防止模块主电源对电池倒灌充电。

2.2 三线接口时序到底是怎么回事

DS1302的通信时序本质上是“一个使能信号 + 一串时钟脉冲 + 一位一位收发数据”。CE引脚拉高表示一次通信开始,拉低表示结束。SCLK的上升沿用于写入数据(主机发送命令或数据到DS1302),SCLK的下降沿则用于读出数据(DS1302把数据送到I/O线上)。

写字节的流程,以写命令0x80(写秒寄存器)为例:

  1. CE拉高,至少保持4微秒后再操作SCLK。
  2. 发送8位命令字节,每次SCLK上升沿前,先把I/O线上准备好这一位的电平(低位在前,LSB first)。
  3. SCLK产生一个上升沿,DS1302锁存该位。
  4. 重复直到命令字节发完。
  5. 紧接着再按同样的方式发送8位数据字节,也是低位在前。
  6. CE拉低,通信结束。

读字节的流程略有不同:

  1. CE拉高。
  2. 发送8位读命令(比如0x81表示读秒寄存器)。
  3. I/O线切换为输入模式。
  4. 每产生一个SCLK下降沿后,DS1302会在I/O线上输出一位数据。
  5. 主机在SCLK为低电平时读取I/O线的电平,连续读8位。
  6. CE拉低,通信结束。

这里最重要的一点是:命令字节的最低位bit0决定了是读还是写。0表示写,1表示读。例如,写秒寄存器命令是0x80,读秒寄存器命令就是0x81;写保护寄存器地址是0x8E,读就是0x8F。很多新手第一次写代码时把0x80和0x81搞混,读出来的时间永远不动,就是因为这个bit搞反了。

再强调一下为什么SCLK频率不能太快。DS1302的数据手册上写的最高时钟频率一般是2MHz,但那是极限值,实际设计时我建议控制在200kHz到500kHz之间。STM32主频72MHz,一个空循环延时几微秒很容易实现,没必要把时序逼到极限。时序越慢,抗干扰能力越强,调试越省心。

2.3 寄存器映射与BCD码转换

DS1302内部的时间寄存器地址从0x80开始,按秒、分、时、日、月、星期、年排列。控制寄存器0x8E的bit7是写保护位,默认是1。写保护不关闭的话,所有写入操作都会被拒绝,这是很多新手第一次调DS1302发现“时间怎么都写不进去”的头号原因。

寄存器命令字(写)命令字(读)数据范围说明
0x800x810~59bit7是时钟暂停位,1表示停振
0x820x830~59-
0x840x850~23或1~12取决于bit7的12/24小时制标志
0x860x871~31-
0x880x891~12-
星期0x8A0x8B1~71表示周日
0x8C0x8D0~99只有两位,需要自行加上2000
写保护0x8E0x8F0x00/0x80bit7=1禁止写

DS1302内部存储的并不是我们习惯的十进制数字,而是BCD码。比如秒寄存器里的0x59,BCD码是0b01011001,实际含义是“59秒”,而不是十进制59。BCD码的转换规则很好记:一个字节的高四位表示十位,低四位表示个位。所以从DS1302读出0x59后,要先转换成十进制再打印;写入时,要先把十进制转换成BCD码再写进寄存器。

这种设计虽然多了一步转换,但好处是寄存器里每一位都对应真实数字的每一位,用逻辑分析仪看数据时非常直观。项目中我封装了BCD2DECDEC2BCD两个函数,逻辑也简单:

uint8_t BCD2DEC(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } uint8_t DEC2BCD(uint8_t dec) { return ((dec / 10) << 4) + (dec % 10); }

3. 标准库版本:从GPIO到代码实现

3.1 标准外设库初始化与GPIO配置

标准库版本我用的是STM32F10x标准外设库V3.5.0,这是最经典的版本,网上资料最多。工程结构不复杂:一个main.c用来放主逻辑,一个ds1302.c/ds1302.h用来放DS1302驱动,串口初始化直接写在main里。

GPIO初始化这一步,标准库需要先声明GPIO_InitTypeDef结构体,然后逐个填充参数。PA0和PA1作为时钟输出,设置成推挽复用输出50MHz——注意这里其实不需要复用功能,因为我们是用软件模拟时序,不是用定时器的PWM输出,所以GPIO_Mode_Out_PP就够了。PA2作为数据线,初始化时先配成推挽输出,等到读数据的时候再动态切换成浮空输入或上拉输入。

void DS1302_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* SCLK - PA0, CE - PA1 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* I/O - PA2, 先配成输出模式 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); DS1302_SCLK_LOW(); DS1302_CE_LOW(); }

宏定义方面,为了方便换引脚,我把SCLK、CE、I/O的操作定义成宏:

#define DS1302_SCLK_PIN GPIO_Pin_0 #define DS1302_CE_PIN GPIO_Pin_1 #define DS1302_IO_PIN GPIO_Pin_2 #define DS1302_PORT GPIOA #define DS1302_SCLK_HIGH() GPIO_SetBits(DS1302_PORT, DS1302_SCLK_PIN) #define DS1302_SCLK_LOW() GPIO_ResetBits(DS1302_PORT, DS1302_SCLK_PIN) #define DS1302_CE_HIGH() GPIO_SetBits(DS1302_PORT, DS1302_CE_PIN) #define DS1302_CE_LOW() GPIO_ResetBits(DS1302_PORT, DS1302_CE_PIN) #define DS1302_IO_HIGH() GPIO_SetBits(DS1302_PORT, DS1302_IO_PIN) #define DS1302_IO_LOW() GPIO_ResetBits(DS1302_PORT, DS1302_IO_PIN) #define DS1302_IO_READ() GPIO_ReadInputDataBit(DS1302_PORT, DS1302_IO_PIN)

有个细节需要注意:GPIO_ReadInputDataBit读取的是输入数据寄存器(IDR),只有当引脚配置为输入模式时读到的才是引脚实际电平。如果引脚处于推挽输出模式,读IDR得到的是输出数据寄存器(ODR)的值,不是外部电平。所以读DS1302数据之前,必须先把PA2切成输入模式。

3.2 写字节和读字节的时序实现

标准库版本的时序函数,核心是DS1302_WriteByteDS1302_ReadByte两个函数。写字节的逻辑是:先准备好I/O线的电平,再产生一个SCLK上升沿,DS1302就在上升沿把数据锁存进去。

void DS1302_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x01) { DS1302_IO_HIGH(); } else { DS1302_IO_LOW(); } DS1302_SCLK_HIGH(); delay_us(2); DS1302_SCLK_LOW(); delay_us(2); dat >>= 1; } }

注意这里为什么先用dat & 0x01再右移,而不是每次都取dat & 0x80。因为DS1302要求低位先发,也就是LSB first。如果你习惯了SPI的MSB first,写代码时特别容易搞反。我当时第一次写的时候就是按高位先发,结果DS1302完全没反应。解决方法也很简单:发送之前把命令字节倒序,或者在循环里每次都判断dat & 0x01然后右移,二选一。

读字节的时序就跟写字节不太一样了。读的时候,数据不是由STM32主动发出去的,而是DS1302在SCLK下降沿把数据送到I/O线上。所以STM32要先拉低SCLK,等待DS1302输出数据稳定后读取,然后再拉高SCLK,再拉低,产生下一次下降沿。

uint8_t DS1302_ReadByte(void) { uint8_t i; uint8_t dat = 0; DS1302_IO_INPUT(); for (i = 0; i < 8; i++) { dat >>= 1; if (DS1302_IO_READ()) { dat |= 0x80; } DS1302_SCLK_HIGH(); delay_us(2); DS1302_SCLK_LOW(); delay_us(2); } DS1302_IO_OUTPUT(); return dat; }

这段代码的执行顺序是:先读当前I/O电平(此时SCLK是低电平,对应上一个下降沿之后的数据),然后拉高SCLK,再拉低SCLK,产生一个完整的时钟脉冲。因为DS1302在下降沿时更新I/O输出,所以读取的时机恰好是在数据稳定之后。逻辑上看起来有点绕,但不影响实际运行。

这里我用了DS1302_IO_INPUT()DS1302_IO_OUTPUT()两个宏,它们的实现涉及GPIO模式的重新配置:

#define DS1302_IO_INPUT() GPIO_Mode_Set(GPIOA, GPIO_Pin_2, GPIO_Mode_IPU) #define DS1302_IO_OUTPUT() GPIO_Mode_Set(GPIOA, GPIO_Pin_2, GPIO_Mode_Out_PP)

标准库里并没有直接的“切换模式”函数,所以一般自己封装一个:

void GPIO_Mode_Set(GPIO_TypeDef *port, uint16_t pin, GPIOMode_TypeDef mode) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = pin; GPIO_InitStructure.GPIO_Mode = mode; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(port, &GPIO_InitStructure); }

3.3 标准库串口底层配置与printf重定向

串口部分,我用的是USART1,默认波特率115200、8位数据、无校验、1位停止位。PA9是TX、PA10是RX,这个一定要记牢。USB转串口模块的RXD要接STM32的PA9,TXD接PA10,交叉连接。如果你用CH340模块,电脑上看到的是COM口;如果驱动没装好,打开串口助手会找不到设备,这也是很多人上来就卡住的地方。

void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); /* TX - PA9 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* RX - PA10 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_TX | USART_Mode_RX; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }

标准库中printf重定向比较容易,只要在工程里勾选MicroLIB,然后重写fputc函数即可。我一般这样写:

int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

为什么标准库要用MicroLIB?因为默认的C标准库printf实现很重,会占用大量Flash和RAM,而MicroLIB是ARM专门为嵌入式优化的精简版C库,它把printf里用不到的功能都裁剪掉了,Keil环境下勾选一下就能用,省时省力。如果你不想用printf,也可以直接用USART_SendData配合循环把字符串一个个发出去,但代码会冗余很多,我还是建议直接用printf,调试效率高。

3.4 完整主流程:读时间、打印、循环

标准库版本的主函数逻辑很清晰:初始化串口和DS1302,检测一下是否首次上电,如果是就写入一个默认时间,然后进入主循环,每隔1秒读取一次时间并打印。

int main(void) { DS1302_Time_TypeDef time; Delay_Init(); USART1_Init(); DS1302_GPIO_Init(); /* 首次上电或者需要校时的时候执行一次 */ DS1302_SetTime(0x25, 0x03, 0x15, 0x06, 0x12, 0x30, 0x00); /* 参数依次是:年(0x25=25)、月(0x03=3)、日(0x15=15) */ /* 星期(0x06=星期六)、时(0x12=12)、分(0x30=30)、秒(0x00=0) */ printf("DS1302 Test Start\r\n"); while (1) { DS1302_ReadTime(&time); printf("20%02d-%02d-%02d Week:%d %02d:%02d:%02d\r\n", BCD2DEC(time.year), BCD2DEC(time.month), BCD2DEC(time.date), BCD2DEC(time.week), BCD2DEC(time.hour), BCD2DEC(time.minute), BCD2DEC(time.second)); Delay_Ms(1000); } }

DS1302读时间的时候有个细节:最好连续读两次,第二次的数据才稳定。第一次读的时间可能刚好卡在时间更新点上,比如秒从59跳到00的瞬间,读到的数据会不连续。连续读两次取第二次,可以避免这种情况。这个技巧在数据手册上虽然没明说,但实际调试中确实会遇到“秒数跳变异常”的情况。

4. HAL库版本:一样的芯片,不同的玩法

4.1 HAL库GPIO初始化差异

HAL库版本的GPIO初始化,流程和标准库在思路上是一致的,但API不同。HAL库用GPIO_InitTypeDef结构体定义在stm32f1xx_hal_gpio.h里,配置时除了引脚号、模式、速度外,还需要指定Pull属性。

void DS1302_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* SCLK - PA0, CE - PA1 */ GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* I/O - PA2 */ GPIO_InitStruct.Pin = GPIO_PIN_2; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0 | GPIO_PIN_1, GPIO_PIN_RESET); }

看到区别了吗?HAL库的初始化结构体里多了Pull字段,用来配置上拉/下拉,而在标准库里这个配置是在GPIO模式里直接打包的。HAL库把“模式”“速度”“上下拉”拆成了三个独立字段,配置起来反而更灵活。另外HAL库函数名都是HAL_GPIO_前缀,且把置位/清零统一成HAL_GPIO_WritePin,通过第三个参数GPIO_PIN_SETGPIO_PIN_RESET区分。

在CubeMX里,配置PA0、PA1、PA2为GPIO_Output就可以,其他都不用动,生成代码后自己在main.c里调用DS1302_GPIO_Init即可。

4.2 用HAL库实现DS1302驱动

HAL库版本的驱动函数,在逻辑上跟标准库完全一样,只是底层GPIO操作换了函数名。这里我把完整驱动贴出来,你可以直接抄。

// ds1302.h #ifndef __DS1302_H #define __DS1302_H #include "stm32f1xx_hal.h" typedef struct { uint8_t year; uint8_t month; uint8_t date; uint8_t week; uint8_t hour; uint8_t minute; uint8_t second; } DS1302_Time_TypeDef; #define DS1302_SCLK_PIN GPIO_PIN_0 #define DS1302_CE_PIN GPIO_PIN_1 #define DS1302_IO_PIN GPIO_PIN_2 #define DS1302_PORT GPIOA #define SCLK_HIGH() HAL_GPIO_WritePin(DS1302_PORT, DS1302_SCLK_PIN, GPIO_PIN_SET) #define SCLK_LOW() HAL_GPIO_WritePin(DS1302_PORT, DS1302_SCLK_PIN, GPIO_PIN_RESET) #define CE_HIGH() HAL_GPIO_WritePin(DS1302_PORT, DS1302_CE_PIN, GPIO_PIN_SET) #define CE_LOW() HAL_GPIO_WritePin(DS1302_PORT, DS1302_CE_PIN, GPIO_PIN_RESET) #define IO_HIGH() HAL_GPIO_WritePin(DS1302_PORT, DS1302_IO_PIN, GPIO_PIN_SET) #define IO_LOW() HAL_GPIO_WritePin(DS1302_PORT, DS1302_IO_PIN, GPIO_PIN_RESET) #define IO_READ() HAL_GPIO_ReadPin(DS1302_PORT, DS1302_IO_PIN) uint8_t BCD2DEC(uint8_t bcd); uint8_t DEC2BCD(uint8_t dec); void DS1302_SetTime(uint8_t year, uint8_t month, uint8_t date, uint8_t week, uint8_t hour, uint8_t minute, uint8_t second); void DS1302_ReadTime(DS1302_Time_TypeDef *time); #endif
// ds1302.c #include "ds1302.h" #include "main.h" static void DS1302_DelayUs(uint16_t us) { __IO uint32_t count = us * 8; while (count--) { __NOP(); } } static void DS1302_IO_Input(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_PORT, &GPIO_InitStruct); } static void DS1302_IO_Output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DS1302_IO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DS1302_PORT, &GPIO_InitStruct); } static void DS1302_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x01) { IO_HIGH(); } else { IO_LOW(); } SCLK_HIGH(); DS1302_DelayUs(2); SCLK_LOW(); DS1302_DelayUs(2); dat >>= 1; } } static uint8_t DS1302_ReadByte(void) { uint8_t i; uint8_t dat = 0; DS1302_IO_Input(); for (i = 0; i < 8; i++) { dat >>= 1; if (IO_READ()) { dat |= 0x80; } SCLK_HIGH(); DS1302_DelayUs(2); SCLK_LOW(); DS1302_DelayUs(2); } DS1302_IO_Output(); return dat; } static void DS1302_WriteReg(uint8_t addr, uint8_t dat) { CE_HIGH(); DS1302_WriteByte(addr); DS1302_WriteByte(dat); CE_LOW(); } static uint8_t DS1302_ReadReg(uint8_t addr) { uint8_t dat; CE_HIGH(); DS1302_WriteByte(addr | 0x01); dat = DS1302_ReadByte(); CE_LOW(); return dat; } void DS1302_SetTime(uint8_t year, uint8_t month, uint8_t date, uint8_t week, uint8_t hour, uint8_t minute, uint8_t second) { DS1302_WriteReg(0x8E, 0x00); // 关闭写保护 DS1302_WriteReg(0x80, DEC2BCD(second)); DS1302_WriteReg(0x82, DEC2BCD(minute)); DS1302_WriteReg(0x84, DEC2BCD(hour)); DS1302_WriteReg(0x86, DEC2BCD(date)); DS1302_WriteReg(0x88, DEC2BCD(month)); DS1302_WriteReg(0x8A, DEC2BCD(week)); DS1302_WriteReg(0x8C, DEC2BCD(year)); DS1302_WriteReg(0x8E, 0x80); // 开启写保护 } void DS1302_ReadTime(DS1302_Time_TypeDef *time) { time->second = BCD2DEC(DS1302_ReadReg(0x81)); time->minute = BCD2DEC(DS1302_ReadReg(0x83)); time->hour = BCD2DEC(DS1302_ReadReg(0x85)); time->date = BCD2DEC(DS1302_ReadReg(0x87)); time->month = BCD2DEC(DS1302_ReadReg(0x89)); time->week = BCD2DEC(DS1302_ReadReg(0x8B)); time->year = BCD2DEC(DS1302_ReadReg(0x8D)); }

4.3 HAL库串口初始化与print重定向

HAL库工程一般通过CubeMX生成,串口初始化代码已经在usart.c里自动生成了。默认配置是115200-8-N-1,不需要手动改。有一点要注意,CubeMX生成的MX_USART1_UART_Init里,huart1结构体是全局变量,在main.husart.h里extern声明一下,其他文件才能用。

HAL库的printf重定向和标准库有一个明显区别:如果你直接用Keil的MicroLIB重写fputc,逻辑上跟标准库一样,但底层建议用HAL_UART_Transmit而不是操作寄存器。这样可以保证跟HAL库的状态机同步,避免出现“发送到一半,HAL库状态被其他操作打断”的问题。

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

这里0xFFFF是超时时间,单位是毫秒。我有时候看有人把它写成100或1000,这其实取决于你的波特率。115200波特率下发送一个字节大概要87微秒,100毫秒的超时时间非常充裕了。但如果你在实际项目中波特率很低(比如9600),或者串口正在被中断抢占,超时时间就要给长一点,不然HAL_UART_Transmit可能还没发完就返回超时错误了。

如果你不想用printf,HAL库版本也可以直接调用HAL_UART_Transmit发送一个字符串数组:

void DS1302_PrintTime(DS1302_Time_TypeDef *time) { char buf[64]; int len; sprintf(buf, "20%02d-%02d-%02d Week:%d %02d:%02d:%02d\r\n", time->year, time->month, time->date, time->week, time->hour, time->minute, time->second); len = strlen(buf); HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 1000); }

这种方式比printf更可控,因为不会受重定向和编译器C库的影响,尤其在IAR、GCC等其他工具链上更通用。

4.4 主循环与烧录验证

HAL库版本的主函数里,如果要设置时间,直接调用DS1302_SetTime,传入的是十进制数。比如设置2025年3月15日12点30分:

DS1302_SetTime(25, 3, 15, 6, 12, 30, 0);

需要注意的是,我在DS1302_SetTime内部已经把十进制转成BCD码再写入寄存器,所以外部调用时不用自己转。而在标准库版本里,DS1302_SetTime的参数类型是BCD码,这点在切换库的时候容易搞混。标准库版本如果想传十进制,最好也在函数内部加上DEC2BCD转换,统一接口风格。

编译烧录后,打开串口助手,选择对应的COM口(CH340模块一般显示CH340 COMx),波特率选115200,就能看到每一秒刷新的时间。如果串口助手一片空白,首先检查串口驱动的波特率有没有选对,其次检查USB转串口的RXD和TXD有没有接反,最后看下电路板是不是共地了。这三个问题占了串口不通的九成原因。

5. 常见问题与排查实录

5.1 DS1302时间读不出来或全是0xFF

这是最常见的故障。0xFF表示读到的数据全是高电平,说明I/O线在读取时根本没有被DS1302拉低。排查步骤:

  1. 检查CE引脚是否真正拉高了。很多人在宏定义里把CE和SCLK的引脚搞反,比如SCLK接到了PA0、CE接到了PA1,但代码里定义的宏刚好相反。
  2. 检查I/O引脚切换输入模式的代码是否执行了。如果一直保持输出模式拉高,DS1302输出低电平时会被STM32的输出强驱动顶住,根本读不到低电平。
  3. 用示波器或逻辑分析仪看SCLK和I/O。正常读时序下,SCLK应该是连续的方波脉冲,I/O上应该能看到数据翻转。如果SCLK没有波形,说明代码根本没执行到读函数,先查初始化顺序。

5.2 时间能写入但掉电不保持

写入后如果是正常运行,时间能走,但拔掉主电源再上电,时间就回到初始值。这种情况基本是备用电池没接对。DS1302模块上有两个VCC:VCC1接备用电池,VCC2接主电源。芯片内部的电源选择逻辑是:如果VCC2电压比VCC1高0.2V以上,就用VCC2供电;否则切换到VCC1供电。所以如果你把电池接到VCC2上,那肯定是错的。

另外要注意,CR2032是3V纽扣电池,如果你给VCC1接的是3.3V,虽然芯片不会坏,但电池会一直处于被“压住”的状态,寿命会很短。正确做法是VCC1接电池正极,电池负极接GND。

5.3 写入不生效、设置的时间总是不变

如果读取出来的时间能走,但你想重新设置时间,怎么写都写不进去,十有八九是写保护没有关闭。DS1302的控制寄存器0x8E的bit7是WP位,默认上电后是1,即写保护开启。写入时间寄存器之前,必须先向0x8E写入0x00关闭写保护,写完所有时间后再向0x8E写入0x80开启写保护。有人在关闭写保护后忘记重新开启,这会导致后续如果有其他代码意外写入,时间会被打乱。

5.4 HAL库和标准库串口打印乱码

乱码八成是波特率不一致。电脑串口助手设置的是115200,但STM32实际跑的是9600,那必然乱码。还有个隐蔽问题:HAL库版本用printf重定向后,参考代码里用了huart1这个全局变量,但有些人CubeMX生成的时候把串口名字改成了huart2huart3fputc里写的还是huart1,链接就会报错。解决办法是把CubeMX生成的串口名和fputc里的保持一致。

如果波特率设置没问题,还是乱码,检查一下CH340驱动是否安装正确。Win10/11系统一般能自动识别CH340,但有些精简版系统需要手动安装。串口助手里如果设备列表里连“CH340”字样都看不到,那就是驱动没装好。

5.5 秒数跳动异常或时间偶发漂移

时间大方向是对的,但秒数偶尔跳变,比如从32直接跳到34,中间少了一秒。这种情况多半是读操作撞上了DS1302的内部更新时间。DS1302在每个秒的边界附近会更新所有寄存器,如果此时正好在读数据,读到的内容可能不一致。解决办法就是读两次,第二次的数据作为最终结果,或者读完后跟上次的值对比,如果变化超过2就再读一次。我上文中提到的“连续读两次取第二次”就是这个原因。

5.6 换到HAL库后DS1302完全不动

如果HAL库版本的代码移植后DS1302完全无反应,大概率是GPIO时钟没有打开。标准库用RCC_APB2PeriphClockCmd,HAL库用__HAL_RCC_GPIOA_CLK_ENABLE(),二者用法不同。另一个容易忽略的是HAL库的HAL_GPIO_Init和标准库的GPIO_Init有个差异:HAL库在初始化时会自动把引脚上拉/下拉配置进去,而这会改变该引脚上电瞬间的电平状态。如果CE脚被配置成下拉,而上电时DS1302要求CE为低,实际上就没问题;但如果CE被配置成上拉,那上电瞬间DS1302可能误以为被选中了,出现不可预期的电平跳变。所以你看到CE引脚配置成GPIO_PULLDOWN,是正常的,不用改。

5.7 常见问题速查表

症状可能原因检查方法
读数据全是0xFFI/O方向没切换确认读命令后调用了IO输入模式
时间写不进去写保护未关闭写入前向0x8E写0x00
掉电时间丢失VCC1电池未接或接反万用表量VCC1引脚电压,应有3V左右
串口无输出TX/RX接反或未共地确认PA9接RXD,PA10接TXD
串口乱码波特率不匹配检查串口助手波特率是否为115200
秒数偶发跳变读取撞上内部更新连续读两次取第二次
换HAL库后无反应GPIO时钟未使能确认__HAL_RCC_GPIOA_CLK_ENABLE()已调用

6. 一点实操心得

整套跑通之后,我最大的体会是:DS1302这种老芯片虽然不像现在的I2C/RTC芯片那样自带复杂功能,但它的时序逻辑非常典型,特别适合用来理解“软件模拟时序”的核心思路。你把这个芯片调通了,以后遇到DS18B20、DHT11、甚至驱动一些老式的LCD屏,都会觉得得心应手。

另外我建议你把标准库和HAL库两个版本都编译跑一遍,不要只挑一个看代码。两个版本的差异点集中在GPIO初始化和串口发送这两个地方,其他地方完全一样。当你亲手把标准库的GPIO_SetBits改成HAL库的HAL_GPIO_WritePin后,你就会发现HAL库其实没有想象中那么神秘,它只是把寄存器的操作封装得更彻底了一些。

一个小技巧:如果你手头有逻辑分析仪,哪怕是最便宜的8通道24MHz那种,把SCLK和I/O两根线夹上去看时序,比对着数据手册空想高效得多。写字节的时候重点看SCLK上升沿前后I/O的电平变化,读字节的时候重点看SCLK下降沿后I/O的电平。一眼就能定位是时序顺序问题还是方向切换问题。没有逻辑分析仪的话,也可以用示波器看,只是示波器触发时间长一点,看单次通信波形也够用。

最后再提醒一句:给DS1302设置时间时,记得确认模块上的晶振是不是32.768kHz。有些劣质模块用的是普通晶振,频率不准,走时误差会非常大,一天慢好几秒那种。这个属于硬件层面最不好排查的坑,但遇到时间越走越偏的情况,优先怀疑晶振,换一个好的模块马上就好。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询