简介:面向FPGA初学者与数字电路课程设计的Verilog十字路口交通信号灯控制系统工程包,实现东西、南北双向红黄绿指示、主干道与支干道直行/左转分时放行、倒计时数码管显示及黄灯每秒闪烁过渡。资源共248个文件,压缩包5.5MB,以.v源码、.xdc约束、.bit配置文件为主,并包含综合/仿真脚本(.bat/.tcl)、日志报告(.log/.rpt)及Vivado工程文件(.xpr)等,方便直接打开工程复现与修改。已有4395人学习下载。包内目录结构完整,既有模块化Verilog设计、引脚约束与倒计时逻辑,也提供编译/仿真脚本与生成比特流,适用于课程设计、竞赛训练或FPGA入门综合实践,可据此理解状态机设计与时序控制方法。
1. 为什么是FPGA而不是单片机:从时序确定性说起
先说说我为什么会对这个题目感兴趣。你在路口等红灯的时候,有没有想过一件事:那个倒计时数字和灯色切换,每秒钟都必须精确对齐,晚了几毫秒,绿灯亮了而数字还停在"3",司机就会犹豫要不要起步;数字归零了灯却没变,后面喇叭声就起来了。这种对确定性时序的极致要求,恰好是FPGA的主场,而不是单片机。
很多人一上来就习惯用STM32做交通灯,毕竟串口、定时器、中断都是现成的。但单片机是"顺序执行指令"的机器,一个时刻只能做一件事,它靠定时器中断维持时间基准,靠软件轮询响应按键。一旦程序里同时要处理倒计时刷新、按键检测、模式切换,中断优先级和代码执行时长就会互相挤压,最坏情况下某个事件会延迟几个毫秒才能被响应。FPGA则完全不同,它没有"执行"的概念,而是把电路直接铺在硅片上:计数器电路在跑,按键消抖电路在跑,数码管扫描电路在跑,三者物理上就是并行存在的。换句话说,从FPGA上电那一刻起,倒计时和灯色切换就是硬件级别的"同时进行",根本不存在谁抢谁的时间片的问题。
我自己折腾这个项目时用的是EGO1开发板,板载50MHz晶振,Xilinx Artix-7系列芯片,搭配Verilog HDL设计。整板资源对这个项目来说相当充裕,LUT、FF用量大概只占百分之几,这其实是件好事——新手在综合、实现阶段不太容易因为资源不足而报错,可以专心把逻辑写对。
顺带提一句最新的趋势。热词里那个"stm32h743和fpga实现fmc通信"其实反映了嵌入式领域很重要的一个方向:你完全可以把FPGA当作MCU外设的"时间管家",MCU通过FMC总线来配置FPGA里的寄存器,FPGA负责那些需要硬实时的高频逻辑。交通灯控制系统虽然简单,但它的设计范式——状态机为核心、外围模块并行、顶层模块拼接——和更大规模的FPGA项目(图像处理、PCIe、BISS-C编码器)是完全打通的。把一个交通灯吃透了,之后再看那些动辄几十个模块的复杂工程,思路会清晰很多。
2. 十字路口信号机的状态梳理:红绿灯并不是"三个灯轮流亮"
2.1 真正的路口逻辑比你想象中多一个状态
大多数人第一次写交通灯,脑子里想的是这样一个循环:
- 南北方向绿灯亮,东西方向红灯亮,持续一段时间
- 南北方向黄灯亮,东西方向红灯亮,持续几秒
- 南北方向红灯亮,东西方向绿灯亮,持续一段时间
- 南北方向红灯亮,东西方向黄灯亮,持续几秒
- 回到第一步
这个四状态流程在教材里能跑通,但真实路口的信号机比这复杂得多。最典型的是一个经常被忽略的状态:全红时间,也叫清空时间。当南北方向的绿灯结束后,并不是立刻让东西方向变绿,而是让南北方向先变黄,东西方向继续保持红灯,黄灯结束后,两个方向都保持全红一到两秒,让已经越过停止线的车辆完全离开路口,然后才放行东西方向。如果没有这个全红间隔,东西方向的头车起步时,南北方向的尾车可能还没通过冲突点,路口就会卡死。
如果你想把通行效率考虑进去,还会发现左转相位的存在。简单的两相位控制(南北放行、东西放行)在左转车流量大的路口会造成严重拥堵,因为左转车流和对向直行车流存在冲突点。真实的信号控制通常会有四相位甚至六相位:南北左转专用相位、南北直行相位、东西左转专用相位、东西直行相位,再加上黄灯和全红间隔。对于FPGA课程设计来说,我建议第一步先把两相位加全红间隔做扎实,这是所有复杂逻辑的基础;学有余力再加一个南北左转单独放行的扩展相位,这时候状态机的优势就真正体现出来了。
2.2 状态转移表是设计的"法律文件"
无论状态有多少个,第一步永远是画状态转移表,而不是直接写代码。这是我在这个项目里学到的最重要的一件事:状态机不先想清楚就上手Verilog,写出来基本必乱。
以基础两相位加全红间隔为例,设信号灯的公共周期为T,其中:
- 相位1:南北绿灯,持续G1秒
- 相位1尾部:南北黄灯,持续Y秒
- 全红间隔:南北和东西全红,持续R秒
- 相位2:东西绿灯,持续G2秒
- 相位2尾部:东西黄灯,持续Y秒
- 全红间隔:南北和东西全红,持续R秒
把G1=20秒、Y=3秒、R=2秒代入,完整的状态转移表如下:
| 状态 | 南北方向 | 东西方向 | 持续时间 | 下一状态 |
|---|---|---|---|---|
| S0 | 绿灯 | 红灯 | 20秒 | S1 |
| S1 | 黄灯 | 红灯 | 3秒 | S2 |
| S2 | 红灯 | 红灯 | 2秒 | S3 |
| S3 | 红灯 | 绿灯 | 20秒 | S4 |
| S4 | 红灯 | 黄灯 | 3秒 | S5 |
| S5 | 红灯 | 红灯 | 2秒 | S0 |
这张表看起来简单,但它定义了三件关键的事:每个状态的持续时间、每个状态下六个灯(南北红黄绿、东西红黄绿)的亮灭组合、以及状态的跳转顺序。后面写代码时,状态编码、计数器终值、灯组输出全部顺着这张表来,逻辑不可能乱。
2.3 选择Moore型还是Mealy型状态机
接下去是状态机建模类型的选择。交通灯控制本质上是一个Moore型状态机:输出只取决于当前状态,而与输入无关——不管有没有车来,绿灯该亮多久就亮多久(除非你加了车辆检测传感器做感应控制,那就是另一回事了)。Mealy型状态机的输出由状态和输入共同决定,适合那些需要"输入一到立刻反应"的场景,比如按键暂停、紧急车辆优先通行这类交互逻辑。
我的建议是主体控制用Moore型,按键和模式切换放在顶层另做一套并行逻辑去干预状态的跳转路径。这样做的最大好处是:输出信号稳定,不会因为输入的毛刺导致灯色闪烁。你在写代码时会发现,Moore型状态机的输出逻辑只需要一个简单的case语句根据当前状态赋值即可,干净利落。
3. 三段式状态机的RTL设计思路与代码实战
3.1 为什么我坚持推荐三段式而不是一段式
Verilog描述状态机有经典的一段式、两段式、三段式写法。新手最容易在网上搜到一段式的范例,因为写起来最短:
always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S0; else begin case (state) S0: begin if (cnt_done) state <= S1; // 同时在这个always块里给输出赋值 light_sn = 3'b001; light_ew = 3'b001; end endcase end end这种写法的问题在于:状态寄存器和输出逻辑、跳转逻辑全部挤在一个时间控制块里,输出使用的是阻塞赋值,仿真容易通过,但综合后容易产生毛刺,而且一旦状态多了,这个块会变得极其臃肿,改一个灯色的延迟时间可能要翻半天。
三段式的核心理念是"各司其职":
- 第一段:时序逻辑,负责状态寄存器的更新
- 第二段:组合逻辑,负责计算下一状态
- 第三段:时序逻辑或组合逻辑,负责根据当前状态产生输出
这个划分本质上是在模仿真实硬件电路的物理结构:状态寄存器是一组D触发器,下一状态逻辑是一大块组合逻辑,输出逻辑又是一组组合逻辑。把代码的结构和硬件结构对齐,不仅可读性强,时序分析时的路径也清晰。
3.2 完整的三段式交通灯控制代码
直接给出一份我调试通过的骨架代码,基于50MHz系统时钟,G1=20秒、Y=3秒、R=2秒。
module traffic_light ( input wire clk, input wire rst_n, output reg [2:0] light_sn, // {red, yellow, green} output reg [2:0] light_ew, output reg [5:0] cnt_remain // 倒计时剩余秒数,用于数码管显示 ); localparam IDLE = 3'd0; localparam SN_GREEN = 3'd1; localparam SN_YELLOW = 3'd2; localparam ALL_RED_1 = 3'd3; localparam EW_GREEN = 3'd4; localparam EW_YELLOW = 3'd5; localparam ALL_RED_2 = 3'd6; localparam G1 = 20; localparam Y = 3; localparam R = 2; reg [2:0] state; reg [2:0] next_state; reg [31:0] cnt_clk; // 时钟周期计数 reg [5:0] cnt_sec; // 秒级倒计时,最大值需覆盖G1 // 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 第二段:组合逻辑,计算下一状态 always @(*) begin next_state = state; case (state) IDLE: next_state = SN_GREEN; SN_GREEN: if (cnt_sec == 1) next_state = SN_YELLOW; SN_YELLOW: if (cnt_sec == 1) next_state = ALL_RED_1; ALL_RED_1: if (cnt_sec == 1) next_state = EW_GREEN; EW_GREEN: if (cnt_sec == 1) next_state = EW_YELLOW; EW_YELLOW: if (cnt_sec == 1) next_state = ALL_RED_2; ALL_RED_2: if (cnt_sec == 1) next_state = SN_GREEN; default: next_state = IDLE; endcase end // 第三段:时序逻辑,产生输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin light_sn <= 3'b001; // 全红 light_ew <= 3'b001; cnt_sec <= 0; end else begin case (state) SN_GREEN: begin light_sn <= 3'b100; light_ew <= 3'b001; cnt_sec <= G1; end SN_YELLOW: begin light_sn <= 3'b010; light_ew <= 3'b001; cnt_sec <= Y; end ALL_RED_1: begin light_sn <= 3'b001; light_ew <= 3'b001; cnt_sec <= R; end EW_GREEN: begin light_sn <= 3'b001; light_ew <= 3'b100; cnt_sec <= G1; end EW_YELLOW: begin light_sn <= 3'b001; light_ew <= 3'b010; cnt_sec <= Y; end ALL_RED_2: begin light_sn <= 3'b001; light_ew <= 3'b001; cnt_sec <= R; end default: begin light_sn <= 3'b001; light_ew <= 3'b001; cnt_sec <= 0; end endcase end end // 秒脉冲产生与倒计时计数 reg [31:0] clk_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) clk_cnt <= 0; else if (clk_cnt == 49_999_999) begin clk_cnt <= 0; if (cnt_sec > 0) cnt_sec <= cnt_sec - 1; end else clk_cnt <= clk_cnt + 1; end endmodule注意一个细节:我在第三段状态机的case块里给cnt_sec直接赋了一个固定值,但在另一个always块里又对cnt_sec做减法。这看起来像"多驱动",实际上不是,因为两个块在不同的状态隐含条件下工作:状态机的case块只在状态跳变的那个时钟周期加载新值,秒计数块每个1秒脉冲减1,二者通过状态切换天然互斥,不会产生竞争。
这里还藏着一个新手非常容易犯的错误:计数器是"减到0再切状态"还是"减到1就切状态"。我上面的代码用的是"cnt_sec == 1就切状态",原因是状态机的第三段时钟沿一到,cnt_sec会被加载为新状态对应的初始值,如果等到cnt_sec减到0才切换,那么状态S0的绿灯实际点亮时间是G1+1秒,因为切过去的那个时钟周期灯还是绿的,数码管却显示0了。用"==1"提前一拍切换,就能保证绿灯点亮时间和数码管显示数值严格一致。这个问题在仿真里很容易被忽略,上板后表现为灯色切换和倒计时数字对不上,排查半天才反应过来。
3.3 时钟分频的两条路线:真实分频还是脉冲使能
上面代码里我用clk_cnt产生了一个周期为1秒的使能脉冲。这里有一个重要的工程经验:不要用计数器分频出一个频率为1Hz的时钟信号直接给其他模块用。
原因有两层:第一,全局时钟网络(BUFG)的资源是有限的,你把分频后的1Hz信号连到普通逻辑,综合工具不会把它当成真正的时钟,后续时序约束会变得非常麻烦;第二,如果你把1Hz信号同时用作多个模块的时钟,各个模块之间天然存在时钟相位不确定性,跨时钟域处理不当就是亚稳态。
正确做法是:系统始终用50MHz原始时钟,每个时钟上升沿检查一下"秒使能"信号是否为高。为高就执行一次秒更新逻辑,为低就保持不动。这个秒使能脉冲本质上是一个全局节拍器,所有时序逻辑都踩着同一个50MHz节拍,但只有使能拉高那一拍才"走一步"。这种设计在数字IC领域叫门控时钟替代方案,也是笔试题里常考的点。
4. 外围配套模块:倒计时扫描、按键消抖与模式切换
4.1 数码管动态扫描:用人的视觉暂留掩盖刷新时序
交通灯系统必须把剩余秒数显示给司机和行人看,所以板上至少需要两组数码管。EGO1开发板通常自带6个共阳极数码管,段选信号共用,位选信号分开。所谓动态扫描,就是在极短的时间内轮流点亮各个数码管:先让第一个管子显示数字5,其余全部熄灭,维持2毫秒;再让第二个管子显示数字3,其余熄灭,维持2毫秒;如此循环,一个周期下来,人眼看到的就是稳定的"53"。
扫描频率的选择比较讲究。如果每位数码管的刷新率低于50Hz,肉眼就能察觉到闪烁;高于几百Hz也不会更好,反而挤占逻辑资源。我用的6位数码管,每位点亮2ms,6位一轮共12ms,换算下来刷新率约83Hz,实际观感非常稳定。
数码管显示是用一个七段编码表把数字0到9映射为abcdefg七段码。这里要特别注意共阳极和共阴极的区别:共阳极数码管的公共端接高电平,某段对应引脚为低电平时才点亮;共阴极刚好相反。写编码表之前先看清楚开发板原理图,否则你会在板子上看到一个"数字0显示为8"的诡异画面,以为是硬件坏了。
4.2 按键消抖的两种方案和我的取舍
交通灯系统的按键交互一般包括:复位、夜间模式切换(黄灯全闪)、紧急模式(全红闪烁)、通行时间设置。物理按键在按下和释放的瞬间,由于金属弹片的机械振动,会产生持续约5到20毫秒的不稳定毛刺,如果不做消抖,一个按键按下去会被FPGA误判成按了十几次,倒计时数字直接跳飞。
最简单的消抖方案是延时采样:检测到按键电平变化后,启动一个20毫秒的计数器,计数器结束后再读一次按键电平,如果和变化后的电平一致,就认为按键状态稳定了。这种方案代码量极少,适合时间紧迫的情况。
我实际用的是另一种方案:状态机消抖。它的思路是监测按键电平的连续稳定时间——按键信号刚跳变时,并不立即承认这次跳变,而是开启一个计数器,如果电平能在设定时间内持续不跳动,才确认按下有效。相比延时采样,它多消耗一点逻辑资源,但整个消抖过程是"事件驱动"的,响应速度更快,而且能把"按下"和"释放"两个事件都准确识别出来,方便后续做长按、短按等功能扩展。
消抖完成后的按键信号还要再做一次边沿检测——取一个寄存器保存上一拍的按键值,当前拍为高、上一拍为低就是上升沿,反之就是下降沿。这样处理之后,按键按下的那一刻只会产生一个时钟周期的脉冲,状态机用这个脉冲去做模式切换,不会重复触发。
4.3 顶层模块的拼接思路
有了状态机、数码管扫描、按键消抖三个子模块,顶层模块的工作就是实例化它们,并把信号连起来。这个阶段容易犯的错误是信号命名混乱。我习惯用一套统一的命名规范:所有跨模块信号用"模块名_信号名"的格式,比如key_ff1、seg_scan_data、light_sn_out。这样综合报错或者用ILA抓信号的时候,一眼就能看出问题出在哪个环节。
顶层模块建议做成纯组合逻辑连线的外壳,不在顶层写任何复杂的always块。好处是当系统规模扩大,比如加入车流量检测模块、绿波带协调模块时,顶层依然保持清爽,每个模块可以独立修改、独立仿真。
5. 仿真验证与板级调试:仿真过了不等于上板能跑
5.1 testbench怎么写才能暴露出真问题
交通灯的仿真流程和一般模块类似,但有两点值得特别留意。第一,仿真时间要拉长。G1是20秒,一个完整周期是50秒,你必须在testbench里让仿真跑完至少一个完整周期,才能确认状态机的循环路径没有问题。刚开始我不会把每个时间参数改短,而是直接给完整的50秒仿真,看波形文件里状态机的跳转是否符合状态转移表。
第二,除了系统时钟,还要在testbench里模拟异步复位信号的行为:先拉低几个时钟周期,释放后再观察复位后的首状态是否正确。异步复位的释放时刻如果离时钟上升沿太近,状态寄存器可能进入亚稳态,这类问题只有在仿真里用"故意刁难"的方式才能暴露。具体做法是把复位释放的时刻设在时钟沿附近,比如时钟上升沿前1纳秒释放复位,看看代码能不能正确恢复到IDLE状态再接SN_GREEN。
仿真通过之后,还有一个被很多人跳过的步骤:看综合后的原理图。Vivado的综合结果里有一个视图叫Schematic,可以直观看到你的状态机被综合成了什么样子。如果你看到状态机的实现方式是one-hot编码,这是一件好事;如果看到一个庞大的解码逻辑,那多半是状态编码选择有问题。
5.2 上板调试的三个经典翻车现场
这个项目我前后演示过很多次,最有教育意义的其实是翻车案例。
翻车一:复位按键接的引脚没有配置成带内部上拉。开发板的按键通常按下为低电平,但引脚配置错误的话,悬空时FPGA读到的是随机值,结果一上电状态机就在各个状态间乱跳。解决办法是在XDC约束文件里给按键引脚加上PULLUP属性,或者选用板上本来就带外部上拉电阻的按键接口。
翻车二:数码管段选信号的引脚约束弄反了。如果开发板的原理图里,段选信号标注的是a、b、c、d、e、f、g、dp,而你的约束文件写的是倒序,那么显示出来的数字会完全错位。这类问题排查的时候,手动做一个"点亮g段"的最小测试程序,比对着代码猜快得多。
翻车三:全红状态下数码管应该显示0,结果什么也不显示。这是我见过最多人问的问题。原因很可能是你的段选编码表里数字0对应的是"所有段全部点亮",但你没有把dp(小数点)段处理掉,导致数码管把小数点也点亮了,看上去像是数字上方多了一个点。说实话这个问题不算严重,但演示效果大打折扣,处理方法是编码表里把dp位固定为熄灭。
5.3 用ILA看内部信号:比连LED灯调试高效得多
如果开发板支持JTAG调试,强烈建议加一个ILA(Integrated Logic Analyzer)IP核来观察内部信号。ILA的作用相当于一个片内逻辑分析仪,你可以在Vivado的硬件管理器里,像用示波器一样观察FPGA内部任意信号在特定触发条件下的波形。比如设置"cnt_sec == 1"作为触发条件,就能精确抓到一次状态切换前后light_sn、next_state等信号的变化,直接验证我的"提前一拍切换"逻辑是否生效。
相比之下,把状态机的每一位都引到LED上进行观察,是一种低效且极容易误判的调试方式。LED只有亮灭两种状态,你很难从六个灯的闪烁组合里读出状态编码的数值,还会受到人眼视觉暂留的影响。ILA配合触发条件,才是现代FPGA调试的正确姿势。
6. 从课程设计走向真实信号控制:扩展方向和我的一点体会
6.1 加一个车流量感应模块
如果两相位的基础版做完还有余力,我建议往感应控制方向扩展。在路口埋设车辆检测器(地感线圈或红外对射),当某个方向的绿灯时间用完时,检测器如果发现该方向仍有连续车辆通过,就自动延长绿灯时间;如果车流量很小,就跳过延长时间直接切换相位。这个逻辑本质上是一个带外部输入的Moore状态机,输出的灯色依然只取决于当前状态,但状态的驻留时长由流量输入动态修改。FPGA的并行优势在这里体现得淋漓尽致:多路传感器信号可以同时接入,每路对应一个独立的检测模块,不存在MCU需要轮询扫描传感器带来的延迟。
6.2 绿波带协调:多路口联动才是FPGA该干的活
另一个进阶方向是把多个路口的信号机通过串口或以太网连接起来,实现绿波带协调控制。假设一条主干道上有三个连续路口,协调控制的目的是让车队以某个平均速度行驶时,到达每个路口都恰好看绿灯。这就需要对三个路口的信号周期做统一规划,让它们的相位差固定在某个特定值。用FPGA来做这件事的优势在于:三个路口的控制器可以用同一个系统时钟通过同步机制对齐,省掉了软件同步协议中的不确定性。虽然这个复杂度对于课程设计来说有点超纲,但理解了这个思路之后,你再看FPGA在工业控制、高速数据采集、图像处理这些领域的位置,会更有底气。
6.3 我做完这个项目后的三点体会
第一,状态机的设计功夫不在写代码,而在画状态转移表。代码只是把表翻译成Verilog,前期把表想清楚,后面基本一次通过;表没理顺就上手写代码,后面就是无穷无尽的改状态、改条件、改输出。第二,模块化设计和信号命名规范,在项目小时候"没感觉",但等你想加第四个功能模块的时候,会庆幸自己当初没把所有逻辑都写在一个顶层文件里。第三,也是最重要的:上板验证永远比仿真更重要。仿真通过只能说明你的逻辑在理想情况下是对的,而板子上的时钟抖动、引脚电平、复位行为这些非理想因素,只有真正跑起来才能暴露。
这个项目的价值不在于交通灯本身——毕竟真实的信号机方案比这复杂得多——而在于它用最小的硬件规模,把你带入了FPGA设计最核心的思维模式:以时钟为节拍,以状态为记忆,以并行为骨架。这套思维模式一旦建立起来,无论你以后去写PCIe还是去调图像处理流水线,底子都是通的。我个人的建议是,做完交通灯别急着扔,把它作为你验证新学知识点的一个"试验田"——今天加一个异步FIFO通信,明天加一组PWM调光,后天加一个串口协议,每个新知识点都在这块板子上做一遍,你对FPGA的理解会突飞猛进。
本文还有配套的精品资源,点击获取