简介:基于Cyclone II系列EP2C35F672C6芯片的交通灯控制程序,面向FPGA学习者与数字逻辑课程设计人群,完整演示了十字路口四组红绿黄灯的四种状态轮换逻辑,并通过四组数码管实时显示红绿黄灯倒计时时长。压缩包解压后共120个文件,内容约1.25MB,其中traaffic目录下为Quartus II工程文件,程序文件夹保留编写过程中失败与最终修改的迭代版本,错误文件夹记录了开发期间遇到的典型编译错误。vhd文件为硬件描述源码,另含大量qpf、qsf工程配置和cdb、rpt等编译报告文件,目录结构清楚,适合对照参考。程序功能完整、状态机思路清晰,从状态编码到数码管驱动均有完整实现,可直接作为交通灯控制类实验或课程设计的原型。资源目前已有420人学习浏览,作者将调试中的失败尝试与排错过程一并打包,对理解FPGA开发流程、时序约束与状态机设计尤其有参考价值。 说句实话,这颗Cyclone II-EP2C35F672C6已经是十几年前的老芯片了,但直到现在我带新人入门FPGA,还是会让他们用这颗芯片把交通灯程序完整跑一遍。为什么?因为这个题目小到能在一节课里讲清楚,又大到能把状态机、时钟分频、按键消抖、数码管动态扫描这些基本功全部串起来。这篇文章我就以EP2C35F672C6为硬件平台,把交通灯程序的完整设计思路、关键代码和上板调试过程拆开讲一遍,适合正在学FPGA的初学者,也适合想复习状态机设计的在职工程师。
1. 有些芯片越老反而越适合教学:EP2C35F672C6的定位
1.1 型号拆解:这些数字和字母分别代表什么
很多新手拿到EP2C35F672C6这颗芯片,第一反应是去背参数,其实型号本身就说明了一切。EP是Cyclone系列的前缀,2代表Cyclone II代,C35代表大约3.5万个逻辑单元(LE),F672代表FBGA封装、672个引脚,C6代表速度等级是6,也就是中等偏上的速度档位。
这颗芯片的资源做交通灯属于“杀鸡用牛刀”,但正因为资源富余,新手不用去考虑资源优化,可以专心把逻辑写对。672个引脚里面包含了大量用户IO,板子上一般会引出LED、数码管、按键、VGA、串口等外设,交通灯项目需要的输入输出接口它全都覆盖了。开发环境方面要特别注意,Cyclone II只能用Quartus II 13.0sp1及更早的版本,后来的Quartus Prime已经不再支持这个器件系列,装错版本会直接导致器件列表里找不到芯片。
1.2 交通灯为什么是FPGA入门绕不开的题目
交通灯程序最妙的地方在于,它天然就是一个有限状态机(FSM)。十字路口的红绿灯切换逻辑清晰,状态之间没有复杂的嵌套分支,非常适合用来理解状态机的三段式写法。同时它又必须处理“时间”这个维度,自然会引出时钟分频、计数器、使能脉冲这些数字电路的基本功。等你把交通灯跑通,再去写UART、SPI、I2C这些通信协议,会发现底层的状态机思路是完全一样的。
我见过不少新手一上来就去啃SDRAM控制器或者以太网协议栈,结果被复杂时序打得毫无脾气。与其那样,不如老老实实在交通灯项目上把状态机的肌肉记忆练出来。而且交通灯的验证反馈极其直观:LED灯按预期闪烁,就说明你的状态转移和计数逻辑是对的;灯的颜色乱了,基本就是状态编码或者转移条件写错了,调试起来没有黑盒感。
2. 交通灯程序的核心:四相位状态机和秒信号设计
2.1 状态怎么划:不是想当然,而是有交通语义
设计状态机之前,先想清楚十字路口到底有哪几种灯色组合。最简单也最符合真实交通语义的划分是四个相位:南北绿灯、东西红灯;南北黄灯、东西红灯;南北红灯、东西绿灯;南北红灯、东西黄灯。注意红灯不需要单独占用一个状态,红灯时长等于对方绿灯和黄灯时长之和,这是一个非常关键的理解点。
很多新手会画出八个状态,把每个方向的每种颜色都当成独立状态,这在逻辑上没错,但会让状态转移图变得很啰嗦。更合理的做法是只保留四个有效灯色状态,再额外增加行人相位和紧急相位作为扩展状态。这样状态机的层次就非常清晰:正常切换走四相位循环,遇到行人请求或紧急事件时,插入对应的特殊状态。
2.2 秒信号不要用慢时钟,用tick脉冲
交通灯需要以秒为单位计时,而FPGA板载晶振通常是50MHz,所以第一步要解决分频问题。新手最常见的写法是直接把50MHz分频成一个1Hz的慢时钟,然后用这个慢时钟去驱动状态机。这种写法在仿真里看不出问题,但在实际工程里会埋雷——慢时钟是由计数器的某个位翻转产生的,会产生时钟偏斜,而且跨时钟域处理不好容易出亚稳态。
我推荐的做法是产生一个tick脉冲,也就是每秒产生一个单时钟周期的高电平脉冲,状态机仍然跑在50MHz时钟域下,只在这个tick有效的那一拍完成计数和状态转移。这样做的好处是:整个设计只有一个时钟域,时序约束简单,综合后的时序更好收敛,而且SignalTap抓波形的时候不会看到一堆毛刺。
tick生成的核心思路是用一个计数器从0数到49_999_999,数满时输出一个周期的高脉冲并清零。代码写出来大概是这样:
reg [25:0] cnt; reg tick; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; tick <= 1'b0; end else if (cnt == 26'd49_999_999) begin cnt <= 0; tick <= 1'b1; end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end注意计数器位宽必须是26位,因为50_000_000这个数已经超过了2的25次方,如果只声明25位,综合时会直接报错或者出错。这也是一个非常典型的新手坑。
2.3 状态机的Verilog实现
状态编码我习惯用独热码或者简单的二进制编码。交通灯状态少,二进制编码就够用了。定义一个局部参数:
localparam S_NS_G = 3'd0; localparam S_NS_Y = 3'd1; localparam S_EW_G = 3'd2; localparam S_EW_Y = 3'd3; localparam S_PED = 3'd4; localparam S_EMG = 3'd5;然后用组合逻辑算出下一状态和下一状态的持续时间,时序逻辑负责状态寄存器和倒计时。这里有一个很重要的设计习惯:状态转移用的计数值,应该在“进入下一个状态的那一刻”就被赋值,而不是在状态内部慢慢累加。这样设计的好处是每个状态的持续时间一目了然,而不是靠一堆if else去猜当前状态已经跑了多久。
reg [4:0] cnt_time; reg [2:0] state_reg; reg [2:0] next_state; reg [4:0] next_time; always @(*) begin case (state_reg) S_NS_G: next_state = ped_req ? S_PED : S_NS_Y; S_NS_Y: next_state = S_EW_G; S_EW_G: next_state = ped_req ? S_PED : S_EW_Y; S_EW_Y: next_state = S_NS_G; S_PED: next_state = S_NS_G; S_EMG: next_state = S_NS_G; default: next_state = S_NS_G; endcase case (next_state) S_NS_G: next_time = 5'd30; S_NS_Y: next_time = 5'd5; S_EW_G: next_time = 5'd30; S_EW_Y: next_time = 5'd5; S_PED: next_time = 5'd15; S_EMG: next_time = 5'd10; default: next_time = 5'd30; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state_reg <= S_NS_G; cnt_time <= 5'd30; end else if (emergency_sync) begin state_reg <= S_EMG; cnt_time <= 5'd10; end else if (tick) begin if (cnt_time == 0) begin state_reg <= next_state; cnt_time <= next_time; if (next_state == S_PED) ped_req <= 1'b0; end else begin cnt_time <= cnt_time - 1'b1; end end end灯色输出不要用组合逻辑直接驱动,而是再做一级寄存器打拍,这样能避免状态切换瞬间组合逻辑产生的毛刺被直接送到LED上。实际工程里LED对毛刺不敏感,看起来问题不大,但这个习惯在驱动对时序敏感的芯片时非常重要。
3. 真正决定能不能上板的:按键消抖、紧急模式和行人请求
3.1 机械按键消抖:两级同步加计数
按键在按下和松开的瞬间,机械触点会产生几毫秒到几十毫秒的抖动,反映在电平上就是一段不稳定的毛刺。如果不做处理,按下一次按键,FPGA可能会检测到多次跳变,状态机就会连续跳好几个状态,看起来就像按键“失灵”了。
消抖的思路是:检测到电平变化后,开始计时,如果这个电平能稳定维持一段时间(通常取10到20毫秒),就认定这是一个有效的按键状态,否则重新计时。我的习惯是20毫秒,对应50MHz时钟就是100万个周期,计数器位宽需要20位。
消抖模块内部必须先做两级同步,这是应对亚稳态的标准做法。机械按键属于异步输入,直接接进always块里采样,存在建立时间和保持时间不满足的风险,轻则数据采错,重则把整个状态机带飞。两级同步虽然不能完全消除亚稳态,但能把它发生的概率降低到可忽略的程度。
代码实现上,我习惯把同步、计数、输出做在一个模块里:
module debounce #(parameter WAIT_CYCLES = 1_000_000)( input wire clk, input wire rst_n, input wire key_in, output wire key_out, output wire key_pulse ); reg [1:0] sync_reg; reg [19:0] cnt; reg key_stable; reg key_prev; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sync_reg <= 2'b11; cnt <= 0; key_stable <= 1'b1; key_prev <= 1'b1; end else begin sync_reg <= {sync_reg[0], key_in}; if (sync_reg[1] != key_stable) begin cnt <= 0; end else if (cnt != WAIT_CYCLES - 1) begin cnt <= cnt + 1'b1; end else begin key_stable <= sync_reg[1]; end key_prev <= key_stable; end end assign key_out = key_stable; assign key_pulse = key_stable & ~key_prev; endmodulekey_pulse是按键稳定后的单周期脉冲,非常适合用来做行人请求这种“触发一次就够”的场景。如果直接用key_out电平信号去触发,按键按多久就可能触发多少次,逻辑会很难控制。
3.2 紧急模式的优先级设计
紧急模式(比如救护车、消防车通过)要求无论当前是什么灯色,都立刻让路口进入全红状态。这个需求在状态机里体现为一个最高优先级的强制跳转,优先级要高于tick计数和正常状态转移。
实现方式并不复杂,在状态的时序逻辑最前面判断emergency_sync:
else if (emergency_sync) begin state_reg <= S_EMG; cnt_time <= 5'd10; end这里有个细节值得注意:emergency_sync是电平信号,意味着按下紧急模式按键期间,状态机会一直停留在S_EMG状态,不会退出。只有松开按键后,才会开始倒计时10秒,然后恢复到南北绿灯。这个行为是合理且有意的——它表示“紧急事件持续期间,路口始终保持全红”,符合真实场景的预期。
如果你希望一个脉冲就能锁定紧急状态,可以在外部加一个寄存器,把按键脉冲锁存成电平标志,等紧急状态走完再自动清除。这个留给你自己扩展,能把这个想明白,状态机的基本功就过关了。
3.3 行人请求按键:一个脉冲触发的状态插入
行人过街按钮的需求是:行人按下按钮后,当前方向的车流绿灯结束后,不是切换到正常的黄灯,而是先进入一个全红状态,让行人过街,然后再恢复到默认相位。
实现上用一个ped_req寄存器作为请求标志。按键消抖模块输出的key_pulse置位ped_req,状态机在S_NS_G或者S_EW_G状态判断到ped_req为真时,下一状态切换到S_PED,而不是正常切换到黄灯。当状态真正进入S_PED后,清零ped_req。
这里有一个很容易看走眼的点:ped_req的清零时机必须放在“进入S_PED的那一刻”,而不是放在“检测到ped_req为真的时候”。如果提前清零,组合逻辑算出来的next_state可能又变回原来的黄灯状态,导致行人相位永远进不去。这个问题的本质是组合逻辑和时序逻辑对信号变化时刻的感知不同,调试的时候可以用SignalTap同时抓state_reg和ped_req,一眼就能看出谁先谁后。
4. 仿真和实测:ModelSim波形与SignalTap采集
4.1 testbench:怎么造出1Hz的场景
交通灯一秒一秒地跳,如果testbench真的按50MHz跑完一个完整循环,仿真时间会非常长,等到天亮可能都看不到一次状态切换。所以我通常会把分频模块的计数值参数化,仿真时把DIV改小,比如从49_999_999改成49,这样每50个时钟周期产生一个tick,整个状态机在几微秒内就能完成多次状态切换。
testbench的核心就是产生时钟、复位、按键激励,然后把顶层模块的输出引出来看波形:
module tb; reg clk = 0; reg rst_n = 0; reg key_emergency = 1; reg key_ped = 1; wire [7:0] seg; wire [5:0] sel; wire ns_g, ns_y, ns_r, ew_g, ew_y, ew_r; traffic_light_top uut( .clk(clk), .rst_n(rst_n), .key_emergency(key_emergency), .key_ped(key_ped), .ns_g(ns_g), .ns_y(ns_y), .ns_r(ns_r), .ew_g(ew_g), .ew_y(ew_y), .ew_r(ew_r), .seg(seg), .sel(sel) ); always #10 clk = ~clk; initial begin #100 rst_n = 1; #5000 key_ped = 0; // 模拟按下行人来车请求 #200 key_ped = 1; #10000 key_emergency = 0; #200 key_emergency = 1; #10000 $stop; end endmodule仿真时验证的核心是几个点:复位后默认状态是不是南北绿灯;tick进来后计数是否递减;cnt_time归零时状态是否切换到黄灯;ped_req置位后状态机是否能在当前绿灯结束后进入S_PED。把这几个点确认了,上板的信心就有了大半。
需要提醒的是,仿真里看到的现象和实际硬件有差距。仿真默认是理想时序,不会出现按键抖动的毛刺,也不会有时钟偏斜。所以消抖模块在仿真里的表现只能证明逻辑通路正确,不能证明它在真实抖动下也能稳定工作。上板实测才是最终检验。
4.2 SignalTap抓内部信号时要注意被优化
SignalTap是Quartus里内置的逻辑分析仪,它在综合时会把探针信号插入到用户逻辑里,然后通过JTAG把采样数据回传到PC上显示。这比拿示波器去戳芯片引脚方便太多了,尤其是观察state_reg、cnt_time这样的内部寄存器。
新建.stp文件后,要添加要观测的信号。问题来了:综合器在优化逻辑时,可能会把一些寄存器优化掉,比如计数器的高位如果没有被用到外部输出,综合器可能对它做等价变换,导致SignalTap里看不到这个信号。解决办法是在Verilog源码里给这些寄存器加综合属性,告诉综合器不要动它们:
(* keep *) reg [2:0] state_reg; (* keep *) reg [4:0] cnt_time; (* keep *) reg ped_req;加了keep之后,这些寄存器会保留在网表里,SignalTap就能抓到了。另外采样深度不要开太大,256或者512足够看到几次状态切换,深度开大了会消耗大量的嵌入式存储块,对Cyclone II这种老器件来说资源本来就紧张。
SignalTap采样触发条件可以设置成cnt_time == 0,这样状态机每次跳转前都能抓到完整的当前状态和下一状态值,排查转移条件错误特别有效。
5. 引脚分配、数码管倒计时和那些翻车记录
5.1 引脚分配必须对照原理图,不能靠猜
凡是精心设计的状态机最后栽在引脚分配上的案例,我见过不止一次。LED灯不亮、数码管闪烁、按键没反应,很多时候不是代码问题,而是引脚分配压根不对。
每块开发板的LED、数码管、按键所连接的FPGA引脚都不尽相同,必须对照原理图来确定。比如某块板子的LED1接在PIN_87上,而且低电平有效,另一块板子可能就变成了PIN_88、高电平有效。不看原理图直接套网上别人的引脚约束文件,百分之百要翻车。
时钟引脚尤其要注意,必须接在FPGA的专用时钟输入引脚上,一般是CLK0到CLK15这些标号。如果随意接到一个普通IO口上,即使逻辑上能采到时钟,也会引入很大的时钟延迟和抖动,严重时会导致时序收敛不了。这类问题排查起来非常痛苦,因为不是逻辑错误,而是物理层面的约束错误,SignalTap都帮不上太多忙。
5.2 数码管动态扫描显示倒计时
交通灯如果只亮灯没有数字倒计时,总觉得少了点什么。给系统加上数码管显示,既能实时看到当前相位还剩几秒,又顺便练习一下动态扫描这个高频考点。
动态扫描的原理是利用人眼的视觉暂留效应,在极短的时间间隔内轮流点亮每一位数码管,同一时刻只有一位在显示,但因为切换频率够快(通常1kHz以上),人眼看起来就像所有位同时点亮。实现上需要一个稳定的刷新时钟,比如2kHz,然后用一个计数器轮流选通位选信号,同时把对应位的段码送到段选总线。
倒计时的数值来自cnt_time,需要拆成十位和个位两个BCD码,再经过七段译码得到段码。这里有个小技巧:如果cnt_time的初值是30,倒计时过程中十位可能是0,为了让高位数码管在值为0时熄灭而不是显示0,要加一个消隐逻辑。这个细节能让显示效果看起来专业很多,很多教程里不会提。
七段译码可以用一个case语句实现,把数字0到9映射到对应的段码。注意共阴和共阳数码管的段码是反的,开发板原理图上会标注,这个也要先确认,不然数字显示出来全是乱码。
5.3 我实际踩过的5个坑
第一个坑:计数器位宽不够。50MHz分频成1Hz需要计数到49999999,这个数用32位当然没问题,但如果图省事声明成16位,仿真时波形看起来正常,一综合就报越界错误。解决方法是先算好需要的最大位宽,或者直接用参数化代码,通过$clog2函数自动计算位宽。
第二个坑:复位极性搞反。开发板上的复位按键有的按下是低电平,有的是高电平,如果复位信号极性配反,整个系统会一直处于复位状态,灯全部不亮。上电后第一件事应该是用SignalTap抓一下rst_n的实际电平,分清楚是高有效还是低有效。
第三个坑:在always块里写延迟。有些新手为了让LED闪烁故意在时序逻辑里写#1000这种延迟语句,这在仿真里能工作,但综合时会被忽略或者直接报错。记住一条铁律:可综合代码里绝对不允许出现任何形式的延迟控制符,时间控制全靠计数器和状态机。
第四个坑:数码管扫描频率太低。刷新频率如果低于50Hz,人眼能明显看到闪烁。我之前一次把扫描周期做到了250ms,数码管就像在打暗号一样一闪一闪的。直接调整刷新时钟分频系数到2kHz左右就消失了。这个问题的本质是对“视觉暂留时间大约100ms”这个生理特性不敏感,知道了原理就能举一反三。
第五个坑:SignalTap观测不到内部信号。前面提到的keep属性需要加对位置。很多时候新手加在了模块例化处,而不是信号定义处,结果还是抓不到。keep要加在reg声明的那一行,Verilog 2001语法还支持在信号名后用(* *)的形式注记,写起来更直观。
交通灯这个项目做完之后,建议你再做两件事:一是把四相位扩展成带左转箭头的六相位路口,二是把固定配时改成根据车流量动态调整配时。这两个扩展方向分别对应了复杂状态机的拆分能力和状态机之间通信协调的能力,能把交通灯程序真正吃透,后续学PCIe或者DDR控制器不敢说直接上手,但至少不会被状态机困住手脚。
本文还有配套的精品资源,点击获取