简介:面向51单片机课程设计与期末大作业的完整交通灯设计方案,基于STC/AT89系列等常见51内核,包含源码、实验报告PDF与原理图等。项目覆盖LED数码管倒计时、按键调整、紧急模式等典型功能,代码注释详细,即使新手也能快速上手,适合电子、自动化、嵌入式等相关专业学生参考与复用。压缩包共58个文件,约1.47MB,主要包含C语言源码(.c/.h)、Keil工程文件(.uvproj/.uvopt)、编译生成文件(.hex/.obj/.lst等)以及实验报告PDF和原理图相关文件。其中.hex可直接烧录到开发板验证,PDF报告可直接用于课程设计文档参考,整体结构清晰,按模块划分。该资源已有405人学习,被评价为满分大作业资源,能直接部署使用。下载后可获得一套可运行的交通灯控制系统源码、配套实验报告和原理图等,既可用于课程答辩演示,也能帮助理解定时器中断、按键扫描、状态机设计等单片机核心知识点,具有较高参考价值。
1. 这块板子不只是一组红绿灯
很多人的课程设计都栽在同一个地方:功能单一,只有两组灯按固定时间闪,答辩老师一问“中断怎么用的”就卡住。这个基于51单片机的交通灯项目,代码里除了基础的红绿黄状态切换,还带了矩阵键盘调时、DS18B20温度读取、红外遥控解码和蜂鸣器驱动,等于把定时器、外部中断、单总线、串行按键扫描全过了一遍。文件里给的ProgramCode.hex是直接能烧的产物,实验报告PDF里含原理图和设计过程,适合期末大作业、课程设计这类需要“能跑、能讲、能改”的场景。下面从工程文件拆起,把每一块代码怎么工作、参数怎么调、调试时看哪里,完整过一遍。
2. 先看懂工程再动手:文件的职责划分与硬件映射
拿到压缩包先别急着打开Keil,把文件清单读一遍,基本就能还原整套系统的设计思路。这个工程里既有驱动层代码,也有逻辑层代码,还有一堆编译中间产物,分清哪些是源码、哪些是生成物,才不会在改代码时改错文件。
2.1 从工程文件反推整体架构
解压后看到的.c和.h文件对应的是各个硬件模块,下面这张表把核心文件对应的功能列出来:
| 文件 | 模块 | 职责 |
|---|---|---|
| main.c | 主程序 | 状态机调度、初始化、主循环 |
| LED.c / LED.h | 交通灯显示 | 红黄绿LED点亮与熄灭控制 |
| Timer.c / Timer0 | 定时器 | 产生时基,驱动倒计时与数码管刷新 |
| Key.c / MatrixKey.c | 按键输入 | 独立按键与矩阵键盘扫描,调整时间参数 |
| Int0.c / IR.c | 红外接收 | 外部中断0触发NEC协议解码 |
| DS18B20.c + OneWire.c | 温度采集 | 单总线时序,读取环境温度 |
| Buzzer.c | 蜂鸣器 | 倒计时结束提示与超温报警 |
| Delay.c | 延时 | 微秒级和毫秒级软件延时 |
注意,Delay.h、Timer.h这些头文件是模块对外暴露的接口,而.obj、.lst、.m51、.build_log.htm都是Keil编译时自动生成的,不需要手动维护。ProgramCode.hex是最终烧录文件,实验报告PDF里还有原理图和端口分配表。
2.2 逻辑层与驱动层的边界
main.c里跑的是一套状态机,它只关心“当前处于哪个通行方向、剩余多少秒”,并不直接操作引脚。真正控制引脚高低电平的是LED.c,这就把逻辑和硬件解耦了。比如在main.c里改一个路口方向的时间,不需要动LED.c里的任何一行;反过来,如果把LED从P0口换到P1口,也只需要改LED.c里的引脚宏定义和段码表,状态机完全不受影响。
这种分层习惯在单片机项目里非常重要,尤其是课程设计要做功能扩展时。你后续想加一个倒计时数码管显示,只需要在Timer中断里多调一个显示刷新函数,不需要去main.c里找哪里在控制电平翻转。
2.3 从代码反推引脚分配
参考实验报告PDF里的原理图,这套设计在Proteus仿真中常见接法是:四组红黄绿LED分别接P0口和P2口(高电平驱动或低电平驱动取决于原理图中的LED接法),数码管段码接P0、位选接P2或P3口的高位;矩阵键盘占用P1口低四位和高四位;DS18B20数据线接P3.7或P1.0;红外接收头输出接INT0(P3.2);蜂鸣器接P2.5之类带负载能力的引脚。
拿到实物或仿真文件后,第一件事就是对照原理图确认每个模块的引脚。源码里的LED.h头文件下会有类似这样的宏定义:
#define RED_EAST P0_0 // 东向红灯 #define YELLOW_EAST P0_1 // 东向黄灯 #define GREEN_EAST P0_2 // 东向绿灯 #define RED_WEST P0_3 // 西向红灯 #define YELLOW_WEST P0_4 // 西向黄灯 #define GREEN_WEST P0_5 // 西向绿灯这里每个宏定义就是把一个具体的单片机引脚映射成语义化的名字。好处是代码里读到GREEN_EAST = 1就知道是在点亮东向绿灯,而不是去记P0_2是什么。引脚分配与原理图不一致时,改的是这一块宏定义,而不是散落在各个函数里的电平操作。如果你的Proteus仿真图换了一个单片机型号,比如从AT89C51改成STC89C52RC,引脚兼容,但要注意P0口默认是开漏输出,必须外接上拉电阻才能得到高电平。
3. 红绿灯时序的表层与底层:定时器重装、状态机与倒计时刷新
交通灯的核心问题只有一个:如何让时间精准地走。软件延时会被按键扫描和红外解码阻塞,时间一长就漂移。这个工程的做法是典型的“定时器产生时基 + 主循环跑状态机”结构,下面拆开看。
3.1 Timer0为什么配置成50毫秒中断
以12MHz晶振、Timer0工作在方式1(16位定时器)为例,单次定时上限是65.535毫秒。要产生一个50ms的中断周期,初值计算方式为:
TH0 = (65536 - 50000) / 256; // 高8位 0x3C TL0 = (65536 - 50000) % 256; // 低8位 0xB0因为每个机器周期是12个时钟周期,即1微秒,定时50000个机器周期就是50毫秒。中断服务程序里维护一个软件计数器,每计20次就是1秒。
void Timer0_ISR(void) interrupt 1 { TH0 = 0x3C; // 重新装载初值 TL0 = 0xB0; tick_count++; if (tick_count >= 20) // 50ms * 20 = 1000ms { tick_count = 0; second_flag = 1; // 置秒标志,主循环处理 } }interrupt 1是Keil C51里Timer0中断的固定入口号,不能改。主循环检测到second_flag为1时,才对状态机执行一次秒递减。把耗时操作全部放在主循环而不是中断里处理,可以减少中断服务程序的执行时间,避免影响下一次定时中断的响应。
3.2 东西与南北直行的状态机切换
交通灯的本质是一个四状态循环:东西绿灯/南北红灯,东西绿灯闪,东西黄灯,南北绿灯/东西红灯,南北绿灯闪,南北黄灯。用枚举定义状态,用switch-case驱动状态迁移:
typedef enum { EW_GREEN_NS_RED, EW_GREEN_BLINK, EW_YELLOW_NS_RED, NS_GREEN_EW_RED, NS_GREEN_BLINK, NS_YELLOW_EW_RED } TrafficState; TrafficState current_state = EW_GREEN_NS_RED; unsigned char second_counter = 0; void traffic_state_machine(void) { switch (current_state) { case EW_GREEN_NS_RED: set_leds(1, 0, 0, 0, 1, 0); // 东西绿、南北红 if (second_counter >= 30) // 持续30秒 { current_state = EW_GREEN_BLINK; second_counter = 0; } break; case EW_GREEN_BLINK: blink_green(); // 绿灯闪3秒 if (second_counter >= 3) { current_state = EW_YELLOW_NS_RED; second_counter = 0; } break; // 其余状态类似:黄灯3秒→切换南北方向 } }这里的second_counter在主循环里由second_flag驱动递增。参数30和3就是每个状态持续时长,放在宏定义里可以统一修改。这个状态机的关键设计点在于:每个状态的下一个状态是唯一的,中间没有跳变状态,所以即使按键在任何时刻修改了second_counter,也不会打乱状态顺序,只会提前或延后切换。
3.3 倒计时显示与动态扫描
数码管倒计时是这个设计里最容易被看出“有没有用心”的部分。多位共阴数码管扫描不能把所有位同时点亮,需要一位一位轮流刷新,靠视觉暂留形成“同时亮”的效果。
code unsigned char seg_code[] = { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, // 0 1 2 3 4 0x92, 0x82, 0xF8, 0x80, 0x90 // 5 6 7 8 9 }; void display_number(unsigned char num) { unsigned char tens = num / 10; // 十位 unsigned char ones = num % 10; // 个位 P2 = 0x01; // 选通第一位(个位) P0 = seg_code[ones]; delay_short(5); // 位选保持时间 P2 = 0x02; // 选通第二位 P0 = seg_code[tens]; delay_short(5); }0xC0是共阳数码管里数字0的段码,P2控制位选,P0输出段码。函数末尾没有清屏操作是因为下一个周期马上会重新选通位选,短暂的过渡帧人眼分辨不出来。刷新频率不能太慢,否则会闪烁,实际调用方式是在Timer中断里每5到10毫秒刷新一次。
注意,如果原理图用的是共阴数码管,段码表需要取反,即~0xC0。这是移植到其他开发板时最常见的坑之一。
3.4 矩阵键盘按键调时
矩阵键盘部分,Key.c负责检测按下,MatrixKey.c负责行列扫描。三乘四矩阵键盘扫描思路是逐行拉低,读列电平来判断是哪个键按下:
unsigned char matrix_key_scan(void) { unsigned char row, col; P1 = 0x0F; // 低四位拉低,高四位输入 if ((P1 & 0x0F) == 0x0F) return 0; // 无按键 for (row = 0; row < 4; row++) { P1 = ~(0x10 << row); // 逐行拉低 for (col = 0; col < 4; col++) { if (!(P1 & (0x01 << col))) { return row * 4 + col + 1; // 返回键值 } } } return 0; }P1 = 0x0F先把低四位作为扫描输出、高四位作为输入,检测到按键后再逐行扫描定位具体位置。拿到键值后,在main.c里分配给不同功能:加减秒数、切换方向、进入配时模式。配时模式里修改的是EW_GREEN_TIME等常量变量,状态机运行时直接读这些变量,实现不停机调时。
4. 扩展模块的接入方式:DS18B20温度、NEC红外与蜂鸣器联动
文件列表里出现DS18B20.c、IR.c和Buzzer.c,说明这已经不是一个单纯的交通灯,而是带环境感知的复合系统。这在课程设计答辩中属于加分项,评委看到的不只是“红绿灯会跳”,还能追问出传感器时序、外部中断、协议解析这些更底层的东西。
4.1 OneWire单总线读DS18B20的几个时序点
DS18B20走的是OneWire单总线协议,一根数据线既做电源又传数据,时序要求严格。复位、写0、写1、读时隙这四种操作必须满足微秒级的时间窗口。OneWire.c里的延时函数是核心,常见的简化版读温度函数长这样:
bit ds18b20_read_bit(void) { bit dat; DATA_PIN = 0; // 拉低总线启动时隙 _nop_(); DATA_PIN = 1; // 释放总线 _nop_(); dat = DATA_PIN; // 在15us窗口内采样 delay_us(50); return dat; }读一位的时序:主机先把总线拉低至少1微秒,然后释放,在15微秒内读取总线电平。DS18B20返回1就输出高,返回0就输出低。读取整字节就是连续读8位,低位在前。
代码里_nop_()是Keil C51内置的微秒级空指令。OneWire时序对延时非常敏感,如果在Proteus仿真里跑,要把仿真步长调小;在实物上跑,如果读出的温度恒定是85,通常是复位时序里的延时不够,把复位时拉低时间从480us加大到600us再试。
4.2 基于外部中断0的NEC红外解码
IR.c借助Int0.c提供的外部中断0来捕捉红外接收头的下降沿。NEC协议里,引导码是9ms高电平加4.5ms低电平,数据位通过高低电平的时间长短区分:560us高+1.69ms低表示0,560us高+2.25ms低表示1。
解码思路是利用定时器测量两次下降沿之间的时间间隔:
void Int0_ISR(void) interrupt 0 { unsigned int pulse_width = get_timer_value(); // 读取Timer1计数值 reset_timer(); if (pulse_width > 13000) // 约9ms引导码 { ir_receiving = 1; ir_bit_count = 0; } else if (ir_receiving) { if (pulse_width > 2000) // 约1.6~2.25ms → 数据位1 { ir_data |= 1; } ir_data <<= 1; ir_bit_count++; if (ir_bit_count >= 32) // 地址+地址反码+数据+数据反码 { ir_frame_done = 1; ir_receiving = 0; } } }pulse_width > 2000这个阈值由Timer1在12MHz晶振下的计数频率决定,具体数值要按实际晶振调整。32位数据里前16位是地址码和地址反码,后16位是数据码和数据反码,判断(ir_data & 0xFF) == (~(ir_data >> 8) & 0xFF)有效才算接收成功。
4.3 蜂鸣器的两种触发场景
Buzzer.c里有独立的发声函数,给不同场景不同的声音模式。倒计时最后5秒每秒短响一声,提示行人准备;DS18B20读到的温度超过设定的上限值(比如40摄氏度),连续长鸣报警。
void buzzer_single_beep(void) { buzzer_on(); delay_ms(100); buzzer_off(); delay_ms(100); }这里驱动蜂鸣器注意一点:有源蜂鸣器直接给电平就能发声,无源蜂鸣器必须给特定频率的方波才能发声。这个工程里用的是有源蜂鸣器,所以buzzer_on()直接拉高引脚即可。如果替换成无源蜂鸣器,需要增加一个定时器翻转I/O口产生2kHz到4kHz的方波,否则只会听到“咔哒”声。
5. 从工程到验收:编译烧录、Proteus仿真与常见故障定位
这个项目到能演示状态只差两步:编译出hex文件,然后把hex加载进Proteus仿真或烧进实物单片机。最后一环往往卡在环境配置和连线细节上,而不是代码本身。
Keil里打开ProgramCode.uvproj后,先检查晶振频率是不是12MHz,这个值影响延时函数和定时器初值的实际效果。点魔术棒进入Options for Target,在Target页确认Xtal(MHz)填的是12.0,然后按F7编译。编译输出0 Error 0 Warning时,在Objects文件夹里能找到ProgramCode.hex。
Proteus仿真时,双击原理图中的单片机芯片,在Program File一栏选择这个hex文件,晶振频率设为12MHz后点运行。如果仿真中LED不亮,优先检查LED的方向和限流电阻。源码里如果写的是LED_on为高电平,但原理图上LED接的是共阳接法(另一端接VCC),那点亮条件是引脚输出低电平,逻辑就对不上。
实际部署中还有几个高频故障:
- 数码管显示乱码。先确定是共阴还是共阳,对照原理图把段码表整体取反。
- 按键无效。矩阵键盘扫描里
P1 = 0x0F后的短暂延时被优化掉了,加几个空循环或delay_us(10)。 - 红外遥控没反应。测量红外接收头OUT脚空闲时是不是高电平,空闲不是高说明接线或供电有问题。
- 温度始终显示85。DS18B20的DQ引脚缺少4.7k上拉电阻到VCC,加上就好。
答辩时如果被问“为什么用外部中断而不是轮询处理红外”,可以回答:红外解码要求微秒级精度,轮询会被主循环中其他任务阻塞,错过下降沿导致整帧数据错乱;外部中断可以保证每次跳变都被记录,配合定时器实现精确脉宽测量。
最后有个提升演示效果的技巧:把按键调时和温度显示联动起来。比如按下功能键后,数码管前两位显示温度,后两位显示倒计时剩余秒数,这样一次答辩就能把“传感器采集”和“人机交互”都覆盖进去,加分项就落在实际演示里了。
本文还有配套的精品资源,点击获取