ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Cyclone II的FPGA交通灯控制系统设计与实现

基于Cyclone II的FPGA交通灯控制系统设计与实现 简介基于 Cyclone II-EP2C35F672C6 的交通灯程序是一份完整的 FPGA 入门级工程参考面向正在学习 Quartus II 和硬件描述语言的在校学生及自学者重点解决十字路口信号灯状态切换与数码管倒计时显示问题。工程通过状态机控制四个方向共四组红绿灯的四种运行状态并配有四组数码管同步显示红、绿、黄灯剩余时间可直接作为课程设计或实验模板使用。压缩包共 120 个文件仅 1.25MB主要包含 Quartus 工程文件qpf、qsf、vhd、sof、编译报告与配置文件rpt、hdb、rdb以及 doc、docx、readme 等说明文档结构清晰。解压后有三个文件夹traffic 为主工程目录程序文件夹保留编写过程中失败与最终修改的版本错误文件夹记录了编译中遇到的常见问题与解决办法非常适合对照学习与复盘。目前已有 420 人学习对想快速上手 FPGA 交通灯项目、理解完整设计流程并避开典型坑点的同学具有实际参考价值。基于Cyclone II-EP2C35F672C6的交通灯程序搞FPGA的人应该都有印象Altera的Cyclone II系列虽然已经停产很多年但在工控、教学和低成本项目里仍然是存量很大的一个家族。我今天要拆解的这套交通灯程序用的就是Cyclone II家族里比较有代表性的EP2C35F672C6芯片。这顆芯片672个引脚的FBGA封装逻辑单元有三万多放在今天看不算什么但在当年做数字逻辑课程设计、毕业设计或者小型交通控制验证平台那可是妥妥的主力器件。这套交通灯程序解决的核心问题是用硬件描述语言在FPGA上实现一个典型十字路口的交通信号灯控制系统包含红绿灯状态切换、倒计时显示、紧急模式处理等功能。它既能跑仿真也能直接下载到开发板上看真实效果。很适合正在学Verilog或者数电后想动手做点东西的同学也适合需要给教学实验平台做演示项目的工程师。我当年在实验室折腾这块板子的时候踩了不少坑从锁相环配置到引脚分配再到时序仿真每一个环节都有值得记录的经验。这篇文章就把整个程序的思路、实现细节和调试过程都摊开来讲希望能帮后来者少走弯路。1. 项目整体设计与芯片选型思路1.1 为什么选择EP2C35F672C6先说说芯片选型这件事。很多人会问做个交通灯而已用个51单片机不就行了为什么要用FPGA这其实是两个层次的问题。从功能本身看51单片机确实可以完成交通灯逻辑用定时器中断加IO口翻转就搞定了。但如果把要求提高一点——要求所有信号灯的切换严格同步、要求毫秒级的时钟精度、要求在同一个时钟沿上完成所有状态的原子性切换、要求同时驱动多组数码管动态扫描——FPGA的并行硬件优势就体现出来了。EP2C35F672C6的具体资源是33,216个逻辑单元LE105个M4K嵌入式内存块35个18x18硬件乘法器4个PLL锁相环可用IO引脚约475个。做交通灯控制逻辑单元可能只用了不到5%但这不是重点——重点是PLL可以做时钟分频和相移M4K可以存显示查表数据大量的IO足够同时接信号灯LED、数码管段选位选、按键输入和传感器信号后续扩展余量非常充足。另外还有个很现实的原因Cyclone II系列的开发板在教学平台里存量很大二手板子也不贵器件文档和参考资料最全。对一个学习性质或教学性质的项目来说用什么芯片不重要重要的是资料能不能查得到、问题能不能搜得到、板子坏了能不能换。EP2C35F672C6在这几点上都很友好。1.2 总体架构与模块划分这套交通灯程序我采用的是经典的自顶向下设计方法整个系统分成五个模块时钟管理模块利用PLL把板载50MHz晶振分频出1Hz标准时钟和数码管扫描用的1kHz时钟状态机控制模块核心逻辑管理十字路口红绿灯的状态转换和持续时间倒计时计数器模块为每个状态生成对应的剩余时间并输出BCD码供数码管显示数码管扫描显示模块动态扫描驱动四位数码管显示两个方向的倒计时按键与紧急模式模块提供手动切换、紧急全红、夜间黄灯闪烁等功能这五个模块之间的连接关系很明确时钟管理模块给其他模块提供时钟状态机模块根据当前状态控制信号灯输出同时给计数器模块下达计时指令计数器把剩余时间送到显示模块紧急模式模块通过按键信号干预状态机的状态跳转。之所以把功能切得这么细是为了调试方便。比如发现数码管显示不对我只需要单独仿真显示模块而不需要把整个系统重新跑一遍。这也是FPGA开发和单片机编程一个很大的思维差异——硬件描述语言里的模块就像搭积木每个模块是独立的一块模块之间通过接口连接。2. 交通灯状态机设计与时序控制2.1 经典四状态模型与扩展设计交通灯的核心是一个有限状态机我在程序设计里沿用了教科书上最经典的四状态模型然后在此基础上做了扩展。四个基本状态是这样的S0东西方向红灯南北方向绿灯持续时间通常设30秒S1东西方向红灯南北方向黄灯持续时间5秒S2东西方向绿灯南北方向红灯持续时间30秒S3东西方向黄灯南北方向红灯持续时间5秒状态在S0到S3之间循环就实现了十字路口最基本的交通控制。南北和东西两个方向的绿灯时间可以独立配置比如早高峰的时候把主路的绿灯时间加长到45秒次路保持25秒只需要改一个参数而已。我在这个基础上还加了一个扩展版本采用了四相位设计——把直行和左转分开控制。这在实际的城市路口里很常见尤其是有单独左转信号灯的路口直行和左转必须分时放行否则会互相干扰。四相位的状态分别是相位一南北直行绿灯南北左转红灯东西方向全红相位二南北左转绿灯南北直行红灯东西方向全红相位三东西直行绿灯东西左转红灯南北方向全红相位四东西左转绿灯东西直行红灯南北方向全红每个相位持续时间可以在顶层文件里用parameter定义比如南北直行35秒、南北左转15秒、东西直行40秒、东西左转15秒加上每个相位之间2到3秒的全红清空时间防止路口车辆还没走完就放行下一方向。2.2 时间基准的生成分频与PLL配置交通灯对时间基准的要求其实比很多人想的要高。如果时钟偏了短时间内看不出什么但一小时下来可能就会积累几秒的误差一天下来红绿灯切换的节奏就会和实际时间有出入。所以不能用简单的计数器从50MHz一路数到50,000,000来产生1Hz时钟——这个方式本身没错误差取决于晶振的精度50MHz晶振的典型误差是50ppm一天累积误差大概4.3秒还能接受——但更好的做法是直接用PLL做整数分频把50MHz变成精确的1Hz同时通过PLL的输出时钟和反馈机制来减小抖动。在Quartus II里配置PLL很简单打开MegaWizard选择ALTPLL输入频率填50MHz输出频率填1Hz会自动生成一个pll.v文件。这个模块会在内部用压控振荡器配合反馈分频器产生目标频率比我用纯计数器分频要稳定得多。但这里有个坑PLL的输出频率是有下限的Cyclone II的PLL最低输出频率大约是1.5MHz左右不能直接输出1Hz。所以实际操作分成两步——先用PLL把50MHz变成2MHz再用一个模2,000,000的计数器分频得到1Hz。这个1Hz时钟专门给状态机用而数码管动态扫描需要的高频扫描信号可以从另一个PLL输出直接取比如10kHz用来做扫描时钟。我见过有的同学图省事直接用50MHz计数分频产生1kHz扫描时钟结果发现数码管亮度不均匀。原因就是扫描周期太长人眼能感觉到闪烁。照片是看不出来的但人眼实测就是不舒服。改成10kHz之后情况好很多。2.3 状态转换的时序逻辑实现状态机我用的是三段式写法——状态转移逻辑、状态寄存器、输出逻辑分开写。为什么要分开因为组合逻辑和时序逻辑混在一起仿真和排错都会变痛苦。三段式的好处是结构清晰状态寄存器一个always块次态计算一个always块输出逻辑一个always块各管各的。核心的状态机部分代码如下以四相位为例module traffic_fsm ( input wire clk_1hz, input wire rst_n, input wire emergency, input wire night_mode, output reg [5:0] north_south_light, output reg [5:0] east_west_light, output reg [3:0] ns_counter_out, output reg [3:0] ew_counter_out ); // 状态编码使用独热码避免译码毛刺 localparam S0 4b0001, // 南北直行 S1 4b0010, // 南北左转 S2 4b0100, // 东西直行 S3 4b1000; // 东西左转 reg [3:0] current_state, next_state; reg [7:0] counter; reg [7:0] state_duration; // 状态寄存器 always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) current_state S0; else current_state next_state; end // 状态转移与计数 always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) counter 0; else if (emergency) counter 0; else if (counter state_duration) counter 0; else counter counter 1; end always (*) begin case (current_state) S0: next_state (counter state_duration) ? S1 : S0; S1: next_state (counter state_duration) ? S2 : S1; S2: next_state (counter state_duration) ? S3 : S2; S3: next_state (counter state_duration) ? S0 : S3; default: next_state S0; endcase end // 输出逻辑信号灯编码 always (*) begin // 默认输出组合逻辑赋值 case (current_state) S0: begin north_south_light 6b001001; // 南北直行绿灯 east_west_light 6b100100; // 东西全红 state_duration 35; end S1: begin north_south_light 6b010010; // 南北左转绿灯 east_west_light 6b100100; state_duration 15; end S2: begin north_south_light 6b100100; // 南北全红 east_west_light 6b001001; // 东西直行绿灯 state_duration 40; end S3: begin north_south_light 6b100100; east_west_light 6b010010; // 东西左转绿灯 state_duration 15; end default: begin north_south_light 6b100100; east_west_light 6b100100; state_duration 1; end endcase end endmodule这段代码里有两个细节值得注意。第一个是状态编码用了独热码也就是每个状态占用一个bit寄存器里同时只有一位为1。好处是状态译码非常快组合逻辑简单而且状态跳转的毛刺概率远低于二进制编码。代价是多用几个触发器——四个状态需要四个触发器而不是两个——但EP2C35有3万多个LE四个触发器完全可以忽略不计。第二个细节是counter和state_duration的比较。我在S0状态下counter从0数到state_duration当counter state_duration时跳转到下一个状态。这意味着S0实际持续了state_duration1秒。如果我的本意是S0持续35秒那state_duration应该设成34。但更方便的做法是counter从1开始计数数到state_duration时跳转这样state_duration就等于实际持续时间不用每次去换算加一减一。我当时第一次写仿真的时候没注意这个细节波形上S0永远比预期多了一秒排查了很久才发现是这个比较条件的问题。这种边界条件的坑在FPGA开发里很常见仿真波形一出来就要仔细对边沿。3. 代码实现与关键模块实操3.1 倒计时显示模块的数码管动态扫描数码管动态扫描是交通灯显示部分的核心。四位数码管分别显示南北方向和东西方向的倒计时数每个方向两位数。动态扫描的原理其实很简单人眼有视觉暂留效应只要数码管以足够高的频率交替点亮——工程上通常20ms以内刷新一遍就没问题——看起来就像是四个数字同时亮着。我这里用10kHz的扫描时钟四位轮一遍只要0.4ms远低于20ms的闪烁感知阈值视觉上非常稳定。扫描逻辑用了一个两位的计数器每隔0.1ms切换一次位选信号module seg_display ( input wire clk_10kHz, input wire rst_n, input wire [7:0] ns_bcd, // 南北方向倒计时 BCD input wire [7:0] ew_bcd, // 东西方向倒计时 BCD output reg [3:0] an, // 位选信号 output reg [7:0] seg // 段选信号 ); reg [1:0] scan_cnt; reg [3:0] current_digit; // 扫描计数器 always (posedge clk_10kHz or negedge rst_n) begin if (!rst_n) scan_cnt 0; else scan_cnt scan_cnt 1; end // 位选与数据选择 always (*) begin case (scan_cnt) 2b00: begin an 4b1110; current_digit ns_bcd[3:0]; // 南北个位 end 2b01: begin an 4b1101; current_digit ns_bcd[7:4]; // 南北十位 end 2b10: begin an 4b1011; current_digit ew_bcd[3:0]; // 东西个位 end 2b11: begin an 4b0111; current_digit ew_bcd[7:4]; // 东西十位 end default: begin an 4b1111; current_digit 4b0000; end endcase end // 段选译码 always (*) begin case (current_digit) 4d0: seg 8b11000000; 4d1: seg 8b11111001; 4d2: seg 8b10100100; 4d3: seg 8b10110000; 4d4: seg 8b10011001; 4d5: seg 8b10010010; 4d6: seg 8b10000010; 4d7: seg 8b11111000; 4d8: seg 8b10000000; 4d9: seg 8b10010000; default: seg 8b11000000; endcase end endmodule这里段选信号用的是共阳数码管的编码低电平点亮。如果是共阴数码管需要把整个case反转取反我见过有人直接用共阳编码接共阴数码管结果数字全乱了。接线之前一定要搞清楚自己板子上是共阳还是共阴这是最基础也最容易被忽略的问题。数码管显示还有一个细节需要注意倒计时数字要在状态切换的瞬间同步更新。比如从南北绿灯切到南北黄灯倒计时应该从30立刻跳到5而不是从30一路减到0再变5。这个同步逻辑是在状态机模块里完成的状态机的输出直接决定了计数器的初值而不是让计数器独立跑完整个周期。如果不这么做会出现显示和实际信号灯状态不匹配的情况看起来非常奇怪。3.2 按键消抖与紧急模式实现交通灯系统在实际场景里还需要人工干预的能力最常见的是紧急模式下让所有方向都亮红灯——救护车、消防车通过时用——以及夜间模式下让黄灯闪烁提醒过往车辆小心通行。这两个功能在FPGA里实现都不复杂但按键输入的处理有个经典问题机械抖动。机械按键按下的时候触点不是一次性闭合的而是会在几毫秒内来回抖动好几次如果不做消抖处理一次按下可能会被判断成好几次导致状态跳多次。消抖的办法有很多最常用的是延时判断——检测到按键电平变化后延时20毫秒左右再采样一次如果确认还是新的电平才认为按键有效。module debounce ( input wire clk_1kHz, input wire rst_n, input wire key_in, output reg key_pulse ); reg [2:0] key_shift; reg [4:0] delay_cnt; reg key_state; reg key_press_flag; // 边沿检测 延时消抖 always (posedge clk_1kHz or negedge rst_n) begin if (!rst_n) begin key_shift 0; key_state 1b1; key_pulse 0; end else begin key_shift {key_shift[1:0], key_in}; if (key_shift 3b000 key_state 1b1) begin key_state 1b0; // 连续3ms低电平确认按下 key_pulse 1b1; end else if (key_shift 3b111 key_state 1b0) begin key_state 1b1; // 连续3ms高电平确认松开 key_pulse 1b0; end else begin key_pulse 1b0; end end end endmodule这个模块用了一个三位移位寄存器连续三次采样都是低电平才确认按键按下。在1kHz时钟下相当于连续3毫秒的稳定低电平才触发脉冲。这个做法的好处是代码简单、资源占用少而且对于交通灯控制这种对响应速度没有苛刻要求的场景完全够用。触发脉冲key_pulse会送到状态机模块置位emergency信号。紧急模式的状态处理也有讲究。我当时的做法是紧急模式信号拉高后状态机不管当前在哪个状态都把输出强制切到全红状态。紧急模式信号撤销后状态机从刚才被打断的状态继续——而不是从S0重新开始——这样才能保证恢复后四个方向的红绿灯仍能按正常的剩余时间运行。实现方式是在状态转移逻辑里加一个条件判断if (emergency) next_state current_state; 同时让输出逻辑在emergency信号为高时输出全红编码。这里有个容易犯的错如果紧急模式恢复后从S0重新开始那么被打断方向的绿灯时间会凭空多出一段导致后续所有切换时间点都有偏差。虽然单独看一个周期可能不觉得有什么问题但多个周期累积下来会影响整个路口的通行效率。所以恢复机制一定要小心设计。3.3 顶层模块综合与引脚分配把各个子模块写完之后需要在一个顶层文件里把它们实例化并连接起来。顶层模块的任务是把PLL产生的时钟接到各个子模块把按键信号接到状态机把状态机的输出接到IO端口。然后是引脚分配。这一步在Quartus II里通过Assignment Editor完成我需要把代码里定义的端口名字映射到EP2C35F672C6的实际物理引脚。这块板子的引脚分配信息一般会有一个board-specific的引脚文件但如果不确定最稳妥的方法是用板子自带的引脚约束示例——比如LED灯是PIN_XX、数码管是PIN_XX——查一遍Quartus里的Pin Planner对照原理图逐一填写。我自己第一次做引脚分配的时候犯过一个低级错误把时钟引脚分配到了普通IO上结果程序下载后完全没有反应。因为FPGA的全局时钟网络必须通过专用的时钟引脚比如EP2C35F672C6上的PIN_17等输入普通IO无法驱动全局时钟树。错误之外还有一个判断经验信号不工作时先检查引脚分配是不是和原理图一致再用SignalTap抓内部信号基本能定位90%的问题。4. 仿真验证、板级调试与常见问题排查4.1 Modelsim仿真关键操作写完了代码下一步就是仿真。仿真这一步绝对不能跳过我在实验室见过太多人代码一写完就急急忙忙下载到板子上结果出了问题不知道是代码问题还是接线路问题排查效率极低。正确的做法是先仿真、再综合、再下载。Modelsim仿真的时候我通常会写一个testbench用一个变量tick计数模拟1Hz时钟。因为1Hz时钟的仿真时间太长了——真实跑35秒在仿真里就是35秒的时间单位——实际做法是给状态机模块的时钟一个相对快的时钟比如直接用50MHz计数到一个很小的值来模拟状态跳转先验证状态转换逻辑是否正确。timescale 1ns/1ps module tb_traffic_fsm; reg clk_1hz; reg rst_n; reg emergency; wire [5:0] ns_light; wire [5:0] ew_light; // 直接给1Hz时钟用循环控制仿真时长 initial begin clk_1hz 0; forever #500 ms clk_1hz ~clk_1hz; // 半周期500ms end endmodule这里#500的单位是仿真器的时间精度如果time scale是1ms那500就是500ms。Modelsim里有一个很实用的操作在wave窗口可以直接对信号添加分隔符把状态机状态、倒计时数值、信号灯输出分别放在不同的分组里看起来一目了然。我在调试状态转换的时候经常把currentstate和counter放在一起对照着看状态跳转的边界条件是否满足。4.2 板级调试中遇到的几个坑仿真通过不代表板子上就能跑板级调试才是真正考验耐心的地方。我把自己实际遇到过的问题整理成了表格方便按图索骥问题现象可能原因排查方法程序下载后无任何反应时钟引脚分配错误、PLL未配置成功检查Pin Planner时钟引脚用SignalTap看PLL锁定信号数码管显示乱码共阳共阴类型搞错、段选引脚顺序接反逐个测试段选线确认编码和数码管类型匹配信号灯闪烁但不按规律切换1Hz时钟异常、状态机计数器位宽不足检查分频模块用SignalTap抓clk_1hz波形按键按下没反应消抖时间过长或按键有效电平判断反了检查消抖模块用LED临时观察按键原始信号某个方向灯始终不亮IO口烧了或引脚被复用换个引脚或用万用表测板子通路其中最简单也最经典的问题就是计数器位宽不足。比如南北方向绿灯35秒如果用5位二进制计数器最大只能数到31那么计数到31之后就会自动回零绿灯永远只持续31秒我的第一版代码就出过这个问题。位宽计算很简单计数N需要的位宽是ceil(log2(N1))35秒需要6位40秒需要6位。为了保险我直接统一用8位计数器多几个触发器而已省得每次改状态时长都要重新算位宽。还有一个高频坑是信号灯编码和板子接线的对应关系。我的代码里用了6位输出控制三个灯的六路LED南北方向红黄绿、东西方向红黄绿但板子上LED所在的引脚顺序可能是乱的。解决办法是写一个简单的LED扫描测试程序——把所有LED以1Hz频率依次点亮——下载到板子上验证引脚顺序。这个过程虽然枯燥但确实必要我花了不到十分钟就确认了所有引脚对应关系避免了后续反复烧录排查。4.3 时序约束与时序收敛Cyclone II虽然是老器件但该有的时序约束还是要做。Quartus II里通过TimeQuest Timing Analyzer添加时钟约束告诉工具PLL输出的时钟频率是多少、输入引脚到寄存器之间的延迟要求是多少、输出引脚到外部器件之间的延迟要求是多少。这项工作如果只做功能仿真往往看不出来但是下载到板子上以后如果出现偶发性的状态错乱——比如一小时才出现一次——很可能就是时序不满足要求导致的。我在交通灯项目里用了50MHz输入经PLL分频出2MHz再分频得到1Hz时钟树上至少有三级分频每一级的时钟偏斜如果没有约束都可能在极端情况下产生亚稳态。约束的方法是在SDC文件里加上这样几行create_clock -name clk50m -period 20.000 [get_ports {clk_50m}] create_generated_clock -name clk2m -source [get_ports {clk_50m}] -divide_by 25 [get_pins {pll_inst|altpll_component|auto_generated|pll1|clk[0]}] create_clock -name clk_1hz -period 1000.000 [get_pins {counter_1hz|reg_out}]写约束的时候我只是给了一个大致的方向具体引脚名字按自己的工程修改。TimeQuest会给出每个时钟域的建立时间和保持时间分析报告只要setup slack和hold slack都是正的就说明时序收敛没问题。提示老芯片的时序约束也是一个学习过程。不必一开始就追求所有路径的时序全部优化到极致重点是先把时钟约束建好让TimeQuest有据可依。FPGA开发里最怕的不是时序报告有warning而是没有约束文件工具只能按默认逻辑分析——那样的时序报告参考意义不大。5. 关于这颗老芯片和这套程序的一些个人体会回到EP2C35F672C6本身。这颗芯片放在2025年的今天确实不算先进的器件3万多的逻辑单元做不了大项目但用来跑一个交通灯控制程序绰绰有余。很多初学者有一个误解觉得FPGA开发一定得用最新的芯片、最高的资源其实不然。做数字系统设计关键是掌握状态机设计思想、时序概念、模块化方法这些基本功在任何一代器件上都是相通的。Cyclone II的资料量大、坑基本都被前人踩平了反而是非常适合学习的载体。另外有一点想多说一句交通灯这个题目虽然经典到有点老掉牙但它对数字逻辑设计的覆盖是非常完整的。状态机建模、计数器分频、动态扫描显示、按键消抖、顶层例化、时序约束数字系统设计的主要知识点全都涉及了。我当时在这块板子上折腾了大概三天每天都有新的理解最后把四相位加紧急模式全部调通的时候对FPGA设计流程的整个感觉和第一天完全不一样了。这套交通灯程序后续还可以扩展很多方向比如加入车流量传感器来自适应调整绿灯时长或者用摄像头识别车辆排队长度来优化信号配时。EP2C35的硬件乘法器和M4K内存虽然不多但做做简单的传感器数据处理还是够用的。如果你手里正好有一块Cyclone II的开发板不妨照这个思路把交通灯程序自己写一遍。遇到问题的话多在Quartus的时序报告和Modelsim的波形上花时间相信我绝大多数问题都能在上面找到答案。本文还有配套的精品资源点击获取
返回列表