简介:这份QuartusⅡ交通灯控制电路工程面向数字逻辑电路设计、芯片仿真课程的学习者与课程设计者,围绕十字路口甲乙两车道交替通行场景,给出主道75秒、辅道25秒、黄灯先亮3秒且每秒闪烁一次,并支持按键切换模式的完整实现思路。压缩包共167个文件,约2.42MB,包含bdf原理图、vwf波形文件、bsf与cdb/hdb等编译中间文件,以及qsf工程设置、sof下载配置、rpt编译报告和readme说明等,覆盖从顶层设计、模块拆分到仿真验证与器件配置的主要环节。已有3293人学习,说明该案例在交通灯控制与QuartusⅡ入门实践中具有较高参考价值。读者可据此对照理解状态机计时、车道切换与黄灯闪烁逻辑,复用原理图模块和波形激励,检查引脚分配与编译结果,并在此基础上修改通行时长或扩展紧急按键等功能,适合作为数字逻辑实验、课程设计及FPGA初学的完整工程样例。
1. Quartus II 编译通过、波形也漂亮,为什么这块交通灯控制电路一上板就翻车
实验室里最常见的一幕:Quartus II 工程编译无错,波形仿真也漂亮,下载到板子上一看,交通灯控制电路的红灯亮着就是不灭——这种事遇上一两次就见怪不怪了。交通灯这个题目几乎每个做数字逻辑电路设计的人都会碰上一次,但只要牵涉到芯片仿真和真机上板,问题就容易集中爆发。它看着只是红绿黄三个灯轮流亮,骨子里是“分频 + 状态机 + 倒计时 + 译码 + 引脚约束”一整套数字系统,工程结构该有的部分一个不缺。这份 Quartus II 工程适合三类人:刚学完触发器、想找个能上板的完整例子练手的新手;手上已经有块 FPGA 板子、想拿它把工具链走一遍的读者;以及需要一份能演示、能改参数的课设底稿的人。下面按文件结构、核心模块、跑通流程、踩坑记录、二次开发几段拆开讲,能照着抄的地方都把代码贴出来。
2. 工程结构拆解:一个压缩包里到底装了哪几类文件
2.1 解压之后先认文件名,别急着点编译
拿到一个 Quartus II 工程包,第一步永远是看文件名,而不是直接双击打开。这类交通灯工程解压后通常长这样:
traffic_light/ ├── traffic_light.qpf # Quartus 工程文件,双击它打开才对 ├── traffic_light.qsf # 设置文件:器件型号、引脚分配、时序约束全在这 ├── db/ # 编译中间产物,可以整个删掉重生成 ├── incremental_db/ # 增量编译数据库,同样可删 ├── output_files/ # 编译输出:.sof .pof .fit.rpt .sta.rpt ├── rtl/ │ ├── traffic_top.v # 顶层 │ ├── clk_div.v # 分频 │ ├── traffic_fsm.v # 主状态机 │ └── seg_dec.v # 七段译码 ├── tb/ │ └── tb_traffic.v # 仿真测试平台 ├── sim/ # ModelSim 波形和脚本 └── doc/ # 说明书、状态转换图、引脚表.qpf只是工程门户,真正的信息在.qsf里。很多人换了台机器打开工程,引脚全丢、器件型号变成默认的,就是因为只拷了.qpf没拷.qsf。db/、incremental_db/这两个目录体积最大又最没用,压缩包里带着它们纯粹是打包时没清理,删掉能省一大半空间,下次全编译会自己重建。
output_files/里的.sof是下载到 FPGA 内部 SRAM 的文件,断电即失;.pof是烧到配置芯片的,断电保留。这两个别搞混,尤其交作业演示时,用.sof演示完拔电再插上,灯不亮,白紧张一场。
2.2 原理图输入还是 Verilog:两条路线的取舍
交通灯这类工程在流传过程中通常有两种形态:早期的是.bdf原理图文件,用 74 系列计数器、译码器搭出来,画面上全是器件符号和连线;后期的是.v文本文件,用状态机写。
原理图版本的优点是直观,答辩时指着图讲信号流向,评委一眼能看懂;缺点是改一个参数要动一片连线,加个数码管倒计时就得再塞两个 74160,连线一乱就查不出问题。文本版本的优点是可参数化、可复用、便于仿真,缺点是初学者对“状态机”这三个字有心理阴影。
我的建议是:如果压缩包里两种都有,以上板验证为准选能跑通的那个,以二次开发为准读文本那个。原理图工程适合用来理解信号流,文本工程适合用来改配时。常见做法是把原理图当参考图,把 Verilog 当主干,反正两者在顶层是等价的。
有一点要提前说清楚:原理图输入在 Quartus II 里叫 Block Diagram/Schematic File,扩展名.bdf,它是可以被顶层 Verilog 例化的,也可以反过来把一个 Verilog 模块生成符号塞进原理图。混合设计完全合法,不用纠结“必须二选一”。
3. 核心模块拆解:分频、状态机、倒计时、译码的四段链路
3.1 时钟分频:把板载高频时钟压成 1Hz 心跳
FPGA 板子上的晶振一般是几十 MHz,交通灯的秒级节拍必须自己做。教科书上爱写“分频器”,实际代码就是一个计数器加一次翻转:
// 高频时钟 -> 1Hz 方波,占空比 50% // 参数化是为了仿真时能把 2500 万缩成 5,否则波形跑一天也看不到变化 module clk_div #( parameter CNT_MAX = 25_000_000 // 50MHz 下 0.5s 计满一次 )( input wire clk, // 板载晶振 input wire rst_n, // 低电平异步复位 output reg clk_1hz // 1Hz 方波 ); reg [24:0] cnt; // 2^25 = 33,554,432,够装 25,000,000 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 25'd0; clk_1hz <= 1'b0; end else if (cnt == CNT_MAX - 1) begin cnt <= 25'd0; clk_1hz <= ~clk_1hz; // 计满翻转,高低各半个周期 end else begin cnt <= cnt + 1'b1; end end endmodule这段代码有两个参数必须记牢:CNT_MAX决定节拍快慢,cnt的位宽决定能不能装下这个值。50MHz 晶振想得到 1Hz,一个完整周期是 1 秒,共 50,000,000 个时钟,翻转法每次计 25,000,000 个时钟翻一次,高低电平各 0.5 秒,合成周期 1 秒。位宽写成[23:0]就只能数到 16,777,215,永远等不到计满,灯一动不动——这是新手最常踩的坑之一。
换板子怎么办?把CNT_MAX按新晶振重算一遍就行。100MHz 的板子填50_000_000,12MHz 的填6_000_000,不需要动其他任何一行。这也是我坚持分频器必须参数化的原因:配时和晶振是两个正交的东西,耦合在一起改起来就难受了。
仿真时用defparam或者例化时覆盖参数:
clk_div #(.CNT_MAX(5)) u_div ( .clk (clk), .rst_n (rst_n), .clk_1hz (clk_1hz) );这样仿真里 5 个时钟就出一次节拍,波形窗口里几微秒就能看完一整个红绿周期。
3.2 主状态机:四状态够用,六状态更贴近真实路口
交通灯的本质是一个有限状态机。最简版四个状态:主绿副红、主黄副红、主红副绿、主红副黄,循环一圈正好一个完整周期。想让“黄灯”出现在两个方向交替之间而不重叠,四个状态就够;如果想模拟左转相位或者夜间黄闪模式,就得扩到六个甚至八个。
四状态版的骨架:
module traffic_fsm #( parameter T_GREEN = 6'd30, // 绿灯时长,单位秒 parameter T_YELLOW = 6'd5, // 黄灯时长 parameter T_RED = 6'd25 // 红灯时长 = 对向绿 + 黄 )( input wire clk_1hz, // 秒节拍 input wire rst_n, output reg [1:0] main_light, // 2'b01 绿 2'b10 黄 2'b00 红 output reg [1:0] side_light, output reg [5:0] main_cnt, // 主向剩余秒数,送数码管 output reg [5:0] side_cnt ); localparam S_MG_RS = 2'd0, // 主绿 副红 S_MY_RS = 2'd1, // 主黄 副红 S_MR_GS = 2'd2, // 主红 副绿 S_MR_YS = 2'd3; // 主红 副黄 reg [1:0] state, next_state; reg [5:0] tick; // 当前状态已持续秒数 // 第一段:状态寄存器 always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin state <= S_MG_RS; tick <= 6'd0; end else begin state <= next_state; tick <= (state != next_state) ? 6'd0 : tick + 1'b1; end end // 第二段:次态组合逻辑,配时全靠这里 always @(*) begin next_state = state; case (state) S_MG_RS: if (tick >= T_GREEN - 1) next_state = S_MY_RS; S_MY_RS: if (tick >= T_YELLOW - 1) next_state = S_MR_GS; S_MR_GS: if (tick >= T_GREEN - 1) next_state = S_MR_YS; S_MR_YS: if (tick >= T_YELLOW - 1) next_state = S_MG_RS; default: next_state = S_MG_RS; endcase end // 第三段:输出,必须有默认值,否则综合出锁存器 always @(*) begin main_light = 2'b00; side_light = 2'b00; case (state) S_MG_RS: begin main_light = 2'b01; side_light = 2'b00; end S_MY_RS: begin main_light = 2'b10; side_light = 2'b00; end S_MR_GS: begin main_light = 2'b00; side_light = 2'b01; end S_MR_YS: begin main_light = 2'b00; side_light = 2'b10; end default: begin main_light = 2'b00; side_light = 2'b00; end endcase end // 倒计时数值:红灯读对向的绿灯时长,绿灯读自己的 always @(*) begin main_cnt = (state == S_MR_GS || state == S_MR_YS) ? (T_GREEN - tick) : (T_GREEN - tick); side_cnt = T_GREEN - tick; end endmodule关键点在三段式写法里。第一段只做寄存,第二段纯组合算次态,第三段纯组合算输出。这种写法看着啰嗦,但好处是仿真波形上能直接对着state、next_state、tick三个信号看出问题出在判断还是出在输出。两段式写起来短,可一旦输出错,你得把整个 always 块翻一遍。
tick的重载逻辑要注意:state != next_state时清零,否则自增。这意味着状态停留的第一秒tick = 0,判断条件写成tick >= T_GREEN - 1才对,写成tick == T_GREEN会多停一秒。
参数之间的关系必须自洽:T_RED = T_GREEN + T_YELLOW。红灯时长不是独立参数,它是对向两个状态的和。新手喜欢把三个时间各填一个数,结果两个方向周期对不上,一晚上都调不出来。
3.3 倒计时与七段译码:从二进制到“人眼可读”
状态机吐出的是 6 位二进制剩余秒数,人要看的是两位十进制。中间要过一级“二-十进制拆位”和一级“七段译码”。
拆位用一个除法取余的纯组合逻辑就能写,但对综合工具不太友好。更常见的做法是直接让计时器工作在 BCD 域:
// 60 以内秒数的 BCD 计数,个位 0-9,十位 0-5 always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin sec_lo <= 4'd0; sec_hi <= 4'd0; end else if (sec_lo == 4'd9) begin sec_lo <= 4'd0; sec_hi <= (sec_hi == 4'd5) ? 4'd0 : sec_hi + 1'b1; end else begin sec_lo <= sec_lo + 1'b1; end end十位到 5 就归零,天然把范围卡在 00~59,不需要额外判满。这种写法在资源上比除法器省得多,综合出来就是几个比较器。
译码部分要区分共阴和共阳,这是上板翻车的高发区:
// 共阳极数码管:段码位为 0 时该段点亮 module seg_dec ( input wire [3:0] bin, output reg [7:0] seg ); always @(*) begin case (bin) 4'd0: seg = 8'hc0; // 0 4'd1: seg = 8'hf9; // 1 4'd2: seg = 8'ha4; // 2 4'd3: seg = 8'hb0; // 3 4'd4: seg = 8'h99; // 4 4'd5: seg = 8'h92; // 5 4'd6: seg = 8'h82; // 6 4'd7: seg = 8'hf8; // 7 4'd8: seg = 8'h80; // 8 4'd9: seg = 8'h90; // 9 default: seg = 8'hff; // 全灭,别让无效值乱亮 endcase end endmoduledefault分支千万别省。省了以后综合工具会推断出一堆锁存器,报一堆 warning,而且无效输入下数码管显示什么完全随机,调试时能把人逼疯。
如果板子上是共阴数码管,把上面的值按位取反(8'hc0变8'h3f)即可。判断方法很简单:上板后所有段全亮或全灭,基本就是共阴共阳搞反了,不用怀疑代码。
3.4 顶层例化与引脚约束:把模块拼起来,把信号接到管脚
顶层只干两件事:例化、连线。
module traffic_top ( input wire clk, // 板载晶振 input wire rst_n, // 按键复位,低有效 output wire [1:0] main_light, // 主向红黄绿 output wire [1:0] side_light, // 副向红黄绿 output wire [7:0] seg, // 数码管段码 output wire [3:0] sel // 数码管位选 ); wire clk_1hz; wire [5:0] main_cnt, side_cnt; clk_div #(.CNT_MAX(25_000_000)) u_div ( .clk(clk), .rst_n(rst_n), .clk_1hz(clk_1hz) ); traffic_fsm u_fsm ( .clk_1hz (clk_1hz), .rst_n (rst_n), .main_light (main_light), .side_light (side_light), .main_cnt (main_cnt), .side_cnt (side_cnt) ); // 数码管动态扫描:1kHz 左右刷新,人眼看着是常亮 seg_scan u_scan ( .clk (clk), .rst_n (rst_n), .num_lo (main_cnt % 10), .num_hi (main_cnt / 10), .seg (seg), .sel (sel) ); endmodule引脚约束写在.qsf里,格式是 Tcl:
# 时钟与复位 set_location_assignment PIN_23 -to clk set_location_assignment PIN_24 -to rst_n set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to clk # 六路 LED set_location_assignment PIN_101 -to main_light[0] set_location_assignment PIN_102 -to main_light[1] # ... 其余管脚按板子原理图补全引脚号必须对着板子的原理图填,抄别人工程的引脚号是最容易出问题的地方——板子型号不同,同一个PIN_23可能接的是晶振,也可能是某个 LED。IO 电平标准也要对,3.3V 器件配 3.3-V LVTTL,配错了编译能过,上板电平不对,灯微亮或者干脆不亮。
4. 从零跑通:建工程、编译、仿真、上板的完整流程
4.1 新建工程与文件加入
打开 Quartus II,走 File → New Project Wizard,路径别带中文和空格——这不是迷信,工具链对中文路径的处理一直不太稳,报错信息还特别隐晦。器件型号按板子实际填,选错了后面引脚分配会用不了。
工程建好后把rtl/下的.v全部 Add Files 加进来,顶层设成traffic_top。如果压缩包里带的是.bdf原理图,就在顶层文件里用符号工具生成模块再连线。
也可以全程走命令行,做冒烟测试特别方便:
# 全编译:综合 + 布局布线 + 时序分析 quartus_sh --flow compile traffic_light # 如果板子需要烧配置芯片,把 sof 转成 pof quartus_cpf -c output_files/traffic_light.sof output_files/traffic_light.pof--flow compile会自动读.qsf里所有设置,跑完看output_files/下有没有.sof,有就说明工程结构是完整的。这一步比在 GUI 里点半天快得多,用来判断“这个包能不能跑”最省事。
4.2 编译报告要看哪几行
全编译完了别急着下载,先看三份报告。
*.fit.rpt里看资源占用,逻辑单元、寄存器、引脚数用了多少。交通灯这种规模通常只占个位数百分比,如果忽然飙到 80%,多半是哪段组合逻辑写出了巨型 case 或者位宽错得离谱。
*.sta.rpt里看时序。交通灯跑的是秒级节拍,几十 MHz 的时钟约束通常都能满足,红了基本就是时钟没约束上或者用了门控时钟。有个细节:分频出来的clk_1hz千万别当全局时钟去驱动整个设计,它应该只作为使能信号用。用posedge clk_1hz做时钟在功能仿真里没毛病,综合到实际器件上就会因为时钟走线资源不足而报错,这条血泪经验值一个晚上。
*.pin.rpt里核对引脚分配。编译前 Warning 里如果出现 “No location assignment”,说明有端口没分配引脚,工具会自己随便挑,上板就是随机现象。
4.3 波形仿真:把 60 秒压缩成几微秒
仿真这一步最容易劝退人,因为默认参数下CNT_MAX = 25_000_000,跑到仿真器放弃也等不到一个节拍。所以测试平台第一件事就是覆盖参数。
`timescale 1ns / 1ps module tb_traffic; reg clk, rst_n; wire [1:0] main_light, side_light; wire [5:0] main_cnt; // 关键:把分频系数压到 5,状态时长压到 3 秒 traffic_top #( .CNT_MAX (5) ) u_top ( .clk (clk), .rst_n (rst_n), .main_light (main_light), .side_light (side_light), .main_cnt (main_cnt) ); // 20ns 周期时钟 initial clk = 1'b0; always #10 clk = ~clk; initial begin rst_n = 1'b0; #100 rst_n = 1'b1; #10000 $stop; // 跑够几个完整周期就停 end endmoduleModelSim 下跑起来:
vlib work vlog ../rtl/*.v ../tb/tb_traffic.v vsim -c -do "run -all; quit" tb_traffic波形窗口里重点看三个东西:clk_1hz是不是按预期周期翻转;state是不是按 0→1→2→3→0 顺序走,卡在某个状态不动就是次态逻辑的判断条件写错了;main_light和side_light在切换瞬间有没有同时全灭。第三点尤其重要,如果两灯同时亮一下,说明输出逻辑里少了默认赋值。
4.4 上板验证与信号观测
下载走 Tools → Programmer,选.sof,Hardware Setup 里认到 USB-Blaster,Start。如果板子和工具版本对不上,最省事的做法是把下载线插在另一台机器上试一次,先排除驱动问题,别一上来就怀疑代码。
上板之后按这个顺序看:先确认晶振在跑(有些板子有电源灯或心跳灯,闪烁就说明时钟正常);再看六路 LED 是不是按 30/5/25 秒的节奏切换;最后看数码管。数码管如果显示“88”或者整屏闪烁,是扫描频率不对或者没有位选消隐,下一章细说。
上板对、仿真不对,或者反过来,都是信息。仿真对、上板不对,八成是引脚和电气特性;上板对、仿真不对,八成是测试平台没覆盖参数。这两条判断能省掉大量瞎猜。
5. 避坑与排查:仿真漂亮、上板不听话的六种常见情况
5.1 编译通过但灯全亮/全灭
现象:下载成功,六个 LED 一起亮或者一起不亮,数码管无显示。
原因:引脚分配没做,或者共阴共阳搞反。前者是.qsf里端口名和顶层端口名对不上,编译时只报 Warning 不报 Error;后者是段码极性选错,共阳代码驱动共阴管,0 有效变成 0 灭。
解决:打开 Programmer 前的 Pin Planner,逐个核对外设引脚;极性用一个最简单的一位数测试模块验证,先让数码管显示一个“8”,确认硬件对了再上主逻辑。
5.2 数码管显示重影或闪烁
现象:两位数字看起来叠在一起,或者整体在抖。
原因:动态扫描频率太低,通常在 100Hz 以下人眼就能看出闪烁;或者位选切换时没有先消隐,上一段的段码残留到了下一段。
解决:扫描时钟取 1kHz 左右,每次切换位选前先把seg置成全灭(共阳8'hff),下一个时钟再送新段码。这个“先灭后亮”的顺序不能省。
5.3 仿真波形一直不变
现象:跑了几十微秒,state一直是 0。
原因:分频参数没在测试平台里覆盖,CNT_MAX还是 2500 万,仿真时间根本不够。
解决:在例化时用#(.CNT_MAX(5))覆盖,或者把状态时长也参数化一起缩小。仿真平台和综合用的参数必须分开,这不是偷懒,是标准做法。
5.4 状态切换瞬间两个灯同时亮
现象:波形上看,黄灯亮起的那一拍红灯还亮着。
原因:三段式输出的组合逻辑里,某个分支漏了赋值,综合出锁存器,保持上一个状态的值。
解决:进case之前先把所有输出赋默认值(全红或全灭),再在分支里覆盖。这个习惯一旦养成,锁存器警告会少一大半。
5.5 功能仿真通过,时序仿真崩了
现象:RTL 级仿真一切正常,跑门级仿真或者上板高速运行时行为错乱。
原因:异步复位信号没有做同步释放,复位撤销的瞬间正好撞上时钟边沿,触发器进入亚稳态。
解决:复位加两级同步器,或者干脆改成同步复位。交通灯这种应用复位不敏感,用同步复位最省心,别为了“异步复位更可靠”这句话给自己找麻烦。
5.6 换个板子就跑不起来
现象:原工程在自己机器上正常,换一块晶振频率不同的板子,所有时间都变快或变慢。
原因:分频系数是硬编码的,和晶振频率绑死了。
解决:把CNT_MAX提到顶层参数,按晶振频率重算。这个改动很小,但决定了这个工程是“一次性作业”还是“能复用的模块”。
6. 二次开发:把固定配时改成一张可配置的时间表
原始工程跑通之后,真正有价值的部分才刚开始。固定写死 30/5/25 的配时,改一次要动三个parameter再全编译,这种体验用两次就烦了。我一般会做三件事。
第一件,把配时抽成一张表。定义一个参数数组或者一个小的配置模块,把每个相位的时长放进去,主状态机只负责查表。这样加一个“夜间黄闪”模式,就是往表里多写一行,不用碰状态机逻辑:
// 配时表:每一项是 {相位编号, 持续秒数} localparam [1:0] PHASE_MAIN_GREEN = 2'd0, PHASE_MAIN_YELLOW = 2'd1, PHASE_SIDE_GREEN = 2'd2, PHASE_SIDE_YELLOW = 2'd3; // 用函数查表,综合工具会展开成常量 function [5:0] get_duration(input [1:0] ph); case (ph) PHASE_MAIN_GREEN : get_duration = 6'd30; PHASE_MAIN_YELLOW: get_duration = 6'd5; PHASE_SIDE_GREEN : get_duration = 6'd25; PHASE_SIDE_YELLOW: get_duration = 6'd5; default : get_duration = 6'd0; endcase endfunction第二件,把倒计时的“剩几秒”做成统一的一路信号,而不是主副两个方向各算各的。红灯方向的剩余时间等于对向绿灯加黄灯的剩余和,这个关系写死在两个always块里迟早会不一致,抽成一个函数最稳妥。
第三件,加一个“紧急放行”输入。真实路口都有这个东西,实现上也简单:一个高优先级的状态,按键按下时无论当前在哪个相位,三个时钟内切到全红,再按一下恢复正常循环。写的时候注意把紧急状态的退出条件做成边沿触发,别做成电平触发,否则按键按住不放会一直卡在全红。
验证方法我推荐一个小技巧:把CNT_MAX改成 1,让每个时钟都是 1 秒,然后跑 200 个时钟,把state和main_light打印到文本里,人眼扫一遍就知道相位顺序对不对。这比盯着波形窗口拖滚动条快十倍,而且能直接 diff 两次修改的差异。
有一回我改配时改到凌晨,改完只看了波形前 5 微秒就打包发出去,结果对方一上板发现黄灯根本不出现——原因是我把黄灯时长填成了 0,波形前 5 微秒压根还没走到那个相位。从那以后我每次改完参数,都强制把仿真跑满至少两个完整周期,并且把每个状态的进入时刻打印出来核对一遍,不管多晚。希望这套拆法能帮到你,少走几个晚上。
本文还有配套的精品资源,点击获取