交通灯控制实验:三段式状态机与Verilog仿真上板全解析
简介数字逻辑实验“交通灯控制”面向电子工程和计算机科学专业学生提供基于Quartus II和FPGA的硬件描述语言逻辑设计与时序控制方案。该实验以城市交通管理中的红绿黄三色灯为应用场景使用VHDL或Verilog定义灯信号与计数器逻辑实现红灯30秒、绿灯20秒、黄灯5秒的周期自动切换还考虑了紧急情况下的优先权控制等特殊逻辑。压缩包共5个文件包含3个xml配置文件和2个rels关系文件整体仅4.63MB结构精简便于导入Quartus工程后直接查看配置、修改设计并继续编译仿真流程。已有529人学习使用适合数字逻辑课程实验、FPGA入门练习或交通灯控制类课程设计参考。通过本资源可系统理解计数器、状态转换与硬件描述语言在时序控制中的具体应用为后续更复杂的嵌入式系统设计打下扎实基础。1. 交通灯控制实验从状态表到能上板跑起来的硬件描述语言交通灯控制大概是数字逻辑实验里最能检验状态机理解程度的一道坎有人用一堆门电路搭出会跑的模型有人用硬件描述语言写了几十行代码却在上板后看到红绿灯一起亮。问题往往不在逻辑本身而在“状态什么时候跳、计数器和状态谁先走、黄灯到底该插在哪”。这篇笔记用一套标准的十字路口时序主道 30 秒绿灯、5 秒黄灯支道 20 秒绿灯、5 秒黄灯走一遍完整实现从状态机设计到分频、仿真、上板验证。课程设计选了交通灯控制、或者想搞清楚有限状态机怎么落到芯片上的开发者都可以跟着把这套流程手过一遍。2. 主状态机四个状态与六十秒时序怎么切成可综合代码2.1 十字路口的时间片怎么分状态表和转移条件设计交通灯控制器的第一步不是写代码而是把路口的时序关系写成一张状态表。表里的每一行都代表路口的一种“稳定局面”哪边是绿灯、哪边是红灯、黄灯什么时候插入、每个状态持续多少秒。状态主干道灯支干道灯保持时间下一状态S0绿灯红灯30 秒S1S1黄灯红灯5 秒S2S2红灯绿灯20 秒S3S3红灯黄灯5 秒S0这里有两个容易想错的点。第一为什么主干道绿灯结束后不能直接切到支干道绿灯因为路口中间可能还有正在通过的车必须插入一段黄灯做“清空时间”。这 5 秒黄灯的本质是安全缓冲数字逻辑实验里很多人把这个时间省掉结果上板后两个方向的绿灯在波形里直接切过去看似没问题实际上不符合真实路口逻辑答辩时也会被问住。第二S2 状态里主干道红灯会持续 25 秒20 秒支绿 5 秒支黄这意味着主干道方向的车在支道放行期间必须等满两个状态。这个“跨状态等待”恰恰是后面写计数器时容易出 bug 的地方你不能简单地用一个大计数器从头数到尾因为每个状态的持续时长不一样。计数器必须在每次状态跳变时清零再按当前状态的时长重新计数。2.2 三段式状态机状态寄存器、次态逻辑、输出逻辑分开写状态表定下来之后我一般直接用三段式状态机落代码。三段式不是炫技是把“状态跳变”和“输出”这两个最容易互相污染的环节从代码层面隔开综合后不容易生成意想不到的锁存器。// traffic_core.v —— 交通灯核心状态机 module traffic_core ( input clk, // 系统时钟 50MHz input rst_n, // 低电平异步复位 input sec_en, // 1Hz 秒脉冲使能由分频器产生 output reg main_green, output reg main_yellow, output reg main_red, output reg branch_green, output reg branch_yellow, output reg branch_red ); // 状态编码格雷码顺序转移时每次只有 1 bit 变化 localparam S0 2b00; // 主绿 支红 localparam S1 2b01; // 主黄 支红 localparam S2 2b11; // 主红 支绿 localparam S3 2b10; // 主红 支黄 // 各状态保持时间单位秒 localparam T0 5d30; // 主绿 30 秒 localparam T1 5d5; // 主黄 5 秒 localparam T2 5d20; // 支绿 20 秒 localparam T3 5d5; // 支黄 5 秒 reg [1:0] state, next_state; reg [4:0] sec_cnt; reg timeout; // 组合逻辑 1当前状态下秒计数器是否已经到终值 always (*) begin case (state) S0: timeout (sec_cnt T0 - 1); S1: timeout (sec_cnt T1 - 1); S2: timeout (sec_cnt T2 - 1); S3: timeout (sec_cnt T3 - 1); default: timeout 1b0; endcase end // 组合逻辑 2次态逻辑 always (*) begin case (state) S0: next_state S1; S1: next_state S2; S2: next_state S3; S3: next_state S0; default: next_state S0; endcase end // 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state S0; else if (sec_en timeout) state next_state; end // 秒计数器进入新状态后从 0 开始计计到当前状态终值清零 always (posedge clk or negedge rst_n) begin if (!rst_n) sec_cnt 5d0; else if (sec_en) begin if (timeout) sec_cnt 5d0; else sec_cnt sec_cnt 5d1; end end // 第三段输出逻辑全部寄存器输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin {main_green, main_yellow, main_red} 3b100; {branch_green, branch_yellow, branch_red} 3b001; end else begin case (state) S0: {main_green, main_yellow, main_red} 3b100; S1: {main_green, main_yellow, main_red} 3b010; S2: {main_green, main_yellow, main_red} 3b001; S3: {main_green, main_yellow, main_red} 3b001; default: {main_green, main_yellow, main_red} 3b100; endcase case (state) S0: {branch_green, branch_yellow, branch_red} 3b001; S1: {branch_green, branch_yellow, branch_red} 3b001; S2: {branch_green, branch_yellow, branch_red} 3b100; S3: {branch_green, branch_yellow, branch_red} 3b010; default: {branch_green, branch_yellow, branch_red} 3b001; endcase end end endmodule这段代码的核心逻辑分两部分说明。第一部分是第二段次态逻辑和第三段输出逻辑的配合。状态寄存器只在sec_en timeout同时成立时跳变也就是说秒脉冲来了而且当前状态的时间确实数完了才允许进入下一个状态。这避免了“计数器还在走状态却提前跳走”的竞争。秒计数器在同一个时钟沿清零所以不会出现黄灯多亮一秒或者少亮一秒的问题。这里的timeout条件里写的是T0 - 1而不是T0原因是计数器从 0 开始sec_cnt数到 29 时已经经历了 30 个秒脉冲所以 30 秒的终值判断必须写 29。这是新手最容易看懵的地方也是后面仿真时要重点盯住的边界。第二部分是输出编码。{main_green, main_yellow, main_red} 3b100表示绿1、黄0、红0即主干道绿灯亮3b001表示红灯亮。支干道的输出规则同理。所有输出都由state驱动并打了一拍寄存器所以不会出现组合逻辑毛刺直接打到 LED 上的情况。第 5 章的避坑部分会专门展开这一点。复位时主干道绿灯、支干道红灯同时亮这是最“安全”的初始状态默认放行主道支道等待。2.3 状态编码用格雷码还是顺序二进制码很多教材里的交通灯例子状态编码直接写成S000, S101, S210, S311看起来也没问题。但我建议用格雷码也就是S000, S101, S211, S310这样的顺序。原因很简单格雷码保证相邻状态之间只有 1 个 bit 变化。如果状态从 S101跳到 S210顺序编码会让两个 bit 同时翻转组合逻辑里的两条路径延迟稍微有一点差异综合后在波形上就能看到瞬间的毛刺状态。这个毛刺持续时间可能只有几纳秒仿真里不一定看得见但上板后如果被引脚采到就可能让旁边的灯闪一下。用格雷码之后每次跳变只有一个 bit 变化即使路径延迟有差异也不可能出现“跳到第三个编码”的中间态。代价是状态编码可读性差一点但加注释就能解决。对于交通灯这种状态数量很少的控制器这个习惯很值得养成。还有个隐藏好处格雷码配合第二段的 case 写法综合工具更容易推断出低功耗的编码方式虽然对实验课来说功耗不是重点但综合报告里的逻辑门数量会明显少一些。3. 秒脉冲生成从 50MHz 系统时钟到 1Hz 使能信号3.1 为什么推荐“时钟使能”而不是直接分频实验板上几乎都是高频时钟源常见的是 50MHz。状态机要的工作节奏是“每 1 秒跳一次”所以必须先把 50MHz 变成 1Hz 的节拍。常见的错误做法是直接写一个分频器输出一个 1Hz 的方波时钟然后用always (posedge clk_1hz)去驱动状态机。这个做法有两个问题。第一内部产生的时钟信号在 FPGA 里不能随便当全局时钟用综合工具默认会报“门控时钟”或“派生时钟”告警上板后容易出现时钟抖动。第二一个设计里如果同时存在 50MHz 和 1Hz 两个时钟域状态机内部的跨时钟域路径处理起来很麻烦实验课没必要给自己挖这个坑。更稳妥的做法是保持单一时钟域用计数器产生一个“秒脉冲使能”信号宽度只有一个系统时钟周期20ns。状态机仍然在 50MHz 时钟的上升沿工作只是当sec_en为高时才认为“过去了 1 秒”。3.2 可综合的分频器代码与参数边界分频器模块本身很简单但参数边界要注意。// clk_div.v —— 50MHz 分频产生 1Hz 秒脉冲使能 module clk_div #( parameter DIV_CNT 26d49_999_999 // 默认值50M 个时钟周期 1 秒 )( input clk, input rst_n, output reg sec_en ); reg [25:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 26d0; sec_en 1b0; end else if (cnt DIV_CNT) begin cnt 26d0; sec_en 1b1; // 只拉高一个时钟周期 end else begin cnt cnt 26d1; sec_en 1b0; end end endmodule参数DIV_CNT是计数器的终值写成 26 位是因为2^26 67,108,864刚好能容纳 50,000,000 这个数。如果写成 32 位也能跑但没有必要多耗寄存器。计数器从 0 数到 49,999,999一共经过 50,000,000 个时钟周期在 50MHz 下正好是 1 秒。sec_en只在高电平保持一个时钟周期不是持续一秒的高电平。这一点要和认知里的“秒信号”区分开。拿它去驱动状态机时判断条件永远是if (sec_en ...)而不是always (posedge sec_en)。3.3 仿真加速时怎么改参数如果按真实时间仿真一个完整的交通灯周期是 30520560 秒也就是 50MHz 下的 30 亿个时钟周期仿真工具要跑到天荒地老。所以几乎所有人的做法都是把分频参数临时改小让仿真时间缩到几微秒级别。你不需要改状态机里的T0/T1/T2/T3只需要在例化分频器时覆盖DIV_CNT。比如把DIV_CNT从 49,999,999 改成 4那么每 4 个时钟周期就产生一个秒脉冲状态机跑完一个完整周期只需要(305205) * 4 240个时钟周期也就是 4.8 微秒。T值不变状态机的相对逻辑和真实情况完全一致只是“1 秒”在仿真里被压缩成了 80ns。这样改的好处是状态机的判断逻辑、超时边界、跳变时序全部用真实参数验证过只有分频速度被加速。如果直接把 T 参数也改小就可能出现“仿真能过但改回真实 T 后逻辑不对”的情况。我一般只用加速分频器这一个变量。4. 仿真验证把状态跳变和关键波形检查做成一件事4.1 完整跑一轮 60 秒周期为什么值得等做数字逻辑实验很多人仿真只看前几个状态看到绿灯亮了就觉得“逻辑正确”然后直接上板。这种做法特别容易翻车。交通灯控制器的问题几乎都出现在状态衔接处黄灯插入是否正确、秒计数清零和状态跳变是否在同一拍、支干道红灯是否跨状态持续。我给自己定的标准是仿真必须完整跑完至少两轮周期并且每轮都要检查四个状态的切换顺序和波形时刻。下面这张表是完整 60 秒周期里应该观察到的关键节点时间点状态主干道灯支干道灯0 秒S0绿红30 秒S1黄红35 秒S2红绿55 秒S3红黄60 秒S0绿红把波形窗口拉出来对着这张表逐行核对比盯着波形“感觉没问题”要靠谱得多。尤其是第 30 秒和第 35 秒这两个节点能同时验证黄灯插入和状态跳变两个关键逻辑。4.2 testbench 里加自动检查不靠人眼盯波形人眼盯波形会漏尤其是毛刺和偶尔才出现的互斥冲突。我在 testbench 里习惯加两段自动检查代码一段检查主干道绿灯和支干道绿灯是否同时亮一段在状态跳变时打印时间戳。// tb_traffic_light.v —— 仿真测试台核心片段 module tb_traffic_light; reg clk, rst_n; wire sec_en; wire main_green, main_yellow, main_red; wire branch_green, branch_yellow, branch_red; // 分频器仿真加速每 4 个时钟周期产生 1 个秒脉冲 clk_div #(.DIV_CNT(26d4)) u_div( .clk (clk), .rst_n (rst_n), .sec_en(sec_en) ); traffic_core u_core( .clk (clk), .rst_n (rst_n), .sec_en (sec_en), .main_green (main_green), .main_yellow (main_yellow), .main_red (main_red), .branch_green (branch_green), .branch_yellow(branch_yellow), .branch_red (branch_red) ); initial clk 0; always #10 clk ~clk; // 20ns 周期模拟 50MHz // 自动检查 1主绿和支绿绝对不能同时为高 integer err_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) err_cnt 0; else if (main_green branch_green) err_cnt err_cnt 1; end // 自动检查 2状态跳变时打印当前仿真时间和状态编码 reg [1:0] prev_state; always (posedge clk) begin if (prev_state ! u_core.state) $display(t%0tns state%b, $time, u_core.state); prev_state u_core.state; end initial begin rst_n 0; #100; rst_n 1; #20000; // 加速后完整周期 4.8us20us 足够跑 4 轮 if (err_cnt 0) $display(TEST PASSED); else $display(TEST FAILED, error count %0d, err_cnt); #100; $finish; end endmodule这段 testbench 里有两个关键设计。第一个是互斥检查。主干道绿灯和支干道绿灯亮起的时间段在逻辑上不可能重叠一旦重叠说明状态机或输出逻辑有 bug。用err_cnt累计错误次数仿真结束后自动判定 PASS 或 FAIL不需要人盯着波形一帧一帧看。第二个是状态跳变打印。$display在每次state变化时输出当前仿真时间和状态编码这样即使不看波形图也能从日志里还原出完整的时序状态从 00 变 01、01 变 11、11 变 10、10 变 00以及每个状态持续了多少仿真时间。配合加速后的分频参数日志里的时间戳可以直接换算成状态保持时长。4.3 定时器清零与状态跳变的“同一拍”问题仿真中比较容易发现的一个问题是黄灯保持时间变成了 6 秒而不是设定的 5 秒。这个现象看起来像 b ug实际上原因是秒计数器的清零逻辑和状态跳变逻辑不在同一个时钟沿上。举个常见写法状态跳变在sec_cnt 5时发生但秒计数器单独又有一个if (sec_cnt 5) sec_cnt 0。当sec_cnt到达 5 时状态先跳走计数器在下一个时钟沿才清零。结果黄灯状态的第一秒里sec_cnt从 5 被清零成 0要多等一拍再来一个秒脉冲才开始真正计数黄灯就被拉长了一秒。仿真里波形看着只是黄灯宽了一点点但上板后这种误差会累积。第 2 章的代码里已经把这个问题规避了秒计数器的清零条件是timeout也就是“当前状态的秒数已经数完”而状态跳变条件也是sec_en timeout。两者在同一个时钟沿完成不存在先后差。你在自己的代码里如果发现黄灯时间不对优先检查这两个条件是不是各自独立写的而不是共用一个timeout。5. 避坑指南反复出现的五个问题按现象排5.1 上板后两个绿灯同时亮现象仿真波形一切正常下载到开发板之后主干道绿灯和支干道绿灯偶尔同时亮一下路灯一闪而过。原因输出逻辑是组合逻辑直接连到引脚。状态机跳变的一瞬间组合逻辑的多条路径延迟不一致产生了毛刺。仿真工具默认使用理想延迟毛刺很难被捕捉到但真实芯片里路径延迟是真实存在的。解决把输出全部改成寄存器输出用always (posedge clk)在状态跳变后再打一拍。交通灯对这一个时钟的延迟完全不敏感但寄存器输出能把毛刺彻底挡掉。5.2 运行十几秒后状态卡死只有按复位才能恢复现象刚上电时运行正常过了十几秒后灯全部停在一个状态不动了按复位又能重新跑。原因sec_cnt位宽给得不够计数溢出后重新回到一个中间值但这个中间值比timeout判断的终值大得多导致状态机一直等不到清零条件。另外状态机的 case 分支没有写default一旦状态寄存器被某些干扰打到一个非定义编码状态机就永远跳不出来了。解决秒计数器的位宽在能覆盖最大时长的基础上再加一位。最大时长 30 秒5 bit 理论上够用但我习惯给 6 bit留出余量。case 分支里必须写default并让默认状态回到 S0。查看综合报告时注意有没有被推断出锁存器出现锁存器告警基本就是 case 没写全。5.3 黄灯短到几乎看不见现象设置黄灯 5 秒实测只有 1 到 2 秒像闪烁了一下就切走了。原因计数比较条件写成了sec_cnt 5但秒计数器的清零逻辑在另一个 always 块里提前清零。比如某个状态结束时对sec_cnt做了全局清零导致黄灯的计数从未真正到达 5。解决统一用timeout信号管理超时判断而且清零条件和状态跳变条件合并成一个时钟沿来处理。这一点前面讲过把计数器和状态跳变放在同一个执行边界里黄灯时间就不会被“偷走”。5.4 仿真波形里状态编码变成 XX灯全灭现象仿真到一半波形里state变成2bxx所有灯都不亮了。原因测试台在仿真开始后没有给复位信号或者复位信号释放得太早状态机还没稳定就开始工作。另一种可能是组合逻辑里case分支覆盖不全综合或仿真时推断出了不期望的锁存行为。解决testbench 里先让rst_n0保持至少 100ns等时钟稳定后再拉高释放。code 里所有case全部补default并且让默认分支指向安全状态。这样即使进入异常编码也能在下一个时钟沿回到 S0。5.5 仿真完全正常上板后灯乱跳现象仿真输出和波形完全符合预期下载到板子上后灯的周期、顺序、时长全都不对。原因最常见的是时钟引脚没有约束到开发板上晶振所连接的物理引脚综合工具默认把clk分配到了一个普通 IO 上实际跑的时钟可能来自一个没接晶振的引脚。上板前没有看综合日志里关于引脚分配的警告也会漏掉这个问题。解决打开综合工具的引脚分配报告核对clk、rst_n、六个 LED 输出信号是否全部绑定到了实际连接好的物理引脚。尤其检查clk它必须绑到开发板原理图标注为时钟的引脚而不是随便一个 IO。另外看综合日志里有没有multi-driver或inferred latch警告这两个警告几乎每次都预告上板翻车。6. 上板前的回归清单五条验证和最后的实测技巧仿真通过只能说明逻辑层面没问题上板前还要走一遍回归。我通常会在下载前把下面这张清单过一遍验证项操作方法通过标准系统复位上电自动复位再手动按一次复位键主干道绿灯亮支干道红灯亮完整周期用正常分频参数运行一遍 60 秒绿→黄→红→绿顺序正确时间误差小于 1 秒状态编码观察把 state 的两个 bit 引到两颗备用 LED能用二进制数看到 00/01/11/10 循环切换引脚约束核对查看综合报告所有输入输出分配到位没有 warning长时间稳定性持续运行 30 分钟状态不卡死、灯不闪屏、黄灯时间稳定真正开始调板时有一个技巧很管用把状态编码引到两颗 LED 上运行的时候盯着这两颗 LED它们会按照 00→01→11→10 的顺序循环显示。这样你不需要去看交通灯本身的亮灭规律只通过编码变化就能判断状态机是否按预期工作比盯着六颗灯猜状态轻松得多。还有一次调试翻车的经历印象很深当时为了赶进度仿真只跑了 10 微秒看到绿灯亮了就上板结果黄灯时间完全不对。排查到最后原因就是sec_cnt位宽太短导致溢出。从那以后我给自己定了个规矩不管时间多紧上板前必须强制走一遍回归清单用加速后的分频参数把完整周期跑两轮再逐个核对状态跳变的时间戳确认没有互斥和清零问题才允许下载。交通灯这种设计逻辑不复杂但边界问题一旦遗漏上板后的排查成本比写代码高出一个数量级。希望这份笔记能帮你在调交通灯控制时少走几步弯路尤其是计数器位宽和清零时机这两个容易出问题的细节。本文还有配套的精品资源点击获取