ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

51单片机电子时钟课设:定时器中断与DS1302驱动全解析

51单片机电子时钟课设:定时器中断与DS1302驱动全解析 简介一套基于51单片机的电子时钟课设源码工程使用Keil开发工具编写包含DS1302实时时钟驱动与LCD1602液晶显示控制采用按键调校时间。适用于单片机课程设计、毕业设计或入门学习者可直接参考整体思路并复用核心代码。压缩包共27个文件主要涵盖C源文件、头文件、Keil工程配置文件、hex烧录文件和DSN仿真图整体大小约96KB小巧、便于快速下载与编译。目前已有2380人浏览学习项目覆盖时间芯片读取、时间格式转换、液晶屏刷新显示等关键知识点。通过分析源码与仿真工程可以理解主循环调度、按键消抖、多模块接口交互等常用单片机编程方法并可直接在此基础上增加闹钟、温度显示等扩展功能实用性和可读性都不错对于单片机原理与接口技术课程的综合训练也有较高参考价值。1. 电子时钟课设的真正难点不在“时钟”而在“基准与显示”单片机课设电子时钟本质上是一个“时间基准 显示刷新 按键交互”三件事同时跑的实时系统。很多同学在 Keil 里写完代码编译 0 错误 0 警告下载到板子上却发现要么秒跳得不均匀要么数码管闪得厉害要么调时间时按键怎么按都没反应。问题往往出在同一个地方你把显示刷新、按键扫描和时间计数全写在了一个死循环里又用 delay 来拖时间结果谁都在抢 CPU谁都没干好。这个课设的价值也正在于此。它逼你在一个极小的资源范围内51 单片机的 128 字节片内 RAM、2 个定时器、几个 I/O 口把任务拆开、把时间片分清楚。本文假设你用的是 Keil uVision5 C51 编译器 51 单片机STC89C52 或 AT89S52 皆可显示部分用 8 位数码管时钟芯片用 DS1302 作为可选扩展。下面这套做法是我自己做课设和帮人改代码时用得最多、也最不容易翻车的一套骨架。2. 拆开电子时钟的三层结构驱动、逻辑、任务调度2.1 先从 Keil 工程的物理分层说起一个能跑的 Keil 工程不建议把全部代码堆在一个 main.c 里。常见做法是拆成四个文件main.c放任务调度和主循环ds1302.c放时钟芯片驱动如果你用 DS1302 的话display.c放数码管段码和扫描函数key.c放按键扫描与消抖。这样做的好处在你调 bug 时才能体会出来——显示闪了先去 display.c 查刷新频率时间不对去 ds1302.c 查读写时序谁的问题一目了然。一个最小工程的 Keil 配置要点如下表配置项推荐值说明DeviceAT89C52 或 STC89C52RC课设最常见的芯片8K Flash够用Target 晶振频率12.000 MHz后面定时器初值计算都按这个来Output 勾选 Create HEX File必须勾没生成 hex 文件就没办法烧录Memory ModelSmall: variables in DATA课设代码量小Small 模式最省事Code Rom SizeLarge: 64K program防止编译报 C249 代码越界这里特别提醒一点Keil 里 Target 页面的晶振频率只影响软件仿真时的时序不烧进单片机。如果你实物用的 11.0592MHz 晶振但这里填 12MHz仿真出来的串口波特率会不对定时器仿真时长也会和实物差一点。所以填之前先看一眼你的板子用的到底是多少兆的晶振。2.2 数码管驱动里最容易踩的段码和极性坑驱动 8 位数码管显示时间常见方案是动态扫描——每次只点亮一位快速轮巡利用视觉暂留让所有位看起来同时亮。这里的核心是段码表和位选信号。以共阴数码管为例段码表长这样// 共阴数码管段码0-9DP 位在最高位 unsigned char code seg_table[] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F // 9 };但如果你拿到的是共阳数码管段码要做按位取反也就是0x3F变成0xC0。这个问题几乎每年课设都能看到有人卡住现象是显示的数字是乱的比如显示 0 出来是 8或者有一横不亮。判断方法是查原理图看公共端接的是 VCC 还是 GND公共端接 VCC 就是共阳代码里段码取反或者直接在段码表里写按位取反后的值。扫描函数我一般这样写// 在 main.c 中利用定时器中断定时 2ms 调用一次 void display_scan(void) { // 位选P2 低三位控制 74HC138 译码选通第 0~7 位 static unsigned char pos 0; unsigned char code *seg_ptr get_display_buffer(); // 获取显示缓冲区 P0 0x00; // 先消隐防止拖影/鬼影 P2 (P2 0xF8) | pos; // 选中当前位 P0 seg_ptr[pos]; // 输出段码 pos; if (pos 8) { pos 0; } }这段代码的逻辑是中断每 2ms 触发一次每次刷新一位8 位扫完一轮需要 16ms刷新频率约 62.5Hz。这个频率下看不到闪烁也不会因为刷新太频繁占用过多 CPU。注意第一行P0 0x00是关键它叫消隐。如果省略你会看到明显的“拖影”——前一位的残影残留在数码管上数字边缘发虚。2.3 按键扫描不能用 delay 等松手按键部分最常见的翻车写法是这样的// 错误示例按下后 delay 20ms再等松手 if (key 0) { delay_ms(20); ... while (key 0); // 死等松手 }这个写法在功能上能跑但没有电子时钟的“实时”概念了。死等松手期间数码管不再刷新显示会闪一下时间计数也停了。更合理的做法是在主循环里定义按键扫描周期每 10ms 读一次按键电平检测到下降沿从按下到松开才认为是有效按键同时用标志位传递给时间调整逻辑。// key.c每隔 10ms 调用一次 unsigned char key_scan(void) { static unsigned char last_state 0xFF; // 上一次按键状态 unsigned char cur_state; unsigned char key_event 0; // P1 低四位接按键按下为 0 cur_state P1 0x0F; // 检测下降沿之前是高电平现在是低电平 if ((last_state ! cur_state) (cur_state ! 0x0F)) { key_event cur_state; } last_state cur_state; return key_event; }这个方案用状态机的思路消抖不需要 delay。每次读取都同步更新 last_state只有状态发生变化的那一次才返回有效事件。后续在主循环里根据 key_event 的值来决定是切换调整位还是加一减一。3. 用定时器中断搭建“时间基准”初值计算与溢出补偿3.1 为什么不用 delay 计数有些同学的第一个版本是用 delay 制造大约 1 秒的延时然后让秒加一。这个做法在纯软件层面完全无法保证精度——delay 的实际耗时受 for 循环编译优化影响、受中断干扰影响甚至受温度影响。你想让课设答辩时老师看到秒跳得均匀就必须用定时器中断来建立时间基准。定时器中断的原理是硬件计数器按机器周期计数计满溢出后自动跳转中断服务函数我们在中断里对“秒”这个变量做累加。只要定时器初值不变中断触发的时间间隔就是固定的。3.2 12MHz 晶振下定时器初值的完整计算51 单片机的机器周期是 12 个时钟周期。所以 12MHz 晶振下机器周期 12 / 12MHz 1µs。我们要让定时器中断每 50ms 触发一次然后累计 20 次得到 1 秒。那么需要计数 50000 个机器周期。定时器工作在模式 116 位时最大计数是 65536。初值 65536 - 50000 15536 0x3CB0。这里有个细节定时器从初值开始向上计数计到溢出需要 50000 个脉冲所以初值是 65536 减去计数值别写反。// main.c 中定时器 0 的初始化 void timer0_init(void) { TMOD 0xF0; // 只修改定时器 0 相关位 TMOD | 0x01; // 定时器 0模式 116 位 TH0 0x3C; // 高 8 位50000 0xC350 TL0 0xB0; // 低 8 位0x3CB0 ET0 1; // 开定时器 0 中断 EA 1; // 开总中断 TR0 1; // 启动定时器 0 }TMOD 寄存器低 4 位控制定时器 0高 4 位控制定时器 1。很多人的代码直接TMOD 0x01如果后面还要用定时器 1 做串口波特率就会把定时器 1 的模式误清掉。所以我习惯先TMOD 0xF0再用或运算设置本定时器的模式。3.3 中断里的时间累加逻辑// 全局变量volatile 防止编译器优化 volatile unsigned char timer_count_50ms 0; volatile unsigned char sec 0; volatile unsigned char min 0; volatile unsigned char hour 0; void timer0_isr(void) interrupt 1 { // 重装初值 TH0 0x3C; TL0 0xB0; timer_count_50ms; if (timer_count_50ms 20) { // 20 * 50ms 1s timer_count_50ms 0; sec; if (sec 60) { sec 0; min; if (min 60) { min 0; hour; if (hour 24) { hour 0; } } } } }中断里用了 20 次计数累计成 1 秒而不是直接在中断里判断到了 50000 就秒加一——后者的做法意味着你必须每次重装初值都精确而多了一层timer_count_50ms缓冲之后即使有一次中断被高优先级打断导致略微延迟误差也不会累积到显示层因为中断本身每次触发都会重装初值硬件计数是连续的。定时器中断里做秒累加时主循环只管读变量去显示不需要对实时性做过多的等待。注意变量加了volatile关键字。没有这个关键字时Keil C51 的优化器可能把变量优化进寄存器你在主循环里读到的值可能是旧的缓存值表现出来就是时间不走。4. 让时间掉电不丢DS1302 读写时序与 Keil 下的对齐策略4.1 为什么要引入 DS1302定时器中断做的时间基准掉电之后时间就清零了。很多课设要求“电子时钟”具备掉电走时的能力或者要求在开发板上断电后重新上电时间仍能继续走。这就要引入 DS1302——一个带电池备份的实时时钟芯片。它通过三根线CE、SCLK、I/O和单片机通信每次读写 8 位数据时序是标准的 SPI 变体。4.2 寄存器地址表和写时序DS1302 的寄存器地址分成读写两部分。要写入秒寄存器地址是 0x80读是 0x81写分钟是 0x82读 0x83写小时是 0x84读 0x85。更关键的是控制寄存器写地址 0x8E数据最低位 WP 为 0 才允许写为 1 则是写保护。很多人写完时间后没有再拉 WP 位结果下次写失败问题就出在这。// ds1302.c 中字节写入的时序 void ds1302_write_byte(unsigned char addr, unsigned char dat) { unsigned char i; DS1302_CE 0; DS1302_SCLK 0; DS1302_CE 1; // 拉高 CE启动通信 for (i 0; i 8; i) { // 先发地址字节 DS1302_IO addr 0x01; // 低位在前 DS1302_SCLK 1; DS1302_SCLK 0; addr 1; } for (i 0; i 8; i) { // 再发数据字节 DS1302_IO dat 0x01; DS1302_SCLK 1; DS1302_SCLK 0; dat 1; } DS1302_CE 0; // 结束通信 }这段代码的要点是地址字节和数据字节都是低位先发每个 bit 靠 SCLK 上升沿锁存。所以我先拉低 SCLK设置 IO 电平再拉高 SCLK再拉低完成一个 bit 的传输。DS1302_CE 必须在整个传输过程中保持高电平如果中途被拉低通信直接终止。在实际调的时候DS1302 对时序要求不高这反而容易出问题——很多人想当然认为“慢一点没关系”然后用 delay 函数在两行语句之间插入大量延时。delay 越大越容易在写的过程中出现数据错位因为 SCLK 的高电平时间过长芯片内部可能把下一个 bit 提前采样了。我一般只在 CE 拉高之前加一个短延时其余语句之间不加。4.3 读 DS1302 与 BCD 码转换从 DS1302 读出来的数据是 BCD 码不能直接塞给数码管显示。比如秒寄存器里存的 0x59代表 59 秒而不是十进制 59。处理方式有两种一种是乘除转换一种是查表。课设代码量小我倾向直接用移位加乘除简单直观// ds1302.c 读取数据 unsigned char ds1302_read_byte(unsigned char addr) { unsigned char i, dat 0; DS1302_CE 0; DS1302_SCLK 0; DS1302_CE 1; for (i 0; i 8; i) { // 发送地址读命令 DS1302_IO addr 0x01; DS1302_SCLK 1; DS1302_SCLK 0; addr 1; } DS1302_IO 1; // 释放 IO 线准备读数据 for (i 0; i 8; i) { dat 1; // 先收到的位是低位 if (DS1302_IO) { dat | 0x80; } DS1302_SCLK 1; DS1302_SCLK 0; } DS1302_CE 0; return dat; } // BCD 转十进制 #define BCD_TO_DEC(x) (((x) 4) * 10 ((x) 0x0F))读数据时要注意DS1302_IO 1这一步。IO 是三态口单片机写地址时 IO 是输出模式写完地址后如果还保持输出状态读到的数据必然是错的。先置 1再把引脚切换成输入状态才能正确读回数据。还有一个容易忽略的坑写 DS1302 之前必须先关闭写保护即写地址 0x8E、数据 0x00。上电默认 WP 是 1如果不关保护后面所有写寄存器操作都无效但读操作正常所以现象会很迷惑——显示时间能走到 12:00但修改时间写不进去。5. 验证与排错从 Keil 仿真到实物板的高频问题定位5.1 用 Keil 的 Logic Analyzer 验证时基是否准确写完代码先别急着烧录Keil 的 Debug 模式可以帮你做初步验证。进入 Debug 后打开 Peripherals - Timer - Timer 0可以看到 TH0/TL0 的实时值和 TF0 标志。单步运行到定时器中断入口确认重装值确实写入成功。再用 Peripherals - Interrupt 窗口观察 EA 和 ET0 是否为 1。显示刷新问题则用 Logic Analyzer 把 P0.0 和 P2.0 加进来观察是否有规律的周期跳变周期应该在 2ms 左右换算下来每 16ms 完成 8 位扫描。软件仿真下有个坑仿真时晶振频率按 Target 页面的设置来如果你的代码里用_nop_()这种毫秒级延时函数仿真时可能因为频率设置不一致而跑出完全不同的时间。所以我在仿真时只验证逻辑波形不验证时间精度。时间精度要靠实物。5.2 课设高频报错对照表报错信息原因处理办法ERROR C141: syntax error near code段码数组声明写错位置数组加code关键字时确认是全局变量ERROR C249: CODE: too large代码量超出芯片 Flash检查有没有把大数组定义进 code 段把不用的调试代码注释掉WARNING C280: unreachable codewhile 后面直接写了代码死循环后别放执行语句删掉ERROR L107: ADDRESS SPACE OVERFLOW变量太多DATA 空间溢出把大数组移到 xdata或者改用 Small 模型并精简全局变量MISSING ; BEFORE }位选变量类型写错检查unsigned char是否漏写常见于 pos 定义处5.3 一个实用的调试技巧用“秒闪 LED”验证中断精度最后分享一个我常用的验证手段。把 P1.0 接一个 LED在秒变量加一之后翻转一次这个引脚的电平。程序跑起来之后用万用表或逻辑分析仪测 P1.0 的翻转周期。如果两次翻转间隔是 2 秒说明时基误差在可接受范围如果明显快或慢超过 0.2 秒就要回头检查晶振频率和初值计算。这个方法比盯着数码管看“秒跳得准不准”要可靠得多因为人眼的判断误差本身就超过 0.1 秒。调 DS1302 时也可以把 DS1302 读出来的秒值和系统自走的秒值同时打印到串口对比两者是否同步。如果你在 Keil 里用了软件仿真看时间那么记得把sec变量加进 Watch 窗口中断触发一次就能精确看到它是否只加一。本文还有配套的精品资源点击获取
返回列表