STM32驱动DS1302实时时钟:标准库与HAL库双版本实现与串口显示 简介本资源是面向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_SetBits、GPIO_ResetBits、GPIO_ReadInputDataBitHAL库变成了HAL_GPIO_WritePin、HAL_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.3VVCC1CR2032正极备用电池掉电保时间GNDGND共地CEPA1片选使能也叫RSTSCLKPA0串行时钟由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的上升沿用于写入数据主机发送命令或数据到DS1302SCLK的下降沿则用于读出数据DS1302把数据送到I/O线上。写字节的流程以写命令0x80写秒寄存器为例CE拉高至少保持4微秒后再操作SCLK。发送8位命令字节每次SCLK上升沿前先把I/O线上准备好这一位的电平低位在前LSB first。SCLK产生一个上升沿DS1302锁存该位。重复直到命令字节发完。紧接着再按同样的方式发送8位数据字节也是低位在前。CE拉低通信结束。读字节的流程略有不同CE拉高。发送8位读命令比如0x81表示读秒寄存器。I/O线切换为输入模式。每产生一个SCLK下降沿后DS1302会在I/O线上输出一位数据。主机在SCLK为低电平时读取I/O线的电平连续读8位。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/0x80bit71禁止写DS1302内部存储的并不是我们习惯的十进制数字而是BCD码。比如秒寄存器里的0x59BCD码是0b01011001实际含义是“59秒”而不是十进制59。BCD码的转换规则很好记一个字节的高四位表示十位低四位表示个位。所以从DS1302读出0x59后要先转换成十进制再打印写入时要先把十进制转换成BCD码再写进寄存器。这种设计虽然多了一步转换但好处是寄存器里每一位都对应真实数字的每一位用逻辑分析仪看数据时非常直观。项目中我封装了BCD2DEC和DEC2BCD两个函数逻辑也简单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_WriteByte和DS1302_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的PA9TXD接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); /* 参数依次是年(0x2525)、月(0x033)、日(0x1515) */ /* 星期(0x06星期六)、时(0x1212)、分(0x3030)、秒(0x000) */ 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_SET或GPIO_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.h或usart.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可能还没发完就返回超时错误了。如果你不想用printfHAL库版本也可以直接调用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拉低。排查步骤检查CE引脚是否真正拉高了。很多人在宏定义里把CE和SCLK的引脚搞反比如SCLK接到了PA0、CE接到了PA1但代码里定义的宏刚好相反。检查I/O引脚切换输入模式的代码是否执行了。如果一直保持输出模式拉高DS1302输出低电平时会被STM32的输出强驱动顶住根本读不到低电平。用示波器或逻辑分析仪看SCLK和I/O。正常读时序下SCLK应该是连续的方波脉冲I/O上应该能看到数据翻转。如果SCLK没有波形说明代码根本没执行到读函数先查初始化顺序。5.2 时间能写入但掉电不保持写入后如果是正常运行时间能走但拔掉主电源再上电时间就回到初始值。这种情况基本是备用电池没接对。DS1302模块上有两个VCCVCC1接备用电池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生成的时候把串口名字改成了huart2或huart3fputc里写的还是huart1链接就会报错。解决办法是把CubeMX生成的串口名和fputc里的保持一致。如果波特率设置没问题还是乱码检查一下CH340驱动是否安装正确。Win10/11系统一般能自动识别CH340但有些精简版系统需要手动安装。串口助手里如果设备列表里连“CH340”字样都看不到那就是驱动没装好。5.5 秒数跳动异常或时间偶发漂移时间大方向是对的但秒数偶尔跳变比如从32直接跳到34中间少了一秒。这种情况多半是读操作撞上了DS1302的内部更新时间。DS1302在每个秒的边界附近会更新所有寄存器如果此时正好在读数据读到的内容可能不一致。解决办法就是读两次第二次的数据作为最终结果或者读完后跟上次的值对比如果变化超过2就再读一次。我上文中提到的“连续读两次取第二次”就是这个原因。5.6 换到HAL库后DS1302完全不动如果HAL库版本的代码移植后DS1302完全无反应大概率是GPIO时钟没有打开。标准库用RCC_APB2PeriphClockCmdHAL库用__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接RXDPA10接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。有些劣质模块用的是普通晶振频率不准走时误差会非常大一天慢好几秒那种。这个属于硬件层面最不好排查的坑但遇到时间越走越偏的情况优先怀疑晶振换一个好的模块马上就好。本文还有配套的精品资源点击获取