简介:一份基于FPGA的交通灯控制器设计PDF,源自内蒙古工业大学本科毕业设计,面向电子、通信、自动化等专业的FPGA初学者及课程/毕业设计学生,旨在提供从需求分析到VHDL实现、仿真验证的完整参考方案。文档围绕四方向红黄绿交通灯控制展开,明确了红灯20秒、黄灯5秒、绿灯25秒的时序要求,并设计了紧急情况下的HOLD键全红处理;系统采用控制器、分频器、显示器、指示灯、译码器、位选器多模块结构,内容覆盖Quartus II工程创建、VHDL源程序编写、常见编译错误修正及波形仿真方法,并配有设计框图和时序图。压缩包内为单个PDF文档,大小2.88MB,图文结合、步骤清晰,适合作为数字系统课程设计或毕业设计的直接参照。目前已有4081人学习下载,尤其适合想要对照完整设计流程、提升FPGA实践能力的读者。
1. 这个项目到底在做什么:需求拆解与设计思路
1.1 先说清楚需求:控制器要管哪些灯
前几天有个刚接触FPGA的朋友找我,说课程设计要做交通灯控制器,问我这东西到底难不难。我直接告诉他:这大概是FPGA入门阶段最值得亲手做一遍的项目,因为它把数字逻辑里最核心的几个概念全串起来了——分频、计数器、状态机、译码显示,一样都没落下。
先看需求本身。一个标准十字路口,东西方向是主干道,南北方向是支干道。交通灯控制器要做的,就是按照固定时序让两个方向的灯按规则切换。最基础的状态有四个:主路绿灯放行、主路黄灯过渡、支路绿灯放行、支路黄灯过渡。我习惯给一组典型参数:主路绿灯30秒,黄灯5秒;支路绿灯20秒,黄灯5秒。这样一轮完整循环是60秒。
接下来是关键的设计决策:这个系统最核心的部分是什么?答案是有限状态机(FSM)。因为交通灯本质上就是一个“循环切换状态”的逻辑过程——当前状态持续固定时间后跳转到下一个状态,再继续计时,周而复始。这种“时序驱动状态跳转”的场景,就是状态机最典型的应用场合。你把状态图画出来,代码结构基本就定了一半。
1.2 为什么用FPGA做这件事:与单片机方案对比
可能有人会问,这玩意儿用单片机加延时函数不也能做吗?确实能做,但两种方案的思维方式完全不同。单片机是软件轮询,用延时函数实现时间控制时CPU一直在空转,写多路信号同时翻转的逻辑时还要小心中断优先级的影响。FPGA不一样,它本来就是硬件并行执行,所有信号的翻转都在同一个时钟沿驱动,相位关系由硬件天然保证,稳定性好得多。这也是很多学校拿FPGA做这个课设的原因——不是单纯为了做一个红绿灯,而是让你理解状态机思维和硬件并行逻辑,为后面做更复杂的协议解析、图像时序控制打基础。
如果你打算做这个项目,需要提前具备的知识大概有这么几块:Verilog语法基础、计数器与分频器、有限状态机写法、数码管动态扫描或者LED驱动。如果这些名词你还没接触过,也没关系,下面我会把每个模块的写法和为什么这样写讲清楚。
2. 核心模块与Verilog实现解析
2.1 系统时钟分频:从50MHz到1Hz
FPGA开发板上通常有一个板载晶振,常见的频率是50MHz。而我们的交通灯控制逻辑需要的基准时间是1秒,所以第一步就是分频。
最直接的想法是:数到25_000_000个时钟周期,输出翻转一次,这样就能得到1Hz的时钟信号。但这里有一个工程上的关键决策:我不建议你生成一个1Hz时钟然后拿它去驱动状态机。原因在于,在FPGA内部用逻辑生成的时钟叫“门控时钟”(gated clock),这种时钟在布线时会绕过全局时钟树,容易产生时钟偏移和毛刺,板级调试时经常莫名其妙出问题。
更稳健的做法是生成一个1Hz的时钟使能信号(tick脉冲),状态机仍然使用50MHz主时钟,只是在tick信号为高的那一个周期才进行状态跳转。这个方案等效于“1Hz驱动”,但时序质量要好得多。
给出分频器代码:
module clk_div_1hz ( input wire clk, // 50MHz 板载时钟 input wire rst_n, // 异步复位,低有效 output reg tick // 1Hz 使能脉冲 ); reg [24:0] cnt; // 计数 25_000_000 需要 25 位宽 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 25'd0; tick <= 1'b0; end else if (cnt == 25'd24_999_999) begin cnt <= 25'd0; tick <= 1'b1; end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end endmodule注意cnt的位宽:24_999_999换算成二进制需要25位,如果你写成23位或者24位,计数值超出位数上限后会回绕,分频结果就不对了。这个问题我在后面“踩坑实录”里还会细讲,因为它属于那种“看着代码没错、仿真波形却乱七八糟”的经典原因。
2.2 状态机设计:三段式结构
接下来是状态机的设计。状态定义和状态转移逻辑是这里的主菜。先看状态编码:
localparam S_GREEN_MAIN = 2'b00; // 主路绿灯,支路红灯 localparam S_YELLOW_MAIN = 2'b01; // 主路黄灯,支路红灯 localparam S_GREEN_SUB = 2'b10; // 支路绿灯,主路红灯 localparam S_YELLOW_SUB = 2'b11; // 支路黄灯,主路红灯状态编码我直接用了二进制编码,因为只有四个状态,没有必要用独热码(one-hot)去换取组合逻辑的简化,怎么简单怎么来。
状态转移逻辑我用三段式状态机来写,这是工程中比较推荐的结构。三段式把状态跳转、状态寄存器和输出逻辑分成三个always块,各司其职。看代码:
// 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S_GREEN_MAIN; else if (tick) state <= next_state; end // 第二段:组合逻辑,计算下一状态 always @(*) begin case (state) S_GREEN_MAIN : next_state = (cnt_sec >= 30) ? S_YELLOW_MAIN : S_GREEN_MAIN; S_YELLOW_MAIN: next_state = (cnt_sec >= 5) ? S_GREEN_SUB : S_YELLOW_MAIN; S_GREEN_SUB : next_state = (cnt_sec >= 20) ? S_YELLOW_SUB : S_GREEN_SUB; S_YELLOW_SUB : next_state = (cnt_sec >= 5) ? S_GREEN_MAIN : S_YELLOW_SUB; default : next_state = S_GREEN_MAIN; endcase end // 第二段里的 cnt_sec 是秒计数器,tick 为高时加1,状态切换时清零这里有个小细节需要注意:状态跳转依赖sec秒计数器,而sec的语义是“当前状态已持续秒数”。进入新状态时清零,tick到来时加1,当条件满足时跳转。这个“先加后比”还是“先比后加”的时序关系,写错一个条件就会导致每个状态多一秒或少一秒。我的经验是统一用“进入状态时清零,tick加1后与阈值比较”的写法,逻辑最直观,仿真也最容易对。
default分支一定要写。组合逻辑的case语句如果漏掉default,综合工具会推断出锁存器(latch),这是一类很难排查的隐患,后面会专门讲。
2.3 灯控输出与数码管倒计时
状态机负责“什么时候切到哪个状态”,输出逻辑负责“当前状态让哪些灯亮”。这部分我用了一个组合逻辑always块,把状态和灯控信号一一对应:
// 第三段:输出逻辑 always @(*) begin case (state) S_GREEN_MAIN : {red_main, yellow_main, green_main, red_sub, yellow_sub, green_sub} = 6'b001_100; // 主路绿,支路红 S_YELLOW_MAIN: {red_main, yellow_main, green_main, red_sub, yellow_sub, green_sub} = 6'b010_100; S_GREEN_SUB : {red_main, yellow_main, green_main, red_sub, yellow_sub, green_sub} = 6'b100_001; // 主路红,支路绿 S_YELLOW_SUB : {red_main, yellow_main, green_main, red_sub, yellow_sub, green_sub} = 6'b100_010; default : {red_main, yellow_main, green_main, red_sub, yellow_sub, green_sub} = 6'b111_111; endcase end注意每个状态里,同一个方向绝对不允许绿灯和红灯同时点亮——这是交通灯逻辑的基本底线。我见过不少初写代码的人,状态切换条件写错,导致主路刚变红灯的瞬间绿灯还亮着,这在真实场景里就是事故。所以我在编码时用六位变量一次性赋值,保证每个方向只有一个灯位有效,从代码结构上杜绝“同方向双灯同亮”的可能。
如果还要加数码管倒计时显示,就需要BCD码和动态扫描了。倒计时的显示数字跟当前状态的剩余秒数保持一致。比如主路绿灯还剩23秒,数码管就显示“23”。具体做法是:状态切换时把倒计时初值装载到计数器(比如主路绿灯装载30),之后每来一个tick减1,减到0时等待状态跳转。显示模块再把这个倒计时数值拆成十位和个位,通过动态扫描驱动数码管。
3. 仿真验证与板级调试实录
3.1 仿真先行:Testbench设计与波形观察
我自己的习惯是“先仿真、后上板”。别小看这步,很多新手急着下载到板子上看现象,结果灯不亮或者跳变不对,又不知道问题出在逻辑还是约束,排查效率特别低。仿真是把逻辑问题挡在上板之前最有效的手段。
Testbench的核心逻辑很直接:例化被测模块,生成时钟和复位信号,然后跑足够长的仿真时间观察状态跳转。以50MHz时钟为例,仿真30秒意味着要跑15亿个时钟周期,这在仿真器里会非常慢。我一般会把分频参数临时改小,比如把1秒对应的分频数从25_000_000改成25_000,也就是说每0.5毫秒等效真实1秒。这样仿真30秒也就1.5万个周期,瞬间就跑完了。逻辑结构没变,只是时间尺度缩小,验证的是状态跳转关系和输出逻辑,这个技巧非常实用。
仿真波形里重点看三样东西:第一,复位释放后状态是否从S_GREEN_MAIN开始;第二,每个状态的持续秒数是否符合预期;第三,状态切换瞬间灯控输出是否同时变化、有没有“同方向双灯同亮”的情况。如果这三点都对了,逻辑这块基本就稳了。
3.2 上板调试:管脚约束和电平极性
逻辑仿真通过后,下一步是上板。这时候你手里需要两块关键信息:开发板的原理图、约束文件模板。拿常见的EGO1开发板或者Nexys4 DDR这类板子举例,官方一般会提供完整的引脚约束模板,你只需把交通灯模块定义的信号名跟板上的LED灯、数码管引脚对应起来。约束文件里写的就是“信号名->引脚号”的映射关系。
这个环节有两个极易踩的坑。第一是引脚约束写错位,灯不亮或者亮的不是预期那盏灯,这种问题仿真看不到,只能靠核对原理图一条条排查。第二是电平极性,有些板子的LED是高电平点亮,有些是低电平点亮,如果你代码里写的是高电平点亮而板子是低电平有效,那所有灯都会“反着亮”——该灭的亮,该亮的灭,现象非常诡异。数码管也一样,共阳和共阴的段码表完全不同。这些细节必须在写约束和输出逻辑之前就确认清楚。
3.3 综合实现时的常见告警解读
上板之前还有一道工序是综合和实现。Vivado和Quartus这类工具在综合时会给出大量告警,初看很吓人,但有些告警可以直接忽略,有些必须处理。我总结了三类最常遇到的:
第一类是inferred latch,翻译过来叫“推断出锁存器”。出现这个告警,十有八九是组合逻辑的case语句漏写了default分支,或者你没把所有输出都赋值。锁存器在时序上会产生意想不到的保持行为,非常难查。我的做法是:所有组合always块里的case必须写default,所有被赋值的信号在每个分支都要赋值,一个都不能漏。
第二类是Multi-driven net,也就是同一个信号被多个always块赋值。这常见于初学者复制代码时,把分频器里的cnt跟状态机里的cnt用了同一个命名。这个问题会让综合直接报错或产生未知行为,排查方法是全局搜索信号名,确认每个信号只在唯一一个always块里被赋值。
第三类是分频相关的时间约束告警。如果你生成的是门控时钟(前面提到的不推荐做法),工具会报时钟相关约束不满足的告警。解决办法就是改用使能信号的写法,告警自然消失。
4. 踩坑实录:常见问题与排查技巧
4.1 状态跳转正常但灯不亮:管脚约束与电平极性
我曾经帮人调试过一个案例:仿真波形完美——状态跳转对、数码管显示对、灯控输出对,但下载到板子上后所有灯都不亮。我让他先查约束,他信誓旦旦说引脚都看过了没问题。后来我让他把约束文件发来,一眼就发现问题:他把LED的信号约束到了数码管的位选引脚上,而把数码管的段码约束到了LED引脚上。两个IO对调之后,现象就正常了。这事的教训是:约束文件一定要对照原理图逐条核对,别凭记忆写。另外,如果板子上某个灯凝固不动、其他灯正常闪烁,优先检查这个灯是不是被复用成了其他功能(比如某些板子把特定LED和按键复用了同一个引脚)。
4.2 倒计时显示不递减或跳变
数码管倒计时的显示异常,通常有几种原因。最常见的是动态扫描频率太低。动态扫描的原理是人眼余晖效应,数码管一位一位轮流点亮,只要切换频率够高,肉眼看就是同时亮的。如果扫描时钟太慢(比如低于50Hz),人眼就能分辨出闪烁感。一般建议扫描频率在1kHz以上。以4位数码管为例,每位点亮时间约1ms,一轮扫描4ms,刷新率是250Hz,肉眼看起来就很稳定了。如果显示数值乱跳,大概率是倒计时计数器的位宽不够,或者状态切换时初值装载和递减的时序没对好。我的写法是:进入新状态时把初值装载进去,下一个tick到来才开始递减,两者之间用状态寄存器隔开,避免“装载瞬间递减”的竞争。
4.3 按键控制(如紧急模式)无效:消抖问题
如果你在课设要求里加了“按键控制紧急通行”这类功能,那按键消抖是躲不掉的。机械按键在按下和松开的瞬间,电平不会干净地跳变,而是会经历几毫秒到几十毫秒的抖动。如果直接用按键信号驱动逻辑,状态机可能在一个按键动作里被触发好几次。最简单的消抖办法就是在FPGA里做延时判断:检测到按键电平变化后,先把信号延时20毫秒左右,再判断一次是否真的变化了。这个延时可以基于tick来实现,比如tick是1Hz就不太合适,最好有一路1kHz左右的脉冲信号用来计时。原理很简单,但是很多人第一次做会忘记这步,导致“按一下跳好几秒”。
4.4 分频计数器位宽溢出
聊一个看起来很低级但极其常见的坑:分频计数的位宽不够,导致仿真波形在某个时间点突然“归零重来”。比如计数到24_999_999需要25位位宽,有人图省事写成了reg[23:0],结果计数器计到16_777_215就溢出回0,分频比完全错误。如果你仿真时发现波形每隔十七秒左右规律性地乱跳,第一反应就该去查计数器的位宽。我建议养成好习惯:所有计数器都按最终计数上限估算位宽,宁多勿少,反正寄存器资源用不完。
5. 完成之后还能往哪里走
5.1 增加传感器与紧急优先级
基础功能做完后,可以往“智能交通”方向扩展。比如加一个车辆检测传感器,支路有车等待时缩短主路绿灯时间,主路有车流时延长绿灯;再加上紧急车辆优先模式,当急救车或消防车通过时,通过一个输入信号把所有方向强制切到红灯或绿波带。这些功能的核心还是状态机,只是增加了输入信号和抢占逻辑,非常适合用来练手状态机的复杂跳转。
5.2 用PWM调亮度,做成夜晚模式
交通灯在夜晚其实不需要全亮度运行。你可以用PWM(脉冲宽度调制)去控制LED的亮度,夜晚降低占空比,白天提高占空比。PWM的实现本质上又是一个计数器——计数到周期值后输出翻转,占空比决定亮灯比例。这个扩展并不复杂,但能让你的项目在答辩时多一个亮点,也顺便把PWM这个在FPGA里极其常用的知识点练熟。
5.3 往图像处理和SoC方向进阶
再往后,就是跟“FPGA图像处理”和“ZYNQ嵌入式”这类话题接轨了。比如挂一个摄像头统计车流数量,根据车流量动态调整信号灯时长,这就涉及图像采集、帧缓存和简单的车流检测算法。如果你用的是ZYNQ这类带ARM CPU的FPGA平台,还可以让PS端跑调度算法、PL端保持实时灯控,两边通过AXI总线交换数据,这基本就是一个Mini版智慧交通原型了。这块的复杂度比纯逻辑高不少,但确实是把FPGA技能从“会写状态机”提升到“能搭小系统”的很好路径。
整个项目做下来,我对它最深的体会是:交通灯控制器不只是一个“入门练手课设”,它把FPGA开发里最核心的思维——状态机思维、时序思维、并行思维——都浓缩在一个看得见摸得着的例子里。如果你做完之后能不看参考代码、从头默写一遍这个设计,那我可以说,你的FPGA入门阶段算是真正过关了。
本文还有配套的精品资源,点击获取