ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA交通灯设计:硬实时状态机与确定性时序实现

FPGA交通灯设计:硬实时状态机与确定性时序实现

简介:本资源是一套基于FPGA实现的十字路口交通信号灯控制系统完整工程,面向数字电路与Verilog初学者、嵌入式系统课程设计学生及FPGA入门实践者,解决交通灯时序控制、多状态协同与数码管动态倒计时等典型数字系统设计问题。压缩包共248个文件,5.5MB,涵盖Verilog源码(.v)、约束文件(.xdc)、综合/实现/仿真脚本(.tcl/.bat)、日志与报告(.log/.rpt)、IP配置(.pb/.wdf)及烧录所需bit文件等,结构完整,支持Vivado全流程开发。已有4395人学习下载,工程已通过综合验证(含__synthesis_is_complete__标记),提供elaborate.bat、simulate.bat等一键执行脚本,便于快速复现;倒计时逻辑严格遵循主干道直行60秒→左转40秒、支干道直行45秒→左转30秒、黄灯5秒过渡的国标时序,代码模块划分清晰,含顶层控制、计时器、状态机与数码管驱动等可复用子模块,适合教学演示、课程实验与项目参考。

1. 为什么十字路口信号灯不能只靠单片机?FPGA方案在实时性、确定性和多路并发上的硬核价值

某高校交通控制实验室曾用STM32驱动一个四相位十字路口模型:东西直行+左转、南北直行+左转,共8个LED灯组+2个倒计时数码管。初期一切正常,但当加入车流量模拟传感器(红外对射+地磁)并尝试动态调整绿灯时长后,系统开始出现“绿灯已灭但车辆还在通行”“倒计时跳变卡顿”“黄灯闪烁频率忽快忽慢”等现象。根本原因不是代码写错,而是单片机在中断嵌套、定时器重载、GPIO翻转时序上存在微秒级抖动——而交通信号灯的相位切换必须满足严格的时间确定性:黄灯3秒±50ms、红灯与绿灯之间必须有全红间隔(清空路口)、相位切换时刻误差不能超过10ms,否则可能引发仿真事故甚至真实场景下的冲突风险。

FPGA在这里不是“炫技”,而是解决一个本质问题:用硬件并行逻辑固化时序,把“软件跑定时器”变成“电路走时钟边沿”。它不依赖CPU调度、无中断延迟、每个IO口可独立配置上升/下降沿触发、所有状态转换由同步时钟驱动,天然适配交通灯这种强周期、多状态、高可靠需求的控制任务。本方案面向嵌入式初学者和数字电路进阶者,不依赖特定开发板(支持Xilinx Artix-7、Intel Cyclone IV等主流入门型号),从状态机建模、时钟分频、LED驱动到按键消抖全部手写Verilog,不调用IP核,确保你能看清每一拍信号怎么来、怎么走、怎么稳。如果你正被“单片机做交通灯总差那么一口气”困扰,或想真正理解“硬件描述语言如何落地为物理行为”,这篇就是为你写的实操笔记。


2. 用Verilog从零搭建有限状态机:十字路口的6个核心相位与2个强制安全状态

交通灯不是简单循环“红→绿→黄”,而是存在明确的相位约束逻辑。我们按国标常见规范定义6个主相位 + 2个安全兜底状态,全部用同步时序逻辑实现,杜绝毛刺和亚稳态。

2.1 相位定义与转移条件:为什么必须加“全红间隔”和“黄闪过渡”

相位编号名称东西方向南北方向持续时间触发条件安全意义
S0东西通行绿+左绿红30s上电复位 / 全红结束主通行相位
S1东西黄闪黄红3sS0计时结束提醒停止,非紧急停车
S2全红清空红红2sS1结束强制插入,消除冲突
S3南北通行红绿+左绿30sS2结束主通行相位
S4南北黄闪红黄3sS3计时结束提醒停止
S5全红清空红红2sS4结束强制插入,消除冲突
S6故障黄闪黄(1Hz)黄(1Hz)持续按键长按3s / 计时器溢出异常系统降级,人工接管准备
S7夜间模式红黄(1Hz)持续拨码开关置1低流量时段节能运行

提示:S2和S5的“全红清空”不是可选项——它是防止东西直行车辆未完全通过时南北左转车启动导致碰撞的关键设计。很多初学者省略此步,结果仿真波形看似正确,一上板就发现LED状态跳变混乱,根源就在状态转移缺少同步握手。

2.2 Verilog状态机编码:三段式写法保稳定,避免latch推导

// 三段式FSM:时序逻辑+组合逻辑+输出逻辑分离 // module top_traffic(clk, rst_n, btn_manual, sw_night, led_out, seg_out); // clk: 50MHz系统时钟;rst_n: 低电平复位;btn_manual: 手动切换按键(带消抖);sw_night: 夜间模式拨码 // 1. 时序逻辑:状态寄存器更新(同步复位) always @(posedge clk or negedge rst_n) begin if (!rst_n) current_state <= S0; else current_state <= next_state; end // 2. 组合逻辑:下一状态译码(纯组合,无时序) always @(*) begin case (current_state) S0: if (cnt_s0 == MAX_S0) next_state = S1; else next_state = S0; S1: if (cnt_s1 == MAX_S1) next_state = S2; else next_state = S1; S2: if (cnt_s2 == MAX_S2) next_state = S3; else next_state = S2; S3: if (cnt_s3 == MAX_S3) next_state = S4; else next_state = S3; S4: if (cnt_s4 == MAX_S4) next_state = S5; else next_state = S4; S5: if (cnt_s5 == MAX_S5) next_state = S0; else next_state = S5; S6: next_state = S6; // 故障态锁死 S7: next_state = S7; // 夜间态锁死 default: next_state = S0; endcase end // 3. 输出逻辑:根据当前状态生成LED和数码管信号(同步输出,防毛刺) always @(posedge clk or negedge rst_n) begin if (!rst_n) begin led_out <= 8'b1111_1111; // 全灭 seg_out <= 8'hFF; // 数码管全灭 end else begin case (current_state) S0: begin led_out <= {4'b0001, 4'b1100}; // 东:绿左绿, 南:红红红红 → 实际接线按位定义 seg_out <= disp_time(cnt_s0); end S1: begin led_out <= {4'b0010, 4'b1111}; // 东:黄, 南:红 seg_out <= disp_time(cnt_s1); end S2: begin led_out <= {4'b1000, 4'b1000}; // 全红 seg_out <= 8'h00; end S3: begin led_out <= {4'b1100, 4'b0001}; // 东:红红, 南:绿左绿 seg_out <= disp_time(cnt_s3); end S4: begin led_out <= {4'b1111, 4'b0010}; // 东:红, 南:黄 seg_out <= disp_time(cnt_s4); end S5: begin led_out <= {4'b1000, 4'b1000}; // 全红 seg_out <= 8'h00; end S6: begin led_out <= {4'b0101, 4'b0101}; // 东西南北全黄闪(1Hz) seg_out <= 8'h00; end S7: begin led_out <= {4'b1000, 4'b0101}; // 东全红,南黄闪 seg_out <= 8'h00; end default: led_out <= 8'b1111_1111; endcase end end

关键参数说明:

  • MAX_S0= 50_000_000 × 30 = 1,500,000,000(30秒@50MHz),实际工程中会拆分为多级计数器防综合失败
  • disp_time()是数码管动态扫描函数,将倒计时数值转为7段码+位选信号,需配合1kHz扫描时钟
  • LED输出采用{4'b0001, 4'b1100}格式,高位4bit为东西方向(D0=东直行绿,D1=东左转绿,D2=东黄,D3=东红),低位4bit为南北方向(同理),物理接线必须与代码严格一致

为什么不用一段式或两段式?
一段式易产生锁存器(latch),在FPGA中导致不可预测功耗和时序违规;两段式组合逻辑输出易受毛刺干扰。三段式将状态转移、条件判断、输出驱动彻底隔离,是工业级交通控制的底线写法。


3. 时钟分频与倒计时设计:如何让50MHz晶振精准输出3秒黄灯和30秒绿灯

FPGA开发板常用50MHz或100MHz晶振,但交通灯需要秒级、毫秒级精确定时。直接用大计数器(如1.5G次计数)不仅综合慢、资源占用高,且一旦计数器位宽超限会导致溢出错误。必须采用多级分频+预置计数器架构。

3.1 两级分频:先降到1Hz,再用1Hz驱动状态计时器

// 第一级:50MHz → 1Hz(精度0.00002%) reg [25:0] div_cnt_1s; reg clk_1s; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt_1s <= 0; clk_1s <= 0; end else begin if (div_cnt_1s == 24_999_999) begin // 50M / 2 = 25M,再/25M=1Hz div_cnt_1s <= 0; clk_1s <= ~clk_1s; end else div_cnt_1s <= div_cnt_1s + 1; end end // 第二级:1Hz → 状态计时器使能(避免在高频下做秒级计数) reg [31:0] cnt_s0, cnt_s1, cnt_s2, cnt_s3, cnt_s4, cnt_s5; always @(posedge clk_1s or negedge rst_n) begin if (!rst_n) begin cnt_s0 <= 0; cnt_s1 <= 0; cnt_s2 <= 0; cnt_s3 <= 0; cnt_s4 <= 0; cnt_s5 <= 0; end else begin case (current_state) S0: if (cnt_s0 < MAX_S0_SEC) cnt_s0 <= cnt_s0 + 1; else cnt_s0 <= 0; S1: if (cnt_s1 < MAX_S1_SEC) cnt_s1 <= cnt_s1 + 1; else cnt_s1 <= 0; S2: if (cnt_s2 < MAX_S2_SEC) cnt_s2 <= cnt_s2 + 1; else cnt_s2 <= 0; S3: if (cnt_s3 < MAX_S3_SEC) cnt_s3 <= cnt_s3 + 1; else cnt_s3 <= 0; S4: if (cnt_s4 < MAX_S4_SEC) cnt_s4 <= cnt_s4 + 1; else cnt_s4 <= 0; S5: if (cnt_s5 < MAX_S5_SEC) cnt_s5 <= cnt_s5 + 1; else cnt_s5 <= 0; default: ; endcase end end

参数设定逻辑:

  • MAX_S0_SEC = 30(绿灯30秒),MAX_S1_SEC = 3(黄灯3秒),MAX_S2_SEC = 2(全红2秒)
  • 所有计数器均为32位,足够覆盖最长999秒倒计时(远超实际需求)
  • 关键点:计时器只在对应状态内累加,状态切换时自动清零,避免跨状态计数错误

3.2 倒计时数码管显示:动态扫描防鬼影,BCD码转7段码

数码管需1kHz以上扫描频率才能肉眼无闪烁。我们用独立的1kHz时钟驱动位选,用当前状态倒计时值驱动段码:

// 1kHz扫描时钟(从50MHz分频) reg [15:0] div_cnt_1k; reg clk_1k; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt_1k <= 0; clk_1k <= 0; end else begin if (div_cnt_1k == 49_999) begin // 50M / 50k = 1kHz div_cnt_1k <= 0; clk_1k <= ~clk_1k; end else div_cnt_1k <= div_cnt_1k + 1; end end // 四位数码管动态扫描(假设共阴极,位选低有效) reg [3:0] seg_sel; reg [7:0] seg_data; always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) begin seg_sel <= 4'b1110; // 选第0位 seg_data <= 8'h00; end else begin case (seg_sel) 4'b1110: begin seg_data <= bcd_to_seg({4'd0, cnt_sec[7:0]}); seg_sel <= 4'b1101; end // 个位 4'b1101: begin seg_data <= bcd_to_seg({4'd0, cnt_sec[15:8]}); seg_sel <= 4'b1011; end // 十位 4'b1011: begin seg_data <= bcd_to_seg({4'd0, cnt_sec[23:16]}); seg_sel <= 4'b0111; end // 百位 4'b0111: begin seg_data <= bcd_to_seg({4'd0, cnt_sec[31:24]}); seg_sel <= 4'b1110; end // 千位 default: seg_sel <= 4'b1110; endcase end end // BCD转7段码(共阴极,a-g对应bit6-bit0,dp=bit7) function [7:0] bcd_to_seg; input [3:0] bcd; begin case (bcd) 4'h0: bcd_to_seg = 8'b00000011; // a-g亮,dp灭 → 0 4'h1: bcd_to_seg = 8'b10011111; // b,c亮 → 1 4'h2: bcd_to_seg = 8'b00100101; // a,b,d,e,g → 2 4'h3: bcd_to_seg = 8'b00001101; // a,b,c,d,g → 3 4'h4: bcd_to_seg = 8'b10011001; // b,c,f,g → 4 4'h5: bcd_to_seg = 8'b01001001; // a,c,d,f,g → 5 4'h6: bcd_to_seg = 8'b01000001; // a,c,d,e,f,g → 6 4'h7: bcd_to_seg = 8'b00011111; // a,b,c → 7 4'h8: bcd_to_seg = 8'b00000001; // a-g全亮 → 8 4'h9: bcd_to_seg = 8'b00001001; // a,b,c,d,f,g → 9 default: bcd_to_seg = 8'b11111111; // 全灭 endcase end endfunction

注意:cnt_sec需从各状态计数器(cnt_s0等)中取当前值,经BCD转换后送显。若直接送二进制会导致数码管显示乱码——这是新手最常踩的坑之一。


4. 按键消抖与模式切换:硬件去抖比软件延时更可靠,3种物理输入的处理策略

FPGA面对机械按键,必须解决抖动问题。常见误区是“用10ms延时等待稳定”,但FPGA没有delay(10),且单纯计数延时无法应对长按、连击等场景。正确做法是用状态机实现硬件级消抖,并区分短按(切换相位)、长按(进入故障态)、双击(夜间模式)。

4.1 硬件消抖状态机:20ms采样窗口+3次连续确认

// 按键消抖模块(输入key_in,输出key_valid,高电平有效) reg [19:0] key_cnt; // 20ms计数器(50MHz下=1M计数) reg key_sync1, key_sync2; // 两级同步防亚稳态 reg key_debounced; reg [1:0] deb_state; // 同步采样 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync1 <= 0; key_sync2 <= 0; end else begin key_sync1 <= key_in; key_sync2 <= key_sync1; end end // 消抖状态机 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_cnt <= 0; deb_state <= 0; key_debounced <= 0; end else begin case (deb_state) 0: begin // 等待按键按下(低电平) if (!key_sync2) begin key_cnt <= 0; deb_state <= 1; end end 1: begin // 计数20ms,确认持续按下 if (key_cnt == 1_000_000 - 1) begin if (!key_sync2) begin key_debounced <= 1; deb_state <= 2; end else deb_state <= 0; end else key_cnt <= key_cnt + 1; end 2: begin // 等待释放 if (key_sync2) begin key_cnt <= 0; key_debounced <= 0; deb_state <= 0; end end endcase end end assign key_valid = key_debounced; // 消抖后干净信号

为什么必须两级同步?
按键信号来自异步域(机械开关),直接接入FPGA时钟域会因建立/保持时间不满足导致亚稳态,表现为按键“有时响应有时不响”。两级寄存器同步是FPGA设计铁律,缺一不可。

4.2 模式切换逻辑:长按3秒触发故障态,双击切换夜间模式

// 长按检测(3秒) reg [25:0] long_cnt; reg long_press; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin long_cnt <= 0; long_press <= 0; end else begin if (key_valid) begin if (long_cnt == 149_999_999) begin // 3s@50MHz long_press <= 1; long_cnt <= 0; end else long_cnt <= long_cnt + 1; end else begin long_cnt <= 0; long_press <= 0; end end end // 双击检测(两次按键间隔<500ms) reg [23:0] dbl_cnt; reg key_last; reg dbl_click; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_last <= 0; dbl_cnt <= 0; dbl_click <= 0; end else begin if (key_valid && !key_last) begin // 下降沿检测 if (dbl_cnt < 24_999_999) // <500ms dbl_click <= 1; else dbl_click <= 0; dbl_cnt <= 0; end else if (!key_valid) dbl_cnt <= dbl_cnt + 1; key_last <= key_valid; end end // 主控逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin mode_night <= 0; mode_fault <= 0; end else begin if (long_press) mode_fault <= 1; // 长按进故障态 if (dbl_click) mode_night <= ~mode_night; // 双击切夜间 if (sw_night) mode_night <= 1; // 拨码强制夜间 end end

血泪经验:不要用“按键松开后延时判断是否长按”,这会导致响应滞后。必须在按键按下期间持续计时,松开即刻生效——这才是真实交通控制面板的操作手感。


5. FPGA引脚约束与上板调试:Vivado中.tcl约束文件编写、Signal Tap抓波形、3个致命避坑指南

写完代码只是第一步,能否在开发板上稳定运行,取决于引脚分配、时序约束和调试手段。很多开发者卡在这一步,反复烧录却LED不亮,其实是约束文件写错了。

5.1 引脚约束文件(.xdc)编写:必须指定IO标准、驱动强度、压摆率

以Digilent Nexys A7(Artix-7)为例,关键约束如下:

# 时钟约束(必须!否则综合工具无法优化时序) create_clock -period 20.000 -name sys_clk [get_ports clk] set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets clk] # LED约束(共阳极,低电平点亮,需指定IOSTANDARD) set_property PACKAGE_PIN U16 [get_ports {led_out[0]}] # 东直行绿 set_property IOSTANDARD LVCMOS33 [get_ports {led_out[0]}] set_property DRIVE 8 [get_ports {led_out[0]}] set_property SLEW SLOW [get_ports {led_out[0]}] # 按键约束(主动低,需上拉) set_property PACKAGE_PIN T18 [get_ports btn_manual] set_property IOSTANDARD LVCMOS33 [get_ports btn_manual] set_property PULLUP true [get_ports btn_manual] # 数码管位选(共阴极,低电平选中) set_property PACKAGE_PIN U13 [get_ports {seg_sel[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {seg_sel[0]}] # 数码管段码(a-g, dp) set_property PACKAGE_PIN V10 [get_ports {seg_data[0]}] # a set_property PACKAGE_PIN V11 [get_ports {seg_data[1]}] # b set_property PACKAGE_PIN U12 [get_ports {seg_data[2]}] # c # ... 其他段码依此类推

关键点:

  • DRIVE 8表示8mA驱动能力,匹配LED限流电阻(通常220Ω)
  • SLEW SLOW降低边沿速率,减少EMI干扰,避免相邻信号串扰
  • PULLUP true确保按键未按下时为高电平,符合消抖逻辑

5.2 Signal Tap逻辑分析仪抓波形:验证状态机跳变和计时器清零

Vivado自带Signal Tap,无需外接逻辑分析仪。添加信号步骤:

  1. 在Synthesis后,点击Tools → Logic Analyzer → Open New Logic Analyzer
  2. 添加信号:current_state,cnt_s0,cnt_s1,key_valid,clk_1s
  3. 设置触发条件:current_state == S0 and cnt_s0 == 29(绿灯最后1秒)
  4. Run Trigger,观察波形是否在30秒整点跳变至S1,且cnt_s0归零

现象:若cnt_s0未归零,说明状态转移逻辑有误(如next_state赋值时机不对);若current_state跳变毛刺多,说明消抖未起作用或时钟域不同步。

5.3 避坑指南:3个让90%新手翻车的致命细节

现象1:上电后LED全亮或全灭,按键无反应
原因:引脚约束中漏写了PULLUP true,导致按键输入悬空,key_valid随机震荡;或IOSTANDARD写成LVDS等不匹配电平。
解决:用万用表测按键引脚电压,未按下时应为3.3V;检查.xdc文件中所有get_ports名称是否与Verilog端口名完全一致(大小写敏感)。

现象2:数码管显示数字跳变、鬼影严重
原因:动态扫描时钟clk_1k未约束,综合工具将其优化为组合逻辑,导致频率不稳;或seg_sel未用寄存器打一拍,毛刺直接传到位选引脚。
解决:在.xdc中添加create_clock -period 1000.000 -name seg_clk [get_ports clk_1k];seg_sel输出前加一级寄存器同步。

现象3:全红相位只有1秒,黄灯只亮2秒
原因:计数器比较值MAX_Sx_SEC写错(如MAX_S1_SEC = 2而非3),或状态机中cnt_s1在S1状态外被意外清零。
解决:用Signal Tap抓cnt_s1波形,确认其在S1内从0累加到3后归零;检查always @(posedge clk_1s)块中是否有多余的else分支清零了计数器。


6. 进阶技巧:用ILA核在线调试状态机、添加车流量自适应逻辑、从Verilog迁移到VHDL的3个转换要点

做到上板稳定运行只是起点。真正的工程能力体现在可维护性、可扩展性和可验证性上。这里分享三个实战中高频使用的进阶技巧。

6.1 用ILA(Integrated Logic Analyzer)替代Signal Tap:实时观测内部信号,不占用户IO

Signal Tap需占用FPGA引脚,而ILA核集成在PL端,通过JTAG在线抓取任意内部信号,且支持深度存储(如4096点)。添加步骤:

  1. 在Block Design中,Add IP → Debug Core → ILA
  2. 右键ILA →Connect to Debug,勾选要观测的信号(current_state,cnt_s0,key_valid)
  3. 设置触发条件:current_state == 2'b01 and cnt_s0 == 29(S1相位第29秒)
  4. 生成Bitstream后,Open Hardware Manager → Run Trigger

优势:ILA可设置多级触发(如“先等S0结束,再等S1第29秒”),Signal Tap仅支持单级;ILA数据可导出CSV供Matlab分析时序抖动。

6.2 添加车流量自适应逻辑:用计数器统计红外对射脉冲,动态调整绿灯时长

真实路口需根据车流调节。我们在东西向加2路红外对射(ir_east_in,ir_east_out),南北向同理:

// 车辆计数模块(上升沿检测) reg ir_east_in_d0, ir_east_in_d1; wire ir_east_in_pos = (~ir_east_in_d1) & ir_east_in_d0; always @(posedge clk) begin ir_east_in_d0 <= ir_east_in; ir_east_in_d1 <= ir_east_in_d0; end // 30秒内计数(与S0同步) reg [15:0] car_cnt_east; always @(posedge clk_1s or negedge rst_n) begin if (!rst_n) car_cnt_east <= 0; else if (current_state == S0 && ir_east_in_pos) car_cnt_east <= car_cnt_east + 1; end // 动态绿灯时长:基础30s + 每10辆车+1s(上限45s) wire [7:0] green_adj = (car_cnt_east >= 10) ? ((car_cnt_east / 10) > 15) ? 8'd15 : (car_cnt_east / 10) : 8'd0; assign MAX_S0_SEC = 30 + green_adj;

注意:car_cnt_east必须在S0相位内累加,S1开始即清零,否则会累积历史数据。实际项目中还需加防抖滤波(如要求连续3次脉冲才计1车)。

6.3 Verilog到VHDL迁移要点:3个必须重写的语法陷阱

很多学校课程先教VHDL,而企业多用Verilog。若需迁移,注意:

Verilog习惯VHDL等效写法易错点
assign a = b & c;a <= b and c;(需在process外)VHDL中<=是信号赋值,:=是变量赋值
always @(posedge clk)process(clk) is begin if rising_edge(clk) thenVHDL必须显式写rising_edge()
if (cnt == MAX) cnt=0;if cnt = MAX then cnt := 0; end if;VHDL中=是比较,:=才是赋值

我的习惯:新项目一律用Verilog,因其语法贴近C,团队协作成本低;但阅读老项目VHDL时,先用EDA工具(如GHDL)语法检查,再逐行对照转换——别信自动转换脚本,它们会把时序逻辑搞成组合逻辑。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进