51单片机课程设计电子时钟:定时器中断、数码管扫描与DS1302串口校时

发布时间:2026/9/19 1:04:33
51单片机课程设计电子时钟:定时器中断、数码管扫描与DS1302串口校时 简介在嵌入式入门与课程设计中51单片机常被用来理解“定时、显示、交互、通信”这套基础工程链路。其核心原理是利用定时器中断产生稳定时基再通过IO口动态扫描驱动数码管或LCD1602完成显示机械按键需要消抖时间数据常以BCD码存储掉电保持则依赖DS1302等时钟芯片。把这些模块组合起来技术价值不只是做出一个电子时钟而是掌握中断调度、外设驱动、串口校时和误差分析等通用能力。典型应用场景包括单片机课程设计、实验板验证、Proteus仿真到实物调试。围绕51单片机课程设计电子时钟从定时器0初值、数码管动态扫描、按键状态机到DS1302掉电走时与串口校时逐步定位走时误差和验收细节便能把一个课设题目做成可演示、可验收的工程练习。1. 从单片机课程设计电子时钟说起为什么它仍是 51 单片机最值得做的一个题目每年做单片机课程设计电子时钟的人几乎都会撞上同一个现象Proteus 里指针走得规规矩矩一烧到板子上一晚上就慢两分钟或者数码管最高位总是比别的位暗一截又或者按键按一次跳两个数。这道题表面只是让数码管显示时分秒实际把 51 单片机最核心的几块内容全串起来了——定时器中断、IO 口动态扫描、按键消抖、BCD 码转换还常常要加掉电走时和串口校时。任何一环参数设错现象都会以时间不准这种最笼统的方式暴露出来。下面按一次完整课设的落地路径来写计时内核用定时器 0 还是 DS1302、显示用动态扫描数码管还是 LCD1602、三个按键怎么按才不抖、串口怎么发一条校时命令每一块都给出能直接抄进 Keil 工程的代码和需要调的参数。适合正在交课设的人也适合想把 51 定时器、串口和显示驱动重新捋一遍的从业者。默认技术栈是 Keil C51 加 STC89C52RC 或 AT89C52晶振 12MHz仿真用 Proteus实物用最小系统板配四位共阴数码管或 LCD1602都是课设里最容易拿到、资料最全的组合。2. 计时内核怎么定51 单片机定时器 0 中断与走时误差来源计时内核决定了这个电子时钟的上限。用软件定时器累加还是外挂 DS1302 这类走时芯片直接决定掉电之后时间还在不在、日误差能不能压住。而不管选哪条路51 单片机的定时器 0 都是要先吃透的一环。2.1 12MHz 晶振下把定时器 0 配成 50ms初值计算与 TMOD 设置51 单片机的一个机器周期等于 12 个振荡周期。12MHz 晶振时一个机器周期正好 1us于是定时器每计一个数就是 1us。16 位定时器最大能数 65536 个数也就是最长 65.536ms 一次所以想定时 1 秒必须分段。课设里最常见的分段方式是 50ms 一次、中断 20 次凑满 1 秒取初值 65536 − 50000 15536也就是 0x3CB0。#include reg52.h /* 12MHz 晶振下 50ms 的定时初值要按实际晶振频率重算 */ #define TH0_INIT 0x3C #define TL0_INIT 0xB0 static unsigned char tick50ms 0; /* 50ms 中断计数满 20 次为 1 秒 */ bit sec_flag 0; /* 秒标志交给主循环处理 */ void Timer0_Init(void) { TMOD 0xF0; /* 只清定时器 0 的配置位不动定时器 1 */ TMOD | 0x01; /* 定时器 0 工作在模式 116 位定时 */ TH0 TH0_INIT; /* 装载高字节 */ TL0 TL0_INIT; /* 装载低字节 */ ET0 1; /* 允许定时器 0 中断 */ EA 1; /* 打开总中断 */ TR0 1; /* 启动计数 */ } void Timer0_Isr(void) interrupt 1 { TH0 TH0_INIT; /* 模式 1 不自动重装必须在中断里补回初值 */ TL0 TL0_INIT; if (tick50ms 20) { tick50ms 0; sec_flag 1; /* 这里只置标志不做显示、不做按键 */ } }模式 1 最大的坑就是不自动重装。如果中断函数里忘了写TH0/TL0那两行定时器会从 0x0000 一直数到溢出下一次中断间隔变成 65.536ms一秒就被拉成 1.31 秒一晚上能快出好几个小时。这也是课设里时间越走越快最常见的原因而不是什么玄学。定时器模式TMOD 低 4 位12MHz 下最大定时是否自动重装电子时钟里合适吗模式 00x008.192ms否不推荐精度和余量都差模式 10x0165.536ms否推荐配 50ms 最顺手模式 20x020.256ms是适合做串口波特率发生器模式 30x03拆分使用否一般不用除非要两个独立定时器2.2 中断服务函数只置标志显示和按键留在主循环中断里做的事情越少越好。数码管动态扫描要 1ms 左右刷一位LCD1602 写一条指令要几十微秒按键消抖还要延时 10 到 20ms这些都塞进中断里会让 50ms 定时本身被拖偏。正确的分工是中断只负责数时间和置标志主循环负责刷新显示、扫描按键、解析串口。void main(void) { unsigned char hour 12, min 0, sec 0; Timer0_Init(); while (1) { if (sec_flag) { /* 主循环里做真正的秒进位 */ sec_flag 0; if (sec 60) { sec 0; if (min 60) { min 0; if (hour 24) hour 0; } } } Key_Scan(); /* 按键扫描 */ Display_Refresh(hour, min, sec); /* 显示刷新 */ Uart_Poll(); /* 串口校时帧检查 */ } }把进位放在主循环还有一个好处调试时可以在sec_flag处下断点观察中断到底有没有按 20 次一秒地进来比盯着数码管猜要快得多。2.3 走时误差的三个来源晶振精度、中断重装、计数方式第一个来源是晶振本身。普通直插 12MHz 晶振的精度大概在 20 到 50ppm1ppm 换算到一天是 86.4ms也就是说 20ppm 一天就差 1.7 秒50ppm 一天差 4.3 秒。这是物理层面的软件改不了只能靠后期校准或者换温补晶振。第二个来源是初值算错。很多课设板子为了配合串口通信装的是 11.0592MHz 晶振而代码里的初值还是按 12MHz 抄的。11.0592MHz 下一个机器周期是 12 ÷ 11.0592 ≈ 1.0851us同样数 50000 个数实际是 54.255ms一天下来会快差不多 2 小时。这种情况下要按实际机器周期重算11.0592MHz 下取 46080 个数才是 50ms初值为 65536 − 46080 19456 0x4C00。/* 11.0592MHz 晶振专用一个机器周期约 1.0851us 50ms / 1.0851us ≈ 46080 个数初值 65536 - 46080 0x4C00 */ #define TH0_INIT 0x4C #define TL0_INIT 0x00第三个来源是进位方式。用unsigned char存秒没问题但如果把总秒数存在unsigned int里做除法换算每次中断都要跑一次除法会明显拉长中断响应时间。更稳的写法是单独用秒、分、时三个字节只在需要显示时再转 BCD。3. 显示方案怎么选数码管动态扫描与 LCD1602 驱动显示部分决定了这个课设的观感分也决定了你要花多少时间在接线和调试上。四位共阴数码管便宜、直观、适合展示动态扫描功力LCD1602 字符多、不用反复算段码两者在课设里都很常见但驾驶方式完全不同。3.1 数码管动态扫描的段码表、位选与消隐共阴数码管的段码是 a 到 g 加小数点对应 P0.0 到 P0.7共阳则整体取反。动态扫描的本质是一次只点亮一位靠人眼视觉暂留连成完整数字所以必须解决两个问题段码给对了没有以及上一位关闭干净了没有。后者叫消隐不消隐就会出现上一位的笔画串到下一位上。/* 共阴段码表索引 0~9 分别对应数字 0~9 */ const unsigned char SegCode[10] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; void Display_Refresh(unsigned char hour, unsigned char min, unsigned char sec) { static unsigned char pos 0; unsigned char digit; /* 先消隐段选全灭再切换位选这一步不能省 */ P0 0x00; switch (pos) { case 0: digit hour / 10; P2 0xFE; break; /* 位选 P2.0 有效 */ case 1: digit hour % 10; P2 0xFD; break; case 2: digit min / 10; P2 0xFB; break; case 3: digit min % 10; P2 0xF7; break; default: digit 0; break; } P0 SegCode[digit]; /* 给段码 */ pos (pos 1) 0x03; /* 四位循环 */ }这段代码每次调用只刷一位所以主循环跑得越快刷新越均匀。为了不让刷新速度受按键和串口影响实践中更稳的做法是把这段搬进 1ms 的定时器中断里单独刷秒标志再交给主循环用。位选接到 P2 口的高四位还是低四位取决于板子的走线改P2的赋值即可但注意位选有效电平是低还是高共阴数码管配三极管驱动时往往是低电平有效。3.2 LCD1602 的初始化指令序列与忙标志等待LCD1602 是标准的 HD44780 控制器上电后必须按固定顺序发指令顺序错了就是白屏或者一行方块。常用的 8 位模式初始化序列是功能设置 0x38、显示开且不显光标 0x0C、写入后地址自增 0x06、清屏 0x01。清屏指令需要约 1.52ms 才能执行完所以后面必须给足延时。sbit LCD_RS P2^5; sbit LCD_RW P2^6; sbit LCD_EN P2^7; #define LCD_DATA P0 void LCD_WriteCmd(unsigned char cmd) { LCD_RS 0; /* 写指令 */ LCD_RW 0; /* 写操作 */ LCD_DATA cmd; LCD_EN 1; LCD_EN 0; /* 使能下降沿锁存 */ DelayMs(2); /* 清屏等指令需要 1.52ms 以上统一给 2ms */ } void LCD_Init(void) { DelayMs(15); /* 上电后等待内部复位 */ LCD_WriteCmd(0x38); /* 8 位数据接口两行显示5x7 点阵 */ LCD_WriteCmd(0x0C); /* 开显示关光标关闪烁 */ LCD_WriteCmd(0x06); /* 写一个字符后地址自动加一 */ LCD_WriteCmd(0x01); /* 清屏 */ }如果不想死等延时可以读忙标志 BF把 RS 拉低、RW 拉高读数据口的最高位为 1 表示还在忙。但课设里用固定延时更简单代价是刷新率低一些对电子时钟这种秒级刷新完全够用。对比项四位共阴数码管LCD1602占用 IO段选 8 位加位选 4 位数据 8 位加控制 3 位驱动难度需要段码表、消隐、限流电阻指令序列固定调试直观显示内容只能显示数字和少量字母可显示数字、字母、符号刷新负担需要 1ms 级动态扫描只在数值变化时重写课设里的优势体现扫描和定时器功底出成果快答辩好讲3.3 Proteus 仿真与实物之间的三个差异第一个差异是限流电阻。仿真里数码管不加限流电阻照样亮实物上必须每一位串 220Ω 到 470Ω否则段电流超过 20mA时间长了会烧数码管或者打坏单片机 IO。第二个差异是驱动能力。仿真不体现 P0 口开漏的问题实物上 P0 口驱动数码管段选时必须加上拉电阻典型值 4.7k 到 10k。第三个差异是时钟源仿真里的晶振频率是理想值实物晶振有偏差同一天里仿真版和实物版走时不一致几乎必然发生别拿仿真结果去判断走时准不准。4. 按键设置、DS1302 掉电走时与串口校时能显示时间只是及格线课设的加分项通常落在能不能改时间掉电后时间还在不在能不能用上位机对时这三件事上。它们分别对应按键交互、外部走时芯片和串口通信。4.1 三个按键的状态机与 20ms 消抖三个按键各自的职责通常是K1 切换调整位时/分/秒K2 当前位加一K3 确认退出。用状态机来描述比一堆if嵌套清楚得多也更容易讲给老师听。状态正常显示调整时调整分调整秒K1 按下进入调整时切换到调整分切换到调整秒回到正常显示K2 按下无动作时加一满 24 归零分加一满 60 归零秒清零K3 按下无动作保存并退出保存并退出保存并退出机械按键按下瞬间会有 5 到 10ms 的抖动检测到低电平之后延时 20ms 再确认一次是最省事的做法。但如果Key_Scan()放在主循环里延时会拖慢显示刷新所以更常见的写法是检测到变化就算一次不做阻塞延时靠主循环本身的高频轮询来过滤。unsigned char Key_Scan(void) { static unsigned char last 0xFF; /* 上次的电平状态0xFF 表示都没按 */ unsigned char now P3 0x07; /* 假设三个按键接 P3.0~P3.2低有效 */ unsigned char pressed 0; if (now ! last) { /* 电平变化才处理天然过滤长按重复 */ if (now ! 0x07) { /* 有键按下 */ pressed (~now) 0x07; /* 转换成位掩码 */ } last now; } return pressed; }这段代码不做阻塞延时代价是它对抖动只有一次采样实际要更稳可以在检测到变化后再延时 10ms 复查一次并更新last。参数上唯一要改的是P3 0x07里的掩码取决于按键接在哪几个引脚。4.2 DS1302 的读写时序与 BCD 码转换DS1302 走的是三线串行接口CE 使能、SCLK 时钟、IO 数据线都是自己用 IO 模拟的。它内部寄存器存的是 BCD 码也就是 0x23 表示 23不是十进制 23做显示前必须转换。这一步写错最典型的现象是到 9 之后跳到 16。sbit DS_CE P1^0; sbit DS_SCLK P1^1; sbit DS_IO P1^2; unsigned char DS1302_Read(unsigned char addr) { unsigned char i, dat 0; DS_CE 0; DS_SCLK 0; DS_CE 1; for (i 0; i 8; i) { /* 先发地址低位在前 */ DS_IO addr 0x01; addr 1; DS_SCLK 1; DS_SCLK 0; } for (i 0; i 8; i) { /* 再读数据同样是低位在前 */ dat 1; if (DS_IO) dat | 0x80; DS_SCLK 1; DS_SCLK 0; } DS_CE 0; return dat; } /* BCD 转十进制高四位乘 10 加低四位 */ unsigned char Bcd2Dec(unsigned char bcd) { return (bcd 4) * 10 (bcd 0x0F); } /* 十进制转 BCD除以 10 的结果左移四位加余数 */ unsigned char Dec2Bcd(unsigned char dec) { return ((dec / 10) 4) | (dec % 10); }读秒寄存器地址是 0x81读分是 0x83读时是 0x85写的话最低位改成 0。要注意秒寄存器的最高位是时钟停止位 CH上电第一次初始化时如果它是 0晶振就没起振读出来时间永远不变写的时候记得给 0x80。4.3 串口校时命令的帧格式与解析串口校时不需要复杂协议一个固定帧头加时分秒再加校验就够了。下面这套格式在任何串口助手里都能手工敲出来方便答辩现场演示。字节含义示例0帧头固定 0xA50xA51时BCD 或十六进制0x142分0x303秒0x004前四字节异或校验计算值void Uart_Init(void) /* 12MHz 晶振9600bpsSMOD1 */ { SCON 0x50; /* 模式 1允许接收 */ TMOD 0x0F; TMOD | 0x20; /* 定时器 1 模式 2做波特率发生器 */ TH1 0xE6; TL1 0xE6; /* 12MHz SMOD1 下的 9600 初值 */ PCON | 0x80; /* SMOD 1 */ TR1 1; ES 1; EA 1; } void Set_Time(unsigned char h, unsigned char m, unsigned char s) { if (h 24 m 60 s 60) { DS1302_Write(0x84, Dec2Bcd(h)); /* 注意写秒前先清 CH 位 */ DS1302_Write(0x82, Dec2Bcd(m)); DS1302_Write(0x80, Dec2Bcd(s)); } }解析时先确认帧头再算一遍异或跟第 5 字节比对对了才写入这样能挡掉大部分乱码。如果用的是 11.0592MHz 晶振波特率初值要换成 0xFD这一点跟前面定时器初值是同一类问题晶振换频率所有跟时间相关的常数都得跟着重算。5. 从能跑到能验收Keil C51 工程配置、编译期时间戳与日误差实测很多课设卡在最后一步不是功能没做出来而是细节没打磨Keil 里 Target 的晶振频率跟板子对不上、上电时间永远是 00:00:00、演示时被问你这一天差多少秒答不上来。最后一章就落在这些能立刻体现水平的点上。第一件事是把 Keil 的 Target 时钟频率改成实际值。Project → Options for Target → Target 页里的 Xtal 填 12.0 或 11.0592它只影响软件仿真时的计时但很多人用 Keil 的软件仿真验证延时函数频率填错就会得出延时不准的错误结论。第二件事是用编译期时间戳做上电初值。C51 编译器内置了__DATE__和__TIME__两个宏分别是编译日期和编译时间格式形如Jan 15 2025和14:32:07。把时分秒字段解析出来当作上电初始时间就能保证每次烧录后时钟都从接近当前时刻开始走省去反复手动对表。const char build_time[] __TIME__; /* 形如 14:32:07编译那一刻的时间 */ void Load_Compile_Time(void) { unsigned char h, m, s; /* 字符 0~9 减 0 得到数字两位数拼成十位和个位 */ h (build_time[0] - 0) * 10 (build_time[1] - 0); m (build_time[3] - 0) * 10 (build_time[4] - 0); s (build_time[6] - 0) * 10 (build_time[7] - 0); Set_Time(h, m, s); /* 写进 DS1302或者直接赋给软件时钟变量 */ while (DS1302_Read(0x81) 0x80) /* 等待 CH 位被清掉确认晶振起振 */ DS1302_Write(0x80, Dec2Bcd(s)); }注意__TIME__是编译时刻而不是烧录时刻中间隔多久误差就有多大做现场演示前重新编译一次即可。第三件事是测日误差。方法很朴素把板子通电记下数码管当前时间和标准时间的差值放一天后再记一次差值就是日误差。按前面算的普通晶振 20 到 50ppm 对应一天 1.7 到 4.3 秒如果实测一天差了几分钟那基本不是晶振问题回去查中断里有没有重装初值、晶振频率跟初值是否匹配。验收点检查方法典型故障现象秒进位观察 59 到 00 是否连续到 60 才归零说明判断写成 60走时精度记录 24 小时偏差偏差达分钟级查中断重装和晶振频率掉电保持断电 10 秒后上电回到初始值查 DS1302 后备电池按键调整三个键各按 20 次跳数查消抖和状态机切换串口校时串口助手发一帧 5 字节无反应先查波特率初值和 SMOD最后补一个容易被忽略的细节Set_Time里写秒之前一定要处理 CH 位否则 DS1302 会一直停在停止状态表现为串口发了校时命令、显示也确实变了但秒针就是不动这个现象在课设现场问倒过不少人。本文还有配套的精品资源点击获取