简介:本资源是面向嵌入式初学者与STM32开发者的DS1302实时时钟驱动实战项目,聚焦于在主流低成本MCU STM32F103C8T6上实现高精度时间管理功能,解决无内置RTC备份域或需独立高稳定性时钟源的典型工程需求。压缩包含200个文件,以35个.c源文件、36个.h头文件构成核心驱动与HAL适配层,37个.o与.d文件支持编译构建,另有uvprojx工程配置、axf/hex可执行镜像、map调试信息及stm32f10x系列外设驱动(如RCC、TIM、I2C等)完整集成,整体6.84MB,结构规范,便于直接导入Keil MDK编译运行。已有604人学习下载,提供从GPIO模拟三线协议、DS1302寄存器读写、时间设置/读取函数到低功耗模式配置的全流程代码实现,附带清晰的时序控制逻辑与电源稳定性处理说明,是掌握嵌入式外设驱动开发与时间系统设计的优质入门范例。
1. 项目缘起:为什么是STM32F103C8T6与DS1302的组合?
如果你手头有一块STM32F103C8T6的最小系统板,想给它加上一个掉电也能走时的“心脏”,DS1302时钟芯片几乎是绕不开的经典选择。这个组合在电子爱好者、学生项目和许多小型嵌入式系统中非常常见,原因很简单:成本极低、接线直观、驱动成熟。STM32F103C8T6,也就是我们常说的“蓝桥杯”板子或者“核心板”,以其极高的性价比和丰富的资源(72MHz主频、64KB Flash、20KB RAM、丰富的外设)成为入门和中等复杂度项目的首选。而DS1302这颗老牌的实时时钟芯片,虽然通信速率慢(SPI三线制)、精度一般(典型误差±2ppm,即每月约±5分钟),但它自带32.768kHz晶振和充电电路,最关键的是,它有一个小小的31字节的RAM,在完全掉电的情况下,依靠一颗纽扣电池就能维持时钟运行数年。
我最初接触这个组合是为了做一个带时间显示的温湿度数据记录器。项目要求不高,但必须保证设备断电重启后,时间能接着走,而不是回到某个固定的起始点。市面上虽然有更精准、接口更快的RTC芯片(如DS3231,I2C接口,内置温补),但对于大多数“够用就行”的场景,DS1302+STM32F103C8T6的方案,无论是从硬件成本(芯片加晶振不到2元)还是软件复杂度(自己写个模拟SPI驱动就行)来看,都是最务实的选择。很多新手觉得驱动DS1302麻烦,其实拆解开来,无非就是GPIO模拟时序、读写寄存器、处理BCD码这三件事。接下来,我就结合一个完整的项目实例,从硬件连接到软件驱动,再到实际应用中的坑点,把这件事彻底讲透。
2. 硬件连接:不仅仅是接对线那么简单
拿到DS1302模块和STM32F103C8T6最小系统板,第一步就是连线。DS1302采用三线接口:CE(片选,有的也叫RST)、I/O(数据线)、SCLK(时钟线),另外还有VCC、GND和VCC1(主电源)、VCC2(备份电池)。接线看起来简单,但细节决定成败。
2.1 引脚定义与连接方案
首先明确双方引脚:
DS1302模块端:
VCC: 接主电源,通常3.3V或5V。STM32F103C8T6的IO口是3.3V电平,所以模块VCC接3.3V更安全。GND: 接地。CLK(SCLK): 时钟信号线。DAT(I/O): 双向数据线。RST(CE): 片选/复位线。VCC1: 备用电池正极(通常接3V纽扣电池)。VCC2: 主电源正极(通常与VCC内部相连,模块上可能已处理)。
STM32F103C8T6端:
- 任意三个GPIO口,用于模拟SPI时序。我习惯用
PA4作为CE,PA5作为SCLK,PA6作为I/O。选择这些引脚没有特殊要求,只要不是用于特殊功能(如调试口)的普通IO即可。
- 任意三个GPIO口,用于模拟SPI时序。我习惯用
连接关系如下表所示:
| STM32F103C8T6引脚 | 连接至 DS1302模块引脚 | 备注 |
|---|---|---|
| 3.3V | VCC | 主电源 |
| GND | GND | 共地 |
| PA4 (GPIO Output) | RST (CE) | 片选,高电平有效 |
| PA5 (GPIO Output) | CLK (SCLK) | 时钟线 |
| PA6 (GPIO Output/Input) | DAT (I/O) | 双向数据线,需配置为开漏输出或读写时切换模式 |
注意:DS1302的数据线
I/O是双向的。一种常见的驱动写法是,将STM32对应的GPIO(如PA6)配置为开漏输出模式,并外接一个4.7kΩ~10kΩ的上拉电阻到3.3V。这样,当STM32输出高电平时,实际由外部上拉电阻拉高,STM32可以释放总线(输出高阻态),此时DS1302才能将数据拉低,实现读操作。如果不方便外接上拉电阻,也可以在软件上采用“读写时动态切换GPIO模式”的方法:写数据时设为推挽输出,读数据前设为浮空输入并读取引脚电平。后者对代码要求稍高,但省了一个电阻。
2.2 电源与电池的讲究
这是第一个容易踩坑的地方。DS1302的VCC1和VCC2引脚关系到掉电保持。
- 正常供电时:
VCC(主电源)有效,DS1302由VCC供电,并同时给备份电池(如果可充电)进行涓流充电。 - 掉电时:当
VCC电压低于VCC1(电池电压)时,芯片自动切换到由备份电池供电,维持时钟运行和RAM数据。
关键点1:电池选择。DS1302支持可充电电池(如3.6V可充镍镉电池)或不可充电电池(如3V锂锰纽扣电池CR2032)。模块上通常有一个焊盘或跳线来选择充电功能。如果你用的是普通的CR2032不可充电电池,务必确保模块上的充电电路被禁用(断开相关跳线),否则长时间上电可能会对电池过充,导致电池损坏甚至发生危险。我个人的习惯是,对于一次性项目,直接使用不可充电电池并禁用充电,最简单可靠。
关键点2:上电时序。DS1302的数据手册强调,在VCC≥2.0V之前,CE和SCLK必须为低电平。对于STM32,这意味着在初始化GPIO时,应先将其配置为输出低电平,然后再给DS1302模块上电(或者确保STM32先于DS1302完成初始化)。虽然很多情况下不严格遵守也不会立即出问题,但这是一个良好的设计习惯,能避免一些玄学问题。
3. 驱动编写:从时序图到可用的代码
硬件连好后,核心就是软件驱动。DS1302的通信协议是一种特殊的SPI三线协议,每次传输以字节为单位,先低位(LSB)后高位(MSB)。驱动函数主要围绕两个核心操作:写一个字节、读一个字节。
3.1 底层时序模拟
首先,我们需要根据DS1302的数据手册定义几个最基本的延时和GPIO操作函数。DS1302对时序要求并不严苛,在STM32的72MHz主频下,用简单的空循环延时即可满足。关键是要理解时序图中的建立时间(tCC、tCDH等)和保持时间。
// ds1302.h 部分定义 #define DS1302_CE_PIN GPIO_Pin_4 #define DS1302_CE_PORT GPIOA #define DS1302_SCLK_PIN GPIO_Pin_5 #define DS1302_SCLK_PORT GPIOA #define DS1302_IO_PIN GPIO_Pin_6 #define DS1302_IO_PORT GPIOA // 简单的微秒级延时函数,基于SysTick或循环实现 void DS1302_Delay_us(uint32_t us); // GPIO置高/置低宏定义 #define DS1302_CE_H() GPIO_SetBits(DS1302_CE_PORT, DS1302_CE_PIN) #define DS1302_CE_L() GPIO_ResetBits(DS1302_CE_PORT, DS1302_CE_PIN) #define DS1302_SCLK_H() GPIO_SetBits(DS1302_SCLK_PORT, DS1302_SCLK_PIN) #define DS1302_SCLK_L() GPIO_ResetBits(DS1302_SCLK_PORT, DS1302_SCLK_PIN) #define DS1302_IO_H() GPIO_SetBits(DS1302_IO_PORT, DS1302_IO_PIN) #define DS1302_IO_L() GPIO_ResetBits(DS1302_IO_PORT, DS1302_IO_PIN) #define DS1302_IO_READ() GPIO_ReadInputDataBit(DS1302_IO_PORT, DS1302_IO_PIN)然后是核心的字节读写函数。这里以“开漏输出+上拉电阻”的硬件接法为例,I/O线始终配置为开漏输出模式。
// 向DS1302写入一个字节 (命令或数据) void DS1302_WriteByte(uint8_t dat) { uint8_t i; // 将GPIO配置为推挽输出模式(如果之前是输入) GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DS1302_IO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DS1302_IO_PORT, &GPIO_InitStructure); for(i=0; i<8; i++) { DS1302_SCLK_L(); // 时钟线拉低,为数据变化提供稳定窗口 DS1302_Delay_us(2); // 先低位后高位 if(dat & 0x01) { DS1302_IO_H(); // 输出1 (实际依靠上拉电阻) } else { DS1302_IO_L(); // 输出0 (MCU主动拉低) } DS1302_Delay_us(2); DS1302_SCLK_H(); // 时钟上升沿,DS1302采样数据 DS1302_Delay_us(2); dat >>= 1; // 准备下一位 } // 最后一个时钟下降沿后,保持IO状态 DS1302_SCLK_L(); } // 从DS1302读取一个字节 uint8_t DS1302_ReadByte(void) { uint8_t i, dat = 0; // 先将GPIO配置为开漏输出,并输出高电平(释放总线) GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DS1302_IO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DS1302_IO_PORT, &GPIO_InitStructure); DS1302_IO_H(); // 释放总线,由上拉电阻拉高 for(i=0; i<8; i++) { dat >>= 1; // 先右移,最后读到的位会在最高位 DS1302_SCLK_H(); // 先产生一个时钟上升沿(对于读操作,是DS1302输出数据的触发边沿) DS1302_Delay_us(2); // 将GPIO切换为输入模式,读取引脚电平 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(DS1302_IO_PORT, &GPIO_InitStructure); DS1302_Delay_us(1); // 小延时等待电平稳定 if(DS1302_IO_READ() == Bit_SET) { dat |= 0x80; // 如果读到高电平,则将最高位置1 } // 读完后,将GPIO切回开漏输出模式,为下一个时钟周期做准备 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_Init(DS1302_IO_PORT, &GPIO_InitStructure); DS1302_SCLK_L(); // 时钟下降沿 DS1302_Delay_us(2); } return dat; }注意:上述读函数中动态切换GPIO模式的方法,是为了兼容没有外接上拉电阻的情况。如果硬件上已经接了可靠的上拉电阻,并且IO口配置为开漏输出模式,那么读数据时,可以不用切换模式,直接读取输入数据寄存器(
GPIOx->IDR)即可,因为开漏模式下,当MCU输出高电平时,引脚实际处于高阻态,可以读取外部电平。但为了代码的通用性和可靠性,动态切换模式是更稳妥的做法。
3.2 寄存器操作与BCD码转换
DS1302的时钟日历信息(秒、分、时、日、月、星期、年)存储在特定的寄存器中,这些寄存器地址的最高位(bit7)固定为1,并且读写操作是通过地址命令区分的:读命令=地址|0x01,写命令=地址|0x80。例如,秒寄存器的地址是0x80,写秒寄存器就是0x80,读秒寄存器就是0x81。
更重要的是,DS1302存储的时间数据是BCD码(Binary-Coded Decimal)。例如,十进制数23,在BCD码中表示为0x23(二进制0010 0011)。所以我们需要编写BCD码与十进制数相互转换的函数。
// BCD码转十进制数 uint8_t BCD2DEC(uint8_t bcd) { return ((bcd>>4)*10) + (bcd&0x0F); } // 十进制数转BCD码 uint8_t DEC2BCD(uint8_t dec) { return ((dec/10)<<4) | (dec%10); }有了这些基础,我们就可以封装对DS1302特定寄存器的读写函数了。
// 向DS1302的某个寄存器写入数据 void DS1302_WriteReg(uint8_t reg_addr, uint8_t dat) { DS1302_CE_H(); // 片选有效 DS1302_WriteByte(reg_addr); // 写入寄存器地址(写命令) DS1302_WriteByte(dat); // 写入数据 DS1302_CE_L(); // 片选无效,结束传输 } // 从DS1302的某个寄存器读取数据 uint8_t DS1302_ReadReg(uint8_t reg_addr) { uint8_t dat; DS1302_CE_H(); // 片选有效 DS1302_WriteByte(reg_addr | 0x01); // 写入寄存器地址(读命令) dat = DS1302_ReadByte(); // 读取数据 DS1302_CE_L(); // 片选无效 return dat; }4. 初始化与时间设置:避开写保护陷阱
拿到一个新的DS1302,或者第一次使用,第一步是初始化并设置正确的时间。这里有一个至关重要的寄存器:写保护寄存器(地址0x8E)。它的最高位(bit7)是写保护位(WP),当WP=1时,禁止对任何时钟/日历寄存器进行写操作。所以,在设置时间前,必须先清除写保护(向0x8E写入0x00)。
4.1 完整的初始化与设置流程
一个健壮的初始化流程应该包括以下步骤:
- GPIO初始化:配置
CE、SCLK为推挽输出,并初始化为低电平;配置I/O为开漏输出并上拉(或按需配置)。 - 关闭写保护:向写保护寄存器(0x8E)写入0x00。
- 停振与设置时间:DS1302的秒寄存器(0x80)的最高位(CH)是时钟停止位。CH=1,振荡器停止;CH=0,振荡器运行。在设置时间时,应先将CH位置1(停振),设置完所有时间寄存器后,再将CH位清0(起振)。这样可以避免在设置过程中时钟走动导致的数据错乱。
- 开启写保护(可选):时间设置完成后,可以向0x8E写入0x80,重新开启写保护,防止程序跑飞意外修改时间。
下面是一个设置2024年5月27日星期一,下午15点30分45秒的示例函数:
typedef struct { uint8_t year; // 年 (00-99) uint8_t month; // 月 (01-12) uint8_t day; // 日 (01-31) uint8_t week; // 星期 (01-07, 01=周日,02=周一,依此类推,DS1302定义) uint8_t hour; // 时 (00-23) uint8_t min; // 分 (00-59) uint8_t sec; // 秒 (00-59) } DS1302_TimeTypeDef; void DS1302_SetTime(DS1302_TimeTypeDef *time) { // 1. 关闭写保护 DS1302_WriteReg(0x8E, 0x00); // 2. 停振 (秒寄存器CH位置1) DS1302_WriteReg(0x80, 0x80); // 写入0x80,即1000 0000,CH=1 // 3. 依次设置时间寄存器 (注意寄存器地址和BCD码转换) DS1302_WriteReg(0x82, DEC2BCD(time->sec)); // 秒,注意:此时CH位已被我们设为1,这里写入的秒值应不含CH位 // 更正:上面有误。我们已通过0x80寄存器停振,设置秒时应使用0x80地址,并包含CH位。 // 更标准的做法是:先读秒寄存器,保留CH位,再组合新的秒值写入。 uint8_t sec_reg = DS1302_ReadReg(0x81); // 读秒寄存器 sec_reg &= 0x80; // 保留CH位 sec_reg |= DEC2BCD(time->sec); // 加上新的秒值(BCD) DS1302_WriteReg(0x80, sec_reg); // 写入秒寄存器(包含CH控制) DS1302_WriteReg(0x84, DEC2BCD(time->min)); // 分 // 时寄存器(0x86)的bit6是12/24小时制选择,0=24小时制。我们通常用24小时制。 DS1302_WriteReg(0x86, DEC2BCD(time->hour) & 0x3F); // 确保bit6为0 DS1302_WriteReg(0x8A, DEC2BCD(time->week) & 0x07); // 星期,低3位有效 DS1302_WriteReg(0x88, DEC2BCD(time->day)); // 日 DS1302_WriteReg(0x8C, DEC2BCD(time->month)); // 月 DS1302_WriteReg(0x8E, DEC2BCD(time->year)); // 年 // 4. 启动振荡器 (清除秒寄存器的CH位) sec_reg = DS1302_ReadReg(0x81); // 再次读取 sec_reg &= ~0x80; // 清除CH位 (bit7) DS1302_WriteReg(0x80, sec_reg); // 5. (可选)重新开启写保护 // DS1302_WriteReg(0x8E, 0x80); }踩坑记录1:秒寄存器的CH位。这是最容易出错的地方。很多简单的驱动代码在设置时间时,直接向秒寄存器写入
DEC2BCD(sec),这可能会意外地将CH位清0(如果DEC2BCD(sec)小于128),导致时钟在我们还没设置完其他寄存器时就开始走动。更安全的做法是像上面那样,先读取原寄存器值,保留CH位,再合并新的秒值。
4.2 读取时间与数据处理
读取时间就相对简单了,只需要依次读取各个寄存器,并将BCD码转换为十进制即可。同样需要注意时寄存器的12/24小时制位。
void DS1302_GetTime(DS1302_TimeTypeDef *time) { uint8_t temp; temp = DS1302_ReadReg(0x81); // 读秒 time->sec = BCD2DEC(temp & 0x7F); // 忽略CH位 time->min = BCD2DEC(DS1302_ReadReg(0x83) & 0x7F); // 读分 temp = DS1302_ReadReg(0x85); // 读时 if(temp & 0x40) { // 12小时制 // 处理12小时制,略复杂,一般建议设置为24小时制 time->hour = BCD2DEC(temp & 0x1F); if(temp & 0x20) time->hour += 12; // PM位 } else { // 24小时制 time->hour = BCD2DEC(temp & 0x3F); } time->week = BCD2DEC(DS1302_ReadReg(0x8B) & 0x07); // 读星期 time->day = BCD2DEC(DS1302_ReadReg(0x87) & 0x3F); // 读日 time->month= BCD2DEC(DS1302_ReadReg(0x89) & 0x1F); // 读月 time->year = BCD2DEC(DS1302_ReadReg(0x8D)); // 读年 }5. 实战进阶:RAM使用、涓流充电与精度校准
基本的读写时间功能实现后,DS1302还有几个高级功能值得挖掘,这些功能在特定场景下非常有用。
5.1 使用内部31字节RAM
DS1302除了时钟寄存器,还有31字节的通用静态RAM,地址从0xC0到0xFC(偶数地址写,奇数地址读)。这块RAM在电池供电下也能保持数据,可以用来存储一些关键的配置参数、设备状态或者日志索引。例如,在一个数据记录器中,可以用它来存储当前已存储的数据文件编号、系统运行标志位等。
// 向DS1302的RAM写入数据 // ram_addr: RAM地址 (0-30,对应31个字节) void DS1302_WriteRAM(uint8_t ram_addr, uint8_t dat) { if(ram_addr > 30) return; // 地址检查 DS1302_WriteReg(0xC0 + (ram_addr<<1), dat); // 地址计算:0xC0 + addr*2 } // 从DS1302的RAM读取数据 uint8_t DS1302_ReadRAM(uint8_t ram_addr) { if(ram_addr > 30) return 0; return DS1302_ReadReg(0xC1 + (ram_addr<<1)); // 地址计算:0xC1 + addr*2 }注意:RAM的地址映射是每个字节对应两个命令地址(写和读),且是连续的。写入地址从0xC0开始(0xC0, 0xC2, 0xC4...),读取地址从0xC1开始(0xC1, 0xC3, 0xC5...)。上面的代码通过左移一位(乘以2)来简化计算。
5.2 涓流充电寄存器配置
涓流充电寄存器(地址0x90)控制着DS1302如何用主电源VCC给备份电池充电。这个功能仅当使用可充电电池时才需要启用。寄存器格式为:TCS(bits 7-4) +DS(bits 3-2) +RS(bits 1-0)。
TCS: 涓流充电选择。只有1010这个二进制模式才能使能充电器,其他任何值都会禁用充电器。DS: 二极管选择。01表示一个二极管(压降约0.7V),10表示两个二极管串联(压降约1.4V)。RS: 电阻选择。00=2kΩ,01=4kΩ,10=8kΩ,11=1kΩ。电阻值决定了充电电流的大小。
例如,要配置为:使能充电,使用一个二极管,选择2kΩ电阻。则计算如下:TCS=1010,DS=01,RS=00,合并为一个字节:1010 0100=0xA4。
// 启用涓流充电 (假设使用一个二极管和2kΩ电阻) void DS1302_EnableTrickleCharge(void) { DS1302_WriteReg(0x8E, 0x00); // 关闭写保护 DS1302_WriteReg(0x90, 0xA4); // 写入配置:1010 0100 // DS1302_WriteReg(0x8E, 0x80); // 可选:重新开启写保护 } // 禁用涓流充电 void DS1302_DisableTrickleCharge(void) { DS1302_WriteReg(0x8E, 0x00); DS1302_WriteReg(0x90, 0x00); // 任何非0xAx的模式都会禁用充电 // DS1302_WriteReg(0x8E, 0x80); }重要警告:如果你的备份电池是不可充电的(如CR2032),绝对不要启用涓流充电,否则会损坏电池并可能引发安全问题。在初始化DS1302后,最好显式地写一次0x00到0x90寄存器以确保充电功能关闭。
5.3 精度校准与长期误差补偿
DS1302是低精度RTC,其精度由外部32.768kHz晶振决定,受温度影响较大。如果你对时间精度有较高要求(比如一天误差希望控制在几秒内),就需要校准。
软件校准法:这是最常用的方法。用一个高精度的时间源(如GPS模块、NTP网络时间、手机APP)作为参考,连续运行设备一段时间(比如一周),记录DS1302的累计误差秒数。然后计算出一个“每日误差”值(秒/天)。在每次读取时间后,在软件中根据这个误差值进行加减补偿。你甚至可以将这个校准系数存储在DS1302的RAM中,这样掉电后也不会丢失。
// 假设通过测量,发现该DS1302每天快5秒 #define TIME_ERROR_PER_DAY 5 // 单位:秒/天 (正值为快,负值为慢) // 在获取时间后,进行软件补偿 void DS1302_GetTimeWithCompensation(DS1302_TimeTypeDef *time) { static uint32_t last_compensate_tick = 0; static int32_t accumulated_error_s = 0; // 累计误差,单位秒 uint32_t current_tick = HAL_GetTick(); // 获取系统tick DS1302_GetTime(time); // 获取原始时间 // 计算自上次补偿后经过了多少天(近似) uint32_t elapsed_ms = current_tick - last_compensate_tick; float elapsed_days = elapsed_ms / (1000.0f * 3600.0f * 24.0f); // 计算这段时间产生的误差 accumulated_error_s += (int32_t)(TIME_ERROR_PER_DAY * elapsed_days); // 如果累计误差超过1秒,就进行补偿 if(accumulated_error_s >= 1) { // 增加秒数(因为时钟快,需要减时间,但这里我们通过增加读取的秒数来间接“推迟”显示) // 更直接的方法是修改time结构体,但注意进位 // 这里简化处理:直接调整秒字段 time->sec += accumulated_error_s; // 注意:这可能导致秒>60 while(time->sec >= 60) { time->sec -= 60; time->min++; // 需要处理分钟、小时、日、月、年的进位,这里省略复杂的日历计算 // 建议使用一个完整的“时间戳加减秒数”的函数来处理 } accumulated_error_s = 0; // 清零累计误差 last_compensate_tick = current_tick; } else if(accumulated_error_s <= -1) { time->sec -= (-accumulated_error_s); // 时钟慢,需要加时间,即减秒数 // 同样需要处理借位 accumulated_error_s = 0; last_compensate_tick = current_tick; } }踩坑记录2:软件补偿的复杂性。上面的示例代码非常简化,实际实现一个完整的、考虑所有日期进位的“时间加减”函数并不简单。对于要求不高的场合,可以只校准到“分钟”级别,或者使用现成的时间库(如C语言的
time.h中的mktime和localtime)来处理。另一种更简单的思路是,不修改读取到的时间值,而是在需要显示或使用时,用一个独立的“系统参考时间”(基于DS1302和误差计算得出)来代替。
6. 项目集成:构建一个完整的时钟应用
将DS1302集成到STM32项目中,通常还需要显示(如OLED、LCD)和用户输入(如按键)来设置和查看时间。这里以常见的0.96寸OLED(SSD1306驱动,I2C接口)和三个按键(设置、加、减)为例,勾勒一个简易时钟项目的框架。
6.1 系统框架与主循环设计
项目采用一个简单的状态机来管理不同界面(正常显示、设置时分秒等)。主循环负责按键扫描、状态更新和显示刷新。
// 状态定义 typedef enum { MODE_DISPLAY, MODE_SET_HOUR, MODE_SET_MINUTE, MODE_SET_SECOND, MODE_SET_SAVE } Clock_Mode_t; Clock_Mode_t g_clock_mode = MODE_DISPLAY; DS1302_TimeTypeDef g_current_time; uint8_t g_blink_flag = 0; // 用于设置时光标闪烁 int main(void) { // 硬件初始化 HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 初始化GPIO,包括DS1302和按键引脚 DS1302_Init(); // 初始化DS1302 GPIO和关闭写保护等 OLED_Init(); // 初始化OLED // 初始化时间(第一次使用或需要重置时调用) // DS1302_SetTime(&default_time); // 主循环 while (1) { // 1. 按键扫描与处理 Key_ScanProcess(); // 2. 读取DS1302时间 (每500ms读一次,避免频繁读取) static uint32_t last_read_tick = 0; if(HAL_GetTick() - last_read_tick > 500) { DS1302_GetTime(&g_current_time); // 或使用带补偿的版本 last_read_tick = HAL_GetTick(); } // 3. 根据状态更新显示 OLED_Clear(); switch(g_clock_mode) { case MODE_DISPLAY: Display_NormalTime(&g_current_time); break; case MODE_SET_HOUR: Display_SetTime(&g_current_time, 0); // 0表示小时位闪烁 break; case MODE_SET_MINUTE: Display_SetTime(&g_current_time, 1); // 1表示分钟位闪烁 break; case MODE_SET_SECOND: Display_SetTime(&g_current_time, 2); // 2表示秒位闪烁 break; case MODE_SET_SAVE: Display_Saving(); break; } OLED_Refresh(); // 4. 处理闪烁计时 static uint32_t last_blink_tick = 0; if(HAL_GetTick() - last_blink_tick > 300) { // 300ms闪烁一次 g_blink_flag = !g_blink_flag; last_blink_tick = HAL_GetTick(); } HAL_Delay(50); // 主循环延时 } }6.2 按键处理逻辑
按键处理函数Key_ScanProcess()需要实现状态切换和数值调整。例如:
- 设置键:短按在
MODE_DISPLAY、MODE_SET_HOUR、MODE_SET_MINUTE、MODE_SET_SECOND、MODE_SET_SAVE之间循环。在MODE_SET_SAVE状态下,长按确认保存并返回MODE_DISPLAY。 - 加键/减键:在设置模式下,对当前选中的时间位进行加/减操作,并处理边界(如小时0-23,分钟0-59)。
void Key_ScanProcess(void) { static uint8_t set_key_cnt = 0; // 检测设置键按下 if(SET_KEY_IS_PRESSED()) { set_key_cnt++; if(set_key_cnt == 1) { // 消抖后首次按下 // 短按逻辑:切换模式 switch(g_clock_mode) { case MODE_DISPLAY: g_clock_mode = MODE_SET_HOUR; break; case MODE_SET_HOUR: g_clock_mode = MODE_SET_MINUTE; break; case MODE_SET_MINUTE: g_clock_mode = MODE_SET_SECOND; break; case MODE_SET_SECOND: g_clock_mode = MODE_SET_SAVE; break; case MODE_SET_SAVE: g_clock_mode = MODE_DISPLAY; break; } } else if(set_key_cnt > 100) { // 长按约2秒 (假设20ms扫描一次) // 长按逻辑:在保存模式下,确认保存 if(g_clock_mode == MODE_SET_SAVE) { DS1302_SetTime(&g_current_time); // 将修改后的时间写入DS1302 g_clock_mode = MODE_DISPLAY; OLED_ShowString(0,0,"Saved!",16); OLED_Refresh(); HAL_Delay(500); } set_key_cnt = 101; // 防止重复触发 } } else { set_key_cnt = 0; } // 处理加键和减键(在设置模式下有效) if(g_clock_mode >= MODE_SET_HOUR && g_clock_mode <= MODE_SET_SECOND) { if(ADD_KEY_IS_PRESSED()) { HAL_Delay(50); // 简单消抖 if(ADD_KEY_IS_PRESSED()) { switch(g_clock_mode) { case MODE_SET_HOUR: g_current_time.hour = (g_current_time.hour + 1) % 24; break; case MODE_SET_MINUTE: g_current_time.min = (g_current_time.min + 1) % 60; break; case MODE_SET_SECOND: g_current_time.sec = (g_current_time.sec + 1) % 60; break; } while(ADD_KEY_IS_PRESSED()); // 等待按键释放 } } // 减键逻辑类似... } }6.3 显示函数实现
显示函数负责在OLED上绘制时间。正常显示模式下,可以显示年、月、日、星期、时、分、秒。设置模式下,可以让当前正在设置的数字闪烁(通过g_blink_flag控制)。
void Display_NormalTime(DS1302_TimeTypeDef *t) { char buf[32]; // 显示日期 sprintf(buf, "%04d-%02d-%02d", 2000+t->year, t->month, t->day); OLED_ShowString(0, 0, buf, 16); // 显示星期 const char *week_str[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; OLED_ShowString(90, 0, week_str[t->week-1], 16); // 注意DS1302星期是1-7 // 显示时间 (大字体) sprintf(buf, "%02d:%02d:%02d", t->hour, t->min, t->sec); OLED_ShowString(20, 3, buf, 24); } void Display_SetTime(DS1302_TimeTypeDef *t, uint8_t pos) { char buf[32]; sprintf(buf, "%02d:%02d:%02d", t->hour, t->min, t->sec); // 根据闪烁标志和位置,决定是否显示该位 if(!(g_blink_flag && (pos==0))) OLED_ShowChar(20, 3, buf[0], 24); else OLED_ShowChar(20, 3, ' ', 24); if(!(g_blink_flag && (pos==0))) OLED_ShowChar(36, 3, buf[1], 24); else OLED_ShowChar(36, 3, ' ', 24); OLED_ShowChar(52, 3, ':', 24); // 冒号始终显示 if(!(g_blink_flag && (pos==1))) OLED_ShowChar(68, 3, buf[3], 24); else OLED_ShowChar(68, 3, ' ', 24); if(!(g_blink_flag && (pos==1))) OLED_ShowChar(84, 3, buf[4], 24); else OLED_ShowChar(84, 3, ' ', 24); OLED_ShowChar(100, 3, ':', 24); if(!(g_blink_flag && (pos==2))) OLED_ShowChar(116, 3, buf[6], 24); else OLED_ShowChar(116, 3, ' ', 24); if(!(g_blink_flag && (pos==2))) OLED_ShowChar(132, 3, buf[7], 24); else OLED_ShowChar(132, 3, ' ', 24); // 底部提示 OLED_ShowString(0, 6, "Set:Short=Next Long=Save", 12); }通过以上步骤,一个基于STM32F103C8T6和DS1302的完整时钟系统就搭建起来了。从最底层的GPIO时序模拟,到驱动封装、时间处理,再到上层应用逻辑和显示交互,整个过程涵盖了嵌入式开发中硬件驱动、状态机、人机交互等多个核心环节。虽然DS1302是一个老芯片,但通过这个项目,你能扎实地掌握实时时钟应用的整套方法论,这对于后续使用更高级的RTC(如STM32内部的RTC外设、DS3231等)有着直接的帮助。
本文还有配套的精品资源,点击获取