51单片机+Protues交通灯仿真:状态机与定时器实战 简介这份资源面向51单片机初学者与电子工程相关专业学生提供一套基于Proteus仿真的交通灯控制系统完整工程用于在无硬件条件下练习定时器、中断与I/O端口编程。压缩包共14个文件、约37KB包含C语言源程序、编译生成的hex文件、Keil工程配置uv2、opt、m51、lst等以及Proteus电路设计文件dsn可直接加载仿真观察红黄绿灯按30秒、20秒、5秒的时序交替运行。已有891人学习下载说明其在入门实践中有一定参考价值。读者可从中获得可运行的交通灯控制逻辑、定时器与中断服务程序的实现范例以及Proteus与Keil联调的工程组织方式便于对照理解计数误差、中断处理等常见问题的调试思路适合作为课程实验或自学项目的起步模板。1. 从一块“只会闪灯”的开发板说起这个交通灯仿真项目到底能干什么很多人第一次碰 51 单片机写完流水灯就卡住了——代码能跑但不知道一个“像样”的系统长什么样。这个 Protues 仿真交通灯项目就是来补这一课的它把十字路口红绿灯的完整控制逻辑连同能在仿真软件里直接跑起来的电路一起打包给你。你不需要焊板子、不需要买元器件打开工程就能看到两个方向的车道灯按秒切换黄灯闪烁、倒计时、紧急按键一个不少。适合谁刚学完 C51 语法、想找一个能从头读到尾的完整小系统的学生也适合要带实验课、想找现成案例改参数的老师。它不炫技但把“程序怎么控制硬件”这件事讲得很实在。2. 项目结构与技术选型为什么是 51 Protues 这套组合2.1 技术栈拆解C51 写逻辑Protues 当“虚拟面包板”这个项目的技术栈非常克制核心就两块Keil C51 负责编译单片机程序Protues 负责电路仿真。选 51 而不是 STM32原因很现实——交通灯的逻辑本质是“定时 状态机 IO 翻转”8 位机完全够用而且 51 的寄存器少、中断结构简单初学者能把注意力放在“状态怎么迁移”上而不是被时钟树和 HAL 库绕晕。Protues 的价值在于它把“代码跑起来”和“电路接对了”这两件事解耦了。你在 Keil 里编译出 .hex 文件拖进 Protues 的单片机模型里点运行虚拟的 LED 就按你的代码亮灭。接错引脚、忘了加上拉电阻、晶振频率设错这些在真实硬件上要拿万用表查半天的问题在仿真里改个属性就能验证。常见做法是先用 Protues 把逻辑跑通再照着仿真电路去焊实物能省掉大量“灯不亮到底是代码问题还是接线问题”的排查时间。需要说明的是Protues 对 51 的仿真速度受电脑性能影响晶振设成 12MHz 时定时器精度和真实芯片基本一致但如果你的电脑比较老仿真跑起来会有点卡这时候别急着怀疑代码先把仿真速度调成“实时”再观察。2.2 目录结构一个最小可运行工程长什么样拿到这份资源解压后大致是这么几块我按重要性排一下交通灯仿真/ ├── 程序/ │ ├── main.c # 主循环 状态机调度 │ ├── timer.c # 定时器初始化与中断服务 │ ├── traffic.h # 状态枚举、引脚定义、时间常量 │ └── traffic.uvproj # Keil 工程文件 ├── 仿真/ │ ├── traffic.DSN # Protues 原理图 │ └── traffic.PWI # 仿真配置 └── 说明.txt # 晶振频率、hex 路径等关键参数traffic.h是整个工程的“配置中心”引脚分配和时间参数都集中在这里改东西基本只动这一个文件。main.c里是一个典型的前后台结构主循环轮询按键和状态切换定时器中断负责秒计数和倒计时刷新。traffic.DSN是 Protues 的原理图打开后能看到 51 最小系统、两组红黄绿灯、一个数码管倒计时模块和一个紧急按键。这里有个容易忽略的点Keil 工程里的输出 hex 路径必须和 Protues 里单片机模型加载的 hex 路径对上否则你改了代码重新编译仿真里跑的却还是旧程序。说明.txt 里一般会写清楚这个路径第一次跑之前先核对一遍。2.3 状态机设计交通灯的本质是几个状态在轮转交通灯看着复杂拆开就是一个有限状态机。这个项目里定义了四个主状态东西绿灯/南北红灯、东西黄灯/南北红灯、东西红灯/南北绿灯、东西红灯/南北黄灯。每个状态有固定的持续时间时间一到就迁移到下一个状态。用枚举把状态列出来比一堆 if-else 堆在一起清楚得多/* traffic.h —— 状态定义与时间常量 */ typedef enum { EW_GREEN_NS_RED 0, /* 东西绿南北红 */ EW_YELLOW_NS_RED, /* 东西黄南北红 */ EW_RED_NS_GREEN, /* 东西红南北绿 */ EW_RED_NS_YELLOW /* 东西红南北黄 */ } TrafficState; #define GREEN_TIME 20 /* 绿灯持续秒数 */ #define YELLOW_TIME 3 /* 黄灯持续秒数 */ #define RED_TIME (GREEN_TIME YELLOW_TIME) /* 红灯 对方绿黄 */这段代码的关键在于RED_TIME不是独立写死的而是由绿灯和黄灯时间推导出来。这样你改绿灯时长时红灯会自动跟着变不会出现“绿灯 20 秒、红灯还是 15 秒”这种逻辑矛盾。参数说明GREEN_TIME和YELLOW_TIME单位是秒实际计时靠定时器中断累加后面会讲怎么把秒映射到定时器初值。状态迁移的触发条件只有一个——当前状态的倒计时归零。这种“单条件迁移”让逻辑非常稳不会出现多个条件互相打架的情况。我见过一些同学把按键也塞进迁移条件里结果紧急模式下状态乱跳这就是没把“正常轮转”和“异常干预”分开。3. 核心代码拆解定时器、状态切换和倒计时怎么落地3.1 定时器中断把 12MHz 晶振切成 1 秒51 的定时器是这套逻辑的心脏。晶振 12MHz 时机器周期是 1 微秒定时器工作在模式 116 位最多定时 65.5 毫秒所以标准做法是每 50 毫秒中断一次中断 20 次凑成 1 秒。代码大概长这样/* timer.c —— 定时器0初始化50ms 中断一次 */ void Timer0_Init(void) { TMOD 0xF0; /* 清除 T0 模式位 */ TMOD | 0x01; /* T0 模式116位定时器 */ TH0 0x3C; /* 65536 - 50000 15536 0x3CB0 */ TL0 0xB0; ET0 1; /* 开定时器0中断 */ EA 1; /* 开总中断 */ TR0 1; /* 启动定时器0 */ } /* 中断服务每 50ms 进来一次 */ void Timer0_ISR(void) interrupt 1 { static unsigned char tick 0; TH0 0x3C; /* 重装初值必须放在中断里 */ TL0 0xB0; if (tick 20) { /* 20 × 50ms 1s */ tick 0; if (countdown 0) countdown--; /* 秒计数递减 */ } }逻辑说明TH0/TL0的初值 0x3CB0 对应 15536从 15536 数到 65536 溢出正好 50000 个机器周期也就是 50 毫秒。中断里必须重新装初值因为模式 1 不会自动重载忘了重装的话第二次中断时间就全乱了——这是新手最常翻的车之一。tick用static修饰保证它在多次中断之间保持值凑够 20 次才减一秒。参数怎么改如果你把晶振换成 11.0592MHz机器周期变了初值要重新算不能直接抄 0x3CB0。常见做法是用“65536 - 晶振频率/12 × 定时毫秒数”这个公式推算完取整再转十六进制。3.2 状态切换函数一个 switch 管住四盏灯状态迁移和灯的控制放在一个函数里主循环检测到倒计时归零就调用它/* main.c —— 状态切换与灯光输出 */ void Switch_State(void) { switch (current_state) { case EW_GREEN_NS_RED: EW_GREEN 0; EW_YELLOW 1; EW_RED 1; /* 低电平点亮 */ NS_GREEN 1; NS_YELLOW 1; NS_RED 0; countdown GREEN_TIME; current_state EW_YELLOW_NS_RED; break; case EW_YELLOW_NS_RED: EW_GREEN 1; EW_YELLOW 0; EW_RED 1; NS_GREEN 1; NS_YELLOW 1; NS_RED 0; countdown YELLOW_TIME; current_state EW_RED_NS_GREEN; break; /* 后两个状态对称略 */ } }逻辑说明这里假设 LED 是低电平点亮共阳接法所以 0表示亮。每个 case 里先设置灯的状态再重置countdown最后把current_state指向下一个状态。注意顺序——先设灯再改状态如果反过来下一次进函数时灯和状态就对不上了。参数说明EW_GREEN这些是sbit定义的位变量对应traffic.h里的引脚分配比如sbit EW_GREEN P1^0;。改引脚只动头文件Switch_State不用碰。这种“引脚定义和逻辑分离”的写法二次开发时特别省事。黄灯闪烁是另一个细节有些实现是在黄灯状态里再开一个 500ms 的翻转标志让黄灯一亮一灭。这个项目里如果黄灯是常亮而不是闪烁你可以在定时器中断里加一个yellow_blink标志每 500ms 翻转一次然后在黄灯状态下用这个标志控制输出。3.3 倒计时显示数码管动态扫描的两个坑倒计时用两位数码管显示51 的 IO 口不够用所以是动态扫描——段选和位选分时复用。核心逻辑是每 5ms 切换一次位选利用人眼视觉暂留看起来是同时亮的/* display.c —— 数码管动态扫描放在定时器中断里调用 */ void Display_Refresh(void) { static unsigned char pos 0; P0 0x00; /* 消隐防止拖影 */ if (pos 0) { DIGIT_SEL_TENS 0; /* 选十位 */ P0 seg_code[countdown / 10]; } else { DIGIT_SEL_UNITS 0; /* 选个位 */ P0 seg_code[countdown % 10]; } pos !pos; }逻辑说明P0 0x00消隐这一步不能省否则切换位选时上一位的段码会短暂残留看起来就是数字“糊”在一起。seg_code是共阳/共阴数码管的段码表取决于你的硬件接法接反了显示就是乱码。两个坑一是扫描频率太快单片机忙不过来太慢会闪5ms 左右比较稳二是countdown在中断里被减在显示里被读如果主循环里也在改它可能出现读到一半被中断打断的情况简单做法是显示只读、只在状态切换时写。4. 跑起来从 Keil 编译到 Protues 点运行4.1 Keil 侧编译出 hex 并确认输出路径打开traffic.uvproj先检查两处Target 里晶振频率填 12MHz和 Protues 里单片机模型一致Output 选项卡勾选“Create HEX File”。然后按 F7 编译看 Build Output 窗口有没有0 Error(s)。编译通过后hex 文件默认在工程目录的Objects文件夹下。如果编译报错说找不到reg52.h说明 Keil 的器件包没装全装一个 C51 的器件支持包就行。这一步卡住的人不少但原因很简单不是代码问题。4.2 Protues 侧加载 hex、核对晶振、点运行打开traffic.DSN双击单片机模型在 Program File 里选中刚才编译出的 hexClock Frequency 填 12MHz。然后点左下角的运行按钮正常情况下两组灯会开始按绿→黄→红轮转数码管显示倒计时。如果灯不亮先看单片机模型有没有加载 hex属性里路径为空就是没加载再看 LED 的接法是不是和代码里的高低电平假设一致。如果倒计时不动检查定时器中断有没有开——EA和ET0两个都要置 1只开一个中断不会进。提示Protues 里改完 hex 路径后最好把仿真停止再重新运行热加载有时候不生效跑的还是旧程序。4.3 紧急按键一个外部中断打断正常轮转项目里有个紧急按键按下后所有方向变红松开后恢复。实现上一般用外部中断 0/* 外部中断0紧急模式 */ void Ext0_ISR(void) interrupt 0 { if (EMERGENCY_BTN 0) { /* 按下 */ EW_GREEN 1; EW_YELLOW 1; EW_RED 0; NS_GREEN 1; NS_YELLOW 1; NS_RED 0; emergency_flag 1; } else { /* 松开 */ emergency_flag 0; Switch_State(); /* 重新进入正常轮转 */ } }逻辑说明中断里只做“全红”和“恢复”两件事正常的状态机靠emergency_flag在主循环里暂停。这样紧急模式和正常轮转互不干扰不会出现按键松开后状态错乱。参数上要注意按键消抖硬件上并一个电容或者软件里加 10ms 延时判断否则一次按下可能触发多次中断。5. 避坑与排查仿真跑不通时先看这几条5.1 灯全亮或全灭先查共阳共阴和消隐现象仿真一运行所有 LED 同时亮或者同时灭代码逻辑看着没问题。原因多半是 LED 接法和代码里的电平假设反了——代码按低电平点亮写电路却接成了共阴。解决打开 Protues 看 LED 的公共端接 VCC 还是 GND共阳接 VCC 时低电平点亮共阴接 GND 时高电平点亮把Switch_State里的 0/1 整体取反即可。5.2 倒计时跳变不规律定时器初值没重装现象数码管数字跳得忽快忽慢或者一秒变成两秒。原因定时器模式 1 不自动重载中断里忘了写TH0/TL0重装语句第二次中断的起点就漂了。解决确认中断服务函数开头有重装初值且初值和晶振频率匹配。这个坑我在早期项目里踩过仿真看着“差不多”一上实物时间全乱。5.3 改了代码仿真没变化hex 路径没更新现象Keil 里改了绿灯时长重新编译Protues 里还是老样子。原因Protues 加载的是旧路径下的 hex或者编译输出路径和加载路径不是同一个。解决核对 Keil 的 Output 路径和 Protues 单片机模型里的 Program File 路径改完停止仿真再重新运行。5.4 数码管显示重影消隐语句被优化掉现象两位数字叠在一起边缘发虚。原因动态扫描切换位选前没有把段选清零或者消隐语句被编译器优化。解决在切换位选前加P0 0x00如果被优化把P0声明成volatile或者加一句空操作。扫描间隔控制在 5ms 左右太快太慢都会影响观感。5.5 紧急按键一按就死机中断里做了太多事现象按下紧急键后程序卡住灯不再变化。原因外部中断里调用了带延时的消抖函数或者中断嵌套没处理好。解决中断里只置标志位和改灯消抖放到主循环里做中断服务函数越短越稳。6. 二次开发把四状态交通灯改成带人行道的六状态系统这个项目最值得动手改的地方是把“车行道”扩展成“车行 人行”的联动系统。思路不复杂在原有四个状态基础上插入两个人行绿灯状态——东西车绿时南北人行绿反之亦然。状态枚举从 4 个扩到 6 个Switch_State里加两个 case人行灯的引脚在traffic.h里补上定义。改的时候有个顺序问题要注意人行绿灯必须在对应方向车绿之后、车黄之前结束否则会出现“车已经黄了人还在走”的危险逻辑。我一般会在状态迁移里加一个“人行提前结束”的中间态让人行灯比车绿灯早 3 秒变红闪这个细节在真实路口很常见仿真里加上会显得专业很多。验证方法很直接改完编译在 Protues 里跑一轮完整周期拿秒表对一下每个状态的持续时间看人行和车行是否严格互锁——任何时刻只要有一个方向的车是绿的垂直方向的人行就不能是绿的。这个互锁条件可以用一个简单的断言在代码里检查仿真时如果触发就点亮一个调试 LED。另一个改造方向是加夜间模式通过一个拨码开关切换夜间只有黄灯闪烁。实现上在主循环里判断开关状态如果开了夜间模式就跳过状态机直接让黄灯按 500ms 翻转。这个改动很小但能让项目从“课程作业”变成“有点产品味”的东西。血泪经验是每次改状态机之前先把原来的状态迁移图画在纸上标清楚每个状态的进入条件和退出条件改完对照图检查一遍。我早期图省事直接改代码结果两个状态互相指向仿真里灯疯狂闪烁查了半小时才发现是迁移条件写反了。从那以后我每次动状态机都强制先画图再写代码这个习惯帮我省下的调试时间比画图花的多得多。希望帮到你。本文还有配套的精品资源点击获取