简介:这份面向单片机课程设计学习者的交通灯控制系统结课报告,以AT89C51为核心,针对路口拥堵、等待时间不合理与急车强通等问题,给出从需求分析到软硬件实现的完整方案。资源包仅含1个docx文档,约353KB,体量轻便,适合直接查阅与参考。文档围绕系统方案原理图、AT89C51原理、独立键盘扫描、时间显示与交通灯输出模块展开,并说明紧急情况控制及时间调整电路;软件部分涵盖体系架构与运行效果展示,附录中还有总结与心得,可帮助读者理解定时器秒信号、P0/P2口数码管倒计时、按键增减直行时间等关键实现。已有2881人学习,适合课设选题、方案撰写、仿真调试与报告排版时对照参考,快速搭建交通灯控制框架并补齐紧急通车、时间调节等功能细节。
1. 一块零售价不到三块钱的单片机,为什么还在路口教学板上转
很多人第一次碰基于 AT89C51 单片机的交通灯控制系统,做完的状态是:灯会亮、数码管会跳、按键也能按。但一放到“双向路口”的真实约束下就翻车——两个方向同时绿灯、倒计时从 3 直接跳到 1、夜里不该亮绿灯的时候绿灯还亮着。这个题目在课程设计和入门实训里出现频率极高,不是因为它简单,而是因为它把 51 单片机最核心的三件事压缩在了一个板子上:IO 口的驱动能力、定时器的时基精度、状态机的切换顺序。这篇文章讲的是能上电连续跑、能拿秒表测周期、能被别人照着复现的那一版,适合手里已经有最小系统板、会写for循环但还没搞清“为什么灯暗得几乎看不见”的读者。
2. 路口先拆成相位和灯组:AT89C51 的 32 个 IO 怎么分
做交通灯第一步不是打开编译器,而是拿张纸把路口画出来,标清楚有几组灯、每个方向有几种颜色、哪些颜色在时间上互斥。这一步没想清楚,后面代码写得再漂亮,灯序也是错的。
2.1 双向路口的相位定义与全红清空
按最常见的一条主干道加一条支路(东西向为干道、南北向为支路)来定,每个方向三盏灯,一共六路输出。真正决定系统能不能用的不是灯的数量,而是相位的排列。一个能安全运行的循环至少包含五个相位:
| 相位 | 东西向 | 南北向 | 时长 | 作用 |
|---|---|---|---|---|
| P0 | 绿 | 红 | 25 s | 干道放行 |
| P1 | 黄 | 红 | 3 s | 干道清空 |
| P2 | 红 | 红 | 2 s | 全红缓冲 |
| P3 | 红 | 绿 | 20 s | 支路放行 |
| P4 | 红 | 黄 | 3 s | 支路清空 |
全红那 2 秒是整个设计中最容易被省略、也最容易出问题的一段。没有它,东西向黄灯灭的瞬间南北向绿灯立刻亮起,如果路面上有抢黄灯的车辆或者行人还没走完,逻辑上就是两个方向同时拥有通行权。红灯全亮的 2 秒是一个纯靠时序保证的安全裕量,代码里必须单独占一个状态,不能靠“关灯再开灯”的间隙蒙混过去。
周期总长 25+3+2+20+3 = 53 秒。这个数字是可以改的,但不建议随手把干道绿改到 60 秒以上——一是数码管只有两位,超过 99 秒要另做处理;二是周期太长会让支路等待时间不可接受,实际路口是靠感应线圈动态调整的,教学板没必要。
2.2 引脚分配表与最小系统电路
AT89C51 有 4 个 8 位并行口,共 32 根 IO,但这个题目里真正要精打细算的是 P0 和 P1。P0 口内部没有上拉电阻,是开漏结构;P1、P2、P3 内部有弱上拉。这个差异直接决定了数码管段码接哪个口。
| 引脚 | 分配功能 | 电平约定 |
|---|---|---|
| P1.0 / P1.1 / P1.2 | 东西红 / 黄 / 绿 | 低电平点亮 |
| P1.3 / P1.4 / P1.5 | 南北红 / 黄 / 绿 | 低电平点亮 |
| P0.0 ~ P0.7 | 数码管段码 a~g、dp | 低电平点亮,外接 10 kΩ 排阻上拉 |
| P2.0 / P2.1 | 东西向十位 / 个位位选 | 经 PNP 三极管,低电平有效 |
| P2.2 / P2.3 | 南北向十位 / 个位位选 | 经 PNP 三极管,低电平有效 |
| P3.2 / INT0 | 紧急全红按键 | 按下拉低,外部 10 kΩ 上拉 |
| P3.3 / INT1 | 夜间黄闪按键 | 按下拉低,外部 10 kΩ 上拉 |
| XTAL1 / XTAL2 | 12 MHz 晶振 | 两端各 22 pF 对地 |
| RST | 复位 | 10 μF 电解电容接 VCC + 10 kΩ 下拉 |
| EA | 片内程序存储器选择 | 直接接 VCC |
最小系统三件套(晶振、复位、EA)少一个都跑不起来。EA 引脚悬空是最常见的低级失误:AT89C51 内部有 4 KB Flash,EA 拉高才从内部取指,悬空时电平不确定,表现就是“烧录成功但上电没反应”,用万用表量一下 EA 电压就能定位。
2.3 LED 限流电阻和驱动方式:为什么必须灌电流
这一步是新手翻车最集中的地方。很多人把 LED 接成“IO 口 → 电阻 → LED → GND”,烧进去发现灯确实亮,但暗到要用手挡住环境光才看得见。原因在 51 的 IO 结构:输出高电平时靠的是内部一个很弱的上拉管,能提供的拉电流只有几十微安量级;而输出低电平时,下管导通,灌电流能力可以到十几毫安。所以正确的接法是反过来:
VCC ── 330Ω ── LED 阳极 ── LED 阴极 ── P1.xIO 输出低电平,电流从 VCC 经电阻、LED 灌入单片机引脚,灯才够亮。限流电阻按目标电流算。红色 LED 正向压降约 1.8~2.0 V,绿色约 2.0~2.2 V,取 2.0 V 计算、目标电流 9 mA:
R = (5 V − 2.0 V) / 9 mA ≈ 333 Ω,取标准值 330 Ω,实际电流约 9.1 mA。
这个电流下单片机引脚灌电流余量充足(单脚上限约 15 mA,整片总灌电流上限约 71 mA),六路 LED 全亮也只有 55 mA 左右。如果贪亮把电阻降到 150 Ω,单脚电流超过 20 mA,短期看不出问题,连续跑几小时后 IO 口会发热,长期就是玄学故障。
提示:六路 LED 全部用低电平点亮,上电复位时 P1 口默认全是高电平,灯是灭的。如果你发现自己板子上电瞬间全亮,先查是不是把 LED 接成了共阳到 VCC 而 IO 直接拉低,那种接法没有限流电阻会直接烧口。
3. 定时器与状态机:把 53 秒周期写成一张可改的表
灯序靠状态机,时基靠定时器,这两件事合起来才是“能跑”和“跑得准”的分界线。用delay()函数做主循环延时的方案,一旦加上数码管动态扫描就会互相干扰,倒计时立刻开始飘。
3.1 12 MHz 晶振下定时器 0 初值的算法
51 的机器周期是 12 个时钟周期,12 MHz 晶振下机器周期 = 1 μs。定时器 0 工作在方式 1(16 位定时),最大计数值 65536,溢出后产生中断。要把中断周期定成 2 ms:
计数值 = 2000 μs / 1 μs = 2000 初值 = 65536 − 2000 = 63536 = 0xF830 所以 TH0 = 0xF8,TL0 = 0x30。
2 ms 一次中断有两个目的:一是数码管四位轮流点亮,每位 2 ms,整屏刷新率 125 Hz,人眼完全看不到闪;二是把 2 ms 累加到 500 次正好 1 秒,作为倒计时的秒基准。
这里有一个必须写进代码的细节:方式 1 不会自动重装初值,中断服务函数里必须手动把 TH0、TL0 重新赋值,而且这两条赋值要放在函数最前面。如果放到函数末尾,中间那段数码管扫描的耗时会被算进下一次定时,误差一点点累积,跑一小时能差出几十秒。
另一个误差来源是晶振本身。如果板上焊的是 11.0592 MHz(为了方便串口波特率),却按 12 MHz 算初值,实际中断周期会变成 2.17 ms,一秒会走成 0.92 秒,一小时快将近 5 分钟。检查方法很简单:让倒计时从 60 走到 0,用手机秒表对一遍,差超过 2 秒就是时基算错了。
3.2 相位表驱动的状态机写法
状态机有两种写法:switch-case堆五大段,或者用数组存表。前者的缺点是改一个时长要翻三处代码,改完还容易漏;后者把“数据”和“逻辑”分开,调参只动表。
/* 灯色编码:0=红 1=绿 2=黄 */ /* 每行 {东西灯色, 南北灯色} */ unsigned char code PH_LIGHT[5][2] = { {1, 0}, /* P0 东西绿,南北红 */ {2, 0}, /* P1 东西黄,南北红 */ {0, 0}, /* P2 全红缓冲 */ {0, 1}, /* P3 东西红,南北绿 */ {0, 2} /* P4 东西红,南北黄 */ }; /* 各相位持续秒数,改配时只改这一行 */ unsigned char code PH_TIME[5] = {25, 3, 2, 20, 3};用code关键字把表放到程序存储区,不占宝贵的 128 字节 RAM。AT89C51 只有 128 字节 RAM,堆栈、显示缓冲、各种标志位加起来很容易吃掉一半,表放在 Flash 里是必须的。
状态切换的逻辑放在秒中断里,判断条件用remain > 1而不是remain > 0:
if (remain > 1) { remain--; /* 正常倒计时 */ } else { phase++; /* 本相位结束,进入下一相位 */ if (phase >= 5) phase = 0;/* 五个相位循环 */ remain = PH_TIME[phase]; /* 装载新相位时长 */ }为什么是> 1?如果写成> 0,remain从 1 减到 0 后还要再等一个中断才切换,每个相位会白白多出 1 秒,跑完一圈周期就是 58 秒而不是 53 秒。这种错误在数码管上看不出来,只有拿秒表掐整周期才会暴露。
3.3 完整可编译的 C51 主程序
下面这份代码可以直接在 51 编译器里建工程编译,烧进最小系统板就能跑。
#include <reg51.h> typedef unsigned char u8; typedef unsigned int u16; /* ---------- 灯组 ---------- */ sbit EW_R = P1^0; sbit EW_Y = P1^1; sbit EW_G = P1^2; sbit NS_R = P1^3; sbit NS_Y = P1^4; sbit NS_G = P1^5; /* ---------- 数码管位选(经 PNP 三极管) ---------- */ sbit DIG_EW_T = P2^0; sbit DIG_EW_U = P2^1; sbit DIG_NS_T = P2^2; sbit DIG_NS_U = P2^3; sbit KEY_EMG = P3^2; sbit KEY_NIGHT = P3^3; /* 共阳数码管 0~9 段码,低电平点亮 */ u8 code SEG[10] = {0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,0x80,0x90}; u8 code PH_LIGHT[5][2] = {{1,0},{2,0},{0,0},{0,1},{0,2}}; u8 code PH_TIME[5] = {25, 3, 2, 20, 3}; u8 phase = 0; u8 remain = 25; u16 ms_cnt = 0; u8 half_flag = 0; /* 500 ms 翻转,用于黄闪 */ u8 emg_on = 0; u8 night_on = 0; /* ---------- 按灯色码输出 ---------- */ void SetLight(u8 ew, u8 ns) { EW_R = 1; EW_Y = 1; EW_G = 1; /* 先全灭,避免切换瞬间两色同亮 */ NS_R = 1; NS_Y = 1; NS_G = 1; if (ew == 0) EW_R = 0; else if (ew == 1) EW_G = 0; else EW_Y = 0; if (ns == 0) NS_R = 0; else if (ns == 1) NS_G = 0; else NS_Y = 0; } /* ---------- 四位动态扫描 ---------- */ void DigScan(void) { static u8 idx = 0; P2 |= 0x0F; /* 先关全部位选,消鬼影 */ P0 = 0xFF; /* 段码全灭 */ switch (idx) { case 0: P0 = SEG[remain/10]; P2 &= ~0x01; break; case 1: P0 = SEG[remain%10]; P2 &= ~0x02; break; case 2: P0 = SEG[remain/10]; P2 &= ~0x04; break; case 3: P0 = SEG[remain%10]; P2 &= ~0x08; break; } idx = (idx + 1) & 0x03; } /* ---------- 定时器 0:2 ms ---------- */ void T0_Init(void) { TMOD = (TMOD & 0xF0) | 0x01; /* T0 方式 1,16 位 */ TH0 = 0xF8; TL0 = 0x30; ET0 = 1; EA = 1; TR0 = 1; } void T0_ISR(void) interrupt 1 { TH0 = 0xF8; /* 重装必须放最前面 */ TL0 = 0x30; DigScan(); if (++ms_cnt >= 500) { ms_cnt = 0; half_flag = !half_flag; if (!emg_on && !night_on) { if (remain > 1) { remain--; } else { phase++; if (phase >= 5) phase = 0; remain = PH_TIME[phase]; } } } } /* ---------- 按键扫描,10 ms 消抖 ---------- */ void KeyScan(void) { static u8 cnt_emg = 0, cnt_night = 0; if (KEY_EMG == 0) { if (++cnt_emg >= 5) { emg_on = !emg_on; cnt_emg = 0; } } else cnt_emg = 0; if (KEY_NIGHT == 0) { if (++cnt_night >= 5) { night_on = !night_on; cnt_night = 0; } } else cnt_night = 0; } void main(void) { T0_Init(); while (1) { if (emg_on) { SetLight(0, 0); /* 双向全红 */ } else if (night_on) { if (half_flag) SetLight(2, 2); /* 双黄闪 */ else SetLight(0, 0); } else { SetLight(PH_LIGHT[phase][0], PH_LIGHT[phase][1]); } KeyScan(); } }几处值得说明的地方。SetLight开头把六路全部拉高再按需点亮,是为了保证任何一次灯色切换都不出现“先开新灯再关旧灯”的中间态,那个中间态哪怕只持续几微秒,在示波器上就是一个毛刺,接继电器负载时可能引发误动作。
DigScan里的顺序是“关位选 → 清段码 → 送新段码 → 开位选”。如果反过来先送段码再切位选,上一位的数字会残留到下一位上,显示出来就是重影,尤其1这种笔画少的数字旁边会多出一横。
KeyScan每 2 ms 被调用一次,计数到 5 就是 10 ms,满足机械按键的消抖窗口。这里没有用外部中断,因为按键数量少,轮询更简单,也不占中断资源。
4. 数码管倒计时与两个特殊模式
灯会按顺序亮了只是及格线,真正让这套系统“像个路口的设备”的,是倒计时读数稳定、应急按键一按就全红、夜里能切成黄闪。
4.1 两位共阳数码管的动态扫描与位选驱动
倒计时用四位一体或两只两位的共阳数码管,东西、南北各两位。共阳的意思是所有 LED 的阳极连在一起做位选,段码端给低电平才点亮某一段,所以段码表里 0 对应0xC0(a~f 亮、g 灭),这个表在代码里已经给出。
位选不能直接接单片机引脚。一位数码管点亮时,最多 8 段同时导通,每段按 5 mA 算就是 40 mA,远超单个 IO 脚的 15 mA 上限。正确做法是每个位选串一个 PNP 三极管(例如常见的 8550),单片机引脚接三极管基极串 1 kΩ 限流,集电极接数码管公共阳极,发射极接 VCC。单片机输出低电平时三极管导通,这一位得电。
P0 口送段码时必须外接 10 kΩ 排阻上拉到 VCC。P0 是开漏结构,不加上拉时输出高电平就是悬空,段码全是乱的,现象是数码管显示一堆莫名其妙的笔画,或者干脆一位都不亮。排阻的一端接 VCC,另一端接 P0.0~P0.7,一共 9 个引脚,焊接时注意方向。
刷新率这块,每位 2 ms、四位一轮 8 ms,对应 125 Hz。低于 50 Hz 人眼会看出抖动,高于 200 Hz 单片机负担加重且没有必要。如果发现显示发暗,先看是不是扫描函数里加了delay,再看三极管基极电阻是不是选太大导致导通不充分。
4.2 紧急按键中断与夜间黄闪的实现
紧急模式的需求是:无论当前处于哪个相位,按下按键后两个方向立即全红,再按一次恢复。这个逻辑用轮询实现比中断更稳——中断里改状态标志,主循环里判断标志再调用SetLight,中间隔一次循环的时间只有几微秒,人感觉不到延迟;而如果在中断服务函数里直接操作 IO,会和定时器中断抢 P1 口,偶尔出现灯色错乱。
夜间黄闪模式改成两个方向都输出黄灯,以 1 Hz 频率闪烁(亮 500 ms、灭 500 ms),红灯绿灯全灭。half_flag每 500 ms 翻转一次,主循环根据它决定这一瞬间是亮黄还是全灭。这里要注意,夜间模式下倒计时要冻结,remain不能继续减,否则退出夜间模式后倒计时读数会莫名其妙变小。
4.3 主循环和中断的分工:哪些活不能放进中断
整个程序只有两个执行上下文:定时器中断和主循环。分工原则很简单——和时间精度强相关的放中断,和状态判断相关的放主循环。
中断里只做三件事:重装定时器初值、扫描一位数码管、累加毫秒计数并在满 500 次时递减remain。这三件事的共同点是执行时间固定且极短,加起来不超过 100 μs,占 2 ms 中断周期的 5%,余量充足。
主循环里做灯色输出和按键扫描。SetLight里有六条赋值语句,执行时间虽然也短,但它依赖phase这个会被中断修改的变量——如果放在中断里,等于在中断上下文中读写共享变量,虽然 51 是单核不会真并发,但代码可读性会变差,排查问题时很难说清某一时刻的灯色到底由谁决定。
绝对不要放进中断的:任何形式的delay循环、串口打印、浮点运算。51 的浮点库一次除法要几百个机器周期,塞进 2 ms 中断里直接导致下一次中断丢失,表现就是数码管突然卡一下、倒计时偶尔跳两秒。
5. 调试排查:灯不亮、显示乱码、走时不准的五个坑
5.1 灯暗到几乎看不见:把 LED 接成了拉电流驱动
现象:程序烧进去,灯确实按顺序变了,但亮度极低,室内光线下基本看不清,用万用表量 IO 口电压在 4.5 V 左右。
原因:LED 接成了“IO 口 → 电阻 → LED → GND”,靠 IO 输出高电平提供电流。AT89C51 的高电平输出靠内部弱上拉,拉电流只有几十微安,根本带不动 LED,实际电流可能只有 0.1 mA 量级。
解决:改成灌电流接法,VCC 经限流电阻接 LED 阳极,阴极接 IO 口,程序逻辑不变(仍然是低电平点亮),只是硬件接法反过来。改完实测电流 9 mA 左右,亮度正常。
5.2 数码管显示乱码或全灭:P0 口忘了上拉
现象:灯序完全正常,但数码管要么一位都不亮,要么显示一堆看不出是数字的笔画,换一块数码管还是一样。
原因:P0 口内部没有上拉电阻,是开漏输出。送段码时输出高电平的那几位实际处于悬空状态,数码管的对应段处于不确定电平,显示就乱了。很多人会误以为是数码管坏了或者段码表写错,反复改SEG[]数组,改一天也改不出来。
解决:在 P0.0~P0.7 和 VCC 之间接一个 10 kΩ 排阻(9 脚的那种,公共端接 VCC)。接上之后段码立刻正常。这个坑的血泪经验是:只要看到“灯正常但显示异常”,第一反应就应该是查 P0 上拉,而不是查代码。
5.3 数码管越跑越暗、单片机发烫:位选直连 IO 口
现象:刚上电显示正常,跑十几分钟后亮度明显下降,用手指摸单片机芯片能感觉到温热,时间再长可能出现某一位彻底不亮。
原因:位选没有加三极管,数码管公共阳极直接接 P2 口。一位数码管八段全亮时总电流可达 40 mA,虽然单片机短时间能扛住,但长期超过单个 IO 脚的灌电流上限,端口内部的下拉管会逐渐劣化。
解决:每个位选加一只 PNP 三极管,基极串 1 kΩ 电阻,发射极接 VCC,集电极接数码管公共端。改完之后整机工作电流从 90 mA 降到 60 mA 左右,芯片温度明显下降。顺带提一句,如果用的是共阴数码管,位选要换成 NPN 三极管接 GND,极性搞反的后果是"全部位同时亮",这个现象很好辨认。
5.4 倒计时走时不准:晶振频率和初值对不上
现象:让倒计时从 25 数到 0,用手机秒表测,实际用了 23 秒或者 27 秒;连续跑一小时,累计偏差能到几分钟。
原因:板上焊的是 11.0592 MHz 晶振(很多人从串口实验板直接拿过来用),但定时器初值是按 12 MHz 算的。11.0592 MHz 下机器周期是 1.085 μs,2 ms 需要 1843 个机器周期而不是 2000,按 2000 算出来的中断周期是 2.17 ms,秒基准整体偏慢约 8%。
解决:先确认板上晶振的实际频率(看丝印或者用示波器量 XTAL2 引脚),再按65536 − 目标微秒数 × 晶振频率 / 12重算初值。11.0592 MHz 下取 2 ms 的初值是 65536 − 1843 = 63693 = 0xF8CD,即 TH0 = 0xF8、TL0 = 0xCD。换晶振必须同步改初值,这条没有商量余地。
5.5 按一次键状态跳好几次,或者灯序突然错乱
现象:紧急按键按一下,系统在全红和正常之间来回切了好几次才稳定;或者某次切换后两个方向同时绿灯,需要断电重启才恢复。
原因有两个。一是按键没有消抖,机械触点在按下和松开的几毫秒内会产生多次通断,轮询代码每次读到低电平就翻转一次标志,一次按键被识别成了三五次。二是phase变量在中断里自增、在主循环里被读取,如果没有做边界保护,phase可能短暂出现等于 5 的值,而PH_LIGHT[5]已经越界,读到的是数组后面的随机数据,灯色自然就乱了。
解决:按键加 10 ms 软件消抖(连续 5 次读到低电平才认一次),并且在硬件上给按键并联一个 0.1 μF 电容到地。相位切换后立刻做if (phase >= 5) phase = 0;的边界检查,这一行看起来多余,但少了它就是一个随机故障源。另外,两个方向同时绿灯还有一种可能是相位表本身写错了顺序,比如把{1,0}和{0,1}中间的全红相位漏掉,这种错误只能靠对着表格逐行核对,仿真软件也发现不了。
6. 从“能跑”到“敢接负载”:双向独立倒计时和绿灯末段闪烁
做到上面那一步,板子已经能稳定循环,但离真实路口还差两个东西:倒计时是两方向共用一个remain,红灯方向看到的数字和绿灯方向一样,实际路口不是这样;绿灯结束得太突然,司机没有心理准备。
双向独立倒计时的改法是给相位表加两列,每行存两个初值,进入相位时分别赋给ew_cnt和ns_cnt,两个变量各自递减。表格变成{东西灯色, 南北灯色, 持续秒数, 东西倒计时, 南北倒计时},比如“东西绿、南北红”这一行,东西倒计时 25、南北倒计时 28(25 秒绿灯 + 3 秒黄灯),这样南北向的司机看到的是“还有 28 秒才轮到我”,而不是一个从 25 开始重新数的数字。跨相位的连续性靠表格保证,代码逻辑一行都不用改。
绿灯末段闪烁更简单,在秒中断里加一个判断:本相位是绿灯且remain <= 3时,让绿灯跟随half_flag每 500 ms 通断一次。注意闪烁只在绿灯相位生效,黄灯相位不能闪,否则司机分不清是绿灯快结束了还是已经进黄灯。
验证这套时序,我用过两个笨办法但很有效。一是拿手机秒表掐整个周期,从东西绿灯亮的瞬间按下,到下一次东西绿灯亮,读数是 53 秒 ±0.5 秒算合格;二是把板子上的灯换成小功率继电器,带 12 V 指示灯跑两小时,看有没有出现继电器粘连或者灯序错乱——纯 LED 负载掩盖了很多时序毛刺,加上感性负载才会暴露。有一次我就是在继电器测试里发现全红相位只给了 1 秒(表里写错成{0,0}却填了1),LED 上看不出来,继电器“啪”地一声两个方向同时吸合,那一瞬间才反应过来问题在哪。
最后留一个习惯:每次改配时,先把五个相位的秒数加起来写在纸上,跑完之后再用秒表量一遍整周期,两个数对上才继续往下做。这个动作花不了一分钟,但省掉的是后面反复怀疑“是不是定时器初值又算错了”的时间。希望帮到你。
本文还有配套的精品资源,点击获取