FPGA交通灯控制系统设计:三段式状态机与Verilog实战

发布时间:2026/9/7 18:52:44
FPGA交通灯控制系统设计:三段式状态机与Verilog实战 简介面向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秒把G120秒、Y3秒、R2秒代入完整的状态转移表如下状态南北方向东西方向持续时间下一状态S0绿灯红灯20秒S1S1黄灯红灯3秒S2S2红灯红灯2秒S3S3红灯绿灯20秒S4S4红灯黄灯3秒S5S5红灯红灯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 3b001; light_ew 3b001; end endcase end end这种写法的问题在于状态寄存器和输出逻辑、跳转逻辑全部挤在一个时间控制块里输出使用的是阻塞赋值仿真容易通过但综合后容易产生毛刺而且一旦状态多了这个块会变得极其臃肿改一个灯色的延迟时间可能要翻半天。三段式的核心理念是各司其职第一段时序逻辑负责状态寄存器的更新第二段组合逻辑负责计算下一状态第三段时序逻辑或组合逻辑负责根据当前状态产生输出这个划分本质上是在模仿真实硬件电路的物理结构状态寄存器是一组D触发器下一状态逻辑是一大块组合逻辑输出逻辑又是一组组合逻辑。把代码的结构和硬件结构对齐不仅可读性强时序分析时的路径也清晰。3.2 完整的三段式交通灯控制代码直接给出一份我调试通过的骨架代码基于50MHz系统时钟G120秒、Y3秒、R2秒。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 3d0; localparam SN_GREEN 3d1; localparam SN_YELLOW 3d2; localparam ALL_RED_1 3d3; localparam EW_GREEN 3d4; localparam EW_YELLOW 3d5; localparam ALL_RED_2 3d6; 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 3b001; // 全红 light_ew 3b001; cnt_sec 0; end else begin case (state) SN_GREEN: begin light_sn 3b100; light_ew 3b001; cnt_sec G1; end SN_YELLOW: begin light_sn 3b010; light_ew 3b001; cnt_sec Y; end ALL_RED_1: begin light_sn 3b001; light_ew 3b001; cnt_sec R; end EW_GREEN: begin light_sn 3b001; light_ew 3b100; cnt_sec G1; end EW_YELLOW: begin light_sn 3b001; light_ew 3b010; cnt_sec Y; end ALL_RED_2: begin light_sn 3b001; light_ew 3b001; cnt_sec R; end default: begin light_sn 3b001; light_ew 3b001; 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的绿灯实际点亮时间是G11秒因为切过去的那个时钟周期灯还是绿的数码管却显示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位数码管每位点亮2ms6位一轮共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调试强烈建议加一个ILAIntegrated Logic AnalyzerIP核来观察内部信号。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的理解会突飞猛进。本文还有配套的精品资源点击获取