2026/11/2 3:41:41

基于AT89C51的单片机交通灯课程设计:C源码与Proteus仿真

基于AT89C51的单片机交通灯课程设计:C源码与Proteus仿真 简介面向单片机课程设计场景的交通灯控制系统结课报告围绕AT89C51实现十字路口红黄绿灯交替、数码管倒计时、按键调时与紧急通车控制适合电子、自动化相关专业学生参考课设方案与报告组织。资源包仅含1个docx文档约353KB正文按项目需求分析、硬件系统结构、AT89C51原理介绍、独立键盘扫描、时间显示电路、交通灯输出、紧急控制与时间调整、软件体系架构、运行效果展示及总结附录展开章节完整便于快速定位设计要点。目前已有2882人学习可作为交通灯课设的方案梳理、硬件模块理解、功能设计与结课报告撰写参考也能帮助读者复盘定时控制、倒计时显示、人机交互及仿真调试的实现思路适合需要完成同类单片机课设或补充报告素材的读者查阅。1. 每到期末就有人问的单片机课设到底长什么样每年这个时候问“单片机课设交通灯怎么做”的人总能挤满半个技术群。这份基于 AT89C51 的交通灯控制系统课程设计文档把需求分析、硬件原理图、软件流程图、完整 C 源码和仿真效果图整整齐齐放在一份 docx 里属于拿来就能照着搭、照着改的料。它要解决的事情很具体十字路口四个方向红黄绿灯的时序切换、双位数码管倒计时、紧急车辆一键全红放行、通行时间现场可调。硬件只用一片 AT89C51、六只 LED、八位数码管、一片 74HC138 译码器和五个独立按键开发环境是 Keil4 加 Proteus8.8。适合正在赶课设又不想从零啃数据手册的人也适合拿 51 练手状态机写法的入门者。下面按硬件骨架、定时时序、按键交互、仿真排错、参数化改造的顺序拆开讲每一步都能对上文档里的图号和代码行。2. 硬件骨架拆解从最小系统到 P1 口六路灯的电路闭环很多人拿到这类文档第一反应是直接抄代码烧进去结果灯不亮就开始怀疑人生。交通灯这种低速系统硬件出问题的概率远高于软件先把电路骨架捋清楚后面调代码才不至于抓瞎。这一章按信号流向走单片机怎么活起来、灯怎么被驱动、数码管怎么被选中、按键怎么被读进来。2.1 最小系统三件套晶振、复位与 EA 引脚AT89C51 要跑起来外围只需要三样东西时钟、复位、EA 拉高。时钟用 12MHz 石英晶振跨接在 XTAL1 和 XTAL2 之间两头各挂一只 30pF 瓷片电容到地这两只电容不是随便选的它们和晶振一起构成皮尔斯振荡电路容值偏差太大会导致起振困难或者频率漂移。复位电路走经典阻容方案10uF 电解电容正极接 VCC、负极接 RSTRST 再通过 10k 电阻接地同时并一个按键做手动复位。上电瞬间电容相当于短路RST 被拉到高电平随着电容充电电流减小RST 电位回落形成足够宽度的高电平复位脉冲。这里有个新手最容易漏的引脚EA第 31 脚。AT89C51 内部有 4KB Flash程序就存在里面EA 必须接高电平才能从内部 ROM 取指令。如果 EA 悬空或者误接地单片机会跑去外部总线找程序现象就是上电后毫无反应连复位都不管用。我见过不少人在这上面卡一整晚万用表量遍所有引脚都正常就是忘了 EA。时钟频率这件事值得多说一句。原文档里写了“内置最高频率高达 12Hz 的时钟电路”这是笔误实际是 12MHz。这个数字直接决定了后面定时器初值怎么算12MHz 晶振经过 12 分频后机器周期正好是 1us算初值时心算就能出结果。如果换成常见的 11.0592MHz机器周期变成 1.085us同样的 10ms 定时初值就完全不同这一点在第 5 章会展开。2.2 P1 口直驱 LED220Ω 限流与灌电流的取舍六只发光二极管分别代表东西南北四个方向的红黄绿全部挂在 P1 口上P1.0 到 P1.2 是东西方向的绿黄红P1.3 到 P1.5 是南北方向的绿黄红。原文档给出的接法是 LED 阳极经 220Ω 限流电阻接 VCC阴极接单片机引脚也就是低电平点亮、高电平熄灭这叫灌电流驱动。为什么选灌电流而不是拉电流51 单片机的 I/O 口内部结构是准双向口输出低电平时下拉管能吞进十几毫安电流输出高电平时靠内部弱上拉只能吐出几百微安连一只 LED 都点不亮。所以凡是直驱 LED 的场合一律接成低电平有效。220Ω 这个阻值对应的工作点是这样算的LED 正向压降按 1.8V 估限流电阻上的压降是 5V 减 1.8V 等于 3.2V电流 I 3.2 / 220 ≈ 14.5mA。这个电流对普通 φ5 红色 LED 足够亮又没超出单引脚 20mA 的绝对最大额定值。不过要注意总电流。AT89C51 整颗芯片的 VCC 和 GND 引脚能承受的电流有上限六个方向灯如果同时点亮实际上交通灯同一时刻最多亮三只东西绿加南北红这种组合总灌电流在 45mA 左右还在安全范围内。真正吃电流的是数码管下一节会说。代码里对应的位定义是这样的写代码前先把这些 sbit 定好后面所有逻辑都靠它们#include reg52.h typedef unsigned int u16; typedef unsigned char u8; // 东西方向三色灯低电平点亮 sbit DXG P1^0; // 东西 绿 sbit DXY P1^1; // 东西 黄 sbit DXR P1^2; // 东西 红 // 南北方向三色灯 sbit NBG P1^3; // 南北 绿 sbit NBY P1^4; // 南北 黄 sbit NBR P1^5; // 南北 红 // 74HC138 位选接 P2.2~P2.4 sbit LSA P2^2; sbit LSB P2^3; sbit LSC P2^4; // 五个独立按键 sbit K1 P2^0; // 紧急全红 sbit K2 P2^1; // 进入时间设置 sbit K3 P2^5; // 时间加 sbit K4 P2^6; // 时间减 sbit K5 P2^7; // 恢复显示位定义写完之后点灯就是一句DXG 0;。这套命名的好处是代码可读性极强状态机里一眼就能看出哪个方向在放行。参数上唯一要留意的是别把 P1.0 和 P1.1 的顺序写反绿黄红三色在物理排布上通常是竖直排列接线时按顺序插好代码里就不用再调整。2.3 共阳数码管加 74HC138 位选P0 送段码、P2 送位码倒计时显示是整个系统里最容易翻车的部分。四个路口各需要两位数字一共八位数码管如果每位都单独占用一组 I/O51 的引脚根本不够用。所以这里走的是动态扫描方案八位数码管的段引脚全部并联到 P0 口位选端由 74HC138 译码器控制同一时刻只让一位导通靠人眼视觉暂留把八位“拼”成一幅完整画面。先明确数码管的极性。原文档写的是共阳极接法共阳的意思是八段 LED 的阳极连在一起作为公共端公共端接高电平哪一段的阴极给低电平哪一段就亮。这个结论很重要因为它直接决定了段码表长什么样。共阳数码管显示数字 0需要 a、b、c、d、e、f 六段亮、g 和 dp 灭对应段码是 1100 0000也就是 0xC0。而原文档附录里的段码表是 0x3F、0x06、0x5B 这一串那是共阴极的表和硬件接法对不上这是文档里一个实打实的硬伤第 5 章会细说。位选这边用 74HC138 三线译码器A、B、C 三个输入端分别接 P2.2、P2.3、P2.4八个输出端 Y0 到 Y7 分别接八位数码管的公共端。74HC138 的输出是低电平有效的正好匹配共阳数码管“公共端接高电平才亮”的逻辑——被选中的那一位输出低电平其他七位输出高电平天然实现了位隔离。三位二进制能编出 000 到 111 八个地址对应八个数码管一片芯片解决全部位选这就是为什么选它而不是用八个三极管。段码送 P0 口这件事有个硬件前提P0 口内部没有上拉电阻是开漏结构。虽然驱动共阳数码管时段码以低电平为主但做消隐或者显示某些段时仍然需要输出高电平没有上拉就会浮空。所以标准做法是在 P0 口和 VCC 之间接一个 10k 的排阻。原文档提到“需要接上 470 欧上拉电阻以提供足够大的电流”这个 470Ω 偏小会导致高电平时静态电流偏大10k 排阻是更常见的取值。八位数码管对应的位选编码整理成表写代码时照着填就行位选编号CBA (P2.4/P2.3/P2.2)显示内容位置0000西方向 十位左上1001西方向 个位左上2010南方向 十位左下3011南方向 个位左下4100东方向 十位右上5101东方向 个位右上6110北方向 十位右下7111北方向 个位右下2.4 独立按键的五键布局与 P2 口复用人机交互靠五个独立按键不用矩阵键盘是因为键数少、逻辑简单独立按键一个键占一根 I/O程序里判断起来干净利落。分配是这样的K1 接 P2.0 做紧急全红K2 接 P2.1 进入时间设置K3 接 P2.5 时间加K4 接 P2.6 时间减K5 接 P2.7 恢复显示。中间空出来的 P2.2 到 P2.4 正好让给 74HC138 做位选一根不浪费。这种“按键和位选挤在同一个端口”的布局是有代价的。51 的准双向 I/O 口有个特性读引脚之前必须先往该位写 1把内部下拉管关掉否则读回来的永远是 0。P2 口既要输出位选码又要读按键状态如果程序里刚往 P2.2 到 P2.4 写过 0再整体读 P2 口读到的低三位就是 0虽然按键判断用的是位操作不影响结果但养成“读前先写 1”的习惯能避免很多莫名其妙的 bug。按键的硬件接法一般是按键一端接地、另一端接 I/O 口靠单片机内部弱上拉维持高电平按下时变低电平。这种接法不需要外部上拉电阻但内部上拉很弱长导线引入的干扰容易造成误触发。如果做实物而不是仿真建议在每个按键到 VCC 之间加一只 10k 上拉同时在按键两端并一只 0.1uF 电容做硬件消抖软件上再配合延时确认双保险。五键的功能边界在文档里写得很清楚整理成表方便对照按键引脚功能触发方式边界限制K1P2.0紧急全红按下生效松开恢复无K2P2.1进入时间设置按一下进入无K3P2.5时间加每按一次加 1最大 99K4P2.6时间减每按一次减 1最小 0K5P2.7恢复显示按一下退出设置无到这里硬件骨架就闭环了单片机靠晶振和复位活起来P1 口灌电流点灯P0 送段码配合 74HC138 位选点亮八位数码管P2 口兼职读五个按键。剩下的问题全在软件时序上下一章展开。3. 定时器 0 的 10ms 节拍四状态机与 555 秒倒计时怎么落地硬件只是搭了个舞台交通灯的灵魂是时间。红灯亮多久、绿灯什么时候转黄、黄灯闪几下这些全部由定时器节拍驱动。这一章从初值计算讲起一路推到状态机怎么写、倒计时变量怎么管、数码管怎么刷。3.1 TH00xD8、TL00xF0 是怎么算出来的交通灯系统的所有时间都建立在一个基准节拍上原文档选的是 10ms。选 10ms 而不是 1s 是有道理的数码管动态扫描需要毫秒级的刷新频率按键消抖需要毫秒级的采样间隔黄灯闪烁也需要一个能整除的中等粒度10ms 一个节拍把这些需求全兜住了主循环里数够 100 次就是 1 秒。初值的算法是标准流程。定时器工作在方式 1也就是 16 位定时模式计数范围 0 到 65535溢出时产生中断。12MHz 晶振下机器周期 12 / 12MHz 1us要定时 10ms 就需要 10000 个机器周期。所以初值 65536 - 10000 55536换成十六进制是 0xD8F0高八位 0xD8 写 TH0低八位 0xF0 写 TL0。这两个数字不是拍脑袋定的换任何一个晶振频率都得重算。现场重算的通用公式记一下初值 65536 - (定时时间 / 机器周期)。比如手头只有 11.0592MHz 的晶振机器周期是 12 / 11.0592MHz ≈ 1.085us10ms 就是 10000 / 1.085 ≈ 9216 个周期初值 65536 - 9216 56320 0xDC00TH0 写 0xDC、TL0 写 0x00。如果懒得算也可以用定时器计算工具但知道原理才能在出偏差时知道往哪查。初始化代码是这样的注意 TMOD 的低四位管定时器 00x01 表示方式 1void Timer0Init(void) { TMOD 0xF0; // 先清掉 T0 的四位配置 TMOD | 0x01; // T0 工作在方式 116 位定时 TH0 0xD8; // 12MHz 晶振10ms 定时高八位 TL0 0xF0; // 低八位 ET0 1; // 允许 T0 中断 EA 1; // 开总中断 TR0 1; // 启动定时器 }参数说明TMOD 0xF0这一步很关键直接写TMOD 0x01会把定时器 1 的配置一起清掉如果后续想用 T1 做串口波特率就会出问题。养成先与后或的习惯只改自己关心的位。TR0是启动位紧急状态下把它清零就能冻结倒计时恢复时再置 1这比在中断里加一堆判断要干净。定时器中断服务函数里要做三件事重装初值、累加节拍计数、调用时间处理函数。重装初值是方式 1 必须做的因为它不会自动重载忘了写这一句中断只会进一次。u16 tick_10ms 0; // 10ms 节拍计数器 u8 sec_flag 0; // 满 1 秒标志 void Timer0_ISR(void) interrupt 1 { TH0 0xD8; // 方式 1 必须手动重装 TL0 0xF0; tick_10ms; if (tick_10ms 100) // 100 × 10ms 1s { tick_10ms 0; sec_flag 1; // 置位主循环里处理 } DigDisplay(); // 数码管动态扫描必须高频调用 }这里有个设计取舍值得说清楚。数码管扫描放在中断里能保证刷新频率稳定不受主循环影响代价是中断服务时间变长。八位数码管每位点亮加消隐大约几十微秒八位下来三四百微秒占 10ms 中断周期的百分之三到四可以接受。如果位数再多或者中断里有更重的活就得考虑把扫描拆到主循环。秒标志用置位而不是直接在中断里做倒计时是为了把业务逻辑和时基分离主循环里处理状态切换时即使耗时稍长也不会丢节拍。3.2 四状态循环55 秒绿、5 秒黄闪、55 秒绿、5 秒黄闪交通灯的运行逻辑本质是一个四状态循环机。东西放行时南北禁行东西绿亮 55 秒后转黄闪 5 秒然后交换南北绿亮 55 秒后转黄闪 5 秒回到起点。一个完整周期 120 秒两个方向的通行权严格互斥任何时刻都不允许两个方向同时绿。状态和灯色的对应关系整理成表写 switch 时直接照抄状态东西方向南北方向持续倒计时起点说明S0绿灯亮红灯亮55s55东西通行S1黄灯闪红灯亮5s5东西收尾S2红灯亮绿灯亮55s55南北通行S3红灯亮黄灯闪5s5南北收尾原文档代码里用zhuangtai变量记状态用TIME和TIME1两个变量分别记两个方向的剩余秒数。这个双变量设计初看有点绕实际很符合直觉TIME 跟着当前放行方向走TIME1 跟着另一个方向走切换状态时两个变量同时重置。缺点是两边的时长被绑死了想单独把东西方向设成 40 秒、南北设成 70 秒就做不到这个局限在第 6 章会给出改造方案。状态切换的判断放在秒级处理里比在中断里判断更安全因为切换时要改一堆灯的状态耗时不确定void Traffic_Update(void) { if (!sec_flag) return; // 未满 1 秒直接返回 sec_flag 0; if (TIME 0) TIME--; // 当前方向倒计时 if (TIME1 0) TIME1--; // 对向倒计时 switch (zhuangtai) { case 0: // 东西绿南北红 if (TIME 0) { zhuangtai 1; // 进入东西黄闪 TIME 5; TIME1 5; DXG 1; DXY 0; DXR 1; // 低电平点亮 NBG 1; NBY 1; NBR 0; } break; case 1: // 东西黄闪南北红 if (TIME 0) { zhuangtai 2; TIME tiao_time; // 可配置的绿灯时长 TIME1 tiao_time; DXG 1; DXY 1; DXR 0; NBG 0; NBY 1; NBR 1; } break; case 2: // 东西红南北绿 if (TIME 0) { zhuangtai 3; TIME 5; TIME1 5; DXG 1; DXY 1; DXR 0; NBG 1; NBY 0; NBR 1; } break; case 3: // 东西红南北黄闪 if (TIME 0) { zhuangtai 0; TIME tiao_time; TIME1 tiao_time; DXG 0; DXY 1; DXR 1; NBG 1; NBY 1; NBR 0; } break; } }参数说明tiao_time是 K3/K4 调整出来的绿灯时长默认 55范围 0 到 99。每次状态切换时用它重置 TIME 和 TIME1。注意每个 case 里都显式设置了六个灯的状态而不是只改变化的那几只这是为了防止某个状态漏掉某只灯导致“鬼火”——上一轮残留的灯还亮着。这种写法啰嗦但安全调试期特别值。3.3 TIME 与 TIME1两个倒计时变量为什么要分开有人会问东西方向和南北方向的剩余时间在数值上永远此消彼长用一个变量加个方向标志不就行了理论上可以但实际写起来会更麻烦。因为数码管要同时显示两个方向的剩余时间四个路口各两位如果只有一个变量存当前方向的时间另一个方向的时间就得靠 55 减去它算出来遇到黄闪阶段两边都是 5 秒这个减法就对不上了。分成 TIME 和 TIME1 之后显示函数只管把两个变量拆成十位和个位往数码管上送完全不用关心当前是哪个状态void DigDisplay(void) { static u8 pos 0; // 扫描位置 0~7 u8 seg_code; u8 dig_val; switch (pos) { case 0: dig_val TIME / 10; break; case 1: dig_val TIME % 10; break; case 2: dig_val TIME1 / 10; break; case 3: dig_val TIME1 % 10; break; case 4: dig_val TIME / 10; break; // 东西方向复用 TIME case 5: dig_val TIME % 10; break; case 6: dig_val TIME1 / 10; break; case 7: dig_val TIME1 % 10; break; default: pos 0; return; } P0 0xFF; // 共阳消隐先全灭 LSA pos 0x01; // 位选编码送到 74HC138 LSB (pos 1) 0x01; LSC (pos 2) 0x01; seg_code SegTable[dig_val]; // 查共阳段码表 P0 seg_code; // 送段码 pos (pos 1) 0x07; // 环形推进 }参数说明pos用 static 修饰保证跨次调用保持值 0x07等价于对 8 取模比 if 判断快。消隐那句P0 0xFF必须放在位选切换之前顺序反了会出现相邻位的段码串到当前位上也就是俗称的“鬼影”。共阳数码管的消隐值是全 1共阴是全 0写反了不是消隐而是全亮这个坑在第 5 章会单独说。还有一个细节四个路口的倒计时其实只有两种值东西两个路口都等于 TIME南北两个路口都等于 TIME1。所以 pos 为 0、1、4、5 时送的都是 TIME为 2、3、6、7 时送的都是 TIME1。这样显示逻辑和状态机完全解耦改倒计时规则时不用动显示代码。3.4 黄灯闪烁别在 10ms 中断里直接翻转 I/O黄闪看起来最简单实际是最容易写错的一处。直觉写法是在定时器中断里每进一次就DXY ~DXY问题是中断周期是 10ms翻转周期就是 20ms频率 50Hz。50Hz 的方波驱动 LED人眼看到的不是闪烁而是一只亮度减半、边缘微微抖动的灯。在 Proteus 仿真里更明显有时干脆就显示成常亮。正确做法是按秒级节奏翻转。既然已经有一个 1 秒标志就在这个标志上做文章进入黄闪状态后每满 1 秒翻转一次黄灯亮 1 秒灭 1 秒五秒内闪两次半肉眼看得清清楚楚。u8 blink_cnt 0; void Yellow_Blink(void) { if (!sec_flag) return; sec_flag 0; blink_cnt; if (blink_cnt 1) // 每 1 秒翻一次 { blink_cnt 0; if (zhuangtai 1) DXY ~DXY; // 东西黄灯 if (zhuangtai 3) NBY ~NBY; // 南北黄灯 } }想要更快的闪烁节奏把blink_cnt 1改成 0配合 500ms 的节拍或者干脆引入一个 500ms 标志位。参数上没有绝对标准路口大小不同、视距不同闪烁频率可以调但低于 1Hz 会让人觉得灯坏了高于 4Hz 又会糊成一片1 到 2Hz 是常见区间。4. 五个按键的交互逻辑K1 紧急全红与 K3/K4 时间增减按键是人机交互的全部入口也是最容易被低估的部分。交通灯正常运行时的逻辑是单向的、确定的一旦把按键接进来就有了异步事件、有了状态跳转、有了边界条件。这一章把五个键的处理逻辑逐个拆开重点讲清楚紧急状态怎么保存和恢复、时间调整的钳位怎么写、消抖到底该延时多久。4.1 K1 紧急键按下全红松开恢复K1 的功能定义很明确消防车、救护车这类紧急车辆要通过时按下 K1四个路口全部转红倒计时显示关闭所有普通车辆禁行紧急车辆通过后松开 K1系统从原来的状态继续运行。这个需求里藏着一个容易被忽略的点——恢复。如果按下 K1 时只是简单地关掉定时器、点亮所有红灯松开后再从头开始跑那紧急车辆通过后整个路口的相位就乱了本来东西方向还剩 20 秒通行时间恢复后变成南北方向先走容易造成事故。正确做法是保存现场。用两个变量把按下瞬间的zhuangtai和TIME、TIME1存起来恢复时原样写回定时器重新启动倒计时从断点继续u8 saved_state; u8 saved_time, saved_time1; bit emergency 0; void Key1_Scan(void) { if (K1 0) // 检测到按下 { Delay10ms(); // 消抖 if (K1 0 !emergency) { emergency 1; saved_state zhuangtai; // 保存现场 saved_time TIME; saved_time1 TIME1; TR0 0; // 冻结倒计时 DXG 1; DXY 1; DXR 0; // 六灯全红 NBG 1; NBY 1; NBR 0; P0 0xFF; // 数码管全灭 } } else if (emergency) // 松手恢复现场 { emergency 0; zhuangtai saved_state; TIME saved_time; TIME1 saved_time1; Restore_Lights(); // 按保存的状态重新点灯 TR0 1; // 恢复计时 } }参数说明emergency用 bit 类型只占一个位比 u8 省空间51 的内部 RAM 只有 128 字节能省则省。TR0 0冻结定时器之后数码管扫描也停了所以这里要主动把 P0 置全灭否则最后显示的数字会一直停在那里看着不自然。恢复时调用的Restore_Lights()是一个按zhuangtai重新设置六只灯的函数和状态机里的点灯逻辑可以共用一个子函数避免两处代码不一致。原文档的 K1 处理用了hong_biao这个标志做异或翻转并且用while(K10);死等松手。死等的写法在仿真里没问题实物上会让整个系统在按键期间失去响应数码管停止刷新看起来像死机。改成上面这种非阻塞的边沿检测主循环照常跑显示照常刷。4.2 K2 到 K5时间调整的钳位与显示切换时间调整的交互链路是按 K2 进入设置态数码管从倒计时显示切换到设置值显示按 K3 加、K4 减按 K5 退出回到倒计时显示。这段逻辑最容易出问题的地方不是加减本身而是边界。按文档要求红绿灯时长可设 0 到 99黄灯时长和循环次数限 0 到 9十位输非 0 值按 0 处理。这个约束在代码里体现为钳位加到 99 再按就不动了减到 0 再按也不动了。不钳位的话u8 型变量从 0 减 1 会回绕成 255数码管上突然跳出个“55”还算好的跳出乱码就麻烦了。u8 tiao_time 55; // 可调绿灯时长默认 55 bit setting 0; // 是否处于设置态 void Key_Adjust(void) { if (K2 0) // 进入设置 { Delay10ms(); if (K2 0) { setting 1; while(!K2); } } if (K3 0) // 时间加 { Delay10ms(); if (K3 0) { if (tiao_time 99) tiao_time; while(!K3); // 等松手防止连加 } } if (K4 0) // 时间减 { Delay10ms(); if (K4 0) { if (tiao_time 0) tiao_time--; // 下界钳位防止回绕 while(!K4); } } if (K5 0) // 退出设置 { Delay10ms(); if (K5 0) { setting 0; TIME tiao_time; // 新时长立即生效 TIME1 tiao_time; while(!K5); } } }参数说明tiao_time的下界必须显式判断if (tiao_time 0)这一句就是防止减到 0 以下回绕。上界 99 也是同理。while(!K3);这种死等松手是为了实现“按一次加一次”不做的话按住不放会以主循环的速度疯狂累加。如果想让长按支持连续快加就得换成计时器计长按时间复杂度上一个台阶课设场景下不值得。setting变量在显示函数里还有一份用途设置态下数码管显示tiao_time的十位和个位让操作者看得见自己的调整结果退出后切回TIME和TIME1。这个切换在DigDisplay里加一个判断就行不需要单独写一套显示代码。4.3 消抖延时该给多少3.33ms 与 1.116s 的来历原文档提到“采取了延时 3.33ms 和 1.116s”这两个数字看着很精确实际是按键扫描程序里两层延时的时间常数。3.33ms 对应的是消抖延时1.116s 对应的是防连击延时。机械按键的抖动期通常在 5ms 到 20ms 之间触点弹跳次数不定。消抖延时的作用是等抖动结束再采样理论上只要大于抖动期就行工程上普遍取 10ms 到 20ms。原文档的 3.33ms 偏短在接触不良的旧按键上可能消不干净。我一般直接用 10ms代码里Delay10ms()这个名字也更直白。1.116s 这个数量级对应的是“一次按键只算一次”的锁定时间。它的实际作用是在你按下并松开之后短时间内忽略后续的所有跳变。但更可靠的做法是等松手也就是上面代码里的while(!Kx);它不依赖固定时间按多久就等多久松手立刻返回响应更好。延时函数本身也有讲究。Delay10ms()用三重循环实现循环次数和编译器优化等级强相关同一段代码在 Keil 里选 Level 0 和 Level 8 优化实际延时能差好几倍。要精确延时最稳的是用定时器但在按键消抖这种精度要求不高的场合循环延时够用。写的时候记得加volatile或者干脆把循环变量定义在函数内避免被优化掉。void Delay10ms(void) // 12MHz 下约 10ms误差 ±5% { unsigned char a, b, c; for (c 1; c 0; c--) for (b 38; b 0; b--) for (a 130; a 0; a--); }这段是典型的经验参数38 和 130 这两个数是在特定编译器版本和优化等级下试出来的。换编译器、换优化等级都要重新标定。标定方法很简单在延时前后各翻转一个 I/O 口用示波器看脉宽调 b 和 a 的值凑到 10ms 附近。没有示波器就用仿真里的虚拟示波器Proteus 里有。4.4 中断里不做按键扫描的理由有个常见的错误写法是把按键扫描放进定时器中断理由是“10ms 一次正好当消抖”。这个做法在小系统里能跑但隐藏着风险。中断服务程序应该尽量短按键扫描里有延时、有死等松手一旦放进去中断服务时间可能达到几百毫秒期间主循环完全停摆显示刷新、状态机全都卡住。正确的分层是定时器中断只负责时基重装初值、累加节拍、置标志和需要严格周期的显示扫描按键扫描、状态机、时间调整全部放主循环靠标志位和中断通信。主循环里轮询五个按键每个按键的判断都是非阻塞的扫完一圈立刻返回哪怕某一轮什么都没按也只是几十微秒的开销。主循环的骨架大致是这样void main(void) { Timer0Init(); Init_Lights(); // 上电默认东西绿南北红 while (1) { Traffic_Update(); // 秒级状态机 Yellow_Blink(); // 黄灯闪烁 Key1_Scan(); // 紧急键 Key_Adjust(); // 四个调整键 DigDisplay(); // 主循环里再扫一次提高亮度 } }参数说明DigDisplay()在中断和主循环里都调用了看起来重复实际是提高刷新率的手段。中断里那个保证下限主循环里这个在主循环跑得快的时候多刷几次两者叠加能让数码管亮度更均匀。如果担心重入问题可以在主循环版本里加一个忙标志或者干脆只保留中断里的调用亮度也够用。5. 仿真翻车排查从 Keil4 编译到 Proteus8.8 联调的五个坑仿真环境最坑人的地方在于它不会告诉你哪里错了。灯不亮、数码管乱码、按键没反应现象就那么几种原因却可能出在硬件、软件、配置的任何一个环节。这一章按“现象 → 原因 → 解决”的格式记录五个在 51 交通灯项目里出现频率最高的翻车点基本都是我在帮人看工程时反复见到的。5.1 坑一代码怎么都编译不过报一堆语法错误现象把文档附录里的代码整段粘进 Keil4点编译报出十几条错误第一条指向函数定义那一行提示语法错误。原因附录里的代码是从 Word 文档复制出来的格式转换过程中出了两处问题。一处是Void timer_calculate()首字母大写了C 语言区分大小写Void不是关键字编译器不认识。另一处是文档里写了void DigDisplay()和Void timer_calculate()混用同一个工程里风格不统一说明排版时被自动首字母大写了。另外文档里有些地方全角括号混进了代码肉眼看不出来编译器直接报错。解决先把所有函数返回类型统一成小写的void再把代码里看起来像空格但删不掉的位置重新敲一遍或者用编辑器的“显示不可见字符”功能排查全角符号。最省事的办法是把代码粘进 VS Code装个 C/C 插件全角符号和拼写错误会直接标红比在 Keil 里逐行找快得多。5.2 坑二数码管显示乱码或者干脆全亮现象Proteus 仿真跑起来交通灯时序看着正常但八个数码管显示的不是数字是各种残缺的笔画有的段常亮有的段该亮不亮。原因段码表和数码管极性不匹配。原文档明确写了“采用共阳数码管”但附录里的段码表0x3F, 0x06, 0x5B, 0x4F...是共阴极的表。共阳表应该取反0 对应0xC01 对应0xF9以此类推。用共阴表驱动共阳管显示出来的东西自然对不上。另一个连带问题是消隐那句P0 0x00对共阳管来说 0x00 意味着八段全亮所以消隐不但没消掉反而让所有位都亮成一片。解决按硬件实际接法重算段码表。共阳表就是把共阴表按位取反整理好放代码里// 共阳数码管 0~9 段码表低电平点亮 u8 code SegTable[10] { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90 }; // 消隐用 0xFF不是 0x00参数说明code关键字把数组放到程序存储器51 的 RAM 只有 128 字节常量表放 RAM 太浪费。数组长度写 10 而不是 17因为只显示 0 到 9十六进制字符用不到。如果确实要显示 A 到 F再补六项0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E。5.3 坑三按键一按灯就乱闪或者状态乱跳现象仿真里点一下 K1交通灯全红了松开后没有恢复原来的相位而是从东西绿灯重新开始。或者按 K3 加时间数值一下跳好几个。原因两个问题叠在一起。一是 K1 的处理用了hong_biao ~hong_biao这种翻转标志按下一次翻转一次松开时如果抖动产生多次跳变标志就翻乱了恢复的相位自然不对。二是 K3 到 K5 没有等松手主循环一圈几十微秒按住不放的一瞬间就被采样了几十次时间值直接冲到上限。解决K1 改成边沿检测加现场保存按下时存状态、松手时恢复中间用emergency标志防止重入。K3 到 K5 在加减之后加while(!Kx);等松手。另外检查按键引脚有没有和 74HC138 的位选线打架P2 口读之前记得先写 1。还有一处隐蔽的坑Proteus 里的按键模型默认有抖动但给了 10ms 消抖一般够用。如果仿真里还是抖可以在按键属性里把Minimum Active Time调大一点或者在硬件上并一只 0.1uF 电容。5.4 坑四黄灯闪得像常亮或者闪得眼睛疼现象进入黄闪阶段黄灯看起来是常亮的只是亮度比别的灯暗一点或者反过来闪得飞快像在抽搐。原因翻转动作放在了 10ms 定时器中断里每进一次中断翻转一次翻转周期 20ms频率 50Hz。人眼的临界闪烁频率在 24Hz 左右50Hz 远超这个值看起来就是常亮。如果中断周期更短比如 1ms翻转频率到 500Hz那就更看不出来了。解决把翻转动作从 10ms 中断里挪出来改成在秒级标志上翻转也就是第 3.4 节那种写法。每满 1 秒翻一次1Hz 的闪烁节奏清晰可见。如果想调快用 500ms 翻一次2Hz 也合适。关键是翻转的频率必须落在人眼能分辨的范围内1 到 4Hz 之间。顺带提一句Proteus 的仿真时间步长也会影响闪烁的显示效果。默认步长下低频闪烁可能被采样漏掉看着像不闪。可以在仿真设置里把Frame Time调小或者直接看波形图确认翻转是否真的发生了。5.5 坑五倒计时速度不对要么快一倍要么慢一倍现象仿真跑起来倒计时从 55 数到 0实际花了 110 秒或者只花了 27 秒。原因秒级节拍的累加次数算错了。原文档在文字描述里写“定时器定时 50ms确定 50ms 循环 20 次从而获得 1S”但代码里的初值TH00xD8, TL00xF0对应的是 10ms。文字和代码自相矛盾照着文字改代码就会出问题。如果用 10ms 的初值却按 20 次累加1 秒就变成了 200ms倒计时快 5 倍。解决以定时器初值为准反推累加次数。12MHz 晶振、初值 0xD8F0一次中断 10ms要凑满 1 秒就得累加 100 次。如果换了 11.0592MHz 晶振初值变成 0xDC00仍然是 10ms累加次数还是 100。如果改成 50ms 定时初值应该是 0x3CB065536 - 50000 15536 0x3CB0累加 20 次凑 1 秒。最可靠的验证方法是在中断里翻转一个空闲 I/O 口用示波器或者仿真里的虚拟示波器量它的周期是不是精确的 10ms。量出来不对就调初值别靠猜。养成这个习惯之后任何定时相关的 bug 都能在五分钟内定位。6. 从课设到可交付把写死的 55 秒改成参数化配置前面几章讲的都是照文档复现的路子代码跑通、仿真出图、报告写完课设任务就算交付了。但如果想再往前走一步把这个项目从“能演示”变成“能改”最值得动的就是时间参数。原设计里东西方向和南北方向的绿灯时长被TIME和TIME1两个变量绑死永远是同一个值想做一个早晚高峰不对称配时的方案就没法弄。改造思路是把“时长”从运行状态里抽出来定义成一份配置表。四个状态各自的持续秒数存在一个结构体数组里状态机切换时查表取值而不是硬编码 55 和 5typedef struct { u8 e_green; // 东西绿灯秒数 u8 e_yellow; // 东西黄灯秒数 u8 n_green; // 南北绿灯秒数 u8 n_yellow; // 南北黄灯秒数 } PhaseCfg; PhaseCfg cfg { 55, 5, 35, 5 }; // 东西主干道放行更久 // 状态切换时按表取值 TIME (zhuangtai 2) ? cfg.n_green : cfg.e_green; TIME1 (zhuangtai 2) ? cfg.e_green : cfg.n_green;这么一改K3 和 K4 调时间的时候就可以针对当前方向单独调东西方向 60 秒、南北方向 30 秒这种配时变得一行代码的事。再进一步把cfg存到 EEPROM 里AT89C51 没有片内 EEPROM得外挂 24C02掉电不丢配置这就有点真实路口控制器的意思了。另一个值得加的进阶功能是夜间黄闪模式。很多路口在深夜车流稀少时会切成黄灯闪烁提示“注意观察、减速慢行”。实现起来很简单加一个night_mode标志置位时跳过整个状态机只让两个方向的黄灯以 1Hz 交替闪if (night_mode) { if (sec_flag) { sec_flag 0; DXY ~DXY; // 东西黄灯闪 NBY DXY; // 南北黄灯反相闪视觉上更醒目 DXG 1; DXR 1; // 其余灯全灭 NBG 1; NBR 1; } return; // 不再执行正常状态机 }参数上唯一要注意的是night_mode的切换时机最好只在状态 0 或者状态 2 的起始点允许进入中途切入会让倒计时数字突然消失看着像故障。验证改造是否成功我一般走三步。第一步在 Proteus 里把cfg改成两组明显不同的值比如 20 和 10看数码管上的倒计时是不是真的不对称第二步用虚拟示波器量 P1.0 和 P1.3 的高电平持续时间确认和配置表一致第三步把cfg写死成极端值 0 和 99看边界会不会出问题比如绿灯 0 秒时状态机是不是直接跳过。这三步走完基本能确认参数化改造没有引入新的时序 bug。说个自己的教训。早些年做类似的项目我图省事把状态切换的时间全部写成字面量散落在 switch 的各个 case 里后来想调一个参数改了七八处漏掉一处导致东西方向绿灯亮了 55 秒、黄灯却按 35 秒的配置走仿真里看着没什么实际跑起来两个方向的周期对不上越跑越偏。从那以后我每次写状态机不管多小的项目都强制先把时间参数抽成表或者结构体改的时候只动一处。这个习惯救过我很多次。希望帮到你。本文还有配套的精品资源点击获取