简介:这份压缩包是一份基于STM32的数字时钟完整工程,面向嵌入式初学者与单片机爱好者,以STM32为主控,集成LCD显示、RTC实时时钟和闹钟中断功能,并通过Proteus提供虚拟仿真环境,便于快速验证程序逻辑。包内共182个文件,主要包括C/H源码、Keil工程文件(uvprojx/uvoptx)、Proteus仿真工程、hex烧录文件以及编译中间文件,压缩包大小约6.16MB,可直接用于学习、仿真和烧录。目前已有10436人浏览学习。通过阅读源码和工程,可以掌握RTC日历配置、时钟系统初始化、LCD驱动移植、定时器与中断服务程序设计、低功耗模式选择等核心知识点,同时了解STM32标准外设库的常见调用方式和调试思路。对于希望从基础实验过渡到综合项目开发的学习者,这份资料提供了完整、清晰的参考样例,能有效缩短入门周期。
1. 基于STM32的数字时钟设计:从一颗时钟芯片到四位数码管
一块最小系统板能做的事很多,但最直观的反馈就是把时间正确地显示出来。这个基于STM32的数字时钟项目,做的就是这件事:以STM32F103为主控,外挂DS1302实时时钟芯片,用4位一体数码管动态扫描显示时分,配合独立按键调整时间,整套设计在Proteus里仿真与实物共用同一份Keil工程。它非常适合刚学完GPIO、定时器和中断,想拿一个完整周边项目练手的人,也是课程设计或电子设计竞赛里最常被参考的底子。别以为它只是个玩具,DS1302的三线时序、数码管动态扫描、按键状态机这三样,后面做复杂系统时全是高频复用的基本功。
2. 系统架构与硬件设计:先从选型和连线把地基打牢
2.1 先想清楚为什么是 DS1302 而不是内部 RTC
STM32F103内部自带RTC,理论上不需要外挂芯片也能走时。但实际做数字时钟,绝大多数教程和工程还是选DS1302。原因很现实:内部RTC要维持掉电走时,需要VBAT引脚外接电池,同时要把LSE低速外部晶振接好,复位或下载程序时偶尔还会把RTC寄存器弄丢,排查起来比较玄学。DS1302是独立芯片,自带32.768kHz晶振接口和后备电池引脚,主电源断了它自己靠纽扣电池继续走,MCU这边只需要用三个GPIO模拟出一套三线时序就能交换数据。更重要的是,Proteus里有现成的DS1302仿真模型,仿真结果和实物行为一致,这给没有现成板子的同学提供了很大的试错空间。
这套设计的信息流是这样的:按键设定时间写入DS1302寄存器,主循环定期把时间读出来,BCD码转成十进制分段映射成段码,再通过位选切换让4位数码管轮流点亮,利用人眼视觉暂留形成一组稳定的“时:分”显示。整个过程就是一个“写时序、读时序、动态刷新”的循环,不依赖操作系统,也不涉及复杂算法,但每一环都对时序精度有要求。
2.2 引脚分配与 Proteus 仿真搭建
我一般会把资源里给的引脚分配整理成一张表,方便对着接线,也方便后面改板子时快速查:
| 外设 | 引脚 | 说明 |
|---|---|---|
| DS1302 CE | PB12 | 片选,高电平有效 |
| DS1302 SCLK | PB13 | 串行时钟 |
| DS1302 IO | PB14 | 数据线,双向 |
| 数码管段选 | PA0 - PA7 | 共阳数码管8个段 |
| 数码管位选 | PB0 - PB3 | 4位分别点亮 |
| 按键 功能/加/减 | PA8 / PA9 / PA10 | 调时模式切换与数值调整 |
| 蜂鸣器 | PB8 | 预留,做闹钟扩展用 |
段选用PA0-PA7直接驱动,位选加三极管或者直接在仿真里拉高拉低都可以。这里有个细节:PB12、PB13、PB14这三个引脚尽量不要和下载口复用的JTAG引脚冲突,用标准库初始化前先关闭对应引脚的复用功能,我在第一次画板子时就是因为这个原因折腾了半天才让DS1302正常通信。
Proteus搭建时,主控选STM32F103C8,时钟芯片搜DS1302,显示器件用7SEG-MPX4-CA这种4位共阳数码管,按键用普通按钮,晶振电路和复位电路按最小系统标准接法画。不少人搜索时会把Proteus拼成protues,不影响找到资料,但元件库里还是按正确名字找比较快。DS1302在仿真模型里对晶振的处理和实物不太一样,这个坑放到第4章详细说,先记住一句:仿真里不需要像实物那样纠结32.768kHz晶振的起振问题。
2.3 工程目录:Driver 层与 App 层分开的意义
拿到工程之后,先别急着打开main.c猛看,我习惯先看目录结构。这个项目典型的组织方式是:
├─Doc/ 原理图、引脚分配说明 ├─Driver/ │ ├─ds1302.c/.h 时钟芯片三线时序读写 │ ├─smg.c/.h 数码管段码表与动态扫描 │ ├─key.c/.h 按键消抖与事件上报 │ └─delay.c/.h 毫秒/微秒延时 ├─App/ │ └─clock_task.c 调时状态机与时间刷新逻辑 └─Project/ Keil MDK 工程Driver层每个文件只做一件事:ds1302只负责往寄存器里写数据和读数据,不关心读出来的时间要显示到哪里;smg只负责把传入的数字画到对应位,不关心这个数字是秒还是分。App层的clock_task才是业务逻辑,它决定“现在处于运行模式还是调时模式”“按下加键应该加到小时还是分钟”。这么拆分之后,后面把数码管换成LCD1602,只需要重写smg.c的接口实现;把DS1302换成DS3231,也只是改ds1302.c内部的时序代码,App层几乎不用动。这个项目虽然小,但这个分层习惯是从一开始就值得带进后续项目里的。
3. 核心代码拆解:三线时序、动态扫描与调时状态机
3.1 DS1302 读写时序:命令字节与寄存器操作
DS1302的通信协议核心是命令字节。每次访问寄存器,先发出一个8位命令,bit0是读写标志(1读0写),bit1到bit5是寄存器地址,bit7固定为1。然后根据读写方向,要么再发一个8位数据写进去,要么由芯片在时钟下降沿把数据吐出来。整个过程由CE片选保护,CE拉高后时序才开始,结束后必须拉低。
先看写时序的实现,这是最底层的原语:
static void DS1302_WriteByte(uint8_t cmd, uint8_t dat) { uint8_t i; DS1302_CE_H(); // CE拉高,启动一次传输 for (i = 0; i < 8; i++) { DS1302_IO_Write((cmd >> i) & 0x01); // 先发命令字节,LSB在前 DS1302_SCLK_H(); // SCLK上升沿,数据被锁存 DS1302_SCLK_L(); } for (i = 0; i < 8; i++) { DS1302_IO_Write((dat >> i) & 0x01); // 再写数据字节 DS1302_SCLK_H(); DS1302_SCLK_L(); } DS1302_CE_L(); // CE拉低,结束传输 }这里命令字采用LSB先行,所以每次用(cmd >> i) & 0x01取出最低位。写寄存器之前,必须先向写保护寄存器0x8E写入0x00,把WP位清零,否则后续所有写操作都会被芯片拒绝。这一点经常有人忽略,表现出来就是“第一次上电能设置时间,之后再设就写不进去”。我一般会在系统初始化时固定执行一次DS1302_WriteByte(0x8E, 0x00),把写保护默认关掉。
读时序比写时序麻烦一点,因为IO线需要在命令发完后从输出模式切换成输入模式:
static uint8_t DS1302_ReadByte(uint8_t cmd) { uint8_t i, dat = 0; DS1302_CE_H(); for (i = 0; i < 8; i++) { DS1302_IO_Write((cmd >> i) & 0x01); DS1302_SCLK_H(); DS1302_SCLK_L(); } DS1302_IO_ModeInput(); // IO方向切换为输入,示意写法 for (i = 0; i < 8; i++) { dat >>= 1; DS1302_SCLK_H(); DS1302_SCLK_L(); // 下降沿到来,DS1302输出当前位 if (DS1302_IO_Read()) dat |= 0x80; } DS1302_IO_ModeOutput(); // 恢复输出模式 DS1302_CE_L(); return dat; }很多第一次写DS1302驱动的同学会挂在同一个地方:读回来全是0xFF或者全部是0x00,原因就是漏了IO方向切换。标准库里切换方向要重新初始化GPIO,HAL库则用HAL_GPIO_ReadPin和寄存器配置组合实现,核心思想是一样的,都是“发命令时当输出,读数据时当输入”。
DS1302常用寄存器地址和数据格式,值得存一张表:
| 寄存器 | 读命令 | 写命令 | 说明 |
|---|---|---|---|
| 秒 | 0x81 | 0x80 | 低7位BCD码,bit7为CH停振标志 |
| 分 | 0x85 | 0x84 | BCD码,bit7为1时暂停 |
| 时 | 0x89 | 0x88 | 12/24小时模式由bit7控制 |
| 日 | 0x87 | 0x86 | BCD码,范围1到31 |
| 月 | 0x8B | 0x8A | BCD码 |
| 周 | 0x8D | 0x8C | 1到7 |
| 写保护 | 0x8F | 0x8E | bit7为1时禁止写所有寄存器 |
读出来的数据是BCD码,例如0x57表示57秒。要转成十进制,用(bcd >> 4) * 10 + (bcd & 0x0F);反过来写时间时,用(dec / 10) << 4 | (dec % 10)。不少人在显示端看到乱码或者“59后面跟5A”,就是忘记做这个转换。
3.2 数码管动态扫描:从段码表到人眼暂留
4位数码管如果每个位都单独接一组IO,至少需要12个引脚,资源不够。动态扫描的思路是段选共用,位选分时:同一时刻只有一位数码管被点亮,但切换速度足够快,人眼就会把四个位“补全”成一幅完整的画面。
void SMG_Scan(void) { static uint8_t pos = 0; uint8_t num = time_buf[pos]; GPIO_Write(GPIOA, 0x00); // 段码全灭,消隐 GPIO_ResetBits(GPIOB, 0x0F); // 关闭所有位选 GPIO_Write(GPIOA, seg_table[num]);// 放入当前位段码 GPIO_SetBits(GPIOB, (1 << pos)); // 打开当前位 pos = (pos + 1) & 0x03; // 0、1、2、3循环 }注意三条语句的顺序:先消隐,再切位选。如果在新位选打开之后才改段码,前一位显示的残影就会在切换瞬间被新位显示出来,视觉上出现拖影。段码表seg_table里存的是共阳数码管编码,比如数字0对应0xC0,这个根据你的硬件接法取反或调整都行。
扫描频率有门槛。视觉暂留的时间大约20ms,意味着每一位的点亮周期不能超过20ms,否则人眼就能分辨出闪烁。4位数码管走完一轮需要4次扫描,每次按2ms算,一轮8ms,刷新率达到125Hz,远高于50Hz的临界点。这个参数很关键,如果发现显示闪得厉害,先检查扫描函数是不是被主循环里的DS1302读取卡顿拖慢了,基本都在刷新周期超过20ms这个坎上。
3.3 调时状态机与主循环编排
有了底层驱动,主循环的逻辑就可以写得很干净。这里用一个mode变量表示当前状态,0是正常运行,1是调小时,2是调分钟,功能键在三个状态间切换,加减键根据mode决定改哪一位。
int main(void) { SYSTEM_Init(); Clock_Init(); // 首次上电写初始时间,之后只读 while (1) { key_scan(); // 按键扫描,带回弹处理 clock_task(); // 调时状态机 if (time_update_flag) { time_update_flag = 0; Read_DS1302_Time();// 每秒读一次时分秒 } SMG_Scan(); // 动态扫描 } }按键处理我是按三拍写的:检测到低电平先延时20ms二次确认,确认后再等按键释放才执行一次事件。第一拍过滤机械抖动,第三拍防止按一次触发多次。调时状态机拿到事件后,根据当前mode决定“加1分钟”还是“加1小时”,改完立即写回DS1302的对应寄存器,同时重新读出来刷新显示,这样调时过程中用户能实时看到数字变化。运行模式下,显示内容只随每秒一次的读时任务更新,动态扫描则一直高频跑着,两者一个管内容一个管刷新,职责分开。
4. 联调避坑指南:仿真不走、显示闪烁、按键误触的排查记录
这类板子在Proteus联调阶段,问题从来不是某个函数写不出来,而是几个高频坑反复出现。我按实际排查顺序整理了几条血的教训,每一条都是“现象 → 原因 → 解决”的完整链路。
4.1 Proteus里时钟不走,秒位卡住
现象:程序烧进去之后,数码管有显示,但秒位永远不动,或者只跳了一次就停住。
原因:DS1302的Proteus仿真模型内部已经带了完整的时钟源,外部再接32.768kHz晶振反而容易让模型状态异常;更常见的原因是没有往秒寄存器写入初值,芯片上电后内部寄存器是未知状态,CH停振位为1,时钟自然不走。
解决:仿真里优先按模型要求接法做,拿不准晶振就先不接,让模型用自己的内部时钟。代码里每次初始化时先读秒寄存器,如果发现bit7为1或者BCD码非法,就执行一次完整的“关写保护→写秒分时→开写保护”流程,给芯片一个明确的起点。另外要检查CE引脚的时序,CE必须保持到一帧读写全部结束再拉低,不能中途掉电。
4.2 数码管闪烁或亮度不均匀
现象:四个位有的亮有的暗,或者整体在轻微闪烁,看起来非常廉价。
原因:主循环里DS1302读取用了几十毫秒的延时模拟时序,把整个循环周期拖慢,动态扫描频率跌到人眼可分辨区间;还有一种情况是位选切换前没有消隐,拖影叠加导致视觉亮度不一致。
解决:把SMG_Scan函数往高频周期里挪,我一般放到SysTick的1ms中断里调用,定时器只做一件事,不受主循环业务影响。MS1302读取尽量一次读完秒分时多个寄存器,不要在两次读取中间穿插耗时的按键处理。消隐那句GPIO_Write(GPIOA, 0x00)一定要在切位选之前执行,顺序反了就会出现残影。
4.3 断电再上电,时间总是复位
现象:仿真或实物重新上电后,显示时间回到初始值,之前调好的时间全丢。
原因:VCC1后备电池引脚没接东西,断电后DS1302直接掉电;或者代码里每次启动都无条件执行一遍时间初始化,把芯片里存的正确时间覆盖掉了。
解决:VCC1接一个3V电池,仿真里直接放一个3V直流电源或电池模型就行。代码改成带标记的初始化:只在上电后首次检测到“芯片时间无效”时才写入初始时间,之后启动只读不写。标记可以放在DS1302内部RAM或者用一个EEPROM位,简单一点的做法就是用秒寄存器本身的状态来判断。
4.4 按键一次跳多个数,调时像抽风
现象:按一下“加”,时间连续加了好几分钟,根本停不下来。
原因:按键消抖只做了延时确认,没有等待弹起。主循环跑得飞快,第一次确认之后代码还在按键函数里循环,函数返回前又被同一个低电平触发了一次,于是重复执行。
解决:按键处理必须走完整的三拍:低电平检测 → 延时20ms再次确认 → 等待高电平弹起后才上报“一次按下”事件。弹起检测放进去之后,问题基本绝迹。另外功能键和加减键的扫描尽量分开,避免模式切换瞬间的残余电平被当成加减事件。
5. 二次开发方向:从数码管到LCD1602与闹钟功能
5.1 显示层替换成 LCD1602 或 OLED
四位数码管的显示能力有限,只能显示数字,分割符还得靠独立点来表现。把这套工程改成LCD1602或OLED显示,是一个性价比极高的二次开发方向。驱动层完全不用动,DS1302的读写函数原样保留,只需要重写显示模块:
| 显示方案 | 占用引脚 | 显示内容 | 上手难度 |
|---|---|---|---|
| 数码管 | 12个 | 数字为主 | 低 |
| LCD1602 | 6个(含背光) | 字母、数字、自定义字符 | 中 |
| OLED | 2个(I2C) | 任意文字、图形 | 中 |
LCD1602无非就是把“显示时分”这件事从段码映射改成字符串拼接和写命令字,核心不变。OLED更简单,用I2C接口只需要SCL和SDA两根线,刷新函数里把时间格式化打印上去就行。我给这个改造预留的接口是Display_Time(uint8_t hour, uint8_t minute),所有上层逻辑都走这个函数,替换硬件时只改实现。
5.2 加闹钟:状态机并列与验证方法
闹钟的本质就是“比较时间”,现在已经在每秒刷新时间内容,比较逻辑直接挂在读时任务后面:
if (now_h == alarm_h && now_m == alarm_m && sec == 0 && !ring_flag) { ring_flag = 1; GPIO_SetBits(GPIOB, GPIO_Pin_8); // 拉高蜂鸣器 }难度不在比较本身,而在状态机扩展。原来的mode有0、1、2三种状态,加闹钟后至少多出两个状态:设置闹钟小时、设置闹钟分钟。建议把闹钟相关状态独立成新模块,不要往原来的调时状态机里硬塞,否则按键处理会越来越绕。验证方法很直接:把闹钟时间设成当前时间加1分钟,等一分钟看蜂鸣器是否拉高;如果响的时间不对,多半是秒匹配条件出了问题,我习惯给比较条件加一个“秒等于0”的窗口,避免整分钟时重复触发。
5.3 一个值得固化的调试顺序
每次拿到这类有时钟芯片的新工程,我的固定流程是:先跑通底层读写时序,直接把读回来的寄存器值打印或显示出来确认,再写显示和按键逻辑;确认时间能走能改之后,才去动闹钟这种业务功能。跳过第一步直接写业务逻辑,后面多半要回头查时序,浪费时间。这个顺序被我在好几个项目里反复验证过,从那以后我每次都会强制走一遍“时序先通,显示跟上,业务最后”。希望帮到你。
本文还有配套的精品资源,点击获取