简介:在嵌入式入门与课程设计中,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 工作在模式 1:16 位定时 */ 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 下最大定时 | 是否自动重装 | 电子时钟里合适吗 |
|---|---|---|---|---|
| 模式 0 | 0x00 | 8.192ms | 否 | 不推荐,精度和余量都差 |
| 模式 1 | 0x01 | 65.536ms | 否 | 推荐,配 50ms 最顺手 |
| 模式 2 | 0x02 | 0.256ms | 是 | 适合做串口波特率发生器 |
| 模式 3 | 0x03 | 拆分使用 | 否 | 一般不用,除非要两个独立定时器 |
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 到 50ppm,1ppm 换算到一天是 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 | 帧头,固定 0xA5 | 0xA5 |
| 1 | 时(BCD 或十六进制) | 0x14 |
| 2 | 分 | 0x30 |
| 3 | 秒 | 0x00 |
| 4 | 前四字节异或校验 | 计算值 |
void Uart_Init(void) /* 12MHz 晶振,9600bps,SMOD=1 */ { SCON = 0x50; /* 模式 1,允许接收 */ TMOD &= 0x0F; TMOD |= 0x20; /* 定时器 1 模式 2,做波特率发生器 */ TH1 = 0xE6; TL1 = 0xE6; /* 12MHz + SMOD=1 下的 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 会一直停在停止状态,表现为"串口发了校时命令、显示也确实变了,但秒针就是不动",这个现象在课设现场问倒过不少人。
本文还有配套的精品资源,点击获取