简介:面向电子工程与计算机科学专业学生的数字逻辑实验配套资源,主题为交通灯控制,聚焦基于Altera Quartus II平台的时序逻辑设计。内容涵盖VHDL/Verilog编写的红绿灯控制方案,包括信号定义、计数器定时(如红灯30秒、绿灯20秒、黄灯5秒)、状态切换与紧急优先控制,帮助学习者贯通数字逻辑实验流程。压缩包共5个文件,以XML配置文件和rels关系文件为主,XML用于保存工程配置与设计描述,rels则定义资源间关联,包体仅4.63MB,便于快速查看工程结构,方便结合实验指导进行电路搭建与仿真验证。目前已有529人学习,适合数字逻辑课程设计与FPGA入门者参考。通过该资源可了解Quartus工程的目录组织方式,掌握从代码编写、编译仿真到FPGA上板验证的完整实验链路,为后续嵌入式系统设计打下基础。
1. 数字逻辑实验·交通灯控制:不是流水灯,是一台小型时序机
数字逻辑实验里的交通灯控制,很多人一开始以为它就是高级版流水灯,真正动手才发现它是另一类问题:流水灯只要求按顺序点亮,交通灯控制却要求每个灯点亮的时间不一样,而且“红灯之后必须经过黄灯再转对向绿灯”这类状态关系不能被打乱。换句话说,它同时考了时序逻辑设计、计数器应用和状态机思想,是数字逻辑实验里少见的小型工程题。这篇文章写给正在跑这个实验的同学,也写给需要快速出仿真结果的工程师。无论你走面包板接线还是 Verilog 仿真,核心都离不开三件事:状态怎么编码、时间怎么计量、切换时怎么避免毛刺。把这三件事想清楚,交通灯控制就只剩接线和调试了。
2. 先定状态再选方案:四种状态与三条实现路线
2.1 交通灯的本质:四个状态与不等长定时
交通灯控制表面上是控制六盏灯,主干道红黄绿再加次干道红黄绿,真正能出现的主干道和次干道灯组合其实只有四种。主流做法是先列状态表,再谈电路:
| 状态 | 主干道 | 次干道 | 含义 |
|---|---|---|---|
| S0 | 绿灯 | 红灯 | 主干道放行,次干道禁行 |
| S1 | 黄灯 | 红灯 | 主干道准备禁行 |
| S2 | 红灯 | 绿灯 | 次干道放行,主干道禁行 |
| S3 | 红灯 | 黄灯 | 次干道准备禁行 |
这四种状态循环往复,就是交通灯的全部逻辑。难度不在状态本身,而在于每个状态持续的时间不一样。常见的实验参数是主干道绿灯 30 秒、黄灯 3 秒,次干道绿灯 15 秒、黄灯 3 秒,一个完整周期 51 秒,比流水灯那种均匀节拍复杂得多。时间参数不相等,决定了电路里不能只靠一个计数器均匀走,必须给每个状态单独安排计时长度,或者在同一个时间轴上设不同的区间阈值。
我一般会先画一张状态波形图,横轴是秒,把主干道和次干道的灯同时画出来。这张图一画完,两个关键结论就出来了:绿灯时间允许不等长,但黄灯时间必须留足;状态切换有严格先后,不允许从绿灯直接切到对向绿灯。后面无论选硬件还是 HDL,本质都是把这张图翻译成电路或代码,所以这张图值得你多花十分钟画准确。
2.2 三条实现路线:全局计数器、状态机、混合结构
围绕这个实验,最常见的方案有三条,选哪条取决于你的实验平台和成绩要求,也取决于你愿不愿意折腾。
第一条是全局计数器方案。用多片计数器级联,从 0 开始每秒加 1,整个周期 51 秒,计数到位就归零。然后对计数值做区间判读:0 到 29 是主干道绿灯,30 到 32 是主干道黄灯,33 到 47 是次干道绿灯,48 到 50 是次干道黄灯。这个方案思路直接,缺点是按区间译码需要比较多比较器和门电路,接线密,改时间相当痛苦。
第二条是状态机方案。用触发器保存当前状态,每个状态配一个自己的定时计数器。当前状态的时间一到,产生脉冲,触发器翻到下一状态,同时把计数器清零重新计时。这个概念上最接近真实交通灯控制器,也最好扩展,但同步清零和预置加载在面包板上容易出毛刺。
第三条是混合结构,也是我做课设指导时通常建议的做法:用可预置计数器做“单状态定时器”,用两个 D 触发器做状态锁存,每到一个状态结束点,状态切换,同时把下一个状态的持续时间预置进计数器。优点是电路规模小、状态和时长分开管理,后期改时间只改预置值,不用重新接线。下面硬件部分按这条路线讲。
| 方案 | 电路规模 | 改时间的成本 | 扩展急救车模式的难度 |
|---|---|---|---|
| 全局计数器 | 大 | 高,要改区间译码 | 中 |
| 状态机加独立计数器 | 中 | 中,每个状态对应一个计数器 | 低 |
| 混合结构 | 小 | 低,改预置数即可 | 低 |
2.3 秒脉冲的生成:555 定时器与晶振分频怎么选
时间基准是交通灯控制的地基,秒脉冲不准,后面所有状态时间都是伪精确。做这个实验最常用的是 NE555 搭自激多谐振荡器,其次是晶振加分频,再就是开发板或 FPGA 里用时钟分频。
NE555 的做法是元件少、接线简单、频率可调,缺点是很受电阻电容精度影响,频率误差通常在百分之几到十几。这个误差对交通灯来说完全能接受,毕竟你不要求秒脉冲和原子钟对齐。如果你手上有晶振模块和分频芯片,精度当然高,但分频级数多,面包板原本就挤,再加上去容易顾此失彼。
如果是 FPGA 或开发板,直接对板载时钟分频是更理性的选择。比如 50MHz 时钟,数 2500 万个沿翻转一次输出,就是 1Hz,精度远高于 555,还不占用额外芯片。我见过不少同学明明有板载时钟,还非要在外面插一块 555,绕了一圈回来发现频率还不稳,属于没必要的折腾。
2.4 状态编码与输出译码的关系
状态编码会影响组合译码部分的电路规模,初学者容易忽略。两个 D 触发器有四种组合,刚好对应 S0 到 S3,这是最自然的编码方式。有人为了少接几根线,直接让触发器输出控制灯,把状态编码和灯光输出混在一起,结果复位瞬间出现两个红灯同时亮。这里的原则是:状态编码是内部变量,灯光输出是外部行为,两者之间用组合译码隔离,不要直接画等号。
3. 硬件搭线:秒脉冲、状态锁存和灯控译码的分步做法
3.1 模块划分与状态编码
硬件实现先拆模块,我会把整块电路分成四段:秒脉冲产生、单状态定时器、状态锁存器、输出组合译码。这样拆的好处是每一段都能单独调试,出现问题时不用对着整板面包板发呆。
状态锁存用两片 D 触发器就能完成,具体型号可以是 74LS74 这类双 D 触发器,一片芯片刚好保存两位状态。编码就按上一章的表来:S0=00,S1=01,S2=10,S3=11。单状态定时器的核心是一片可预置计数器,预置值由当前状态决定。输出译码部分可以用门电路组合,也可以用译码器芯片。
3.2 秒脉冲源:NE555 参数计算与校准
NE555 接成自激多谐振荡器,振荡频率公式是:
f = 1.44 / ((R1 + 2 × R2) × C)
对应的充电时间是 t1 = 0.693 × (R1 + R2) × C,放电时间是 t2 = 0.693 × R2 × C,周期是两者相加。要得到接近 1Hz 的输出,我常用这一组参数:R1 = 33kΩ,R2 = 56kΩ,C = 10μF。代入公式:
T = 0.693 × (33k + 2 × 56k) × 10μF ≈ 0.693 × 145k × 10μF ≈ 1.005 秒
这个组合周期非常接近 1 秒,误差主要来自电容容量的实际偏差,电解电容的误差常有 20%,所以做好实测校准的准备。校准方式有两种:一是把 R2 换成 47kΩ 电阻串联一个 20kΩ 电位器,边测边调;二是直接用万用表频率档测 555 的 3 脚输出,微调到 1Hz。占空比大约 61%,对数字电路足够,不需要追求 50%。
注意 10μF 电解电容有极性,接反了频率会变得很奇怪。另外,NE555 的电源引脚旁边要放一个 0.1μF 瓷片电容去耦,否则输出波形边缘会有抖动,后面状态切换时容易多翻转一次。
3.3 状态寄存器与单状态定时:同步时序怎么处理
单状态定时的思路是:进入某个状态时,把计数器预置为该状态持续时间减一,然后每个秒脉冲减一次,减到 0 就说明这个状态该结束了。预置成持续时间减一是因为计数器从 0 开始递减,30 秒的绿灯让它从 29 数到 0,一共数 30 个脉冲,正好 30 秒。如果直接预置 30,它会数出 31 秒,这是最容易踩的坑。
状态切换部分,用两个 D 触发器保存状态位。当计数器减到 0 时产生一个借位脉冲,这个脉冲应当作为状态触发信号,让触发器在下一个时钟沿翻到下一个状态。切换完成后,状态编码通过选择器决定新的预置值,写入计数器的预置端,下一拍开始新的计数。
如果你用的计数器只能预置到特定值,记得把次数换算成二进制。比如主干道绿灯 30 秒就预置 29,二进制是 5 位。这里会牵扯到计数器位宽:一个 4 位计数器最多数到 15,超不过 30 秒。所以当实验时长超过 15 秒时,需要级联两片计数器,把宽度扩展到至少 5 位。实验要求若允许缩短时间,也可以先把绿灯设成 6 秒、黄灯 2 秒、次干道绿灯 4 秒,这样 4 位计数器就能覆盖,面包板会清爽很多。
3.4 组合译码输出:从状态编码算出来的灯控真值表
状态位 Q1、Q0 确定后,六盏灯的输出可以直接用组合逻辑表示。用 Q1 表示高位、Q0 表示低位,然后按状态表列出:
- 主干道绿灯 = ¬Q1 ∧ ¬Q0,对应 S0
- 主干道黄灯 = ¬Q1 ∧ Q0,对应 S1
- 主干道红灯 = Q1,对应 S2 和 S3
- 次干道绿灯 = Q1 ∧ ¬Q0,对应 S2
- 次干道黄灯 = Q1 ∧ Q0,对应 S3
- 次干道红灯 = ¬Q1,对应 S0 和 S1
这个关系用两三个与非门就能实现。如果觉得门电路绕,也可以直接用 74LS138 译码器,把三位状态输入变成八路输出,再按灯的位置接上二极管。注意一点:LED 要串限流电阻,一般 300Ω 到 470Ω,否则电流过大会拉低输出电平或者烧灯。
3.5 面包板调试顺序:从电源到状态切换的检查清单
我调试这类电路的顺序是固定的,不跳步。第一步只接 555,用示波器或万用表确认 3 脚有 1Hz 方波,没有这一步后面全白搭。第二步接计数器和预置端,手动给复位,观察计数器输出是否按预置值递减。第三步接状态触发器,确认状态位能在四个值之间循环。第四步接输出译码和 LED,最后做整机联动。
每接一步就停一下,用万用表量关键电平,而不是等所有线接完再一起查。面包板上的常见问题是线松、插错行、芯片方向放反,这些问题在整机调试时会被误以为是逻辑错误,极难定位。先分段验证,能让问题范围缩小到一个模块内。
4. 用Verilog仿真同一套交通灯:分频模块、状态机与验证代码
4.1 分频模块:从高频时钟得到1Hz秒脉冲
如果你用的是 FPGA,建议直接在代码里分出 1Hz 时钟,不要在外面搭 555。下面是一个 50MHz 时钟分频的写法:
// 分频模块:输入50MHz,输出1Hz方波 module clk_div_1hz ( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 低有效复位 output reg clk_1hz // 1Hz 秒脉冲 ); reg [25:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; clk_1hz <= 1'b0; end else if (cnt == 26'd24_999_999) begin cnt <= 26'd0; clk_1hz <= ~clk_1hz; // 翻转一次即半个周期 end else begin cnt <= cnt + 26'd1; end end endmodule这段代码里 24_999_999 是计数值的一半周期,因为 50MHz 经过 2500 万分频后是 1Hz,而 clk_1hz 每个计数周期翻转一次,25M 次翻转一个完整周期。如果你的系统时钟不是 50MHz,把边界值改成时钟频率的一半减一即可,比如 100MHz 就是 49_999_999。复位后 clk_1hz 输出 0,这是刻意做的,保证仿真启动阶段秒脉冲不会半空乱翻。
4.2 状态机主体:状态转移、计时与输出分离
下面是带计时的交通灯状态机,输入就是上一节分频得到的 1Hz 信号:
// 交通灯状态机主体:输入1Hz秒脉冲 module traffic_control ( input wire clk_1hz, input wire rst_n, output reg main_red, output reg main_yellow, output reg main_green, output reg side_red, output reg side_yellow, output reg side_green ); // 状态编码 localparam S_MAIN_GREEN = 2'b00; localparam S_MAIN_YELLOW = 2'b01; localparam S_SIDE_GREEN = 2'b10; localparam S_SIDE_YELLOW = 2'b11; // 时间参数,单位秒 localparam T_MAIN_GREEN = 30; localparam T_MAIN_YELLOW = 3; localparam T_SIDE_GREEN = 15; localparam T_SIDE_YELLOW = 3; reg [1:0] state; reg [7:0] cnt; // 状态转移和计时都在同一个时钟块里 always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin state <= S_MAIN_GREEN; cnt <= 8'd0; end else begin case (state) S_MAIN_GREEN: begin if (cnt == T_MAIN_GREEN - 1) begin state <= S_MAIN_YELLOW; cnt <= 8'd0; end else begin cnt <= cnt + 8'd1; end end S_MAIN_YELLOW: begin if (cnt == T_MAIN_YELLOW - 1) begin state <= S_SIDE_GREEN; cnt <= 8'd0; end else begin cnt <= cnt + 8'd1; end end S_SIDE_GREEN: begin if (cnt == T_SIDE_GREEN - 1) begin state <= S_SIDE_YELLOW; cnt <= 8'd0; end else begin cnt <= cnt + 8'd1; end end S_SIDE_YELLOW: begin if (cnt == T_SIDE_YELLOW - 1) begin state <= S_MAIN_GREEN; cnt <= 8'd0; end else begin cnt <= cnt + 8'd1; end end default: begin state <= S_MAIN_GREEN; cnt <= 8'd0; end endcase end end // 组合逻辑输出 always @(*) begin case (state) S_MAIN_GREEN: begin main_green = 1'b1; main_yellow = 1'b0; main_red = 1'b0; side_green = 1'b0; side_yellow = 1'b0; side_red = 1'b1; end S_MAIN_YELLOW: begin main_green = 1'b0; main_yellow = 1'b1; main_red = 1'b0; side_green = 1'b0; side_yellow = 1'b0; side_red = 1'b1; end S_SIDE_GREEN: begin main_green = 1'b0; main_yellow = 1'b0; main_red = 1'b1; side_green = 1'b1; side_yellow = 1'b0; side_red = 1'b0; end S_SIDE_YELLOW: begin main_green = 1'b0; main_yellow = 1'b0; main_red = 1'b1; side_green = 1'b0; side_yellow = 1'b1; side_red = 1'b0; end default: begin main_green = 1'b1; main_yellow = 1'b0; main_red = 1'b0; side_green = 1'b0; side_yellow = 1'b0; side_red = 1'b1; end endcase end endmodule计时比较值写的是 T_MAIN_GREEN - 1 而不是 T_MAIN_GREEN,这是整段代码里最值得注意的地方。计数器从 0 开始,第 30 个秒脉冲到来时 cnt 等于 29,所以判断 cnt == 29 才是 30 秒。如果你直接写成 cnt == 30,绿灯会亮满 31 秒,仿真波形一对比就看出来了。状态转移和计数器更新写在同一个 always 块里,用非阻塞赋值,这样状态切换和计数清零在同一拍生效,不会出现一个状态多走一拍的问题。
输出部分用组合逻辑,不占用时钟资源,复位后 state 被赋成 S_MAIN_GREEN,灯会稳定停在主干道绿灯,这对实物调试很重要,不会出现上电全灭。
4.3 仿真测试:testbench与波形检查点
testbench 里不需要模拟 50MHz,直接给 1Hz 时钟即可,重点看状态波形:
// 测试平台:等效1Hz时钟跑完整周期 module tb_traffic_control; reg clk_1hz; reg rst_n; wire main_red, main_yellow, main_green; wire side_red, side_yellow, side_green; traffic_control u_dut ( .clk_1hz(clk_1hz), .rst_n(rst_n), .main_red(main_red), .main_yellow(main_yellow), .main_green(main_green), .side_red(side_red), .side_yellow(side_yellow), .side_green(side_green) ); // 每500时间单位翻转一次,等效1Hz initial clk_1hz = 0; always #500 clk_1hz = ~clk_1hz; // 复位后跑80秒,覆盖一个完整周期 initial begin rst_n = 0; #10 rst_n = 1; #80_000 $finish; end // 每个时钟沿打印当前状态和灯状态 always @(posedge clk_1hz) begin $display("t=%0t state=%b main=%b%b%b side=%b%b%b", $time, u_dut.state, main_green, main_yellow, main_red, side_green, side_yellow, side_red); end endmodule仿真通过的标准我一般定四条:复位后第一拍是主干道绿灯;主干道绿灯维持到第 30 秒才切黄灯;黄灯只持续 3 秒;下一个状态切到次干道绿灯,而不是跳灯。此外还要盯一个反向条件:任何时刻主干道和次干道不能同时亮绿灯,这在波形图里一眼就能看出来,实际接线时如果出现这种组合,一定是状态编码或译码逻辑错了。
5. 交通灯控制常见问题排查与避坑清单
5.1 上电后灯全亮或全灭:复位信号不到位
现象:接好线通电,LED 要么全亮,要么全灭,按复位键也没反应。这不是逻辑错,而是上电瞬间触发器和计数器的初始状态不确定。原因:TTL 芯片上电时,D 触发器和计数器的 Q 端是随机态,如果复位端悬空或没有上电复位电路,电路会从一个未知状态开始跑。解决:给所有时序芯片的复位端接一个上电复位电路,常见做法是 10kΩ 电阻接 VCC 到复位端,1μF 电容从复位端到地,并接一个按钮到地,按一下复位。如果复位是低有效,这个电路正好匹配。
5.2 黄灯一闪而过或直接跳过:边界值理解错误
现象:仿真时黄灯只亮了一个时钟周期,或者根本没有黄灯,从绿灯直接跳到对向绿灯。原因多数是计时边界写错,把计数器等于 T 而当成了结束条件,或者把计数器清零和状态切换写在了不同时钟沿,导致两个动作差了一拍。解决:回到代码里确认比较值是 T - 1,并且状态切换和计数器清零必须用同一个时钟沿触发。面包板上如果黄灯闪一下就切走,还要检查秒脉冲频率是否偏高,555 输出如果因为电容容量标错变成 10Hz,黄灯 3 秒硬生生被算成 0.3 秒,肉眼看起来就是一闪而过。
5.3 仿真正确但实物灯不亮:TTL输出驱动不了LED
现象:仿真波形完全正确,接上 LED 后灯很暗甚至不亮。原因:TTL 门的输出驱动能力有限,尤其输出高电平时电流很小,直接驱动 LED 会导致电压被拉低,逻辑电平也跟着错。解决:LED 必须串限流电阻,阻值在 300Ω 到 470Ω 之间,同时优先采用低电平点亮的方式,也就是 LED 阳极接 VCC,阴极经电阻到逻辑输出端,因为 TTL 灌电流能力通常比拉电流强。如果灯的数量多,考虑加一片反向驱动器或 ULN2003 达林顿管阵列,不要硬从逻辑门取电流。
5.4 时间越跑越偏:秒脉冲没有校准和整形
现象:仿真时 30 秒绿灯精确,实物上用手机计时发现明显偏快或偏慢。原因:555 的频率实际误差可能到 10% 以上,电解电容容量离散大,R2 用普通固定电阻也无法微调。解决:把 R2 换成 47kΩ 电阻串 20kΩ 电位器,用万用表频率档实测 3 脚输出,调到 1Hz 再接入后面电路。还有一个易忽略的细节:555 输出沿如果比较缓,后续芯片偶尔会多触发一次,最好在 555 输出后接一级 D 触发器或施密特触发器整形,保证每个秒脉冲只有一个干净的上升沿。
5.5 在FPGA上复位不稳定:复位信号没有同步
现象:用 FPGA 做交通灯时,拨复位键十次里有两次状态不对,仿真却一切正常。原因:外部按键复位信号是异步的,直接接到状态机上,复位释放瞬间可能正好落在外时钟上升沿附近,产生亚稳态。解决:在模块入口做一级或两级复位同步,最简单是在顶层模块里把 rst_n 打两拍再用。我见过有同学把分频模块和状态机模块各用各的复位,结果一个复位一个不复位,状态机跑飞,所以复位信号尽量统一来源,并且所有模块在同一个复位释放沿后开始工作。
6. 进阶:加一个急救车优先模式,顺便验证你的设计余量
交通灯实验的常见加分项是急救车优先,也就是在某一路有紧急请求时,让该方向尽快变绿。这个功能听起来高级,实现起来其实是在验证你这套状态机到底留了多少扩展空间。面包板上用拨码开关模拟急救请求信号;FPGA 上用两个输入按键。
思路是这样:增加两个输入,emergency_main 和 emergency_side。请求到来时不立即切断当前方向,而是等当前状态走完,尤其是黄灯必须走满,否则对向车辆来不及刹停。次干道黄灯结束时,如果 emergency_main 有效,就进入主干道绿灯;主干道黄灯结束时如果 emergency_side 有效,就进入次干道绿灯。这样既保证安全,又完成了优先。
代码上的改动核心是把状态转移条件抽出来,改成带紧急判断的组合逻辑。相关部分示意如下:
// 在状态转移组合逻辑中加入急救车请求 always @(*) begin next_state = state; case (state) S_SIDE_YELLOW: begin if (emergency_main) next_state = S_MAIN_GREEN; // 黄灯结束后响应主路请求 else next_state = S_SIDE_GREEN; // 正常循环 end S_MAIN_YELLOW: begin if (emergency_side) next_state = S_SIDE_GREEN; else next_state = S_MAIN_GREEN; end // 其余状态不变 endcase end验证方法是:在第 40 秒拉高 emergency_main 并保持 20 秒,观察波形。正常时此时次干道正在绿灯或黄灯,请求不能打断黄灯,必须等黄灯结束后主干道才变绿;如果请求在主干道绿灯期间消失,电路应自动回到正常循环,不能在紧急绿和正常绿之间叠加出漏计时。
我做这个实验时最大的教训是,一上来就接急救车模式,结果正常四状态还没调稳,两根额外信号线把面包板搅成一团。后来把整块板拆成四个模块分别验证,才在一个小时内把所有问题清完。先让基础周期稳定循环三十秒以上,再谈扩展功能,这个顺序能省掉大量排查时间。希望这篇文章能帮你少走这一段弯路。
本文还有配套的精品资源,点击获取