用 ego1 开发板玩转交通灯:从状态机到硬件实现的完整实战
你有没有想过,每天路上看到的红绿灯,其实可以用一块小小的 FPGA 芯片自己做出来?这可不是什么遥不可及的工程难题——在 Xilinx 的ego1 开发板上,结合Vivado工具链,我们完全可以亲手搭建一个完整的交通灯控制系统。这个项目不仅是电子类课程的经典大作业,更是理解数字系统设计核心思想的绝佳入口。
今天,我们就以“ego1开发板大作业vivado”为背景,带你一步步拆解这个看似简单、实则内涵丰富的项目。不堆术语,不讲空话,只讲你真正需要知道的设计思路、关键技巧和避坑指南。
为什么选 FPGA 做交通灯?
传统交通灯多用单片机控制,写个循环延时就能搞定。但这种方式本质上是“顺序执行”:CPU 得一个任务接一个任务地处理,扩展性差,响应也不够快。
而 FPGA 不一样。它是并行工作的硬件逻辑,所有模块同时运行,互不干扰。比如你可以让状态机、倒计时、黄灯闪烁、紧急模式这些功能各自独立运作,通过信号联动协调。这种“硬连线”的方式不仅更稳定,也为后续升级留足了空间。
再加上ego1 开发板搭载的是 Xilinx Artix-7 系列的 XC7A50T 芯片,资源足够丰富,又有 6 颗用户 LED 和多个按键可用,简直是教学级项目的完美载体。
更重要的是,整个流程走一遍,你会完整经历:代码编写 → 仿真验证 → 综合实现 → 引脚约束 → 下载调试—— 这正是工业级 FPGA 开发的标准路径。
核心大脑:有限状态机怎么设计才靠谱?
交通灯的本质是什么?是一组有明确规则的状态切换:
- 主路绿 → 黄 → 红
- 支路红 → 绿 → 黄 → 红
- 循环往复
这不就是典型的有限状态机(FSM)吗?
Moore 还是 Mealy?这里推荐 Moore
虽然两种都可以用,但我建议初学者优先使用Moore 型状态机,因为它的输出只依赖当前状态,不受输入影响,行为更可预测,也更容易避免毛刺问题。
我们定义三个核心状态:
| 状态名 | 含义 |
|---|---|
IDLE | 初始状态,全灯灭 |
S1_GYR | 主路绿灯亮,支路红灯亮 |
S2_RGY | 主路红灯亮,支路绿灯亮 |
注意:黄灯可以作为独立状态加入,也可以在 S1/S2 切换前插入短暂延时处理。为了简化逻辑,我们先把它融合进主状态切换流程中。
下面是精简后的 Verilog 实现:
module traffic_fsm ( input clk, input rst_n, output reg [5:0] led_out ); // 状态编码:One-Hot 更适合 FPGA parameter IDLE = 6'b000001; parameter S1_GYR = 6'b000010; parameter S2_RGY = 6'b000100; reg [5:0] current_state, next_state; // 同步状态更新 always @(posedge clk) begin if (!rst_n) current_state <= IDLE; else current_state <= next_state; end // 组合逻辑决定下一状态 always @(*) begin case (current_state) IDLE: next_state = S1_GYR; S1_GYR: next_state = S2_RGY; S2_RGY: next_state = S1_GYR; default: next_state = IDLE; endcase end // 输出解码(Moore型) always @(posedge clk) begin case (current_state) IDLE: led_out <= 6'b111111; // 全灭(共阳极) S1_GYR: led_out <= 6'b110001; // G1=0,Y1=0,R1=1; G2=1,Y2=1,R2=0 S2_RGY: led_out <= 6'b001110; // G1=1,Y1=1,R1=0; G2=0,Y2=0,R2=1 default: led_out <= 6'b111111; endcase end endmodule💡重点提醒:
- 所有状态转移必须覆盖完全,default分支不能少;
- 使用非阻塞赋值<=更新寄存器;
- 输出与状态强绑定,不要掺杂组合逻辑判断。
时间基准:50MHz 怎么变成“一秒一跳”?
ego1 板载时钟是50MHz,也就是每秒震荡 5000 万次。而我们要控制绿灯亮 30 秒,显然不能靠数时钟边沿来计时。
所以必须做一个分频器,把高频时钟降下来,生成一个精准的 1Hz 脉冲信号,作为“秒计数”的使能信号。
分频原理很简单:数够了就翻转
要得到 1Hz 输出,我们需要对 50MHz 进行 25,000,000 分频(因为上升沿触发,半周期各计一次)。
也就是说,计数器从 0 数到 24,999,999,共 2500 万个时钟周期,刚好是 0.5 秒;再清零重新开始,下一个 0.5 秒后拉高输出,形成 1Hz 方波。
不过实际应用中,我们更希望它输出一个单周期脉冲,方便下游模块当作事件触发,而不是电平使能。
来看实现代码:
module clock_divider ( input clk_50m, input rst_n, output reg tick_1s ); reg [25:0] count; // 2^26 > 50M,安全起见用26位 always @(posedge clk_50m) begin if (!rst_n) begin count <= 26'd0; tick_1s <= 1'b0; end else if (count == 26'd24999999) begin count <= 26'd0; tick_1s <= 1'b1; // 仅在一个周期内为高 end else begin count <= count + 1; tick_1s <= 1'b0; end end endmodule这个tick_1s就是我们后续驱动倒计时或状态切换的“心跳信号”。
⚠️ 注意事项:
- 计数器位宽别小了,log₂(25M) ≈ 24.58,至少 25 位,保险起见用 26;
- 输出脉冲宽度尽量窄,避免误触发;
- 若需支持动态调时(如白天/夜晚模式),可将阈值改为参数化输入。
如何连接真实世界?XDC 管脚约束详解
写完代码只是第一步。FPGA 是硬件芯片,每个信号都得对应到物理引脚上,否则烧进去也没法工作。
这就需要用到XDC 文件(Xilinx Design Constraints),它是 Vivado 中用来告诉工具“某个信号该接到哪个引脚”的配置文件。
ego1 的 LED 和按键都连在哪?
根据 Digilent 官方文档,ego1 上的关键 I/O 如下:
| 功能 | FPGA 引脚 | 备注 |
|---|---|---|
| 主时钟 | E3 | 50MHz 有源晶振 |
| 用户复位 | D9 | 低电平有效 |
| LED[0] | H5 | 对应 G1(主路绿) |
| LED[1] | J5 | Y1 |
| LED[2] | T9 | R1 |
| LED[3] | T8 | G2(支路绿) |
| LED[4] | U8 | Y2 |
| LED[5] | R8 | R2 |
全部采用LVCMOS33标准(3.3V CMOS 电平)。
写好你的第一份 XDC 文件
## 时钟输入 set_property PACKAGE_PIN E3 [get_ports clk_50m] set_property IOSTANDARD LVCMOS33 [get_ports clk_50m] create_clock -period 20.000 -name sys_clk_pin [get_ports clk_50m] ## 复位按键 set_property PACKAGE_PIN D9 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] set_property PULLUP true [get_ports rst_n] ;# 上拉防悬空 ## LED 输出 set_property PACKAGE_PIN H5 [get_ports {led_out[0]}] set_property PACKAGE_PIN J5 [get_ports {led_out[1]}] set_property PACKAGE_PIN T9 [get_ports {led_out[2]}] set_property PACKAGE_PIN T8 [get_ports {led_out[3]}] set_property PACKAGE_PIN U8 [get_ports {led_out[4]}] set_property PACKAGE_PIN R8 [get_ports {led_out[5]}] set_property IOSTANDARD LVCMOS33 [get_ports led_out[*]]🔧 关键点:
-create_clock必须加,否则时序分析会报错;
- 按键引脚建议启用内部上拉,避免浮空误判;
- 引脚编号务必核对官方手册,错一个字母都不行!
系统整合:怎么让各个模块协同工作?
现在我们有两个核心模块:
clock_divider: 提供 1Hz 脉冲traffic_fsm: 控制状态跳转
怎么把它们串起来?
最简单的做法是:用tick_1s作为状态机的“步进时钟”,每次脉冲到来就切换一次状态。
但这样太粗暴了——你想啊,如果绿灯要持续 30 秒,难道要等 30 个 tick 才跳?那岂不是要加个计数器?
没错!所以我们需要引入一个状态保持机制:只有当计数完成时,才允许进入下一个状态。
改进方案如下:
// 在 FSM 模块中新增: reg [5:0] timer; // 计数器,最大支持63秒 always @(posedge clk) begin if (!rst_n) begin timer <= 6'd0; end else if (tick_1s) begin case (current_state) S1_GYR: if (timer < 29) timer <= timer + 1; else timer <= 0; S2_RGY: if (timer < 29) timer <= timer + 1; else timer <= 0; default: timer <= 0; endcase end end // 修改 next_state 判断条件 always @(*) begin case (current_state) IDLE: next_state = S1_GYR; S1_GYR: next_state = (timer == 29) ? S2_RGY : S1_GYR; S2_RGY: next_state = (timer == 29) ? S1_GYR : S2_RGY; default: next_state = IDLE; endcase end这样一来,每个状态都能维持整整 30 秒(第0秒到第29秒),第30个 tick 到来时自动切换。
调试经验分享:新手最容易踩的五个坑
我在带学生做这个项目时,总结出以下高频问题,提前规避能省下大把时间:
❌ 1. 忘记加create_clock约束
结果:综合通过,实现失败,提示“no timing constraint”。
✅ 解决:XDC 中必须为主时钟添加周期约束。
❌ 2. LED 接反了(共阳 vs 共阴)
结果:该亮的不亮,不该亮的常亮。
✅ 解决:查原理图!ego1 是共阳极连接,即输出低电平点亮。
❌ 3. 计数器溢出或位宽不够
结果:定时不准,有时快有时慢。
✅ 解决:确保计数器位数 ≥ ceil(log₂(N))。
❌ 4. 状态机卡死在某个状态
原因:缺少 default 分支,或复位信号未正确连接。
✅ 解决:always 加 default,复位信号全程同步处理。
❌ 5. 没做仿真就直接下载
结果:板子跑起来乱闪,根本看不出哪里错了。
✅ 解决:先写 Testbench,用 Vivado Simulator 看波形!
举个简单的测试平台片段:
initial begin clk_50m = 0; rst_n = 0; #100 rst_n = 1; end always #10 clk_50m = ~clk_50m; // 50MHz = 20ns 周期跑完仿真能看到状态跳转、计数递增、输出变化全过程,比看实物调试高效十倍。
还能怎么升级?给你的项目加点料
基础版跑通之后,不妨尝试这些扩展功能,让你的大作业脱颖而出:
✅ 添加数码管显示剩余时间
用另一组 IO 驱动七段数码管,实时显示当前倒计时,直观又专业。
✅ 加入行人过街按钮
增加一个输入按键,按下后提前结束当前相位,进入黄灯过渡,保障行人安全。
✅ 实现车流量自适应调度
模拟接入传感器输入(可用拨码开关代替),根据车流密度动态调整红绿灯时长。
✅ 插入 ILA 逻辑分析仪
在 Vivado 中插入 Integrated Logic Analyzer 核,实时抓取内部信号,无需额外仪器即可深度调试。
写在最后:这不是作业,是通往系统的起点
当你第一次看到 ego1 上的 LED 按照预定节奏有序闪烁时,那种成就感远超写出一段能跑的代码。因为你已经不只是在“编程”,而是在“构建系统”。
这个交通灯项目虽小,却涵盖了现代数字系统设计的核心要素:
- 状态机建模—— 抽象控制流程
- 时钟管理—— 构建时间基准
- 硬件映射—— 连接虚拟与现实
- 模块化设计—— 提升可维护性
更重要的是,它为你打开了 FPGA 工程实践的大门。下一步,你可以尝试 VGA 显示、UART 通信、甚至软核处理器嵌入。每一个新技能,都是在这块小小开发板上生长出来的。
所以别再说“这只是个大作业”了。
你正在做的,是一个真正的控制系统原型。
如果你在实现过程中遇到了其他挑战,欢迎在评论区交流讨论。我们一起把想法变成看得见、摸得着的硬件行为。