Quartus II FPGA交通灯工程:Verilog状态机、数码管扫描与引脚约束实战

发布时间:2026/10/30 11:47:52
Quartus II FPGA交通灯工程:Verilog状态机、数码管扫描与引脚约束实战

简介:「QuartusⅡ交通灯控制电路」是一份面向数字逻辑电路设计与芯片仿真的完整工程实践包,围绕十字路口信号灯控制展开:甲车道为主道,单次通行75秒;乙车道为辅道,每次25秒,两车道交替放行,切换前黄灯先亮3秒并以每秒一次的频率闪亮,同时支持按键切换模式。整套设计可直接用于课程设计、FPGA/CPLD实验与原理图设计练习。包内共167个文件,以bdf原理图、bsf元件符号、vwf波形仿真文件为主体,另含qsf工程配置、sof下载文件、rpt编译报告及大量cdb、hdb等编译中间数据库,工程结构完整,压缩包约2.42MB,已有3293人学习。读者可据此理解分模块原理图的设计思路,学习状态机与计时器配合工作的时序实现方式,并借助波形文件验证黄灯闪烁与车道切换逻辑,对照编译报告排查引脚分配和时序问题,从而快速改动并复现自己的方案。

1. 波形完美、板子不亮:这个工程包解决的正是这个断点

实验室里最常见的翻车现场,不是代码写不出来,而是 Quartus II 里芯片仿真波形跑得干干净净,下载到板子上灯却不按剧本亮:该红的绿灯、该倒计时的时候数码管在闪。这份 Quartus II 交通灯控制电路工程包就是冲这个断点去的——一个十字路口的东西向、南北向三色灯,配两位倒计时数码管、黄灯过渡、手动复位,从分频、状态机、显示扫描到引脚约束全放在一个可编译的工程里。它适合三类人:正在做数字逻辑电路设计课设、需要一个能跑通的参考架构的;刚接触 FPGA、想搞明白「行为级写法和可综合写法差在哪」的;以及手上有块开发板、想拿个完整例子练全流程的。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆。

2. 工程架构拆解:一次红绿灯切换要经过几层

拿到压缩包先别急着点全编译,先把工程的骨架看明白。交通灯表面上是几个灯亮灭,实质上是「时钟 → 计时 → 状态 → 译码 → 管脚」的链路,任何一层错位,波形和实物就对不上。这个工程按可综合的经典拆法分成四块:时钟使能生成、主状态机、倒计时与显示、顶层连线,互相之间只通过使能脉冲和状态编码通信,不做跨模块的时钟传递。

2.1 解压后先看文件清单:工程、源码、约束、波形各在哪

工程主干是一个 Quartus II 的工程文件,加上一堆.v源文件和一份引脚约束。真正的「坑」几乎都集中在.qsf和顶层模块上,因为这两处跟你的板卡强绑定,别人机器上能跑,你机器上不一定。解压后建议先按下面这张表过一遍,知道哪个文件该动、哪个文件别碰。

文件类型作用是否需要改动
traffic_light.qpf工程文件记录工程名与版本,双击即可打开不需要
traffic_light.qsf设置文件器件型号、引脚分配、IO 电平标准必须改,按你板子的原理图
top.v顶层源码例化各子模块、连信号改端口对接时看它
clk_en.v源码分频产生 1 Hz 单周期使能改分频系数
traffic_fsm.v源码四相位状态机与倒计时装载改相位时长、加相位
seg_scan.v源码数码管动态扫描改共阳/共阴、位数
tb_traffic.v仿真测试激励,缩短时间参数改激励再跑
output_files/编译产物.sof下载文件、报告每次全编译后重新生成

有一点要提前说清楚:output_files里的东西是可以删的中间产物,重新全编译就会再生成;但.qsf里的引脚分配是手写进去的资产,删了就找不回来,这也是我建议第一次打开工程就另存一份备份的原因。但凡涉及数字逻辑电路设计的工程,约束文件的价值往往比源码高,源码能重写,约束是照着板子一根线一根线对出来的。

2.2 时钟链:从板载晶振到 1 Hz 的三条路

交通灯最核心的时间单位是「秒」。板载晶振常见是 50 MHz,也就是每秒 5000 万个时钟沿,直接拿来做状态机节拍,那这个路口一秒内能切换几百万次,肉眼只看得到一片糊。所以必须先建立一个 1 Hz 的时间基准。

这件事有三种做法,差别很大:

做法实现方式优点问题
分频出新时钟计数到 2500 万后翻转clk_1hz,用它当always @(posedge clk_1hz)直观引入新的时钟域,综合和时序分析都麻烦,容易出跨时钟域告警
单周期使能系统时钟不变,每 2500 万个周期拉高一个周期的tick单时钟域、时序干净代码里所有逻辑都要判断if (tick)
外部按键/晶振分频模块硬件上单独给低频源省逻辑多数开发板没有现成的 1 Hz 源

这个工程走的是第二种,也就是「使能脉冲」路线,这也是我在实际项目里最推荐的做法。原因很实在:整个设计只有clk一个时钟,Quartus II 的 TimeQuest 时序分析只需要约束一根时钟,报告里不会出现一堆「时钟未被约束」的告警。而分频出新时钟的写法,在老教材里很常见,但在真板子上跑起来经常莫名其妙——这就是玄学感的来源,其实是时钟域没管好。

2.3 状态机怎么划分相位

四相位是最小可用的路口模型:东西绿灯、东西黄灯、南北绿灯、南北黄灯,循环一圈。这里的关键决策是「时长放在哪」。硬编码在case里最省事,但改一次时长要动四处;放在模块参数里,改一个数字就生效。工程用的是参数化写法,四个时长都是parameter:

// 四个相位的持续秒数,改这里就能改配时 parameter EW_GREEN = 15, // 东西直行绿灯 15 秒 parameter EW_YELLOW = 3, // 东西黄灯过渡 3 秒 parameter NS_GREEN = 20, // 南北直行绿灯 20 秒 parameter NS_YELLOW = 3 // 南北黄灯过渡 3 秒

为什么东西向给 15 秒、南北向给 20 秒?这是主干道优先的常见配时思路,实际路口会根据车流量调整,我们做课设时不必较真,但这个「不对称」是有意义的——它能暴露一个很隐蔽的 bug:如果两个方向的绿灯时长写成同一个参数,你测试的时候永远发现不了「倒计时装载错了方向」这种错误;一旦写成两个不同的值,波形上一眼就能看出来。

状态编码建议用localparam起名字,别在case里直接写3'd0。综合工具并不会因为你写了名字就多花资源,但人看得懂。这里还有个细节:四个状态用两位二进制就够,但工程里用三位,是为了后面加左转相位和夜间黄闪留余量,扩展时不用改位宽。

2.4 从零复现:新建工程到第一次全编译

如果你想脱离这个工程自己搭一遍,步骤大致如下,每一步都对应一个容易漏的点:

  1. 打开 Quartus II,新建工程,工程名与顶层实体名保持一致(都叫top)。名字不一致会在综合时报「找不到顶层实体」,这个错我第一次踩的时候找了半小时。
  2. 在器件选择页选对器件族和具体型号,型号必须和板子上的芯片丝印一致。选错了后面 Programmer 里根本认不到设备。
  3. 把四个.v文件加进工程,tb_traffic.v不要加进去当综合源文件,仿真文件只在仿真时引用。
  4. 打开top.v设为顶层,执行一次 Analysis & Elaboration(不是全编译),先看有没有语法和端口连接错误,这一步比全编译快得多。
  5. 按板子原理图在 Pin Planner 里分配引脚,或者直接编辑.qsf。
  6. 全编译,检查 Compilation Report 里有没有 latch 推断、时钟未约束、多驱动这几类告警。

第 6 步值得单独强调。全编译「成功」不等于「正确」,Quartus II 只要语法过得去就会给你出.sof,但里面可能藏着一个组合逻辑环路或者一个被推断成锁存器的always @(*)。数字逻辑电路设计的老手看报告比看代码还仔细,因为代码是你想写的,报告是工具实际综合出来的,二者经常不是一回事。

3. 核心模块拆解:三段 Verilog 撑起整个路口

工程里真正干活的只有三个模块,加起来一两百行。这一章逐段过一遍,每段都说清楚「为什么这么写」和「哪些参数你会想改」。

3.1 分频与使能生成:clk_en.v

这是整个工程的时间心脏。它的职责只有一个:在 50 MHz 的时钟上,每 5000 万个周期吐出一个宽度为单周期的tick脉冲。写法上用的是「计数到 N-1 归零并拉高」的经典结构。

module clk_en #( parameter CLK_FREQ = 50_000_000, // 板载晶振频率,按实际板卡修改 parameter TICK_HZ = 1 // 需要的节拍频率,交通灯用 1 Hz )( input wire clk, input wire rst_n, output reg tick ); // 分频系数:50M / 1 = 50_000_000 localparam integer DIV = CLK_FREQ / TICK_HZ; reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; tick <= 1'b0; end else if (cnt == DIV - 1) begin cnt <= 32'd0; tick <= 1'b1; // 只拉高一个时钟周期,不是 50% 占空比 end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end endmodule

逻辑说明:cnt从 0 数到DIV-1正好是DIV个时钟周期,此时把tick拉高一个周期并立即清零计数器。因为tick只在cnt满的那一拍为高,所以它是「单周期脉冲」而不是方波,下游模块必须写成if (tick)而不是always @(posedge tick)。

参数说明:CLK_FREQ一定要改成你板子的真实频率。我见过有人板子是 24 MHz,代码里留着 50 MHz 的默认值,结果倒计时显示 15 秒实际走了 31 秒,肉眼很难第一眼发现,跑一次秒表才露馅。TICK_HZ保持 1 就行;仿真时把它改成 10 或者把CLK_FREQ改成 100,能让波形在几微秒内跑完一个完整循环。

另外,cnt的位宽是 32 位,减 1 之后最大值约 42 亿,够分到 0.01 Hz 级别。如果你只要 1 Hz,理论上 26 位就够,但 Quartus II 会把多余的高位优化掉,不必手动抠这点资源。

3.2 主状态机:traffic_fsm.v

状态机负责两件事:决定当前哪个方向放行,以及维护当前相位的剩余秒数。写法上刻意拆成「时序部分管状态和计数、组合部分管灯色输出」,两段式,避免把输出也塞进时序逻辑里导致灯色慢一拍。

module traffic_fsm #( parameter EW_GREEN = 15, parameter EW_YELLOW = 3, parameter NS_GREEN = 20, parameter NS_YELLOW = 3 )( input wire clk, input wire rst_n, input wire tick, // 来自 clk_en 的 1 Hz 单周期使能 output reg [2:0] ew_light, // {红,黄,绿},高有效 output reg [2:0] ns_light, output reg [5:0] ew_cnt, // 东西向剩余秒数,直接送数码管 output reg [5:0] ns_cnt ); // 四个相位,用三位编码给后续扩展留余量 localparam S_EW_G = 3'd0, S_EW_Y = 3'd1, S_NS_G = 3'd2, S_NS_Y = 3'd3; reg [2:0] state; reg [5:0] cnt; // 当前相位剩余秒数 // 段一:状态转移 + 计时,只在 tick 到来时推进 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_EW_G; cnt <= EW_GREEN - 1; end else if (tick) begin if (cnt == 6'd0) begin case (state) S_EW_G: begin state <= S_EW_Y; cnt <= EW_YELLOW - 1; end S_EW_Y: begin state <= S_NS_G; cnt <= NS_GREEN - 1; end S_NS_G: begin state <= S_NS_Y; cnt <= NS_YELLOW - 1; end S_NS_Y: begin state <= S_EW_G; cnt <= EW_GREEN - 1; end default:begin state <= S_EW_G; cnt <= EW_GREEN - 1; end endcase end else begin cnt <= cnt - 1'b1; // 每秒减一 end end end // 段二:灯色译码,组合逻辑,默认全红防止锁存器 always @(*) begin ew_light = 3'b100; // 默认红灯亮 ns_light = 3'b100; case (state) S_EW_G: begin ew_light = 3'b001; ns_light = 3'b100; end S_EW_Y: begin ew_light = 3'b010; ns_light = 3'b100; end S_NS_G: begin ew_light = 3'b100; ns_light = 3'b001; end S_NS_Y: begin ew_light = 3'b100; ns_light = 3'b010; end endcase end // 剩余秒数送显示:绿灯相位显示绿灯倒计时,黄灯相位显示黄灯倒计时 assign ew_cnt = cnt; assign ns_cnt = cnt; endmodule

逻辑说明:状态机只在tick为高的那一拍做判断和递减,其余时间保持不动,所以cnt的递减速度严格等于 1 秒一次。当cnt归零时立刻切换到下一个相位,并把新相位的时长减一装进去(减一是因为当前这一秒已经被消耗掉了)。

参数说明:四个parameter决定配时,注意cnt是 6 位,最大 63 秒,如果你想设 90 秒的红灯,位宽要跟着加到 7 位,否则会被截断成 26 秒——这是个很典型、很难靠肉眼发现的错误。

这里有个设计取舍值得说:上面ew_cnt和ns_cnt都直接接了同一个cnt,显示的是「当前相位剩余时间」。如果你要做「红灯等待时间」的显示,那这两路就要分开算,红灯方向的数码管应该显示对方绿灯还剩多久。工程里选简单版本是为了让波形好读,二次开发时这里是最容易改出花样的地方。

3.3 倒计时与数码管动态扫描:seg_scan.v

倒计时的数值要在两位数码管上显示出来,多位数码管没法同时点亮,只能靠扫描:每次只点亮一位,切换速度足够快,人眼就看成同时亮。刷新率建议在 1 kHz 左右,太高会增加动态功耗,太低会肉眼可见地抖。

module seg_scan #( parameter SCAN_DIV = 50_000 // 50MHz / 50000 = 1kHz 刷新 )( input wire clk, input wire rst_n, input wire [3:0] tens, // 十位,已由 BCD 转换得到 input wire [3:0] ones, // 个位 output reg [1:0] sel, // 位选,低有效 output reg [7:0] seg // 段选 {dp,g,f,e,d,c,b,a},低有效 ); reg [15:0] scan_cnt; reg idx; // 0 显示十位,1 显示个位 reg [3:0] cur; // 扫描节拍 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 16'd0; idx <= 1'b0; end else if (scan_cnt == SCAN_DIV - 1) begin scan_cnt <= 16'd0; idx <= ~idx; // 两位轮流 end else begin scan_cnt <= scan_cnt + 1'b1; end end // 位选与当前数字 always @(*) begin case (idx) 1'b0: begin sel = 2'b10; cur = tens; end // 低电平选通左位 default: begin sel = 2'b01; cur = ones; end endcase end // 七段译码,共阳写法:0 表示点亮 always @(*) begin case (cur) 4'd0: seg = 8'hC0; 4'd1: seg = 8'hF9; 4'd2: seg = 8'hA4; 4'd3: seg = 8'hB0; 4'd4: seg = 8'h99; 4'd5: seg = 8'h92; 4'd6: seg = 8'h82; 4'd7: seg = 8'hF8; 4'd8: seg = 8'h80; 4'd9: seg = 8'h90; default: seg = 8'hFF; // 熄灭,同时避免推断出锁存器 endcase end endmodule

逻辑说明:idx每 50000 个时钟翻转一次,两位数码管各占 1 ms,整屏刷新 500 Hz,看着不闪。sel低有效,seg用的是共阳编码(0 亮 1 灭),如果你的板子是共阴,把整张表取反即可。

参数说明:SCAN_DIV直接决定刷新率,公式是CLK_FREQ / (SCAN_DIV * 位数)。改成 5000 会刷到 5 kHz,看着更亮但功耗上去;改成 500000 就会明显看到跳动。

tens和ones这两个输入怎么来的?需要把 6 位二进制秒数转成两位 BCD。常见做法是双 dabble 算法(每次左移一位并加 3 修正),或者干脆用/10和%10,让小综合工具去推断除法器。秒数只有 0 到 63,用除法器资源不心疼,但要注意综合报告里是否出现「inferred divider」这类提示,出现了就说明它在用资源换便利。

3.4 顶层与引脚约束:top.v 加 .qsf

顶层模块只做三件事:例化、连线、引出。它本身不含逻辑,但它是 Quartus II 找端口的入口,端口名一旦改了,.qsf里的引脚分配会全部失配。

module top ( input wire clk, // 板载晶振 input wire rst_n, // 复位按键,低有效 output wire [2:0] ew_light, // 东西向 {红,黄,绿} output wire [2:0] ns_light, // 南北向 {红,黄,绿} output wire [1:0] seg_sel, // 数码管位选 output wire [7:0] seg_data // 数码管段选 ); wire tick; wire [5:0] ew_cnt, ns_cnt; clk_en #( .CLK_FREQ (50_000_000), // 换成你板子的晶振频率 .TICK_HZ (1) ) u_clk_en ( .clk (clk), .rst_n(rst_n), .tick (tick) ); traffic_fsm #( .EW_GREEN (15), .EW_YELLOW(3), .NS_GREEN (20), .NS_YELLOW(3) ) u_fsm ( .clk (clk), .rst_n (rst_n), .tick (tick), .ew_light(ew_light), .ns_light(ns_light), .ew_cnt (ew_cnt), .ns_cnt (ns_cnt) ); // 显示部分:把秒数拆成十位个位后送扫描模块(BCD 转换略) seg_scan u_seg ( .clk (clk), .rst_n(rst_n), .tens (4'd0), .ones (ew_cnt[3:0]), .sel (seg_sel), .seg (seg_data) ); endmodule

引脚约束写在.qsf里,长这样(编号是示例,务必对着自己板子的原理图改):

# 时钟与复位 set_location_assignment PIN_23 -to clk set_location_assignment PIN_25 -to rst_n # 灯组,先确认板子是共阳还是共阴,再决定高有效还是低有效 set_location_assignment PIN_40 -to ew_light[0] set_location_assignment PIN_41 -to ew_light[1] set_location_assignment PIN_42 -to ew_light[2] # IO 电平标准,必须和板子一致,否则编译报错或驱动不了 set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to clk

逻辑说明:top把tick从分频模块传给状态机,把状态机的秒数传给扫描模块,数据流是单向的,没有任何回环。这种单向结构让调试变得非常容易定位——灯不对就查状态机,数码管不对就查扫描。

参数说明:IO_STANDARD不是随便填的。填错了轻则编译告警,重则下载后引脚被拉坏。不同板子的 bank 电压不同,原理图上通常标了3.3V或2.5V,照着填就行。

4. 芯片仿真:testbench 怎么写才看得懂波形

代码写完不代表能用。数字逻辑电路设计里,芯片仿真是最便宜的一次验证:改一行参数、跑几微秒仿真,就能看到完整的四相位循环。上板之后才发现问题,代价就是一遍遍插拔下载线。

4.1 缩短时间参数跑仿真

真实参数下,一个完整循环要 41 秒,仿真器得跑 20 多亿个时钟周期,等不起。做法是例化时把分频系数改小,让 1 Hz 在新的时间尺度上对应几微秒。

`timescale 1ns / 1ps module tb_traffic; reg clk = 1'b0; reg rst_n = 1'b0; wire tick; wire [2:0] ew_light, ns_light; wire [5:0] ew_cnt, ns_cnt; // 仿真专用:把 50MHz 缩成 100Hz 计数上限,加快循环 clk_en #(.CLK_FREQ(100), .TICK_HZ(1)) u_clk_en ( .clk(clk), .rst_n(rst_n), .tick(tick) ); traffic_fsm #( .EW_GREEN(4), .EW_YELLOW(2), .NS_GREEN(5), .NS_YELLOW(2) ) u_fsm ( .clk(clk), .rst_n(rst_n), .tick(tick), .ew_light(ew_light), .ns_light(ns_light), .ew_cnt(ew_cnt), .ns_cnt(ns_cnt) ); always #5 clk = ~clk; // 100MHz 仿真时钟,周期 10ns initial begin #100 rst_n = 1'b1; // 释放复位 #20000 $stop; // 跑够两个完整循环后停下 end endmodule

逻辑说明:CLK_FREQ改成 100,意味着每 100 个仿真时钟产生一个tick;状态机参数也同步缩成 4/2/5/2。这样几百纳秒就能看到一个完整相位,波形窗口里一屏放得下两三个循环。

参数说明:#20000是仿真时长,按你缩短后的总周期估算,宁长勿短。$stop和$finish的区别是前者暂停、后者结束,调波形时用$stop方便回头看。

4.2 波形上该盯哪几个信号

打开波形窗口,别一股脑把所有信号都拖进去,先盯这五个:state、cnt、tick、ew_light、ns_light。看三件事:

第一,tick是不是严格等间隔的单周期脉冲。如果它连着好几个周期都是高,说明分频逻辑写成了电平输出,状态机会连跳好几个相位。

第二,cnt在每个tick到来时只减一,归零的下一拍state变化且cnt重新装载。如果看到cnt跳变或停在某个值不动,多半是tick没接对或者位宽被截断。

第三,相位切换时灯色有没有重叠期。正常情况下ew_light从绿灯切黄灯切红灯,任意时刻只有一位为高。如果出现两位同时为高,那就是组合逻辑的case漏了分支,被综合成了别的东西。

4.3 上板前的一致性核对

仿真通过之后,还有三件事要对照着核查一遍,它们不会在波形里体现,但会决定下载后能不能亮:

  • 引脚分配是否和原理图一一对应,尤其是灯的三根线顺序,红黄绿搞反了现象是「灯会亮但顺序离谱」。
  • 极性是否匹配。板子若是共阳接法,seg编码要整体取反,否则数码管显示的是反码,看着像乱码。
  • 复位键是低有效还是高有效。工程按低有效写(negedge rst_n),如果板子按键按下是高电平,就得改成posedge,否则一上电就一直在复位状态,灯全灭。

5. 避坑与排查:这五类问题占了九成

下面这五条都是我在不同板子上反复撞见的,按「现象 → 原因 → 解决」记,比看手册快。

5.1 编译与综合阶段

现象:全编译报 0 error,但 Programmer 里找不到设备,Start 按钮是灰的。原因:器件型号没选对,或者只跑了 Analysis & Elaboration 没跑全编译,没有生成.sof。Quartus II 的工程器件设置和实际芯片对不上时,软件不会报错,只会安静地不认设备。 解决:Assignments > Device里核对器件族和具体型号,与芯片丝印逐字对上;然后执行全编译,确认output_files下生成了.sof;最后检查下载线驱动是否装好。

现象:报告里出现「Warning: inferred latch for signal xxx」。原因:always @(*)里某条分支没给所有输出赋值,综合工具只能给你补一个锁存器保持原值。 解决:在每个组合always块开头先给默认值(像 3.1、3.3 里那样先写ew_light = 3'b100;),再把case的所有分支补全,最后加default。

5.2 仿真与上板不一致

现象:功能仿真波形完全正确,下载到板子上灯乱跳或干脆不亮。原因:测试平台里用的是缩短后的参数,状态机跑得飞快,掩盖了「秒数位宽不够」或者「计数器溢出」这类问题;也可能仿真里没有真实的上电时序,而板子上复位释放的时刻比时钟稳定还早。 解决:仿真里保留一组接近真实参数的长跑(至少跑完一个完整循环),同时把复位逻辑改成「上电后延时若干周期再释放」,别让状态机在时钟没稳的时候就开始跑。

现象:数码管显示的数字是对的,但亮度忽明忽暗,边缘有拖影。原因:扫描频率太低,或者位选切换和段选更新不是同一拍,出现「旧数字配上新位选」的瞬间。 解决:把SCAN_DIV调小到刷新率 1 kHz 以上;在切换位选的那一拍先输出全灭(seg = 8'hFF),下一拍再给新数字,牺牲一点亮度换干净画面。

5.3 硬件与引脚

现象:灯全亮,或者该亮的灯不亮,但用万用表量引脚有电压变化。原因:共阳共阴接反,或者高有效/低有效写反。代码里3'b001表示绿灯,物理上可能是低电平点亮。 解决:先查原理图上灯的接法,共阳则输出低有效,把译码表整体取反;不确定就写一个只点亮一灯的极简工程试一下,比反复改状态机快。

现象:按下复位键,有时能复位有时不能,灯的状态偶尔卡住。原因:机械按键抖动,一次按下产生多个边沿,状态机被连续复位多次;如果复位信号直接进异步复位端,抖动会让电路反复进出复位态。 解决:加一个简单的按键消抖模块(20 ms 计数稳定后才输出),或者把复位同步化处理;工程里如果没带消抖模块,这是二次开发第一个要补的东西。

提示:这五条里,前三类(器件选错、锁存器、参数缩短掩盖问题)占了新手卡住时间的绝大部分,遇到「明明代码没问题」的情况,先回头查这三处。

6. 二次开发:把两相位改成四相位,顺手把配时抽成表

这个工程最容易上手的第一刀,是把四相位扩成六相位,加上左转。做法不复杂:在localparam里补两个状态S_EW_L、S_NS_L,在case里插入跳转,灯色输出多一位左转箭头。麻烦的是时长参数会从四个变成六个,如果继续用零散parameter,改一次配时要在例化处对六个数字,很容易错位。

我一般的做法是把配时收进一张表:

// 用常量数组描述每个相位的时长,索引与状态编码对应 localparam integer DUR [0:5] = '{15, 3, 12, 20, 3, 12}; // 东西绿 东西黄 东西左 南北绿 南北黄 南北左

综合工具对常量数组的支持没问题,取用时DUR[state]即可。这样加相位就是加一个数,不用动跳转逻辑的结构,改错概率直线下降。第二个改造点是紧急按钮:插入一个最高优先级的状态,任何相位下按下都立刻切到全红,松开后回到初始相位重新计时。注意这个判断要放在状态转移的if最前面,优先级靠代码顺序保证。

第三个是夜间黄闪模式,用一个拨码开关选择,开启时跳过状态机、直接让黄灯按 1 Hz 翻转。实现上不需要动traffic_fsm,在顶层用一个二选一选择器切换两路灯色输出就行,这样原状态机完全不用改,回退也简单。

改完之后怎么验证?我的顺序是:先跑短参数仿真,看相位跳转和灯色不重叠;再把TICK_HZ临时设成 10 跑一遍长仿真,确认倒计时数值跟着变;最后上板,用手机秒表掐一次完整循环,看实际秒数和参数是否吻合。这套流程走下来,改配时、加相位这类需求基本不会翻车。

注意:改完状态编码后,如果.qsf里用了基于寄存器名的分配,编译可能报引脚找不到。养成用端口名而不是内部信号名做引脚分配的习惯,能省掉大量这种血泪经验。

从那以后我每次改状态机,都强制先把tick频率调高跑一遍短仿真,确认跳转表没有死循环再回到真实参数——这个习惯是从一次「上板后灯卡死在黄灯」的教训里换来的,当时查了两小时才发现是新加的相位没有出口。希望帮到你。

本文还有配套的精品资源,点击获取