简介:这是一份以AT89C52/STC89C52单片机为核心的手表设计工程资源,主要面向单片机初学者和嵌入式开发爱好者,适合用作课程设计或自学练手的参考。压缩包共12个文件、约24KB,内含Keil工程配置(uvproj/uvopt)、C语言源码、编译生成的HEX烧录文件以及OBJ/LST等中间产物,并保留了两个bak备份文件,便于修改时回溯原始工程。目前已有176人学习/下载,资源内容覆盖手表核心功能,硬件上包括时钟电路、显示模块、按键输入、电源管理与闹钟电路,软件上涉及初始化、时间更新、界面交互、闹钟触发与低功耗处理。下载后可在Keil中直接打开工程,查看或修改源代码后重新编译、烧录,通过实际运行理解单片机手表的初始化、时间更新、按键响应和闹钟逻辑。
1. 一块52单片机怎么撑起手表的三件大事
抽屉里翻出一片AT89C52,接上12MHz晶振、两个按键、四位数码管和一个蜂鸣器,几根杜邦线连完就能跑一个带对时和闹钟的手表。这不是概念演示,而是很多课程设计和竞赛项目里最常见的起点。基于52单片机的手表核心就三件事:把秒数算准、把时间显示出来、在设定时刻驱动蜂鸣器。它适合三类人:正在做单片机课设的学生,想彻底搞懂定时器中断和按键状态机的初级工程师,以及准备从裸机逻辑过渡到低功耗设计的嵌入式从业者。用52单片机做手表,相当于用一套最小系统把中断、定时、扫描、状态机这几个嵌入式基本功全部串起来。下面按硬件框架、固件设计、工程验证、低功耗技巧四个方向把这个项目拆开,每一步都能对着代码复现。
2. 硬件底子:52单片机手表的电源、晶振与显示驱动
2.1 核心芯片与最小系统:AT89C52的供电与复位
这个手表项目里最常用的芯片是AT89C52或STC89C52,两者引脚和指令集兼容,差别主要在Flash容量、是否支持串口下载,以及STC系列内部多了看门狗和EEPROM。AT89C52自带8KB Flash、256字节RAM、3个定时器/计数器,对于“时、分、秒加闹钟”这样的逻辑复杂度已经完全够用。手表一般用5V供电,如果换成3.3V的STC15系列,核心逻辑不用改,但要注意IO电平。
最小系统看起来简单,但每个元件都有作用:VCC和GND之间放0.1μF去耦电容,防止数码管扫描时电源跌落;XTAL1和XTAL2接11.0592MHz或12MHz晶振,两个20~30pF负载电容;RST引脚用10μF电容加10kΩ电阻组成上电复位;EA引脚必须接VCC,否则单片机从外部ROM取指,程序跑不起来。P0口内部没有上拉电阻,驱动数码管或LCD时一定要外接10kΩ排阻,否则段码和高电平信号会出现随机电平。
这个项目的引脚分配可以固定成下面这张表,后面代码全部按这张表写:
| 引脚 | 功能 | 接法 |
|---|---|---|
| P2.0~P2.7 | 数码管段码 a~dp | 经330Ω限流电阻到数码管段引脚 |
| P1.0~P1.3 | 数码管位选 | 经NPN三极管扩大驱动能力后接公共端 |
| P1.4 | 功能键 | 按键一端接IO,一端接GND,IO外接10kΩ上拉 |
| P1.5 | 加/减键 | 同上 |
| P1.6 | 蜂鸣器输出 | NPN三极管驱动无源蜂鸣器 |
| RST | 复位 | 10μF电容到VCC,10kΩ电阻到GND |
P1口虽然有内部上拉,但内部上拉很弱,直接接按键到地时,在长线场景下容易受工频干扰。我一般会在P1.4和P1.5上再各放一个10kΩ外部上拉,并在按键两端并联0.1μF电容,这样硬件消抖和软件消抖同时做,按键触发会干净很多。
2.2 秒信号的来源:用Timer0产生1秒时基
手表的时间基准来自单片机内部定时器,而不是外挂RTC芯片。用Timer0工作在模式1,也就是16位计数器,每次溢出进入中断,中断里累计溢出次数得到秒。晶振12MHz时机器周期是1μs,要定时50ms,初值就是65536减50000等于15536,十六进制0x3CB0。中断20次正好1秒,这个方案整点误差小,数码管扫描时间也不会挤占中断。
#define FOSC 12000000UL #define TIMER0_LOAD (65536UL - FOSC / 12 / 1000) // 1ms 初值 volatile unsigned int ms_tick; unsigned char sec_cnt; void timer0_isr(void) __interrupt 1 { TH0 = (TIMER0_LOAD >> 8) & 0xFF; TL0 = TIMER0_LOAD & 0xFF; if (++ms_tick >= 1000) { ms_tick = 0; sec_cnt++; } }逻辑说明:FOSC / 12是机器周期频率,除以1000就得到1ms定时需要的计数个数,这样代码基本不需要手工算十六进制初值,看代码的人马上能懂。__interrupt 1是Keil C51的中断号,Timer0对应1,不是向量地址。进入中断后必须重新赋初值,否则从0开始计数,时间会明显偏快。中断里只做标志和秒累加,不处理按键和显示,保证秒信号不受主循环阻塞影响。
如果想用11.0592MHz晶振,把FOSC改成11059200即可,但65536 - 11059200 / 12 / 1000不是整数,算下来初值有小数截断,误差会叠加。这个项目直接用12MHz晶振更直观,微小的频率偏差留到第5章做校准。
2.3 显示方案:从LED数码管到LCD1602
手表显示有两种做法。LED数码管亮度高、扫描简单,适合在现场演示时远距离读数;LCD1602能显示“闹钟开”“星期”和冒号状态,适合做接近产品的原型。这个项目里用四位数码管最省事,动态扫描方式占用IO少。
动态扫描的时序是:先选中一位的位选线,然后在这个位上输出对应段码,保持1ms,再切换下一位。四个位循环一次用4ms,刷新率250Hz,肉眼看不到闪烁。很多新手把位选和段码都放在定时器中断里,结果中断被显示程序拖慢,秒计时失真。我一般把显示刷新放在主循环,定时器中断只设置sec_cnt标志,主循环看到标志再更新显示。
LCD1602则需要初始化时序:延时15ms,写命令0x38设置8位双行,开显示0x0C,输入模式0x06。手表场景里,冒号闪烁是判断秒信号是否正常的直观方式,可以每秒把冒号位置的取反,或者在LCD上用空格和冒号交替刷新。
2.4 按键与闹钟执行的硬件接法
按键输入和闹钟输出决定了手表能不能可靠操作。按键用独立按键结构,一端接IO,一端接GND,检测到低电平就认为按下。闹钟输出端是无源蜂鸣器,无源蜂鸣器需要方波驱动,常见频率1~4kHz。这个项目里用定时器1产生约2kHz方波,P1.6在中断里翻转,驱动三极管导通,声音清脆又不刺耳。
有源蜂鸣器虽然接高电平就响,但它内部振荡器频率固定,声音发闷,而且持续通电流大。手表是电池供电设备,无源蜂鸣器反而更省电,因为它只在翻转瞬间消耗电流。电源管理上,整机峰值电流可能到50mA,CR2032纽扣电池带不动长时间响铃,我一般会在电池输出端加一颗低饱和压降的LDO或者干脆用两节AAA电池供电。
3. 软件骨架:初始化、时间更新与按键状态机
3.1 Keil工程里的初始化函数清单
拿到源码包后,不要急着编译,先把sb.c按功能拆成初始化、定时处理、显示、按键、闹钟五个模块。许多失败案例都出在初始化顺序上:P1口输出高电平没有先设置,导致上电瞬间蜂鸣器误响;定时器TMOD没有按位赋值,两个定时器互相影响。标准顺序是关看门狗、设置IO初始电平、配置定时器、开中断、最后进入主循环。
STC89C52内部看门狗默认关闭,但AT89C52没有看门狗,所以Keil里按AT89C52仿真时不需要处理,烧录到STC芯片时,我建议在初始化最前面加一句WDT_CONTR = 0x00;,防止上一次运行残留的看门狗定时值复位单片机。
| 初始化项 | 寄存器/函数 | 作用 |
|---|---|---|
| IO电平 | P0 = 0xFF; P1 = 0xFF; | 段码和按键IO默认拉高,避免误触发 |
| 定时器0 | TMOD = 0x01; TH0 = 0x3C; TL0 = 0xB0 | 16位定时器,产生50ms时基 |
| 定时器1 | TMOD | = 0x10; TH1 = 0xFE; TL1 = 0x5F |
| 中断 | ET0 = 1; ET1 = 0; EA = 1 | 先开定时器0,蜂鸣器中断用到再开 |
| 显示 | digit_scan_init() / LCD_init() | 设置段码表或LCD工作模式 |
TMOD寄存器低4位控制Timer0,高4位控制Timer1,赋值TMOD = 0x01会把Timer1的模式清零,所以正确写法是先用0x01初始化Timer0,再用|= 0x10设置Timer1。这种写法比一次性写0x11更清晰,后续改任意一个定时器都不影响另一个。
3.2 定时器中断里做时间累计
时间变量直接定义成全局的hour、minute、second,在中断里更新。比较自然的写法是秒进位到分钟,分钟进位到小时,小时到24回零。闹钟比较也可以放在这里,但要注意不能在中端里长时间响铃,否则中断被卡住,秒信号就停了。
unsigned char hour = 12, minute = 30, second = 0; unsigned char alarm_hour = 6, alarm_min = 30, alarm_flag = 0; void timer0_isr(void) __interrupt 1 { TH0 = 0x3C; TL0 = 0xB0; // 12MHz, 50ms 定时 static unsigned char cnt50 = 0; if (++cnt50 >= 20) { // 50ms * 20 = 1s cnt50 = 0; if (++second >= 60) { second = 0; if (++minute >= 60) { minute = 0; if (++hour >= 24) hour = 0; } } alarm_check(); // 每秒检查一次闹钟条件 } }逻辑说明:cnt50是静态变量,只在这个函数内部有效,但函数退出后不会丢值,比全局变量更安全。alarm_check()每秒执行一次,函数内只做比较和置alarm_flag标志,绝对不做while等待按键。中断里放标志,主循环里处理响铃,这是嵌入式设计里典型的“生产者和消费者”关系。
参数说明:如果换用11.0592MHz晶振,50ms定时初值不再是0x3CB0,而是65536 - 11059200 / 12 * 0.05 = 65536 - 46080 = 19456 = 0x4C00。但这个算式的结果是整数,实际上晶振频率本身有误差,长时间运行会出现每天几秒的偏差,这个放到第5章做校准。
3.3 按键扫描与对时菜单的有限状态机
对时菜单最讨厌的问题是按键按下一次跳好几个数。纯软件消抖不能只靠延时,因为延时会让时间显示暂停。我习惯用一个“边沿检测+延时确认”的非阻塞扫描函数。先读P1.4和P1.5,保存上一次的电平状态,只有当本次电平与上次不同时才进入消抖计数,计数期间如果电平又跳回原值就放弃。
#define STATE_RUN 0 #define STATE_HOUR 1 #define STATE_MIN 2 #define STATE_ALM_H 3 #define STATE_ALM_M 4 unsigned char menu_state = STATE_RUN; unsigned char key_flag; void key_scan(void) { static unsigned char last = 0xFF; unsigned char cur = P1 & 0x30; // 只读 P1.4 和 P1.5 if (cur != last) { unsigned char cnt = 0; while (cnt < 200) { cnt++; if ((P1 & 0x30) != cur) return; // 抖动,不是稳定按下 } key_flag = cur; // 只有稳定变化才置标志 } last = cur; }逻辑说明:last记录上一次的P1.4/P1.5状态,cur是当前值。按下和释放都会触发一次变化,但状态机里只对按下瞬间的key_flag响应,释放时虽然也会进入这个函数,但key_flag会再次被赋值,主循环可以通过判断key_flag是否等于期望的按下码来区分。200次空循环只用于消抖,不阻塞其他任务。
按键状态机则用menu_state区分运行、调小时、调分钟、设闹钟小时、设闹钟分钟。功能键负责切换状态,加/减键负责修改当前状态的数值。小时和分钟范围不同,分钟到0~59,小时到0~23,闹钟同样,但闹钟标志在设定完成后保持,不需要每次重新打开。
3.4 闹钟设定、存储与触发输出
闹钟设定跟时间设定复用同一套菜单逻辑,menu_state为STATE_ALM_H时按加/减键修改alarm_hour,为STATE_ALM_M时修改alarm_min。退出菜单后,闹钟值保存在RAM里,掉电会丢。如果使用STC89C52,可以把闹钟值写入内部EEPROM,地址从0x2000开始,上电时读回来,这样能省去每次开机重新设闹钟的麻烦。
触发逻辑每秒比较一次当前时间和闹钟时间,为了保证响铃不只有瞬间,通常判断hour==alarm_hour && minute==alarm_min && second<60,并把alarm_flag置1,然后主循环里让蜂鸣器响60秒。使用定时器1输出2kHz方波:
void alarm_ring_on(void) { TR1 = 1; ET1 = 1; TH1 = 0xFE; TL1 = 0x5F; // 定时0.25ms,翻转产生500us周期=2kHz } void alarm_ring_check(void) // 主循环里调用 { static unsigned short ring_tick; if (alarm_flag) { if (++ring_tick > 60000) { // 60秒后自动关闭 alarm_flag = 0; TR1 = 0; ET1 = 0; ring_tick = 0; } } }逻辑说明:无源蜂鸣器必须在每次定时器1中断里翻转P1.6,才能形成方波。alarm_ring_check使用静态变量ring_tick,主循环每次执行加1,按每循环约1ms估算,60000次大约60秒。这个时间受主循环其他代码影响,不需要精确,因为闹钟提示而已。按功能键提前关闭时,直接把alarm_flag清0,同时关掉ET1和TR1。
4. 工程文件核对与烧录验证:从sb.c到sb.hex
4.1 项目里那些备份文件和清单文件是什么
下载包里的文件名看起来很乱,但每一类都有明确用途。sb.c是主程序源码,sb.uvproj是Keil工程文件,双击它就能打开项目;sb_uvproj.bak和sb_uvopt.bak分别是工程配置和编辑器布局的上一次备份;sb.lnp是链接参数文件,里面记录目标文件和库的路径;sb.M51是内存分配映射文件,所有变量的地址分配都列在里面;sb.LST是编译器产生的列表文件,可以看到每条C语句对应的汇编;sb.plg是编译过程日志;sb.OBJ是编译生成的对象文件;sb.hex是最终烧录用的Intel HEX固件。
拿到源码包,我先看sb.hex文件是否存在,再看它的修改时间是否比sb.c新。如果hex比c旧,说明源码改过但没重新编译,烧录的是过期程序。然后打开sb.M51,搜索STARTUP和C51段,确认代码段没有溢出到XDATA 0x1000范围外。
| 文件 | 作用 | 排查重点 |
|---|---|---|
| sb.uvproj | Keil工程配置 | 确认Device型号和Output路径 |
| sb.c | 主程序 | 检查main函数和中断函数是否完整 |
| sb.hex | 固件文件 | 用STC-ISP加载时看起始地址 |
| sb.M51 | 内存映射 | 定位变量重定义或栈溢出 |
| sb.lnp | 链接参数 | 检查是否重复包含头文件路径 |
4.2 Keil配置与生成hex文件
在Keil uVision中打开sb.uvproj后,最关键的三项设置是:Project菜单里选择芯片型号,这个项目用AT89C52或STC89C52;Options for Target对话框里勾选“Create HEX File”;调试器选项选Simulator或硬件调试器。第一次编译如果报错找不到头文件,检查Include Path是否包含reg52.h所在目录。
命令行编译在批量验证时更快,比如:
C:\Keil_v5\UV4\UV4.exe -b sb.uvproj -o build_log.txt-b参数表示批量编译,编译完成后不会启动GUI;-o参数把输出日志写入build_log.txt。编译结束查看日志里的0 Error(s),只用一行命令就能完成验证。对于讲了无数次课设的实验环境,这个命令比鼠标点击更不容易漏步骤。
4.3 下载到52单片机的步骤与常见坑
标准下载流程是:将USB转串口的TXD接单片机RXD,RXD接TXD,GND共地;打开STC-ISP软件,选择信号STC89C52或AT89C52,串口号按设备管理器里实际占用的选择;加载sb.hex后,点“下载/编程”,然后立即给开发板断电再上电,完成冷启动。
对STC89C52,下载不成功多半是串口被占用,或者单片机没有进入ISP模式。关闭串口调试工具,按下复位键重新上电再试一次。对AT89C52,它没有内置ISP引导码,必须用编程器,程序烧进去之后不会自动复位,需要手动断电上电。
验证hex文件可以用objcopy转成bin看大小:
objcopy -I ihex -O binary sb.hex sb.bin如果objcopy报地址溢出,说明hex里某行起始地址超过了芯片地址空间,可能是工程选错了芯片。转出来的sb.bin大小如果接近8KB,说明Flash已经塞满,后续加功能就得优化代码量了。
5. 参数校准与低功耗技巧:让手表走得准、待机久
5.1 用秒信号和示波器校准偏差
手表时间不准,先不要怀疑算法,多数是晶振频率和初值计算误差积累的结果。把P1.7临时配置为秒脉冲输出,每1秒翻转一次,用示波器测量两次翻转间隔。12MHz晶振下,定时器初值每减少1,50ms定时就缩短1μs,相当于每秒缩短20μs,每天累计约1.728秒。如果实测每天慢10秒,说明每个50ms周期需要缩短约5.6μs,初值应该增加约6;每天快10秒则初值减少6。这个调整量作为经验值,参考后重新编译烧录,再测一天就会稳定很多。
5.2 待机电流压下去:空闲模式与IO配置
AT89C52进入掉电模式后连定时器都停,手表时间会原地不动,不适合用它做低功耗手表。STC89C52支持空闲模式,CPU暂停而定时器继续工作,电流能降低到几毫安。主循环每次刷新完显示、扫描完按键、处理完闹钟,就执行一句PCON |= 0x01;让CPU进入IDLE,被定时器中断唤醒后继续循环:
void main(void) { init_all(); while (1) { disp_refresh(); key_scan(); alarm_ring_check(); PCON |= 0x01; // 进入IDLE,Timer0中断唤醒 } }进入IDLE前要确保没有悬空的中断标志位,否则唤醒后立刻又睡过去,显示会一直停留在上一次的画面。数码管动态扫描在IDLE模式下会停止,所以这里改用每秒刷新一次,平时灭掉位选,既省电又不会让手表看起来闪烁。
5.3 闹钟防抖与掉电保持
闹钟触发瞬间正好有按键扫描,容易把按键抖动误判成对时操作。合理的做法是在alarm_check里增加连续3秒的时间一致性判断,比如second在0~2范围内都满足,且alarm_flag未置位,才打开响铃。这样即使中间有一秒抖动,也不会导致闹钟状态错乱。
另一个实用技巧是掉电前保存闹钟参数。STC89C52内部EEPROM可以在程序运行时改写,我一般站在设置完闹钟后就把alarm_hour和alarm_min写入EEPROM,上电初始化时读回。如果硬件上没有额外掉电检测电路,可以用一个简单策略:每次按键修改闹钟值后立刻写EEPROM,数据量很小,不会磨损Flash。这样即使拔电池,重新上电后闹钟设置依然保留,不用每次重新对时和设闹钟。
本文还有配套的精品资源,点击获取