51单片机电子时钟课设:从Keil定时器初值到数码管显示全解析
简介基于51单片机的电子时钟课程设计完整源码包面向单片机课程设计学生及入门开发者以Keil为开发环境、C语言编写驱动代码结合DS1302实时时钟芯片与LCD1602液晶屏实现时间读取、显示刷新与按键处理功能。项目覆盖驱动初始化、时间读写、中断处理、显示更新等关键环节是理解单片机外设接口与控制逻辑的完整示例。压缩包共27个文件以C源码、头文件、Keil工程文件为主同时包含hex、m51、obj、lst等编译产物和Proteus仿真文件便于直接编译、烧录与仿真验证整体仅96KB轻量易用。目前已有2380人学习下载通过分析工程源码可掌握DS1302与LCD1602底层驱动写法也可作为课设模板快速迁移并在此基础上扩展闹钟、温度显示等功能适合教学实践、课程设计参考与二次开发。1. 单片机课设电子时钟看起来简单卡住的地方全在Keil参数单片机课设里的电子时钟功能预期非常一致六位数码管显示时分秒两个按键能进调整模式上电就开始走。很多同学的源码是从网上找的Keil一编译警告一片烧进STC89C52后看到的要么是数码管乱码要么是秒不走要么是走不了多久就慢出肉眼可见的误差。这个题目同时涉及硬件接线、Keil工程配置、定时器初值、显示扫描和调时状态机真正难的并不是C语言而是弄清楚晶振频率、机器周期、计数器初值三者之间的换算关系。这里直接用51单片机加Keil编程的做法把电子时钟从排线到源码走一遍适合课设里用AT89C52或STC89C52的读者。2. 单片机课设电子时钟的硬件方案与定时器初值计算2.1 六位数码管、按键和晶振怎么接决定了Keil里引脚定义常见做法是P0口输出段码P2口输出位选。P0口内部没有上拉不能直接驱动高电平需要外接10k上拉排阻P2口可以输出低电平选通。如果使用共阳数码管位选引脚给低电平时对应位才会亮。按键一般接在P3口利用内部上拉读取电平。信号单片机引脚连接目标说明段码P0数码管a~dp需要10k上拉排阻位选P2.0~P2.5六个数码管公共端低电平有效调整按键P3.0/P3.1/P3.2按键到GND外接上拉或开内部上拉晶振XTAL1/XTAL211.0592MHz 30pF电容与Keil里的Xtal保持一致对应的Keil引脚定义一般写成宏#define SEG P0 #define DIG P2 sbit KEY_SET P3^0; sbit KEY_ADD P3^1; sbit KEY_SUB P3^2;这里的SEG是段码端口DIG是位选端口sbit把P3引脚定义成位变量后面按键扫描直接读这三个变量。引脚分配会直接影响显示扫描的循环顺序所以定义尽量集中放在文件头部换开发板时只需要改这几行。P0口作为段码输出时如果数码管模块已经带有驱动芯片比如用74HC573或ULN2003那就不需要额外上拉代码里仍用SEG、DIG这组宏。课设里我一般建议直接买带驱动的数码管模块省去焊接排阻的调试时间引脚定义不变对Keil源码没有任何影响。2.2 用11.0592MHz晶振算定时器初值电子时钟才能准电子时钟的秒信号来自定时器中断而不是while(1)里的软件延时。51单片机一个机器周期等于12个时钟周期因此时间基准完全由晶振频率决定。以11.0592MHz晶振为例机器周期约为1.085微秒定时20毫秒需要18432个机器周期。16位定时器初值等于65536减18432算出来是0xB800。// 11.0592MHz 下机器周期 12 / 11.0592MHz ≈ 1.085μs // 20ms 需要的机器周期数 20000 / 1.085 ≈ 18432 // 16位计数初值 65536 - 18432 47104 0xB800 void timer0_init(void) { TMOD 0xF0; // 只修改低四位不影响定时器1 TMOD | 0x01; // 定时器0工作在方式116位计数器 TH0 0xB8; TL0 0x00; // 装载初值 ET0 1; // 打开定时器0中断 TR0 1; // 启动定时器0 EA 1; // 开总中断 }TMOD 0xF0先清掉定时器0的模式位再通过| 0x01把低四位设为0001也就是方式1。方式1是16位计数器最大计数65535足够装下20毫秒的计数需求。TH0和TL0分别存放16位初值的高8位和低8位。晶振换成才算不能照抄0xB800换12MHz时初值会变成0xB1E0。晶振频率机器周期20ms计数次数定时器初值11.0592MHz1.085μs184320xB80012MHz1μs200000xB1E0有些开发板虽然标注12MHz但实际焊接的是11.0592MHz或者反过来。先量板上晶振频率再算初值Keil工程里的Xtal设置也要和它一致。仿真时不一致不会造成编译失败但调试延时函数和波特率都会偏离。2.3 定时器中断里重装初值误差比想象中敏感中断响应本身需要时间处理器进入中断服务程序时要完成压栈和跳转。如果把这些开销算在重装初值之前每次中断都会多出几个机器周期。积累起来电子时钟一天慢几分钟非常正常。比较稳妥的做法是进中断后立刻重装TH0和TL0再处理时间累加。void timer0_ISR(void) interrupt 1 { TH0 0xB8; // 先进来装初值 TL0 0x00; tick_count; if (tick_count 50) { tick_count 0; second; if (second 60) { second 0; minute; } } }interrupt 1是Keil C51规定的中断号对应定时器0。tick_count用来累计20毫秒中断次数加到50就是1秒。这个变量在中断里被改写主循环也可能读取因此定义时要加volatile。volatile unsigned int tick_count 0;volatile告诉Keil编译器每次访问都从内存重新读取不要优化到寄存器里。很多课设出现“秒不走”或“秒跳两次”就是因为这个变量没有被声明成volatile。如果测试时发现秒变量一加就是2说明中断重装初值的位置太靠后中断函数里执行了大量判断逻辑导致定时器多计了不少时间。2.4 数码管动态扫描频率决定了显示会不会闪六位数码管不可能同时点亮只能一位一位轮流扫描利用人眼视觉暂留形成整体显示效果。每位停留时间一般控制在1到2毫秒六位完整扫描一轮大约6到12毫秒刷新率在80到160赫兹肉眼看不到闪烁。如果主循环里只有显示函数没有足够工作扫描过快数码管可能看起来亮度不够如果在每位之间加长延时扫描过慢数字就会明显闪动。void display(void) { unsigned char i; for (i 0; i 6; i) { DIG ~(0x01 i); SEG disp_buf[i]; } }这个实现没有显式延时扫描速度取决于主循环执行速度配合按键扫描和少量计算刚好落在合适区间。注意不要在这个函数里加入几十毫秒的delay否则定时器中断虽然不会丢但主循环响应按键会明显变慢。3. 用Keil C51写电子时钟源码中断、显示、按键一处一处讲3.1 新建Keil工程并生成可烧录的Hex文件Keil里新建Project芯片型号一般选Atmel AT89C52。STC89C52的寄存器大部分兼容Keil老版本没有STC型号选AT89C52也能正常编译。新建工程后在Options for Target里需要改几个地方Xtal填11.0592Output选项卡勾选Create HEX FileC51选项卡的优化等级保持默认或设为Level 2。忘记勾选HEX输出烧录器就找不到.hex文件这是Keil编程最常见的初阶问题。工程结构建议把引脚和初值定义放进config.h#ifndef __CONFIG_H__ #define __CONFIG_H__ #include REGX52.H #define SEG P0 #define DIG P2 sbit KEY_SET P3^0; sbit KEY_ADD P3^1; sbit KEY_SUB P3^2; sbit SEC_LED P1^0; #endifREGX52.H比REG52.H对STC系列兼容更友好。使用头文件宏定义后主文件里不再直接操作P0、P3这种裸端口换单片机型号时只需要改动config.h。Keil对C51编译器支持file.h方式多次包含时用#ifndef防重。3.2 电子时钟源码定时器中断保持走时主循环扫描显示下面是一个可以直接作为课设源码基础的框架覆盖时钟显示、运行和调时核心逻辑。#include config.h code unsigned char seg_code[10] { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90 }; unsigned char second 0, minute 0, hour 0; unsigned char disp_buf[6]; volatile unsigned int tick_count 0; unsigned char mode 0; void timer0_init(void); void display(void); void key_scan(void); void main(void) { timer0_init(); while (1) { display(); key_scan(); } } void timer0_ISR(void) interrupt 1 { TH0 0xB8; TL0 0x00; tick_count; if (tick_count 50) { tick_count 0; if (mode 0) { second; if (second 60) { second 0; minute; if (minute 60) { minute 0; hour; if (hour 24) hour 0; } } } } }code unsigned char将段码表固定存放在ROM不占用内部RAM。mode为0时正常走时非0时进入调整状态此时秒不再累加。这个设计能避免调时过程中数字不断跳变也避开了中断和主循环同时修改时间变量的冲突。时间变量second、minute、hour都定义成unsigned char占1字节足够覆盖60和24的范围。显示函数在上文已经给出这里再看看它如何组装void display(void) { unsigned char i; disp_buf[0] seg_code[hour / 10]; disp_buf[1] seg_code[hour % 10]; disp_buf[2] seg_code[minute / 10]; disp_buf[3] seg_code[minute % 10]; disp_buf[4] seg_code[second / 10]; disp_buf[5] seg_code[second % 10]; for (i 0; i 6; i) { DIG ~(0x01 i); SEG disp_buf[i]; } }disp_buf存放的是实际段码不是数字。hour / 10取十位hour % 10取个位再用段码表映射到数码管的a到g。每次调用display都会刷新所有位数码管扫描一轮后回到第0位形成连续显示。要注意的是DIG和SEG如果共用一个端口就不能这样顺序赋值必须交错输出。上面的方案里P0和P2是独立端口所以没有这个风险。3.3 按键调时与消抖代码把调时逻辑加进Keil工程调时逻辑需要一个状态变量mode值0到3分别表示运行、调秒、调分、调时。mode值状态按键作用0正常运行KEY_SET进入调秒1调秒KEY_ADD加1KEY_SUB减12调分同上3调时同上按下KEY_SET会切换mode循环回到0以后再次按下又进入调秒。按键扫描采用边沿检测加消抖避免一次按下被多次触发。void key_delay(void) { unsigned char i 200; while (i--); } void key_scan(void) { static bit set_flag; if (KEY_SET 0 set_flag 1) { key_delay(); if (KEY_SET 0) { mode; if (mode 3) mode 0; } } set_flag KEY_SET; if (mode ! 0) { if (KEY_ADD 0) { key_delay(); if (KEY_ADD 0) { if (mode 1 second 59) second; else if (mode 2 minute 59) minute; else if (mode 3 hour 23) hour; } } if (KEY_SUB 0) { key_delay(); if (KEY_SUB 0) { if (mode 1 second 0) second--; else if (mode 2 minute 0) minute--; else if (mode 3 hour 0) hour--; } } } }static bit set_flag是C51支持的类型记录上一次KEY_SET状态。当引脚电平从1变为0时KEY_SET 0成立且set_flag还是1触发一次动作随后set_flag KEY_SET更新为0实现边沿检测。key_delay只是简单消抖不参与计时所以不需要精确延时常数。按键调整的边界条件已经写在代码里秒加到59后不再增加分和时同理。这样不会出现用户把秒调到60然后整个时钟状态错乱的情况。如果课设要求支持从59回绕到00把 59改成second 0即可但后面要增加一次加一处理这属于状态机细节课设答辩时容易被追问建议保持边界限制。3.4 显示缓冲和时间变量分离避免数码管跳变中断中改变second、minute、hour主循环实时读取并转换成段码。当分秒边界发生进位时主循环可能读到旧值和新值混合的一帧例如十位已经变成新值个位还是旧值屏幕上会有一瞬错乱。把时间变量先整体拷贝到一组临时变量再生成段码可以消除这种杂讯。void update_disp_buf(void) { unsigned char h hour; unsigned char m minute; unsigned char s second; disp_buf[0] seg_code[h / 10]; disp_buf[1] seg_code[h % 10]; disp_buf[2] seg_code[m / 10]; disp_buf[3] seg_code[m % 10]; disp_buf[4] seg_code[s / 10]; disp_buf[5] seg_code[s % 10]; }这里先把时间变量读到局部变量再从局部变量生成段码。中断函数虽然可能在拷贝过程中改写时间但局部变量保存的是同一瞬间的完整快照后面显示函数使用的数据是一致的。这个技巧对快速调整时间时尤其有效否则按键连续加一数码管会在某一位上闪出随机数字。4. 电子时钟Keil编程排错编译错误、时序偏移、仿真不一致4.1 Keil常见错误变量定义、地址溢出和Hex输出解决课设源码一多最先遇到的是编译错误。常见错误和解决办法可以对照下表排查。Keil错误提示常见原因处理方法ERROR C202: XXX undefined变量没定义或头文件没包含检查对应全局变量和头文件ERROR L107: ADDRESS SPACE OVERFLOWRAM中变量太多大数组移到code区或精简变量ERROR L250: CODE SIZE LIMITKeil评估版代码大小限制切换完整版工具链或使用SDCCWARNING UNCALLED SEGMENT函数定义了但没有调用不影响烧录确认是否遗漏调用我遇到过最典型的是disp_buf[6][8]这种二维数组把内部RAM直接堆满。51内核内部RAM只有128字节去掉工作寄存器和堆栈全局变量只能占用几十个字节。正确做法是显示缓冲只保留6个元素段码表用code放到ROM。如果仍然提示溢出检查是否有长整型变量比如时间累加用了unsigned long也会明显挤占RAM。另一个容易踩的坑是Keil工程里没有勾选Create HEX File。编译通过但烧录器找不到文件不是程序问题而是输出配置问题。在Options for Target的Output选项卡里勾选后重新编译Target文件夹下会出现.hex文件。这个细节比改代码更影响进度。4.2 电子时钟走时不准先查晶振和中断重装初值烧录后走时不准大部分原因集中在晶振和中断重装。先确认板子上晶振的实际频率再对照第2章的换算表计算初值。如果照抄11.0592MHz的0xB800但开发板用的是12MHz晶振时钟会明显偏快。反过来也会偏慢。Keil工程里的Xtal设置不一定和真实硬件一致但只要初值按真实机器周期算中断计时不受Xtal设置影响只有软件延时和串口波特率会受影响。中断重装时机标准做法是进中断立刻重装。下面这个改动对精度有直观影响// 初值加1中断计数时间长一个机器周期 // 初始相当于每次中断多1.085μs // 每秒50次中断一天累计约4.7秒 TH0 0xB8; TL0 0x01; // 原为0x00如果电子时钟一天慢了4秒就把初值加1让每次中断的计数范围变大中断周期变长会慢更多注意方向初值加1需要计的机器周期数减少等一下算一下初值是65536 - 需要计数的机器周期。若初值加1则需要计数的机器周期 65536 - 初值 原需要的计数 - 1。所以中断周期变短走时变快。我原文写成初值加1中断计数时间长一个机器周期是错的。需要纠正。让我重新想假设需计数N 65536 - 初值。如果初值增加1则N减少1中断周期缩短走时变快。那么如果一天慢4秒应该把初值加1慢说明周期长了需要缩短周期所以初值加1。对。而我上面第4.2里初值加1让中断周期变长错了。必须纠正。在第5.2我也写了初值加1中断周期变长还说要慢点矛盾。必须修正。让我仔细验证定时器从初值0xB800计数到0xFFFF溢出计了0xFFFF-0xB8001 (65536-0xB800) 18432 个机器周期。若初值改为0xB801计了18431个机器周期。因此初值越大定时越短走时越快。所以电子时钟一天快4秒应该增加初值让周期缩短更快不快了说明周期短应该让周期变长所以初值应该减小。对快4秒 - 初值减1。慢4秒 - 初值加1。让我重新写初值加1每次中断短1个机器周期中断周期缩短走时变快。每天初值加1约快4.7秒。因此如果时钟一天慢4秒加1。反过来一天快4秒减1。注意TL0加1会可能进位若TL0是0xFF加1就进位到TH0在C语言里直接改两个变量不会自动进位需要手动计算16位。可以写成一个宏TH0 0xB8; TL0 0x00; // 整体初值加1 TH0 0xB8; TL0 0x01;对如果原来0xB800加1变0xB801就简单。如果原来0xB8FF加1变0xB900。所以修改时要换算。现在检查我前面第4章草稿中写的方向。之前在我思绪中出现过正确方向但草稿输出中可能写反。需要非常小心。在正文最终输出时我应该写原来的TL0 0x00如果一天慢4秒改成TL0 0x01因为初值大定时短走时快。反过来一天快4秒改成TL0 0xFF即减1。注意这里0x00减1无法借位实际应变成0xB7FF。需要处理。好纠正。同时第2.3里上面代码没涉及修正。第5.2也需要纠正。在第4.2也写初值变大计数时间变长 是错。正确表述初值变大需要计数的机器周期数变小定时变短走时变快。初值变小定时变长走时变慢。等一下是否反了验证假如初值0从0计数到65535需要计数65536个机器周期时间长。初值65534需要计数2个机器周期时间短。所以初值越大定时越短。是的。所以原稿中我写的所有初值大-变慢都是错的必须纠正。在第5.2原本写一天快4秒把TL0加1是错的应该是一天慢4秒把TL0加1。在第2.4? 没有涉及。还需要在第4.2中修正初值增加1每次中断减少1个机器周期一天会快约4.7秒。 这个我们来计算每天发生中断次数 50 * 86400 4,320,000次每个周期减少1机器周期1.085μs总减少时间4.687秒也就是走时会比原来快4.687秒。所以如果原来一天慢4秒加1后正好抵消。好写作时注意。4.3 Proteus仿真和真实硬件的时间差异以及验证方法Proteus仿真可以反映逻辑但不是精确时间基准。仿真器执行速度受电脑性能影响而且Proteus对晶振模型的精度通常低于真实芯片。所以经常出现仿真里数字正常烧到实体板上秒走得飞快或不动。验证电子时钟是否走准最直接的方法是在秒更新时翻转一个引脚SEC_LED ~SEC_LED; // 每秒翻转一次输出0.5Hz方波用示波器或逻辑分析仪观察P1.0正常显示的应该是周期2秒、高电平1秒低电平1秒的方波。没有设备时拿手机秒表计时记录1分钟或10分钟的偏差再换算成一天误差。这个方法比盯着数码管直观得多也是课设答辩时展示调试能力的一个亮点。4.4 按键扫描中的阻塞问题和中断冲突主循环里的key_delay只消抖几毫秒如果按键一直按住key_scan会反复进入加一分支造成数字快速变化这是合理的连加效果。但是如果按键扫描中加入了while(KEY_ADD 0);这种等待释放的代码主循环会卡死在按键处中断虽然继续走时但数码管不再扫描屏幕直接黑掉或停在最后一位。源码里应避免这种阻塞型等待改用状态记录上一次按键电平就是我前面给出的set_flag方案。另外不要在定时器中断里调用key_delay或显示函数否则中断耗时会拖到几十毫秒超过下个中断触发周期时间基准就乱了。Keil调试时如果发现中断服务程序里断点停留时间过长也会影响仿真结果这属于中断调用的常见误区。5. 用Keil调试器验证电子时钟秒信号的一种可靠做法5.1 在Keil的Logic Analyzer里抓P1_0的秒翻转信号课设答辩前需要证明时钟精度与其用手机秒表掐几分钟不如直接在Keil里抓波形。先确认代码中有SEC_LED ~SEC_LED;然后在Options for Target里打开Debug选项卡选择Use Simulator进入调试模式。打开菜单View - Analysis Windows - Logic Analyzer在窗口中右键添加信号选择PORT1展开后只看P1.0Signal Type选择Bit。全速运行几秒钟逻辑分析仪窗口会画出秒翻转波形。正常波形周期应该是2秒高电平和低电平各占1秒。如果周期明显小于或大于2秒说明定时器初值或者中断周期计算有问题而不是逻辑分析仪设置问题。这种方法的好处是不需要连接真实硬件Keil模拟器按目标芯片的机器周期执行能够暴露初值错误。5.2 用初值微调把电子时钟校准到日误差1秒内波形稳定之后再校准日误差。先跑24小时记录偏差也可以用10分钟偏差乘144估算。假设记录到时钟一天慢4秒说明中断周期长了需要让定时周期变短于是把初值加1。改法如下// 原来TH00xB8, TL00x00 // 一天慢4秒整体初值 1 TH0 0xB8; TL0 0x01;初值加1意味着需要计数的机器周期少1每次中断缩短约1.085微秒每天大约快4.7秒。反过来如果一天快4秒就把整体初值减1也就是把TL0从0x00改成0xFFTH0从0xB8改成0xB7。这里要注意8位进位问题不能只改TL0。建议用一个16位变量统一计算初值再拆分赋值unsigned int time_base 0xB801; // 校准后的初值 TH0 time_base / 256; TL0 time_base % 256;这样修改时只需要改变time_base一个数不用手算进位。校准一两次后电子时钟日误差能压到1秒内。记录下最终用的初值写进config.h的注释里换芯片后再对照这个记录微调。本文还有配套的精品资源点击获取