☰
基于VHDL的交通灯设计:状态机、时钟分频与FPGA避坑指南
2026/10/14 1:29:05 网站建设 项目流程

如果你的数字电路课或者EDA课程有一个必做实验,八成离不开这个题目:基于VHDL的交通灯设计。它看起来实在不算难——无非是东西方向绿灯亮几十秒,南北方向红灯跟着亮几十秒,再换过来,周而复始。但真上手你就会发现,一个完整数字系统该有的元素它全都有:时钟分频、有限状态机、计数器、数码管动态扫描、同步时序、仿真验证,再加上下载到FPGA开发板之后各种奇奇怪怪的硬件问题。这篇文章就来聊聊我做这个实验反复踩坑后总结的一些经验,希望给刚入门的同学一点参考,也给要带实验的朋友提供一份避坑清单。

1. 问题拆解与选型:别急着写代码,先把需求定清楚

1.1 一个最小可用的交通灯控制器要实现什么

很多人拿到题目就开始写VHDL代码,写一半发现状态定义不清楚、倒计时显示不知道接哪、复位逻辑和灯信号对不上。问题基本出在需求没有先量化。做工程的第一步永远是把行为描述清楚,再想怎么用代码实现。以最常见的十字路口模型为例,一套最小可复用的功能指标可以这样定义:

  • 四组灯:东西方向一组(红、黄、绿),南北方向一组(红、黄、绿)。
  • 正常时序:东西绿灯亮20秒,然后黄灯亮5秒;南北方向在这25秒内保持红灯。接着南北绿灯亮20秒、黄灯亮5秒,东西方向保持红灯。循环往复。
  • 倒计时显示:两组数码管分别显示东西、南北方向的剩余秒数,方便观察。
  • 异步复位:按下复位键后,系统回到初始状态,即“东西绿灯、南北红灯”。

这里的时间参数不是死的,课程要求可能是主路30秒、支路15秒,也可能要求带左转箭头灯。但最小模型先跑通,后面加复杂功能都是在这个框架上做增量。为什么把东西方向设为25秒周期、南北方向也是25秒周期?因为两个方向周期一致、相位差半周期,实现最简洁,状态机的状态数量最少,波形验证也直观。如果你一上来就设计四个方向各自不同的绿灯时间,不是不行,但状态切换和倒计时显示的计算会麻烦很多,教学演示完全没必要。

1.2 为什么这个实验默认用VHDL而不是Verilog

现在Verilog在产业界使用更广泛,但高校课程里“基于VHDL的交通灯设计”依然是经典题目,原因其实很实际。VHDL是强类型语言,信号赋值、端口类型、库引用都很严格,写错的地方编译器能提前拦住一大半;它的实体(entity)和结构体(architecture)分离,非常贴合硬件设计的“引脚封装 + 内部逻辑”思想,对初学者理解模块化设计帮助很大。另外大量已有的实验箱、参考代码、课程资料都是VHDL写的,生态延续性很强。

我的建议是不要纠结语言之争。VHDL语法约束多,写起来确实比Verilog“啰嗦”,但正是这种啰嗦让你在写每一行之前多想想它对应什么硬件结构。交通灯这个体量,用VHDL写核心逻辑大概一百来行,刚好是熟悉语法和培养硬件直觉的好素材。等你把状态机、分频器、显示扫描这些基本招数学会了,以后切到Verilog或者SystemVerilog,也就是一个语法迁移的问题。

1.3 方案选型:状态机优于“超级计数器”

有的同学会这样想:时间都是顺序走的,我直接用一个能计到60秒的计数器,在计数值落在不同区间时让对应的灯亮,不也行吗?确实能跑,但这是典型的“能用”和“好用”之间的差距。用大计数器控制所有现象,时间参数会以魔法数字的形式散落在代码各处,比如“cnt < 500_0000”是绿灯时段,“cnt > 500_0000 and cnt < 625_0000”是黄灯时段,一旦要调整时长,你得重新算一整组边界,非常容易错。后面如果再加夜间模式、紧急全红模式,这种写法基本要推翻重来。

更合理的方式是有限状态机。交通灯本质上是“在一组时间序列控制下反复切换输出状态”的系统,状态机模型就是为这件事而生的。每个状态表示一组灯的组合,状态内只关心“当前已经维持了多久”和“什么时候切走”,逻辑层次非常清楚。代码量和大计数器方案差不多,但可读性、可维护性、扩展性完全不在一个量级。这也是为什么工业界的控制逻辑,比如电梯、自动售货机、家电主控,几乎清一色用状态机实现,而不是靠一堆计数器到处设阈值。

2. 整体架构拆解:交通灯系统的三个核心模块

2.1 顶层模块怎么划分

一个干净的设计,顶层尽量保持简单,内部按功能切成几个部分。我习惯这样划分:

  • 时钟分频模块(clk_div):把开发板上的高频时钟(常见的是50MHz)变成系统需要的“秒使能脉冲”和“数码管扫描使能”。
  • 主控制状态机(traffic_fsm):根据当前状态和秒使能脉冲进行状态切换,同时输出红黄绿灯信号。
  • 倒计时数据生成(count_down):根据当前状态输出各组剩余时间,用于数码管显示。
  • 数码管显示模块(seg_display):接收倒计时的BCD码或二进制数,做动态扫描,输出段码和位选信号。

顶层实体端口一般包括:系统时钟clk、复位rst_n、东西方向三色灯输出、南北方向三色灯输出,以及数码管的段选和位选输出。这样划分的好处是每个模块都能独立仿真、独立替换。比如你觉得数码管扫描写得有问题,直接单独测试seg_display模块,不用把整个交通灯系统都拉出来遛一遍。模块之间用清晰命名的信号连接,顶层不需要知道内部细节,这本身就是硬件工程里很重要的解耦思想。

2.2 状态机设计:四个状态和两种编码

主状态机我的建议是定义四个状态,名字取得能看懂,不要用裸数字:

状态东西方向南北方向持续时间
S_EW_GREEN绿灯亮红灯亮20秒
S_EW_YELLOW黄灯亮红灯亮5秒
S_NS_GREEN红灯亮绿灯亮20秒
S_NS_YELLOW红灯亮黄灯亮5秒

状态从S_EW_GREEN开始,按顺序走到S_NS_YELLOW,然后回到S_EW_GREEN,首尾相接循环。这是最标准的“摩尔型状态机”,输出只取决于当前状态,不取决于输入,用VHDL实现时用case语句对每个状态赋值灯信号即可。

状态编码有两种常见选择:二进制编码(00、01、10、11)和独热码One-Hot(1000、0100、0010、0001)。四种状态用二进制编码最省触发器;独热码的状态寄存器位数更多,但组合逻辑简单、时序更好,适合状态数量较多的场景。对交通灯这个实验,两种编码差异不大,我更推荐二进制编码,代码直观,综合报告也更干净。关键是不要用一个std_logic_vector信号硬编码来表示状态,一定要用自定义枚举类型,这样case语句里写的是状态名,不是看不懂的“10”或“01”。

2.3 秒脉冲生成:为什么“使能脉冲”比“1Hz时钟”靠谱

这是整个实验里我认为最值得讲的一个经验。很多人写分频器,第一反应是:板载时钟50MHz,我要1秒的时间基准,那就分频出一个1Hz的时钟信号,把它接到状态机的时钟端口。这个方案在仿真里能跑通,但在真实FPGA上埋了不少雷。

直接分频出的1Hz信号在FPGA内部走的是普通布线资源,不是专用时钟网络;综合工具不会把它当成系统时钟来建树,时序约束也不好做。真实场景里时钟偏斜、毛刺、跨时钟域问题都可能在现场偶发出现。更关键的是,“用分频后的低频时钟作为状态机时钟”这种做法,会让你的全部状态转移都依赖一个非理想时钟,这是个坏味道。

正确做法是什么呢?保持系统时钟始终是板载高频时钟,在这个统一时钟域里产生一个“秒使能脉冲en_1s”。这个脉冲平时为0,每到整数秒拉高一个时钟周期,然后立刻回到0。时钟还是原来的50MHz,状态机在en_1s为1的那个上升沿更新计数或切换状态。这样所有逻辑都在同一个时钟域里,综合工具处理起来非常干净,时序收敛也稳定。

以50MHz时钟为例,1秒是50,000,000个时钟周期。计数器从0数到49,999,999,正好一个循环,在第50,000,000个周期回到0并输出一个高位脉冲。代码框架如下:

library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity clk_div is port ( clk : in std_logic; rst_n : in std_logic; en_1s : out std_logic ); end clk_div; architecture rtl of clk_div is signal cnt : integer range 0 to 49999999 := 0; begin process(clk, rst_n) begin if rst_n = '0' then cnt <= 0; en_1s <= '0'; elsif rising_edge(clk) then if cnt = 49999999 then cnt <= 0; en_1s <= '1'; else cnt <= cnt + 1; en_1s <= '0'; end if; end if; end process; end rtl;

这个代码里的计数器上限是49,999,999,不是50,000,000,因为计数从0开始。如果板子时钟是100MHz,就把边界改成99,999,999;如果是12MHz等非标频率,先算周期再填数字,不要想当然。我见过不少同学这里少写一个9,或者把边界直接填成50_000_000,结果仿真里时间轴怎么看怎么不对。

2.4 主控制状态机的核心代码

有了秒使能脉冲,状态机写起来就非常顺畅了。内部需要一个计数器记录当前状态已经维持的秒数,每次en_1s拉高就加1,加到该状态的持续时间上限后切换状态并清零计数。核心骨架类似这样:

type state_type is (S_EW_GREEN, S_EW_YELLOW, S_NS_GREEN, S_NS_YELLOW); signal state : state_type := S_EW_GREEN; signal sec_cnt : integer range 0 to 30 := 0; signal en_1s : std_logic; process(clk, rst_n) begin if rst_n = '0' then state <= S_EW_GREEN; sec_cnt <= 0; elsif rising_edge(clk) then if en_1s = '1' then case state is when S_EW_GREEN => if sec_cnt = 19 then state <= S_EW_YELLOW; sec_cnt <= 0; else sec_cnt <= sec_cnt + 1; end if; when S_EW_YELLOW => if sec_cnt = 4 then state <= S_NS_GREEN; sec_cnt <= 0; else sec_cnt <= sec_cnt + 1; end if; -- 其余状态类似 when others => state <= S_EW_GREEN; sec_cnt <= 0; end case; end if; end if; end process;

注意“sec_cnt = 19”代表第20个秒脉冲来时切换,不是“sec_cnt = 20”。因为sec_cnt从0开始,0到19一共20个值。很多人这里会出“多亮1秒”或者“少亮1秒”的偏差,检查时优先看边界条件。

灯信号输出可以用另一个组合逻辑进程实现,用case语句对每个状态分别赋值。这个进程只做输出映射,不处理时序,是整个设计里最容易理解也最容易出问题的地方。最容易踩的坑是case分支覆盖不全,或者没有对所有输出信号做默认赋值,导致综合器“推断”出锁存器latch。锁存器在时序分析里是个麻烦东西,明明仿真看起来没问题,下载到板子上表现却不稳定。后面第4章我会专门讲这个问题怎么排查。

注意:组合逻辑进程里,建议在case之前先给所有输出信号赋一个默认值,比如先让所有灯为灭(或按安全原则先让红灯亮),再在case里覆盖。这样既可以避免锁存器,又能保证状态切换瞬间不会出现“所有灯全灭”或者“绿灯和黄灯同时亮”的危险状态。

3. 实操全过程:从仿真、约束到开发板跑通

3.1 Testbench怎么写才能快速验证

代码写完先别急着上板,仿真验证是第一步。Testbench其实不算太难,但有几个细节直接影响验证效率。

时钟产生一个标准的做法是用generate循环,比如50MHz时钟的周期是20ns:

clk_process : process begin clk <= '0'; wait for 10 ns; clk <= '1'; wait for 10 ns; end process;

复位信号要保证至少覆盖一个完整时钟周期,让异步复位逻辑能够被正确触发。如果你用低电平复位rst_n,就先把rst_n拉低几个周期,再拉高释放。这里特别提醒:复位极性和代码里的if条件必须一一对应,代码写“if rst_n = '0'”,测试平台却把复位默认拉高,仿真波形就会一片红X,这是新手最常见的第一道坎。

还有一个非常实用的技巧:验证状态机完整轮换时,不需要真的仿真20秒。仿真时间是虚拟的,20秒看着不长,但在波形窗口里翻看会累。更高效的办法是把持续时间做成generic参数,或者临时把秒使能逻辑改成毫秒级,验证完再改回来。我常用做法是在顶层模块里加一个speed参数,正常模式用1秒使能,仿真模式用1毫秒使能,一套Testbench就能重复用。

3.2 引脚约束和下载:别让代码死在最后一步

仿真全绿之后,上板才是真正考验。很多同学下载到板子上发现灯不亮或者乱亮,第一反应是代码有问题,重新仿真半天发现逻辑完全正确,最后查来查去是引脚约束没写对。

FPGA开发板的每个LED、按键、晶振都连接到芯片特定的物理引脚,你必须根据开发板原理图把这些引脚名字或编号填进约束文件。比如系统时钟通常接在某个专门的时钟引脚,LED可能通过74系列芯片或直接接FPGA的IO,按键按下可能是低电平也可能是高电平。这些信息每个板子都不一样,一定要以板子自带的原理图和例程为准。

约束文件的写法取决于厂商工具链。Xilinx新工具链用XDC,旧工具链用UCF,Intel的Quartus则是在QSF或SDC里做引脚分配。无论哪种,核心就是把端口名和物理引脚绑定。我去过很多次实验室,发现最容易出问题的点往往不是某个工具的语法,而是“想当然”。看到LED低电平点亮是共阳接法,却用默认的高电平点亮逻辑;看到复位按键按下是低电平,却用if rst_n = '0'逻辑。这些只要照着原理图核对一遍就能避免。

下载到板子之后,正常现象应该是初始状态东西绿灯亮、南北红灯亮,然后周期轮换。如果你发现灯亮但顺序不对,或者数码管乱跳,先不要改逻辑,用万用表或者逻辑分析仪量一下FPGA引脚的实际电平,和期望值对比,很快就能定位是输出逻辑问题还是外部电路问题。

3.3 数码管倒计时显示:动态扫描的正确打开方式

如果实验要求有倒计时显示,数码管这块是绕不开的。四位数码管显示两组秒数,本质是一个动态扫描的过程:任何时候只点亮其中一位,以足够快的速度循环点亮四位,人眼视觉暂留效应会感觉四个数字同时亮着。

动态扫描频率选择很关键。太高,位选切换太快,可能出现重影和拖尾;太低,肉眼能明显看到闪烁。我建议扫描周期在1KHz到2KHz之间,也就是每一位停留500微秒到1毫秒。实现思路可以再用一个计数器产生扫描使能,每来一个扫描周期就让位选信号切换到下一位,同时把这一位对应的数字转换成段码送出去。

这里有一个非常影响显示效果的细节:位选切换时先灭掉段码,再切换位选,再点亮下一位。否则上一位的段码会短暂泄漏到下一位,产生“鬼影”现象。用代码来说就是先让段码全灭,再改变位选信号,最后输出新的段码。这个顺序很重要,很多人数码管显示乱、拖影,问题就出在这里。

段码表也要看板子的共阴共阳接法。共阴数码管“0”的段码通常是0x3F,共阳则是0xC0,高四位和低四位的接线顺序也可能不一样。不要在网上随便抄一个段码表就往上套,最稳的办法是拿一根杜邦线手动让某一段点亮,确认段位和引脚对应关系后再填表。

4. 常见问题与排查技巧实录

4.1 仿真波形全是X,状态机完全不滚

这是我在实验课上遇到频率最高的问题,没有之一。原因基本是复位没生效或者复位极性搞反。Testbench里没有给复位信号赋初值,或者赋了初值但在整个仿真过程中没有翻转,状态机的初始状态就是未定义,所有信号自然显示为X。解决方法是检查Testbench中复位信号的时序,确保先进入复位态,再释放复位。另外检查代码里异步复位的敏感列表是否完整:如果你用了异步复位,process语句的敏感列表里必须同时包含clk和rst_n,漏掉任何一个,复位逻辑都可能是错的。

4.2 综合报告出现latch,状态机像丢了魂

锁存器是组合逻辑进程里的“幽灵”。仿真时ModelSim/Vivado Simulator能给出符合预期的波形,但综合后生成的硬件结构和仿真模型并不一致,导致上板行为异常。出现latch的典型场景是:case语句没有覆盖所有分支,if语句没有写else,或者组合进程的敏感列表漏了输入信号。排查方法很简单,综合完成后查看报告里的warning,搜索“latch”,一旦发现“inferred latch for signal xxx”,就要回头修代码。修法就是在组合进程开头给所有输出信号赋默认值,或者把case补上others分支,让每个状态下所有输出都有确定值。

4.3 下载到板子完全不工作或者灯乱亮

综合下载后不工作,优先怀疑三件事:引脚约束有没有对应、复位极性对不对、LED点亮极性是共阳还是共阴。我见过最经典的案例是代码本身完全正确,但复位引脚约束错了一个位置,按下复位键实际控制的是另一颗LED引脚,系统自然不响应。另一个高频问题是FPGA芯片时钟引脚在原理图上用特殊符号标注,有些人把它当成普通IO约束,结果时钟根本进不来。这时候不要一上来就怀疑逻辑,先用开发板自带LED闪烁例程验证板子和下载环境是否正常,再逐步替换成自己的代码,分阶段定位问题。

4.4 数码管闪烁、乱码、拖影

如果倒计时数字自己会跳变、闪烁,先检查动态扫描频率是否太低;如果是数字错乱,重点检查段码表是否匹配共阴共阳,以及位选信号是否写反;如果是拖影,基本是没做“先灭段、再换位、再送段”的处理。下面这个表基本能覆盖常见现象:

现象常见原因解决办法
仿真全是X复位无效或极性错误、敏感列表不完整检查Testbench复位时序、补全敏感列表
上板不工作引脚约束错误、时钟引脚不对、下载文件不完整核对原理图约束、用LED例程验证环境
灯乱亮LED极性接反、输出逻辑出现latch检查共阳/共阴、修组合进程默认赋值
数码管闪烁扫描频率太低提高扫描使能频率到1KHz以上
数码管乱码段码表错误、位选极性反验证段位、按板子丝印调整
数码管拖影未消隐直接切位先灭段码再切换位选再送新码

把这个表贴在实验记录本里,遇到问题先对号入座,基本能省掉一半排查时间。

5. 把实验做成作品:扩展方向与个人体会

5.1 三个低成本扩展方向

基础交通灯跑通之后,其实有很多值得尝试的扩展玩法,既能加深理解,又能让实验报告更好看。第一个是加入左转箭头灯,把状态从四个增加到七个或八个,每次直行、左转分开发放。第二个是加夜间模式,用一个拨码开关或者按键切换,夜间模式下所有方向黄灯同时闪烁;这个模式在状态机里实现只多一个状态,但能演示模式切换的逻辑。第三个是加车流量感应,用两个按键模拟传感器,当某个方向检测到“有车等待”时,按比例延长该方向绿灯时间。这几个方向都不需要改动底层框架,只是增加状态和条件判断,非常适合做课程设计的提升部分。

如果还想玩得更深一点,可以把倒计时数据从原来的二进制改成BCD码,让数码管显示更自然;也可以把显示模块改成LCD1602液晶屏,滚动显示各方向剩余秒数和当前状态文字。LCD驱动本身就是一个很有价值的练习,写一次到处能用。

5.2 做了很多次之后的一点经验

这个实验我从学生时代做到后来带人做,前前后后经历了不少版本。最大的体会是:先画状态转换图,再写代码,这句话听起来像废话,但真正做到的人不多。交通灯的状态机逻辑不复杂,可是不画图直接开写,很容易在“黄灯和红灯是否同时亮”“绿灯切换瞬间要不要全红过渡”这些小细节上纠缠不清。把状态图、时间表、输出表先画好,代码基本就是翻译的过程,写起来快,错了也容易对照修改。

另外一个特别想说的是:仿真通过只是第一关,上板跑起来才是真的会了。数字逻辑里太多问题是仿真模型和真实硬件行为之间的差异,比如时钟分配、信号延迟、引脚极性、板子噪声。遇到问题别慌,按照“先环境、后约束、再逻辑”的顺序排查,大多数问题都能在一个小时内定位。

最后,工程习惯这件事越早养成越好。文件名、实体名、信号名要起得表意清晰,关键逻辑要加注释,状态条件要用常量而不是魔法数字。这些习惯短期内看起来浪费时间,等到代码量上来、或者隔一段时间再回来看自己的设计时,才知道当年那些“顺手写的注释”和“勤快的模块拆分”有多值钱。交通灯实验是一个很小的系统,但如果你能认认真真走完“定需求—画状态—写代码—仿真—上板—调bug”这整个流程,它对硬件设计的理解帮助比单纯刷一百道题都大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询