1. 项目概述与设计思路
最近在整理一些老项目,翻出来一个基于CPLD的十字路口交通灯控制器设计。这个项目虽然不大,但麻雀虽小五脏俱全,从需求分析、状态机设计、时序分频到数码管动态扫描显示,完整地走了一遍数字逻辑设计的流程。对于刚接触FPGA/CPLD或者想巩固Verilog硬件描述语言的朋友来说,是一个非常不错的练手项目。它不像一些复杂的图像处理或通信协议那样让人望而生畏,却能让你实实在在地理解同步时序电路的设计精髓,尤其是状态机的编写和模块化设计思想。
这个设计的核心目标很简单:模拟一个真实的十字路口交通灯控制系统。我们假设横向道路(东西方向)车流量较大,因此其绿灯通行时间设置为50秒,而纵向道路(南北方向)的绿灯通行时间为30秒。黄灯作为过渡,统一为5秒。整个系统需要循环运行,并且要有一个倒计时显示器,让司机和行人能直观地看到当前相位剩余的通行或等待时间。我选择了Altera(现为Intel)的MAX II系列CPLD芯片EPM240T100C5作为硬件平台,用Verilog HDL来实现所有逻辑。下面,我就把这个项目的设计细节、代码实现以及调试过程中踩过的坑,毫无保留地分享出来。
2. 核心需求与方案选型解析
2.1 交通灯时序逻辑定义
任何设计的第一步都是明确需求。我们这个十字路口交通灯的需求非常具体,就是一个周期性的六状态循环。为了更直观,我通常会把时序画成一个状态转移图或者列成表格。这里我们用表格来梳理一下:
| 状态序号 | 横向道路(东西) | 纵向道路(南北) | 持续时间 | 倒计时起始值 |
|---|---|---|---|---|
| S0 | 红灯 | 绿灯 | 30秒 | 29 |
| S1 | 红灯 | 黄灯 | 5秒 | 4 |
| S2 | 黄灯 | 红灯 | 5秒 | 4 |
| S3 | 绿灯 | 红灯 | 50秒 | 49 |
| S4 | 黄灯 | 红灯 | 5秒 | 4 |
| S5 | 红灯 | 黄灯 | 5秒 | 4 |
一个完整周期就是30+5+5+50+5+5 = 100秒。这里有一个关键点:倒计时显示的值是“剩余秒数减一”。例如,30秒绿灯,我们从29开始倒数到0。这样设计是为了与状态计数器的值对齐,编程时更清晰。
注意:在实际路口设计中,黄灯时间、全红时间(所有方向红灯,用于清空路口)都需要根据道路宽度、车速等因素严格计算。我们这个模型做了简化,但核心的状态机思想是完全一致的。
2.2 为什么选择CPLD而非MCU?
看到这个需求,很多人的第一反应可能是用单片机(MCU)来实现。确实,用MCU配合几个GPIO口,写个定时器中断程序,非常容易实现。但我选择CPLD,主要是基于以下几点考量:
- 真正的并行处理与确定性延时:交通灯控制本质上是一个多路输出严格同步的时序逻辑。CPLD的所有逻辑单元是并行工作的,只要时钟稳定,每个输出信号的跳变都是纳秒级确定性的。而MCU是顺序执行指令,即使使用中断,其响应时间和代码执行时间也存在微秒级抖动,在极端要求同步性的场合(虽然交通灯不敏感),CPLD有天然优势。
- “硬件”实现的可靠性:一旦CPLD的程序编译下载完成,其功能就被“固化”在硬件电路中,像一块专用芯片一样工作,不跑操作系统,没有软件崩溃、死循环的风险(只要设计本身没有逻辑错误)。这对于需要7x24小时不间断运行的基础设施来说,心理上更踏实。
- 学习价值:对于电子、通信、自动化专业的学生或工程师,用HDL语言完成这样一个项目,是对数字电路基础、同步设计理念的一次绝佳实践。它能帮你建立起“硬件思维”,理解寄存器、计数器、状态机这些底层概念是如何在硅片上实现的,这是使用MCU进行上层应用开发难以获得的体验。
- 资源与成本:MAX II EPM240这款CPLD资源对于本项目绰绰有余,且价格与中端MCU相当。它无需外部ROM/RAM,电路非常简洁。
当然,CPLD也有缺点,比如灵活性不如MCU,修改时序需要重新综合、布局布线和下载。但对于一个定死的、规则明确的控制逻辑,CPLD的优势就很明显了。
2.3 系统顶层架构设计
根据需求,我将整个系统划分为四个核心模块,采用自顶向下的设计方法。这样做的好处是结构清晰,便于分工协作和单独调试。
- 分频模块 (
clkdiv):负责将外部高频系统时钟(例如50MHz)分频,产生一个精确的1Hz秒脉冲信号。这个1Hz信号是整个系统状态切换和倒计时的“心跳”。 - 动态扫描时钟模块 (
clk1000div):为了驱动数码管进行动态显示,需要另一个较高频率的扫描时钟(例如几百Hz到几KHz)。这个模块负责产生这个扫描时钟。 - 核心控制与计时模块 (
light):这是系统的大脑。它接收1Hz秒信号,内部维护一个0-99的状态计数器,并根据当前状态值,输出两条道路的红、黄、绿灯控制信号以及对应的倒计时数值。本质上,这是一个摩尔型状态机。 - 显示驱动模块 (
light_dis):这个模块任务最杂。它需要:- 根据核心控制模块送来的倒计时数值,解码出十位和个位,并转换为数码管的段选码。
- 利用动态扫描时钟,分时点亮两个数码管。
- 同时,将核心控制模块送来的灯控信号,输出到对应的LED指示灯上。
顶层模块traffic就像一块主板,把这四个“芯片”连接起来,并定义对外的输入输出接口。这种模块化设计让每一部分功能独立,调试时如果数码管不亮,我就可以专注检查light_dis模块和扫描时钟,而不用去管状态机逻辑是否正确,极大提高了效率。
3. 模块详解与Verilog代码实现
3.1 时钟分频模块:系统的时间基准
时间基准的准确性是整个系统的基石。我们假设开发板提供的原始时钟clk是50MHz(周期20ns)。我们需要两个不同频率的时钟:1Hz用于计时,一个较高频率(代码中是50KHz)用于数码管动态扫描。
1Hz分频模块 (clkdiv):
module clkdiv(clk, rst, second); input clk; input rst; output second; reg [27:0] num; // 计数器寄存器 reg second; // 1Hz输出寄存器 always @ (posedge clk) begin if(!rst) begin num <= 0; second <= 0; end else begin num <= num + 28'd1; if(num == 28'h2faf080) begin // 十进制 50,000,000 second <= ~second; // 翻转,产生1Hz方波 num <= 0; end end end endmodule代码解读与注意事项:
num是一个28位的寄存器。50MHz时钟,要产生1Hz信号,需要分频50,000,000倍。因为每来一个时钟上升沿num加1,所以当num从0计数到50,000,000 - 1时,刚好过了1秒。28'h2faf080就是50,000,000的十六进制表示。second <= ~second这一句是关键。每当计数满1秒,second信号就翻转一次。由于初始值是0,翻转后变1,再等一秒又翻转为0。这样产生的second信号就是一个占空比50%、周期2秒的方波吗?不对!仔细看,它的周期是1秒,高电平和低电平各持续0.5秒。因为每次翻转的间隔是50,000,000个时钟周期,即1秒。- 常见坑点:分频系数计算错误。一定要用
系统时钟频率 / 目标频率。如果系统时钟不是50MHz,比如是25MHz,那么分频系数就是25,000,000。这里如果算错,整个系统的时间就全乱了。 - 优化建议:对于这种大系数的分频,综合工具可能会报告时序警告。一个更稳健的做法是使用PLL(锁相环)IP核来产生精确的低频时钟。但在这个学习项目中,计数器分频是最直接明了的方式。
动态扫描时钟模块 (clk1000div):
module clk1000div(clk, rst, clk_50k); input clk; input rst; output clk_50k; reg [9:0] div; // 10位计数器,可计数到1024 reg clk_50k; always @ (posedge clk) begin if(!rst) begin div <= 0; clk_50k <= 0; end else begin if(div == 999) begin // 计数1000次 clk_50k <= ~clk_50k; div <= 0; end else begin div <= div + 1; end end end endmodule这个模块将50MHz时钟1000分频,得到50KHz的扫描时钟。为什么是50KHz?动态扫描的原理是快速轮流点亮两个数码管,利用人眼的视觉暂留效应,看起来像是同时亮的。扫描频率太低(比如低于60Hz),会看到闪烁;太高则会增加功耗,且对数码管驱动电路要求高。50KHz意味着每个数码管点亮的周期是20us,频率为25KHz,远高于人眼识别范围,显示非常稳定。
3.2 核心控制模块:状态机与倒计时逻辑
这是整个设计的灵魂,用一个状态机来实现时序控制。
module light(rst, second, light0_reg, light1_reg, count); input rst; input second; // 1Hz时钟 output [2:0] light0_reg, light1_reg; // 两个方向的灯控信号 output [6:0] count; // 倒计时数值(0-99) reg [6:0] count; reg [6:0] state; // 状态计数器,0-99循环 reg [2:0] light0_reg, light1_reg; // 状态计数器,每秒加1 always @ (posedge second) begin if(!rst) begin state <= 0; end else begin if(state == 7'd99) begin state <= 0; end else begin state <= state + 1; end end end // 组合逻辑,根据当前状态输出灯控和倒计时 always @ (state) begin if(state < 30) begin // S0: 0-29秒 count <= 29 - state; light0_reg <= 3'b001; // 横向:红 light1_reg <= 3'b100; // 纵向:绿 end if(state > 29 && state < 35) begin // S1: 30-34秒 count <= 34 - state; light0_reg <= 3'b010; // 横向:黄 light1_reg <= 3'b100; // 纵向:红 end if(state > 34 && state < 40) begin // S2: 35-39秒 count <= 39 - state; light0_reg <= 3'b100; // 横向:绿 (注意:这里原文是红灯,根据上下文和逻辑,应是绿灯,可能是原文笔误) light1_reg <= 3'b010; // 纵向:黄 end if(state > 39 && state < 90) begin // S3: 40-89秒 count <= 89 - state; light0_reg <= 3'b100; // 横向:绿 light1_reg <= 3'b001; // 纵向:红 end if(state > 89 && state < 95) begin // S4: 90-94秒 count <= 94 - state; light0_reg <= 3'b100; // 横向:黄 light1_reg <= 3'b010; // 纵向:红 end if(state > 94 && state < 100) begin // S5: 95-99秒 count <= 99 - state; light0_reg <= 3'b010; // 横向:红 (注意:这里原文是黄灯,根据上下文和逻辑,应是红灯,可能是原文笔误) light1_reg <= 3'b100; // 纵向:黄 end end endmodule深度解析与避坑指南:
状态编码:这里没有使用传统的独热码或格雷码,而是直接用一个0-99的线性计数器
state来表征所有状态。这是因为我们的状态转移是简单的顺序循环,且每个状态持续时间明确。state的值本身就代表了系统运行的时间点(第几秒),非常直观。state在1Hz时钟second的上升沿触发加1,实现了状态的自动推进。输出逻辑:第二个
always块是组合逻辑(敏感列表是state),它根据state的当前值,即时计算出count(倒计时)和lightX_reg(灯状态)。- 倒计时计算:
count <= 区间结束点 - state。例如在S0状态(0-29秒),区间结束点是29,所以count从29-0=29递减到29-29=0。这正好是绿灯剩余的秒数(从29显示到0)。 - 灯控编码:我定义了
3'b001为红灯,3'b010为黄灯,3'b100为绿灯。这样每个时刻只有一个位是1,方便后续驱动LED。light0_reg和light1_reg分别控制两个方向的灯。
- 倒计时计算:
一个关键纠错:仔细对照之前的时序表,我发现原始代码的
light模块中,state在35-39秒(S2)和95-99秒(S5)时,对light0_reg和light1_reg的赋值与我们的设计需求不符。根据需求,S2应是“横向黄灯,纵向红灯”,S5应是“横向红灯,纵向黄灯”。原始代码的注释和赋值可能在此处有笔误。上面的代码我已根据需求表进行了修正。这提醒我们,写代码时一定要把状态定义、输出逻辑和需求文档反复核对,最好先画出准确的状态转移图。组合逻辑的潜在风险:使用
always @ (state)这样的组合逻辑块,容易产生锁存器(Latch)或毛刺。虽然在这个简单的分段函数式描述中,综合工具通常能正确推断出多路选择器,但对于复杂的条件分支,更推荐用时序逻辑(在时钟沿赋值)来输出,或者用case语句明确所有分支,并在always块开始处给所有输出赋默认值,以避免生成不期望的锁存器。
3.3 显示驱动模块:动态扫描与信号输出
这个模块负责“翻译”和“展示”,将核心模块的数字信号变成人能看到的灯光和数字。
module light_dis(clk, rst, count, light0_reg, light1_reg, row, light_v, led, ledseg); input clk; // 50KHz扫描时钟 input rst; input [6:0] count; // 倒计时数值 input [2:0] light0_reg, light1_reg; // 两个方向的灯控信号 output [5:0] led; // 可能用于扩展的LED指示 output [7:0] ledseg; // 数码管段选信号 output [3:0] row; // 数码管位选信号,这里复用为方向指示?根据上下文,更像是位选。 output [2:0] light_v; // 最终输出的灯控信号 reg [3:0] row; reg [2:0] light_v; reg state; // 一个简单的状态位,用于切换两个数码管 reg [5:0] led; reg [7:0] ledseg; reg [7:0] ledreg [1:0]; // 存储两个数码管要显示的数字的段码 reg [7:0] led_shu [9:0]; // 0-9的段码查找表 // 初始化段码表,并处理倒计时数值的十位和个位 always @ (posedge clk) begin if(!rst) begin state <= 0; // 初始化0-9的共阳极数码管段码 (假设a段为最低位) led_shu[0] <= 8'h3f; // 0 led_shu[1] <= 8'h06; // 1 led_shu[2] <= 8'h5b; // 2 led_shu[3] <= 8'h4f; // 3 led_shu[4] <= 8'h66; // 4 led_shu[5] <= 8'h6d; // 5 led_shu[6] <= 8'h7d; // 6 led_shu[7] <= 8'h07; // 7 led_shu[8] <= 8'h7f; // 8 led_shu[9] <= 8'h6f; // 9 end else begin state <= state + 1; // 这个state在50KHz下变化太快,可能不是用于位选切换的最佳方式 // 根据count值,分离十位和个位,并查表获取段码 if(count < 10) begin ledreg[0] <= led_shu[count]; // 个位 ledreg[1] <= led_shu[0]; // 十位为0 end else if(count < 20) begin ledreg[0] <= led_shu[count-10]; ledreg[1] <= led_shu[1]; // 十位为1 end else if(count < 30) begin ledreg[0] <= led_shu[count-20]; ledreg[1] <= led_shu[2]; // 十位为2 end else if(count < 40) begin ledreg[0] <= led_shu[count-30]; ledreg[1] <= led_shu[3]; // 十位为3 end else begin // count最大为99,这里只处理到40+,实际应完善 ledreg[0] <= led_shu[count-40]; ledreg[1] <= led_shu[4]; // 十位为4 end end end // 动态扫描输出:轮流点亮两个数码管,并输出对应的灯控信号 always @ (state) begin case (state) 0: begin row <= 4'b0101; // 假设此信号控制位选,点亮第一个数码管(显示十位) light_v <= light0_reg; // 输出横向道路的灯控信号 led <= 6'b111110; // 可能控制其他LED ledseg <= ledreg[1]; // 输出十位的段码 end 1: begin row <= 4'b1010; // 点亮第二个数码管(显示个位) light_v <= light1_reg; // 输出纵向道路的灯控信号 led <= 6'b111101; ledseg <= ledreg[0]; // 输出个位的段码 end default: ; // 保持,或者可以添加更多状态以支持更多位数码管 endcase end endmodule模块剖析与实战技巧:
动态扫描原理:这个模块有两个
always块。第一个在clk(50KHz) 驱动下,主要完成数据准备——将count这个两位十进制数拆分成十位和个位,并通过查找表led_shu转换成对应的7段数码管段码,存储在ledreg[1]和ledreg[0]中。第二个always块是组合逻辑(敏感列表是state),根据一个简单的切换状态state(这里它只用0和1两个值),分时输出段码和位选信号。- 当
state为0时,它选中第一个数码管(row输出特定编码),并将ledreg[1](十位)的段码送到ledseg,同时输出light0_reg控制横向灯。 - 当
state为1时,它选中第二个数码管,输出ledreg[0](个位)和light1_reg。 - 由于
state在50KHz时钟下快速在0和1间切换(注意第一个always块中的state <= state + 1),人眼就看到两个数码管同时稳定地显示数字。
- 当
段码表:
led_shu数组存储了0-9数字对应的7段数码管段码值。这里使用的是共阳极数码管的段码(低电平点亮)。8'h3f对应二进制0011 1111,假设从低位到高位依次是a,b,c,d,e,f,g,dp,那么这就是数字“0”的段码(g和dp段不亮)。务必根据你实际使用的数码管(共阳/共阴)和开发板上的连接顺序,修改这个段码表!这是调试时最常见的问题之一。代码中的问题与优化:
state切换逻辑:第一个always块中state <= state + 1在50KHz下运行,state会从0快速累加到全1再溢出,并不是只在0和1间切换。而第二个always块的case语句只处理了state为0和1的情况,其他情况走default。这会导致大部分时间数码管是熄灭的(因为default分支没有给row和ledseg赋值,它们会保持前值,但很可能不是有效的显示值)。正确的做法是让state只在0和1之间循环。可以修改为state <= ~state,或者用另一个小计数器来生成扫描使能。- 位选信号
row:代码中row是4位,但只用了两个值4‘b0101和4’b1010。这看起来不像标准的单个数码管位选信号(通常1位对应一个数码管的共阳/共阴极)。它可能被用于控制一个4位一体的数码管模块,或者有特殊含义。你需要根据硬件原理图来确定row和数码管的真实连接关系。 count值处理不完整:if-else if语句只处理到count<40的情况,当count为40-99时,都进入了else分支,此时十位被固定为4,这显然不对。应该完善逻辑,正确分离十位和个位。一个更通用的方法是:
但请注意,Verilog中的除法和取余操作会综合成较大的电路。对于CPLD这种资源有限的器件,如果wire [3:0] tens, ones; assign tens = count / 10; assign ones = count % 10; ledreg[1] <= led_shu[tens]; ledreg[0] <= led_shu[ones];count范围固定(0-99),用查找表(LUT)或上面分段判断的方式更节省资源。
信号复用:
light_v这个3位输出信号,在扫描过程中被交替赋值为light0_reg和light1_reg。这意味着它不能直接驱动两个方向的交通灯!在实际硬件连接时,需要将light_v与row(或另一个同步信号)结合,使用外部锁存器或逻辑,在对应的时刻将信号锁存到横向或纵向的灯驱动电路上。或者,更好的方法是修改设计,让顶层模块直接输出两组独立的灯控信号。
4. 系统集成、仿真与上板调试
4.1 顶层模块与引脚分配
顶层模块traffic就像项目的总接线图:
module traffic(clk, rst, row, light_v, led, ledseg); input clk; input rst; output [3:0] row; output [2:0] light_v; output [5:0] led; output [7:0] ledseg; wire [2:0] light0_reg, light1_reg; wire second; wire clk_50k; wire [6:0] count; clk1000div u_clk1000div ( .clk(clk), .rst(rst), .clk_50k(clk_50k) ); clkdiv u_clkdiv ( .clk(clk), .rst(rst), .second(second) ); light u_light ( .rst(rst), .second(second), .light0_reg(light0_reg), .light1_reg(light1_reg), .count(count) ); light_dis u_light_dis ( .clk(clk_50k), .rst(rst), .count(count), .light0_reg(light0_reg), .light1_reg(light1_reg), .row(row), .light_v(light_v), .led(led), .ledseg(ledseg) ); endmodule写完代码后,必须在Altera Quartus II(或现在的Intel Quartus Prime)中创建工程,选择正确的器件型号(EPM240T100C5),然后进行引脚分配。这是硬件连接的关键一步。你需要根据开发板的原理图,将顶层模块的输入输出信号分配到具体的物理引脚上。例如:
clk连接到晶振引脚(如G21)。rst连接到一个按键引脚(如M1)。light_v[2:0]连接到驱动横向红、黄、绿灯的IO口。row[3:0]和ledseg[7:0]连接到数码管的位选和段选引脚。led[5:0]可以连接到一些调试用的LED。
分配完成后,进行全编译(Analysis & Synthesis, Fitter, Assembler)。
4.2 功能仿真与验证
在下载到CPLD之前,强烈建议进行仿真,以验证逻辑的正确性。使用ModelSim或Quartus自带的仿真工具。
- 编写Testbench:创建一个测试平台,为
traffic模块提供时钟clk和复位rst激励。`timescale 1ns/1ns module tb_traffic(); reg clk; reg rst; wire [3:0] row; wire [2:0] light_v; wire [5:0] led; wire [7:0] ledseg; traffic dut ( .clk(clk), .rst(rst), .row(row), .light_v(light_v), .led(led), .ledseg(ledseg) ); initial begin clk = 0; rst = 0; // 初始复位 #100 rst = 1; // 100ns后释放复位 // 可以运行足够长的时间,比如模拟200秒 #200_000_000_000 $stop; // 200秒后停止仿真(假设时钟周期20ns) end always #10 clk = ~clk; // 生成50MHz时钟,周期20ns endmodule - 观察波形:在仿真波形中,你需要重点观察:
- 复位后,
second信号是否以1Hz的频率翻转。 light0_reg和light1_reg是否按照S0->S1->S2->S3->S4->S5的顺序正确变化,每个状态的持续时间是否正确(30秒,5秒,5秒,50秒,5秒,5秒)。count信号是否在每个状态内正确地从最大值递减到0。light_v和row信号是否在快速切换(动态扫描)。- 可以添加逻辑将
ledseg和row解码,观察虚拟的数码管显示数字是否正确。
- 复位后,
通过仿真,你可以在电脑上提前发现并修复大部分逻辑错误,节省大量的硬件调试时间。
4.3 上板调试与问题排查实录
将编译生成的.pof或.sof文件下载到CPLD开发板后,真正的挑战才开始。以下是我在调试此类项目时总结的“查错清单”:
问题1:数码管完全不亮或显示乱码。
- 检查1:电源和使能:确认数码管模块供电正常,共阳/共阴公共端已正确连接并上拉/下拉。
- 检查2:引脚分配:双击检查Quartus中Pin Planner的分配,是否与原理图一致。特别是段选
ledseg[7:0]和位选row[3:0]的顺序,是否与硬件连接一一对应。 - 检查3:段码表:这是最易错点!用万用表或写一个简单测试程序(例如让所有段码输出0),确认你的段码表数据对应到硬件上是正确的亮灭逻辑。共阳极数码管是输出0点亮,共阴极是输出1点亮。
- 检查4:扫描频率和驱动能力:用示波器探头测量
row信号,看是否有规律的方波输出。如果没有,检查clk_50k是否生成,light_dis模块中的state切换逻辑是否正确。如果扫描太快或太慢,调整分频系数。同时确认IO口的驱动电流是否足够点亮数码管,可能需要增加三极管或驱动芯片。
问题2:交通灯变化时序不对,或者不变化。
- 检查1:1Hz时钟:用示波器测量
second网络(可以临时分配到某个LED或IO口上输出),看其周期是否是精确的1秒。如果不是,检查clkdiv模块的分频系数计算是否正确,以及系统时钟clk是否准确。 - 检查2:状态机逻辑:如果时钟正确,但灯不变,可能是复位信号
rst一直有效,或者状态机state没有在second的上升沿递增。可以通过SignalTap II Logic Analyzer(Quartus内置的逻辑分析仪)内嵌到芯片中,抓取second、state、light0_reg、light1_reg等信号的实时波形,这是最强大的调试手段。 - 检查3:输出驱动:确认
light_v信号是否真的从引脚输出了。可以用万用表测量电压,或者接上LED看是否点亮。注意light_v在显示模块中是复用的,可能需要修改设计使其稳定输出。
问题3:倒计时显示数字错误。
- 检查1:
count值:用SignalTap抓取count信号,看其变化是否符合预期(在每个状态内从大到小递减)。 - 检查2:数码管译码:确认
light_dis模块中,将count分解为十位和个位的逻辑是否正确。特别是当count大于等于40时,逻辑是否完善。 - 检查3:动态扫描干扰:如果显示的数字偶尔闪烁或出现重影,可能是动态扫描的两个相位之间有干扰(消隐时间不足),或者位选信号切换时,段码数据没有稳定。可以在切换位选前,先将段码全部熄灭(送消隐码),延迟一小段时间后再送新数据并打开位选。
问题4:设计资源占用和时序违规。
- 编译完成后,查看Quartus的编译报告,关注“Flow Summary”中的逻辑单元(LE)使用情况。对于EPM240,资源应该很充裕。如果接近极限,需要优化代码,比如用状态机代替计数器,或者简化一些运算。
- 查看“Timing Analyzer”报告,确保没有建立时间(Setup Time)或保持时间(Hold Time)违规。对于这个低速应用(最高时钟50MHz),在CPLD上通常很容易满足时序。如果报错,可以尝试降低系统时钟频率,或者优化关键路径。
5. 项目总结与扩展思考
经过设计、编码、仿真、调试这一整套流程,这个基于CPLD的交通灯项目就算完成了。它虽然基础,但涵盖了数字系统设计的核心要素:时钟管理、状态机、计数器、分频器、显示驱动。通过动手解决上面提到的各种问题,你对Verilog语言和硬件设计的理解会深刻得多。
我个人在多次实现类似项目后,有几点深刻的体会:
- 仿真优先:时间再紧,也一定要做仿真。一个小时的仿真调试可能节省你一天的上板抓瞎时间。Testbench要尽量覆盖边界情况,比如复位、状态切换点、计数溢出点。
- 文档与注释:代码里的注释不是可有可无的。尤其是状态编码、信号定义、关键参数(如分频系数),必须写清楚。隔一个月再看,没有注释的代码就像天书。特权同学键盘坏了没写注释的心情可以理解,但事后一定要补上。
- 模块化与可测试性:像本项目这样分成时钟、核心、显示等独立模块,好处太大了。你可以单独测试分频模块的输出,单独测试状态机的逻辑,最后再集成。在设计时,就考虑给关键内部信号(如
state,count)引出到顶层,方便用逻辑分析仪抓取。 - 理解硬件:写Verilog不是写C语言。要时刻想着你是在描述电路。每一个
always块对应一个或一组触发器、锁存器、组合逻辑。if-else和case会综合成多路选择器。想清楚你写的代码会生成什么样的电路,这是避免生成意外锁存器、消除毛刺的关键。
这个项目还可以做很多有趣的扩展:
- 增加手动控制:添加一个按键,按下后让某个方向常绿(紧急车辆通行),松开后恢复自动循环。
- 增加传感器:模拟车辆检测传感器,当某个方向长时间无车时,缩短其绿灯时间。
- 优化显示:使用更复杂的数码管驱动芯片(如TM1637)或者LCD屏,显示更丰富的信息,比如当前状态名称。
- 改为FPGA实现:移植到FPGA上,利用其更大的资源,实现一个多路口、带有时段控制(早高峰、晚高峰不同配时)的复杂交通灯网络仿真。
希望这个详细的拆解和复盘,能帮助你不仅做出这个交通灯,更能理解其背后的设计思想。数字逻辑设计就像搭积木,掌握了这些基础模块,你就能构建出更复杂、更精彩的系统。