ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

QuartusⅡ交通灯控制电路设计:状态机与Verilog实现

QuartusⅡ交通灯控制电路设计:状态机与Verilog实现

简介:这份资源面向数字逻辑电路设计的学习者与课程实践者,提供一套基于QuartusⅡ的十字路口交通灯控制电路完整工程。设计涵盖甲车道主道75秒、乙车道辅道25秒的交替通行逻辑,黄灯先亮3秒再切换车道,且黄灯期间每秒闪亮一次,并支持按键进行模式切换,适合作为数字系统设计、芯片仿真类实验或课程设计的参考方案。压缩包共167个文件,约2.42MB,以bdf原理图、vwf波形文件、bsf符号文件、cdb与hdb编译数据库、qpf与qsf工程配置、sof下载文件及rpt报告等为主,完整保留了从原理图绘制、功能仿真到综合下载的工程链路。目前已有3293人学习下载,读者可借此理清交通灯状态机与计时模块的划分思路,对照波形验证黄灯闪烁与车道切换时序,并参考工程目录组织方式完成自己的仿真与排错。

1. 从一次数字逻辑课设翻车说起:为什么交通灯控制电路值得认真做一遍

带过多届数字逻辑课程设计,我发现一个规律:选交通灯控制电路的人最多,但真正能把方案讲清楚、把仿真跑通、把验收点说明白的人最少。多数人卡在同一个地方——状态机设计得模棱两可,仿真波形对不上,答辩时被问一句“黄灯那两秒怎么来的”就答不上来。这份 QuartusⅡ 交通灯控制电路方案,解决的就是从需求定义到芯片仿真验证的完整链路问题。它适合正在做数字逻辑电路设计课设的学生,也适合需要快速搭一套可演示控制逻辑的从业者。核心不是画个红黄绿三色灯,而是把定时、状态切换、紧急模式、倒计时显示这几个模块用可综合的 Verilog 串起来,并在 QuartusⅡ 里跑通功能仿真和时序约束。

2. 需求拆解与方案选型:为什么用状态机加计数器的组合

2.1 从路口实际时序反推功能清单

交通灯控制电路的需求看起来简单,但真正落到数字逻辑层面,需要拆成可量化的功能点。我一般会先画一张路口时序图,把主干道和支路两个方向的灯态变化按时间轴铺开。一个标准的十字路口,主干道绿灯 30 秒、黄灯 3 秒、红灯 33 秒,支路绿灯 25 秒、黄灯 3 秒、红灯 38 秒,两个方向存在重叠等待区。这些时间参数不能拍脑袋定,要跟倒计时显示模块的位宽对齐——30 秒需要 5 位二进制计数器,3 秒需要 2 位,如果统一用 6 位计数器,高位补零逻辑就要在显示译码前处理掉。

功能清单通常包括这几项:正常模式下的四状态循环(主绿、主黄、支绿、支黄)、每个状态的持续时间可参数化、倒计时数码管实时显示剩余秒数、紧急模式下手动强制全红、复位后从主干道绿灯重新开始。其中紧急模式最容易被忽略,但答辩时导师往往盯着这个问,因为它考察的是你对状态机优先级和异步复位的理解深度。

2.2 状态机与计数器分工的设计逻辑

选型上,我倾向于用 Moore 型有限状态机做灯态控制,用独立的分频计数器做秒脉冲生成。为什么不把定时和状态切换揉在一起?血泪经验:一旦揉在一起,仿真波形里状态跳转和计时器归零的时序关系就会变成玄学,你根本说不清是状态先变还是计数器先清零。分开之后,状态机只负责“当前该亮什么灯”,计数器只负责“这个灯亮了多久”,两者通过一个使能信号握手。

具体来说,状态机定义四个状态:S0 主绿、S1 主黄、S2 支绿、S3 支黄。每个状态输出对应的灯控信号和该状态的持续时间预设值。计数器模块接收预设值,在每个秒脉冲到来时递减,减到零时输出一个 done 脉冲给状态机,触发状态跳转。这样设计的好处是,修改绿灯时长只需要改预设值参数,状态机逻辑完全不用动。

// 状态机核心片段:Moore型,输出仅与当前状态有关 localparam S0_MAIN_GREEN = 2'b00, S1_MAIN_YELLOW = 2'b01, S2_SIDE_GREEN = 2'b10, S3_SIDE_YELLOW = 2'b11; reg [1:0] current_state, next_state; reg [5:0] duration; // 当前状态持续秒数 always @(posedge clk or negedge rst_n) begin if (!rst_n) current_state <= S0_MAIN_GREEN; else if (tick_done) current_state <= next_state; end always @(*) begin case (current_state) S0_MAIN_GREEN: begin next_state = S1_MAIN_YELLOW; duration = 6'd30; // 主干道绿灯30秒 end S1_MAIN_YELLOW: begin next_state = S2_SIDE_GREEN; duration = 6'd3; end // S2、S3 同理,略 endcase end

这段代码的关键在于tick_done信号,它来自计数器模块的借位输出。duration在每个状态进入时被锁存到计数器,计数器从该值递减到 1 时产生一个周期的tick_done高电平。注意duration用组合逻辑赋值,综合工具会推断出多路选择器,如果时序紧张可以改成时序逻辑打一拍。

2.3 分频与秒脉冲生成的参数计算

QuartusⅡ 工程通常用 50MHz 板载晶振,要得到 1Hz 秒脉冲,分频系数是 25,000,000。这个数需要 25 位计数器来承载。我一般写一个参数化分频模块,把分频系数作为 parameter 传入,这样仿真时可以把系数改小到 10 甚至 5,波形跑得快,不用在仿真器里等几百万个周期。

module clk_divider #(parameter DIV = 25_000_000) ( input clk, input rst_n, output reg tick // 1Hz 秒脉冲 ); reg [24:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; tick <= 0; end else if (cnt == DIV - 1) begin cnt <= 0; tick <= 1; end else begin cnt <= cnt + 1; tick <= 0; end end endmodule

仿真时把DIV改成 10,tick每 10 个时钟周期拉高一次,状态跳转在波形里一目了然。这里有个踩坑点:tick只拉高一个时钟周期,如果状态机用tick做时钟沿会引入跨时钟域问题。正确做法是把tick当作使能信号,状态机仍然用系统时钟,在tick有效时判断是否跳转。

3. 总体架构设计:四个模块的职责划分与数据流

3.1 模块划分与端口定义

整个设计拆成四个模块:分频模块clk_divider、状态机模块traffic_fsm、倒计时计数器模块countdown、数码管译码模块seg_decoder。顶层模块traffic_top只做例化和连线,不写任何逻辑。这种划分方式的好处是每个模块可以独立仿真,出问题能快速定位是哪个环节的波形不对。

端口定义上,顶层输入只有clk、rst_n、emergency三个信号,输出是main_light[2:0]、side_light[2:0]和seg_data[7:0]、seg_sel[1:0]。灯控信号用独热码编码,3'b001表示绿灯,3'b010表示黄灯,3'b100表示红灯。倒计时显示用两个数码管分别显示主干道和支路的剩余秒数,位选信号seg_sel做动态扫描。

模块名输入输出功能
clk_dividerclk, rst_ntick50MHz 分频到 1Hz
traffic_fsmclk, rst_n, tick, emergencymain_light, side_light, duration状态跳转与灯控
countdownclk, rst_n, tick, load, durationcount, done递减计数与借位
seg_decoderclk, count_main, count_sideseg_data, seg_sel动态扫描译码

3.2 状态机与计数器的握手时序

状态机和计数器的握手是整个设计里最容易出问题的地方。我一般会画一张时序图:状态机在tick上升沿判断done是否有效,如果有效则跳转到下一状态并输出新的duration;计数器在下一个tick到来时加载新的duration并开始递减。这里有一个周期的延迟,意味着实际绿灯时间是 30 秒加一个时钟周期,对于秒级应用完全可以忽略。

紧急模式的处理需要特别小心。emergency信号有效时,状态机强制跳转到全红状态,同时计数器停止递减。emergency释放后,状态机从主干道绿灯重新开始,而不是回到之前的状态。这个设计决策是为了避免紧急解除后两个方向同时放行的危险情况。代码里用一个优先级最高的条件分支实现:

always @(posedge clk or negedge rst_n) begin if (!rst_n) current_state <= S0_MAIN_GREEN; else if (emergency) current_state <= S_EMERGENCY; // 全红 else if (tick && done) current_state <= next_state; end

注意emergency是异步信号,直接进状态机可能产生亚稳态。通常的做法是加两级同步器再使用,但在课设场景下,如果按键已经做了消抖,直接使用也能通过功能仿真。

3.3 倒计时显示的动态扫描策略

两个数码管显示剩余秒数,如果直接用静态驱动需要 14 根 IO,用动态扫描只需要 8 根段选加 2 根位选。扫描频率一般取 1kHz,人眼看不到闪烁。扫描计数器用 50MHz 时钟分频到 1kHz,每次扫描切换一位数码管,段选数据根据当前显示的位从count_main或count_side中取对应位。

// 动态扫描:1kHz 切换位选 always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt <= 0; else if (scan_cnt == 50_000) scan_cnt <= 0; // 50MHz/50k = 1kHz else scan_cnt <= scan_cnt + 1; end always @(*) begin case (scan_cnt[15]) // 用高位做位选切换 1'b0: begin seg_sel = 2'b01; seg_data = seg_main; end 1'b1: begin seg_sel = 2'b10; seg_data = seg_side; end endcase end

这里有个细节:count_main和count_side是二进制数,需要先转成 BCD 码再送七段译码。我一般写一个bin2bcd模块,用 Double Dabble 算法把 6 位二进制转成两个 BCD 位。如果嫌麻烦,也可以直接在计数器里用 BCD 计数,每减到 0 时从 9 借位,但这样计数器逻辑会复杂一些。

4. 关键模块实现:从 Verilog 代码到 QuartusⅡ 工程配置

4.1 状态机模块的完整实现与参数化

状态机模块的完整实现需要处理四个正常状态加一个紧急状态。每个状态的输出包括主灯和支灯的独热码,以及该状态的持续时间。为了参数化,我把四个持续时间定义成 localparam,放在模块头部,改时间只需要改这四个数。

module traffic_fsm #( parameter MAIN_GREEN_TIME = 6'd30, parameter MAIN_YELLOW_TIME = 6'd3, parameter SIDE_GREEN_TIME = 6'd25, parameter SIDE_YELLOW_TIME = 6'd3 )( input clk, rst_n, tick, emergency, output reg [2:0] main_light, side_light, output reg [5:0] duration, output reg done ); // 状态编码 localparam S0 = 3'd0, S1 = 3'd1, S2 = 3'd2, S3 = 3'd3, S4 = 3'd4; reg [2:0] state, next_state; reg [5:0] cnt; // 内部计数器 // 状态跳转与计时 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S0; cnt <= MAIN_GREEN_TIME; end else if (emergency) begin state <= S4; cnt <= 0; end else if (tick) begin if (cnt <= 1) begin state <= next_state; // 加载下一状态的时间 case (next_state) S0: cnt <= MAIN_GREEN_TIME; S1: cnt <= MAIN_YELLOW_TIME; S2: cnt <= SIDE_GREEN_TIME; S3: cnt <= SIDE_YELLOW_TIME; default: cnt <= MAIN_GREEN_TIME; endcase end else begin cnt <= cnt - 1; end end end // 下一状态组合逻辑 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 @(*) begin case (state) S0: begin main_light = 3'b001; side_light = 3'b100; end S1: begin main_light = 3'b010; side_light = 3'b100; end S2: begin main_light = 3'b100; side_light = 3'b001; end S3: begin main_light = 3'b100; side_light = 3'b010; end S4: begin main_light = 3'b100; side_light = 3'b100; end default: begin main_light = 3'b100; side_light = 3'b100; end endcase end assign duration = cnt; assign done = (cnt <= 1) ? 1'b1 : 1'b0; endmodule

这段代码把计数器和状态机合并了,好处是省了一个模块的握手信号,坏处是状态机模块变重了。如果课设要求模块化程度高,可以把计数器拆出去,用load和done握手。两种方式都能跑通,看导师偏好。

4.2 数码管译码与 BCD 转换

七段数码管的译码表是固定的,共阴极和共阳极的段码不同。QuartusⅡ 工程里通常用共阳极数码管,段码低电平有效。0 到 9 的段码分别是8'hc0, 8'hf9, 8'ha4, 8'hb0, 8'h99, 8'h92, 8'h82, 8'hf8, 8'h80, 8'h90。这个表可以直接用 case 语句实现。

BCD 转换用 Double Dabble 算法,核心思想是每左移一位,如果某个 BCD 位大于等于 5 就加 3。对于 6 位二进制数,最多需要 6 次移位,产生两个 BCD 位。代码不复杂,但要注意移位次数和位宽对齐。

// 6位二进制转BCD,Double Dabble算法 function [11:0] bin2bcd; input [5:0] bin; integer i; reg [11:0] bcd; begin bcd = 12'd0; bcd[5:0] = bin; for (i = 0; i < 6; i = i + 1) begin if (bcd[9:6] >= 5) bcd[9:6] = bcd[9:6] + 3; if (bcd[13:10] >= 5) bcd[13:10] = bcd[13:10] + 3; bcd = bcd << 1; end bin2bcd = bcd; end endfunction

这个函数在综合时会被展开成组合逻辑,资源消耗不大。如果倒计时最大 30 秒,6 位二进制足够,转换结果取低 8 位就是两个 BCD 码。

4.3 QuartusⅡ 工程设置与引脚分配

新建 QuartusⅡ 工程时,器件选 Cyclone IV 系列,具体型号看实验室板子。顶层文件设为traffic_top.v,其余模块放在同一目录下,Quartus 会自动识别例化关系。编译前需要做两件事:一是设置顶层实体,二是分配引脚。

引脚分配根据板子原理图来,时钟输入接 50MHz 晶振引脚,复位按键接一个带消抖的 GPIO,紧急模式按键接另一个。灯控输出接 LED 引脚,数码管段选接 8 个 GPIO,位选接 2 个 GPIO。分配完引脚后跑一次全编译,看资源占用和时序报告。如果时序不满足,通常是组合逻辑路径太长,可以在状态机输出加一级寄存器打拍。

注意:引脚分配前务必确认板子原理图,不同厂商的 FPGA 开发板引脚定义不同,接错引脚可能导致 LED 常亮或不亮,排查起来很费时间。

5. 仿真验证与避坑指南:波形对不上的排查思路

5.1 ModelSim 功能仿真流程

QuartusⅡ 自带 ModelSim-Altera 仿真工具,也可以调用外部 ModelSim。我一般写一个 testbench,把分频系数改小到 10,这样 30 秒的绿灯在仿真里只需要 300 个时钟周期就能跑完。testbench 里例化顶层模块,给clk加 20ns 周期的时钟,rst_n先拉低 100ns 再拉高,然后跑 5000ns 观察波形。

`timescale 1ns/1ns module tb_traffic; reg clk, rst_n, emergency; wire [2:0] main_light, side_light; wire [7:0] seg_data; wire [1:0] seg_sel; // 例化顶层,分频系数改小 traffic_top #(.DIV(10)) u_top ( .clk(clk), .rst_n(rst_n), .emergency(emergency), .main_light(main_light), .side_light(side_light), .seg_data(seg_data), .seg_sel(seg_sel) ); initial begin clk = 0; rst_n = 0; emergency = 0; #100 rst_n = 1; #5000 $stop; end always #10 clk = ~clk; // 20ns周期 endmodule

仿真波形里重点看几个信号:state是否按 S0→S1→S2→S3→S0 循环,cnt是否在每个状态递减到 1 后跳转,main_light和side_light是否在状态切换时同步变化。如果state卡在某个值不动,检查tick是否正常产生;如果cnt不递减,检查tick是否接到了状态机的使能端。

5.2 常见问题排查:从现象到根因

现象一:仿真波形里状态跳转比预期快很多。原因通常是分频系数没改小,或者 testbench 里例化时参数传递写错了。解决方法是检查clk_divider的DIV参数是否被顶层正确覆盖,在 ModelSim 里查看cnt信号的最大值是否等于DIV-1。

现象二:倒计时数码管显示乱码或不变。原因可能是 BCD 转换函数的位宽不匹配,或者动态扫描的位选信号接反了。解决方法是单独仿真seg_decoder模块,给固定的count_main和count_side值,看seg_data输出是否对应正确的段码。如果段码对但显示乱,检查位选信号的扫描频率是否太快或太慢。

现象三:紧急模式按下后状态机不响应。原因通常是emergency信号没有做同步处理,或者优先级判断写在了tick条件里面。解决方法是把emergency的判断放在always块的最前面,优先级高于tick和done。如果按键有抖动,加一个 20ms 的消抖计数器。

现象四:Quartus 编译报错“multiple drivers”。原因是在多个always块里对同一个reg变量赋值。解决方法是检查每个reg变量是否只在一个always块里被赋值,组合逻辑和时序逻辑不要混在同一个块里。

现象五:时序报告显示 setup violation。原因是组合逻辑路径太长,比如状态机的next_state经过多级 case 嵌套。解决方法是在next_state后面加一级寄存器,或者把状态编码改成独热码减少组合逻辑级数。

提示:仿真通过不代表上板能跑,上板前先用 SignalTap 抓一下实际波形,重点看复位释放后第一个状态是否正确,以及秒脉冲的周期是否接近 1 秒。

6. 验收与答辩:怎么证明你的交通灯控制电路是可靠的

验收环节,导师通常不会只看灯亮不亮,而是会盯着几个关键指标问。我一般会准备一张验收清单,把每个指标对应的测试方法和预期结果写清楚。第一项是状态循环正确性:复位后主干道绿灯亮,30 秒后转黄灯,3 秒后支路绿灯亮,依次循环。测试方法是秒表计时加肉眼观察,误差在 1 秒内算合格。第二项是倒计时同步性:数码管显示的剩余秒数必须和灯态变化同步,不能出现灯变了数码管还停在 0 的情况。测试方法是在状态切换瞬间观察数码管是否立即刷新。

第三项是紧急模式响应时间:按下紧急按键后,所有灯应在 100ms 内切换到全红。测试方法是用示波器抓按键信号和灯控信号的时间差。第四项是复位可靠性:在任意状态下按复位键,系统应回到主干道绿灯初始状态,数码管显示 30。测试方法是随机时刻按复位,重复 10 次看是否每次都能正确复位。

答辩时最容易被问到的三个问题:一是“黄灯时间为什么是 3 秒”,回答要落到安全规范上,通常黄灯时长根据路口宽度和车速计算,课设里取 3 秒是经验值。二是“两个方向同时亮红灯的情况怎么处理”,回答紧急模式下全红是安全设计,正常模式下不会出现。三是“你的状态机是 Moore 型还是 Mealy 型”,回答 Moore 型,输出只与当前状态有关,避免组合逻辑毛刺。

我一般会在答辩前把仿真波形截图打印出来,标出关键时间点,导师问的时候直接指波形说话,比空口解释有说服力。从那以后我每次带课设都要求学生先跑通仿真再上板,仿真波形不对绝不碰硬件,这个习惯帮我省了至少三周的返工时间。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进