FPGA交通灯控制器设计:从状态机到上板调试全解析

📅 发布时间:2026/9/6 18:52:10
FPGA交通灯控制器设计:从状态机到上板调试全解析
简介基于FPGA的交通灯控制器设计PDF面向电子工程、嵌入式及FPGA初学者讲解如何用VHDL在QuartusⅡ中实现四方向红黄绿交通灯控制包含分频器、译码器、位选器等模块设计可解决交通信号时序控制与紧急状态处理问题。资源为单个PDF文档共2.88MB属于本科毕业设计说明书内容完整已有4081人学习。文档从QuartusⅡ软件操作讲起涵盖工程创建、芯片选择、VHDL编程、编译仿真等环节并给出东西/南北方向各25秒周期、红绿黄灯交替及HOLD紧急全红的设计方案适合课程设计或毕业设计参考。 我记得当年课程设计第一次拿到这个题目心里想“不就是红绿灯嘛三个 case 就完事了”——结果上板那一刻倒计时从 40 直接跳到 38黄灯和红灯同时亮整个路口成了一锅粥。后来我花了一周时间把分频、状态机、数码管扫描整个重写才真正明白这个看起来“简单”的项目里藏了多少工程细节。本文就围绕基于 FPGA 的交通灯控制器设计把从需求分析、模块划分、Verilog 实现、仿真验证到上板调试的完整思路讲清楚。文章适合正在做课设、毕设的同学也适合刚接触 FPGA 想找第一个正经项目的自学者。我不会只丢一个能跑的代码而是要把每个模块为什么这么写、哪一步最容易翻车、怎么在仿真阶段提前发现隐患都讲明白。1. 交通灯控制器这个题目为什么这么多年还不过时1.1 它恰好覆盖了 FPGA 开发的半壁江山很多人选这个题目是因为“简单”但做过之后会发现它几乎把 FPGA 入门阶段最重要的几个技能点全部串起来了状态机设计、同步时序逻辑、计数器分频、组合逻辑与时序逻辑混合建模、七段数码管动态扫描、按键消抖、异步信号同步处理以及配套的 Testbench 仿真方法。这些基本功恰恰是 FPGA 工程师日常工作中每天都在用的东西。一个能把交通灯控制器做得扎实的人再去看 UART、SPI、I2C 这类接口协议思路会顺很多因为底层都是“状态 时序 计数”这三个要素在转。反过来说如果只是把灯点亮就交差那就浪费了这个题目。一个完整、规范的交通灯控制器应该是模块分层清晰、参数化设计、仿真覆盖做全、上板行为稳定的作品。这才是这篇文章想帮你达到的水平。1.2 动手之前先把路口需求和接口规格问清楚写 FPGA 代码和写单片机程序有个很大的区别单片机程序可以从功能出发边写边改FPGA 更强调“先定接口、再谈逻辑”。交通灯控制器看似需求明确但实际上面临很多细节选择路口是十字形还是 T 字形这决定了需要几组灯。每个方向有没有专门的左转箭头灯如果有状态数量直接翻倍。倒计时显示在哪里是每个方向一组数码管还是只在主路和支路各一组有没有传感器输入比如车流量检测、紧急车辆优先请求是否需要夜间模式比如深夜自动切到黄灯闪烁按键复位的有效电平是什么上电默认应该进入哪个状态这些问题在动手前不敲定后面返工的成本很高。我自己第一次做的时候就因为没确认数码管位数把硬件接线看错了结果一个位选的脚接错整个显示全乱。建议你拿到题目后先画一张接口框图把输入输出信号的名字、位宽、极性、作用全部列出来再开始写代码。这个习惯放到真实项目中也是通用的。2. 写代码前先做架构设计模块划分与时钟规划2.1 顶层模块拆分每个模块只干一件事交通灯控制器虽然规模不大但我不建议把全部逻辑堆在一个顶层文件里。按功能拆成四个模块是比较清晰的每个模块的职责单一、接口明确分频模块clk_div把板载时钟分频出 1Hz 的秒脉冲 tick供状态机和倒计时使用。按键处理模块debounce_sync对紧急模式按键进行同步与消抖输出干净的脉冲信号。状态机模块traffic_fsm维护当前路口状态输出主路和支路的红黄绿灯色并给出倒计时初值。数码管显示模块seg_display接收倒计时值通过动态扫描在四位七段数码管上显示。顶层模块只做例化与连线。这样做的好处是调试时能单独把某一模块拿出来仿真定位问题非常快。比如数码管显示乱跳先在 Testbench 里单独测seg_display就不用一遍遍上板改布局。2.2 分频链设计:50MHz到1Hz并不是一行代码的事常见开发板时钟是 50MHz要得到 1Hz 的秒脉冲就是每 50_000_000 个时钟周期输出一个 tick。很多人第一反应是“计数到 50_000_000 翻转”但如果计数器位宽不够就会像我开头提到的那样倒计时直接从 40 跳到 38。这里要用参数化和 tick 两个思路来设计。所谓 tick不是分频出一个 50% 占空比的方波而是每秒钟产生一个单时钟周期的高电平脉冲。这样下游模块只需要检测这个脉冲就行了逻辑更简单。module clk_div #( parameter CLK_FREQ 50_000_000 ) ( input wire clk, input wire rst_n, output reg tick_1s ); reg [25:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 26d0; else if (cnt CLK_FREQ - 1) cnt 26d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) tick_1s 1b0; else if (cnt CLK_FREQ - 1) tick_1s 1b1; else tick_1s 1b0; end endmodule注意这里cnt的位宽50_000_000 的二进制表示需要 26 位所以reg [25:0]。如果你用的是 100MHz 时钟计数上限是 100_000_000同样要保证位宽足够。这个参数化设计还有个好处仿真的时候可以把CLK_FREQ改成一个小值比如 10让时序跑得飞快波形一眼就能看完整个状态周期。2.3 状态机选型Moore型在这类控制器里有天然优势交通灯控制器的核心是状态机。状态机分成 Moore 型和 Mealy 型两种区别在于输出是否依赖输入。对交通灯这个场景我建议用 Moore 型灯色输出只由当前状态决定与外部输入无关。这样综合出来的逻辑更稳定不容易出现因为输入毛刺导致灯色乱跳的情况。定义四个常规状态加一个紧急状态S_MAIN_GREEN主干道绿灯支干道红灯持续 40 秒。S_MAIN_YELLOW主干道黄灯支干道红灯持续 5 秒。S_BRANCH_GREEN支干道绿灯主干道红灯持续 25 秒。S_BRANCH_YELLOW支干道黄灯主干道红灯持续 5 秒。S_EMERGENCY紧急模式两个方向全红持续 10 秒。状态转移的顺序是 S_MAIN_GREEN → S_MAIN_YELLOW → S_BRANCH_GREEN → S_BRANCH_YELLOW → S_MAIN_GREEN形成一个闭环。这里有一个容易忽略的安全细节黄灯期间另一方向必须保持全红不能提前变绿。很多初次设计者会在主干道黄灯结束时直接让支干道变绿但如果黄灯状态本身设计在状态机里这个问题就不存在因为切换只会发生在黄灯时间计数完成后。3. 核心代码实现状态切换、倒计时与七段数码管驱动3.1 状态机与灯色输出逻辑状态机的核心思想是“当前状态 秒脉冲 倒计时归零 → 切换到下一个状态”。倒计时模块和状态机模块需要协同工作我通常把倒计时初值和状态值的对应关系放在组合逻辑里让代码更易读localparam S_MAIN_GREEN 3d0; localparam S_MAIN_YELLOW 3d1; localparam S_BRANCH_GREEN 3d2; localparam S_BRANCH_YELLOW 3d3; localparam S_EMERGENCY 3d4; reg [2:0] state, next_state; // 状态寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_MAIN_GREEN; else if (emg_pulse) state S_EMERGENCY; else if (tick_1s (cnt_remain 6d1)) state next_state; else state state; end // 次态译码 always (*) begin case (state) S_MAIN_GREEN: next_state S_MAIN_YELLOW; S_MAIN_YELLOW: next_state S_BRANCH_GREEN; S_BRANCH_GREEN: next_state S_BRANCH_YELLOW; S_BRANCH_YELLOW: next_state S_MAIN_GREEN; S_EMERGENCY: next_state S_MAIN_GREEN; default: next_state S_MAIN_GREEN; endcase end灯色输出用组合逻辑实现只依赖当前状态reg [2:0] main_light, branch_light; // {R, Y, G} always (*) begin case (state) S_MAIN_GREEN: begin main_light 3b001; branch_light 3b100; end S_MAIN_YELLOW: begin main_light 3b010; branch_light 3b100; end S_BRANCH_GREEN: begin main_light 3b100; branch_light 3b001; end S_BRANCH_YELLOW: begin main_light 3b100; branch_light 3b010; end S_EMERGENCY: begin main_light 3b100; branch_light 3b100; end default: begin main_light 3b100; branch_light 3b100; end endcase end这里灯色的编码是高位到低位依次为 R、Y、G高电平有效。具体到开发板上是直接驱动 LED 还是经过其他逻辑需要对照原理图确认极性不同的板子可能不一样。3.2 倒计时计数器与BCD换算倒计时模块用一个 6 位寄存器cnt_remain即可覆盖 40 秒以内的时长。状态切换时载入对应时长秒脉冲到来时减一always (posedge clk or negedge rst_n) begin if (!rst_n) cnt_remain 6d40; else if (state ! next_state) cnt_remain load_value; // 进入新状态的同一拍装载 else if (tick_1s (cnt_remain 6d1)) cnt_remain cnt_remain - 1b1; else if (tick_1s (cnt_remain 6d1)) cnt_remain load_value; // 归零后立即装载下一个状态的值 else cnt_remain cnt_remain; endload_value需要用组合逻辑根据当前状态译码比如 S_MAIN_GREEN 时装载 40S_MAIN_YELLOW 时装载 5。这里有一个细节不建议用一个独立的“状态切换标志”信号去触发装载直接用state ! next_state判断更简洁但前提是next_state是纯组合逻辑译码出来的否则会产生毛刺。倒计时显示还需要把十进制数拆成 BCD 码也就是十位和个位assign tens cnt_remain / 10; assign ones cnt_remain % 10;在 FPGA 里除法虽然会被综合成组合逻辑但除 10 这种小数值电路不会太大。如果你有性能洁癖也可以改成 case 查表不过对于这种显示场景没有必要。3.3 数码管扫描显示别被动态扫描坑了交通灯倒计时通常用两位数码管显示主路和支路各两个合起来就是四位。为了省引脚开发板普遍使用动态扫描方式也就是同一时刻只点亮一位通过快速轮换制造视觉残留。扫描频率是关键。我见过有人直接把 1Hz 的时钟拿来扫描结果数码管剧烈闪烁。动态扫描频率建议在 1kHz 到 4kHz 之间也就是每 1 到 4 毫秒切一位。以 50MHz 为例可以分频出 2kHz 的扫描使能信号。reg [14:0] scan_cnt; wire scan_clk_en; always (posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt 15d0; else if (scan_cnt 25_000 - 1) scan_cnt 15d0; else scan_cnt scan_cnt 1b1; end assign scan_clk_en (scan_cnt 25_000 - 1);每次扫描使能到来切换一次位选信号an并更新段选数据seg_data。显示内容根据当前位选择对应的倒计时来源主路十位、主路个位、支路十位、支路个位。这里要注意位选切换和段选数据必须同步更新否则会出现“上一位的数据残留在下一位上”的拖影效果。解决方法是先准备好数据再使能位选。4. 仿真验证把看不见的内部节点拉到眼前4.1 Testbench 怎么写才贴近真实仿真不是把 Testbench 跑起来就算完而是要能回答“这个设计到底对不对”。我写 Testbench 时一般会做三件事产生时钟和复位、模拟真实外部输入按键、传感器、用$display和波形文件记录关键信号。交通灯控制器的 Testbench 开头通常长这样timescale 1ns / 1ps module tb_traffic_light; reg clk; reg rst_n; reg key_emergency; wire [5:0] main_light; wire [5:0] branch_light; wire [6:0] seg_data; wire [3:0] an; initial begin clk 1b0; forever #10 clk ~clk; // 50MHz end initial begin rst_n 1b0; key_emergency 1b0; #100; rst_n 1b1; #500; // 测试在第60秒左右按下紧急模式 #30_000_000_000; // 如果仿真时间太长可调整分频参数缩短 key_emergency 1b1; #100; key_emergency 1b0; #50_000_000; $finish; end traffic_light_top dut ( .clk(clk), .rst_n(rst_n), .key_emergency(key_emergency), .main_light(main_light), .branch_light(branch_light), .seg_data(seg_data), .an(an) ); endmodule注意我特意提到“可通过参数调整缩短仿真时间”。真实设计中如果你把CLK_FREQ参数设为 10那 40 秒倒计时只需要 400 个时钟周期就能仿真完效率高得多。这也是参数化设计的另一个好处。4.2 最值得测的几类边界场景仿真不能只看正常流程边界情况才是 bug 的重灾区。我建议至少覆盖下面几类复位后的初始状态复位释放后主干道应为绿灯倒计时从 40 开始支干道全红。状态切换瞬间主干道绿灯倒计时到 1再经过一个 tick 后变为黄灯倒计时变为 5此时支干道必须还是红灯不能提前变绿。紧急模式中断在非主干道绿灯期间按键按下状态应进入紧急全红倒计时从 10 开始结束后恢复主干道绿灯。连续运行多个周期至少完整跑 2 到 3 个周期确认状态闭环正确、倒计时没有跳变。这些场景写进 Testbench 后每次改动代码都可以重新跑一遍做回归避免“修好一个 bug 又引入另一个 bug”。4.3 从波形里能看出什么门道打开波形文件后不要只盯着main_light看。我更推荐把state、cnt_remain、tick_1s、seg_data、an这些内部信号全部加进来观察。重点观察三个地方tick_1s 是否每隔固定周期出现一个单脉冲。如果是 50MHz 时钟和参数 50_000_000脉冲间隔应该是 1 秒如果用了可调参数也要确认波形周期符合预期。cnt_remain 是否在状态切换那一拍正确装载。很多初学者的倒计时会出现“新状态已经切过去但倒计时还是上一状态的残余值”的情况本质是装载时序和状态切换没有对齐。数码管扫描波形里位选信号 an 和段选数据 seg_data 是否同步变化。如果数据比位选晚半拍实际显示就会有拖影。仿真阶段发现的绝大多数问题都可以在几分钟内定位。如果跳过这一步直接上板一个波形问题可能要花一个小时甚至更久去折腾。5. 上板调试仿真不会告诉你的那些事5.1 按键消抖和异步信号同步当你把按键接到 FPGA 的引脚上时会发现一个仿真里根本不存在的问题按键按下和释放的瞬间电平会抖动几十毫秒。如果不做消抖紧急模式可能在一次按压中被触发多次状态机乱跳。常规做法是“两级同步 计数消抖”。两级同步的目的是把异步输入信号跨到 FPGA 时钟域避免亚稳态传播。计数消抖的思路是检测到电平变化后连续采样一段时间如果电平稳定不变才认为按键有效。reg [1:0] sync_ff; always (posedge clk or negedge rst_n) begin if (!rst_n) sync_ff 2b00; else sync_ff {sync_ff[0], key_in}; end同步之后的sync_ff[1]还需要做消抖。最简单的办法是用 1kHz 左右的慢时钟采样连续 20 次读到同一个电平才更新按键状态。20ms 的消抖时间对机械按键来说足够了。消抖后再做一次上升沿检测输出一个单脉冲emg_pulse而不是持续的高电平否则状态机在紧急模式下会反复触发切换。5.2 分频参数位宽和复位释放这个坑我在开头提过这里再展开讲当计数器计数到CLK_FREQ - 1时如果计数器位宽不够会在中间某一位溢出。比如只有 16 位位宽的计数器最大值是 65535永远无法数到 50_000_000结果就是分频周期完全不对倒计时快得离谱。所以分频模块的位宽一定要至少满足CLK_FREQ的对数级别。如果拿不准直接多预留几位比如 26 位覆盖绝大多数开发板时钟。另一个坑是复位释放瞬间。上电后如果你的复位是高电平有效而板子的按键复位是低电平有效那要么加反相器要么在约束文件里改电平标准。否则会出现“板子一上电状态机处于随机态倒计时乱跳”的现象。我的习惯是复位信号统一在顶层处理所有子模块的复位极性保持一致并且在 Testbench 里测过复位释放后的第一个状态。5.3 数码管亮度、驱动能力与扫描频率动态扫描虽然省引脚但有个天然缺陷亮度比静态显示低因为每个数码管只有约 1/4 的时间在亮。如果扫描频率太高比如高于 10kHz数码管的寄生电容会让显示模糊太低则会明显闪烁。2kHz 左右是比较折中的选择。另外一个容易踩的坑是驱动能力。FPGA 的引脚输出电流有限如果直接用引脚驱动多位共阳数码管亮度和均匀性都会受影响。很多开发板上已经加了三极管或驱动芯片这时候你只需要查原理图确认极性。如果自己做板子建议在每位的公共端加 PNP 三极管驱动并给段选加上限流电阻否则数码管容易过流发烫。调试时我有个小技巧先用静态显示的方式固定显示一位确认段选和位选极性都正确再改成动态扫描。这样可以把“硬件连线错误”和“逻辑错误”两个变量分开排查。6. 从课设到工程这个项目还能怎么扩展6.1 加入车流量检测与自适应配时交通灯控制器做完基本功能后最有价值的扩展方向是让配时“活”起来。你可以在路口加装红外对射传感器或者简单的光电传感器当检测到支干道有车等待时如果主干道已经放行超过最小时间就提前切换绿灯。更进一步可以在 FPGA 里对一段时间内的车流量做统计动态调整主干道和支干道的绿灯时长这就是自适应交通灯的基本雏形。很多团队还会在这个场景里用到卡尔曼滤波对车流量做平滑估计避免传感器噪声导致配时频繁抖动。当然这是后话但思路是对的FPGA 擅长并行计数和实时响应把传感器数据接入后做统计、滤波、决策正是它的主战场。6.2 与 Zynq 或 STM32 联动做更复杂的控制如果你拿到的是 Zynq 这类带 ARM 软核的芯片或者手头有 STM32 可以通过 FMC 接口与 FPGA 通信那就可以把上层调度逻辑放进 CPUFPGA 专注做底层 IO 和实时控制。比如 CPU 通过 UART 下发配时参数FPGA 根据参数动态加载状态机的时长。这样交通灯就从“固定配时”变成了“可配置、可远程监控”的系统。从工程角度看这种软硬协同的架构更有延展性把所有配时参数做成寄存器映射CPU 端写一个简单的驱动程序上位机再通过串口下发指令。虽然复杂度上了一个台阶但能帮你积累总线协议、中断、寄存器读写这些在真实项目中高频出现的技能。6.3 工程化习惯把代码当成产品来维护无论你的交通灯控制器最终是用来交课程作业还是后续继续扩展我都建议养成几个工程化习惯。模块命名统一所有信号名能一眼看出含义版本管理用 Git哪怕只有一个人开发也可以方便回溯代码里重要逻辑一定要写注释尤其是状态转移条件和装载时序每做完一个阶段就把仿真环境固化下来方便持续回归。这几点看似不起眼但在你后续接触更大规模 FPGA 工程、需要多人协作时真的能救命。我自己见过太多人写完代码三个月后自己都看不懂当时为什么这么写因为没有任何注释和文档。做完这个项目之后我再回头看那些更复杂的 FPGA 设计比如 PCIe 接口、图像处理、高速数据采集底子里的状态机设计、时钟域处理、异步信号同步这些思路其实都是在交通灯这种基础项目里建立起来的。所以别小看它把每一个细节都吃透比赶进度做完更有价值。本文还有配套的精品资源点击获取