1. 项目概述与设计思路

大家好,今天我想和大家分享一个非常实用的FPGA项目——基于Verilog和Quartus的智能交通灯系统设计。这个项目特别适合刚接触数字逻辑设计的初学者,也适合有一定经验的开发者想要深化模块化设计理念的同行。我自己在实际教学中多次使用这个案例,学生反馈都说非常直观,能够快速理解状态机和模块化设计的精髓。

智能交通灯系统看似简单,但包含了数字系统设计的多个核心概念:状态机控制、时钟分频、数码管显示驱动,以及模块间的协同工作。我们设计的系统包含两个路口(主干道和支干道),每个路口有红黄绿三种信号灯,按照特定的时序循环切换。主干道绿灯50秒、黄灯10秒,支干道绿灯50秒、黄灯10秒,同时确保任何时候只有一个方向是通行状态(绿灯或黄灯),另一个方向必须是红灯。

为什么要采用模块化设计呢?在实际工程中,复杂的系统往往由多个功能模块组合而成。模块化设计不仅让代码更易维护和调试,还能提高代码的复用性。比如这个项目中,我们可以将系统划分为分频模块、状态控制模块、显示驱动模块等,每个模块独立设计和仿真,最后再整合到一起。这样即使某个模块需要修改,也不会影响其他模块的功能。

2. 开发环境与工具准备

做FPGA开发,首先得准备好工具链。我们用的是Intel的Quartus Prime开发环境,这是目前最主流的FPGA开发软件之一,支持从设计输入、综合、布局布线到烧录的全流程。Verilog作为硬件描述语言,是我们实现逻辑功能的主要工具。如果你之前接触过C语言,会发现Verilog的语法有很多相似之处,但要注意的是Verilog描述的是硬件电路,而不是软件程序。

Quartus的安装比较简单,直接从Intel官网下载Lite版本就够用了,毕竟我们的设计规模不大。安装完成后,建议先熟悉一下Quartus的界面:Project Navigator用于管理工程文件,Text Editor用于编写Verilog代码,Tool菜单下的Netlist Viewer和RTL Viewer可以查看综合后的电路结构,Simulation Waveform Editor则用于仿真验证。

除了Quartus,仿真工具也非常重要。虽然Quartus自带了仿真功能,但我个人更喜欢使用ModelSim,因为它更专业,波形查看和调试功能更强大。不过对于初学者,Quartus自带的仿真工具也完全够用。关键是要学会如何编写测试脚本(Testbench),以及如何分析仿真波形来验证设计是否正确。

3. 系统架构与模块划分

我们的智能交通灯系统采用自上而下的设计方法。顶层模块负责将所有子模块连接起来,而每个子模块实现特定的功能。系统主要分为三个核心模块:分频模块(将50MHz系统时钟分频得到1Hz时钟)、状态控制模块(负责交通灯的状态转换和时序控制)、显示驱动模块(驱动数码管显示倒计时)。

这种模块划分不仅符合硬件设计的思想,也便于团队协作。比如一个人负责分频模块,另一个人负责状态机,最后再整合。每个模块都可以独立仿真,确保功能正确后再进行联调。在实际项目中,这种方法是提高开发效率的关键。

顶层模块的接口定义也很重要。我们需要明确每个输入输出信号:系统时钟(clk_50MHz)、复位信号(rst),以及输出到LED灯和数码管的信号。输出信号包括主干道和支干道的红黄绿LED控制信号,以及两个数码管的段选和位选信号。这些信号必须与开发板的物理引脚对应,否则无法正确烧录和运行。

4. 分频模块设计与仿真

分频模块是数字系统中最基础的模块之一。我们的开发板提供的系统时钟通常是50MHz,但交通灯的状态切换需要以秒为单位,所以我们必须将50MHz时钟分频成1Hz时钟。1Hz意味着每秒一个脉冲,这样每个脉冲代表一秒,用于倒计时和状态转换。

Verilog实现分频器有多种方法,最常用的是计数器-based分频。50MHz时钟的周期是20ns,要得到1Hz时钟,需要计数50,000,000次。但直接计数这么大数值会占用大量资源,所以通常采用分级分频:先分频到较低频率(比如1kHz),再分频到1Hz。不过为了简化,我们这里直接使用一个计数器实现50,000,000次分频。

分频模块的代码如下:

module clk_div(
    input clk_50MHz,
    input rst,
    output reg clk_1Hz
);
    reg [25:0] count; // 26位计数器,最大计数值50,000,000
    always @(posedge clk_50MHz or posedge rst) begin
        if (rst) begin
            count <= 0;
            clk_1Hz <= 0;
        end else if (count == 26'd50_000_000) begin
            count <= 0;
            clk_1Hz <= ~clk_1Hz;
        end else begin
            count <= count + 1;
        end
    end
endmodule

这段代码通过一个26位计数器实现分频。每当计数器达到50,000,000时,clk_1Hz信号取反,从而产生1Hz时钟。复位信号rst用于初始化和强制清零。

仿真分频模块时,我们需要编写Testbench生成50MHz时钟和复位信号,然后观察clk_1Hz的输出波形。由于计数值很大,仿真时可以通过减少分频系数来加速仿真。比如将50,000_000改为50,这样仿真很快就能看到效果。仿真波形应该显示clk_1Hz的周期是1秒(或按比例缩小后的值),且与系统时钟同步。

5. 状态控制模块实现

状态控制模块是整个系统的核心,它决定了交通灯的工作逻辑。我们采用有限状态机(FSM)来实现,这是数字系统设计中最常用的控制单元设计方法。状态机有四个状态:S0(主干道绿灯、支干道红灯)、S1(主干道黄灯、支干道红灯)、S2(主干道红灯、支干道绿灯)、S3(主干道红灯、支干道黄灯)。

每个状态的持续时间是固定的:S0状态50秒(主干道绿灯),S1状态10秒(主干道黄灯),S2状态50秒(支干道绿灯),S3状态10秒(支干道黄灯)。状态转换由1Hz时钟驱动,每秒检查一次当前状态的计时,时间到则切换到下一个状态。

状态机的Verilog实现如下:

module state_control(
    input clk_1Hz,
    input rst,
    output reg [2:0] main_LEDs, // 主干道LEDs: {red, green, yellow}
    output reg [2:0] branch_LEDs, // 支干道LEDs: {red, green, yellow}
    output reg [7:0] main_time,   // 主干道倒计时
    output reg [7:0] branch_time  // 支干道倒计时
);
    parameter S0 = 2'd0, S1 = 2'd1, S2 = 2'd2, S3 = 2'd3;
    reg [1:0] state;
    reg [7:0] counter;

    always @(posedge clk_1Hz or posedge rst) begin
        if (rst) begin
            state <= S0;
            counter <= 0;
        end else begin
            case (state)
                S0: if (counter < 50) counter <= counter + 1;
                    else begin state <= S1; counter <= 0; end
                S1: if (counter < 10) counter <= counter + 1;
                    else begin state <= S2; counter <= 0; end
                S2: if (counter < 50) counter <= counter + 1;
                    else begin state <= S3; counter <= 0; end
                S3: if (counter < 10) counter <= counter + 1;
                    else begin state <= S0; counter <= 0; end
            endcase
        end
    end

    // 根据状态输出LED控制信号
    always @(*) begin
        main_LEDs = 3'b000;
        branch_LEDs = 3'b000;
        case (state)
            S0: begin main_LEDs = 3'b010; branch_LEDs = 3'b100; end
            S1: begin main_LEDs = 3'b001; branch_LEDs = 3'b100; end
            S2: begin main_LEDs = 3'b100; branch_LEDs = 3'b010; end
            S3: begin main_LEDs = 3'b100; branch_LEDs = 3'b001; end
        endcase
    end

    // 倒计时输出
    always @(*) begin
        case (state)
            S0: begin main_time = 50 - counter; branch_time = 60; end
            S1: begin main_time = 10 - counter; branch_time = 60 - counter; end
            S2: begin main_time = 60; branch_time = 50 - counter; end
            S3: begin main_time = 60 - counter; branch_time = 10 - counter; end
        endcase
    end
endmodule

这段代码定义了四个状态,并通过一个计数器控制状态持续时间。每个状态结束时,计数器清零并切换到下一个状态。组合逻辑部分根据当前状态输出LED控制信号和倒计时数值。

6. 数码管显示驱动设计

数码管显示是系统的重要组成部分,它让用户能够直观地看到倒计时时间。我们用的数码管通常是七段数码管,通过段选信号(控制哪些段亮)和位选信号(控制哪个数码管亮)来显示数字。

数码管显示模块需要完成两个任务:一是将倒计时数值转换为七段数码管的段选信号,二是通过动态扫描方式交替显示两个数码管(主干道和支干道)。动态扫描利用人眼的视觉暂留效应,快速切换位选信号,看起来像是同时显示。

段选译码器可以用查找表实现,将4位二进制数转换为7位段选信号(假设是共阳极数码管)。例如,数字0的段选信号是7'b1000000(对应a段亮,其他段灭),数字1是7'b1111001,等等。

显示驱动模块的代码如下:

module seg_driver(
    input clk_50MHz,
    input [7:0] main_time,
    input [7:0] branch_time,
    output reg [7:0] seg_data,
    output reg [1:0] seg_sel
);
    reg [15:0] counter;
    reg [3:0] digit;
    always @(posedge clk_50MHz) begin
        counter <= counter + 1;
        if (counter == 16'd50000) begin
            counter <= 0;
            seg_sel <= seg_sel + 1;
        end
    end

    always @(*) begin
        case (seg_sel)
            0: digit = main_time / 10;   // 主干道十位数
            1: digit = main_time % 10;   // 主干道个位数
            2: digit = branch_time / 10; // 支干道十位数
            3: digit = branch_time % 10; // 支干道个位数
        endcase
    end

    always @(*) begin
        case (digit)
            0: seg_data = 8'b11000000;
            1: seg_data = 8'b11111001;
            2: seg_data = 8'b10100100;
            3: seg_data = 8'b10110000;
            4: seg_data = 8'b10011001;
            5: seg_data = 8'b10010010;
            6: seg_data = 8'b10000010;
            7: seg_data = 8'b11111000;
            8: seg_data = 8'b10000000;
            9: seg_data = 8'b10010000;
            default: seg_data = 8'b11111111;
        endcase
    end
endmodule

这段代码通过一个计数器控制位选信号的切换频率,约每1ms切换一次数码管。段选信号根据当前显示的数码管和数值生成。注意这里的段选信号是针对共阳极数码管,如果你的开发板是共阴极,需要取反段选信号。

7. 顶层模块集成与引脚分配

顶层模块的作用是将所有子模块实例化并连接起来。它相当于系统的总接线图,定义了模块之间的互连关系以及对外的输入输出接口。顶层模块通常不包含复杂的逻辑,主要是端口映射和线网声明。

我们的顶层模块需要实例化分频模块、状态控制模块和显示驱动模块,并将它们正确连接。分频模块输出的clk_1Hz连接到状态控制模块的时钟输入,状态控制模块输出的倒计时数值连接到显示驱动模块,显示驱动模块的输出直接连接到FPGA的引脚,驱动LED和数码管。

顶层模块的代码如下:

module traffic_light_top(
    input clk_50MHz,
    input rst,
    output [2:0] main_LEDs,
    output [2:0] branch_LEDs,
    output [7:0] seg_data,
    output [1:0] seg_sel
);
    wire clk_1Hz;
    wire [7:0] main_time, branch_time;

    clk_div u_clk_div(
        .clk_50MHz(clk_50MHz),
        .rst(rst),
        .clk_1Hz(clk_1Hz)
    );

    state_control u_state_control(
        .clk_1Hz(clk_1Hz),
        .rst(rst),
        .main_LEDs(main_LEDs),
        .branch_LEDs(branch_LEDs),
        .main_time(main_time),
        .branch_time(branch_time)
    );

    seg_driver u_seg_driver(
        .clk_50MHz(clk_50MHz),
        .main_time(main_time),
        .branch_time(branch_time),
        .seg_data(seg_data),
        .seg_sel(seg_sel)
    );
endmodule

引脚分配是FPGA设计的关键一步,必须根据开发板的原理图将Verilog中的信号映射到正确的物理引脚。例如,系统时钟clk_50MHz可能连接到特定的时钟引脚,LED信号连接到GPIO引脚,数码管段选和位选信号也连接到对应的引脚。在Quartus中,可以通过Assignment Editor或者QSF文件进行引脚分配。

8. 系统仿真与调试技巧

仿真验证是确保设计正确的关键步骤。我们需要编写Testbench来模拟实际输入,并观察输出波形是否符合预期。Testbench通常包括时钟生成、复位控制、输入激励生成,以及输出监测。

对于交通灯系统,我们需要验证几个关键点:状态转换是否正确、倒计时数值是否正确、LED输出是否符合预期、数码管显示是否正确。由于系统包含多个模块,建议先对每个模块单独仿真,再对整个系统进行集成仿真。

分频模块的Testbench示例:

module tb_clk_div;
    reg clk_50MHz, rst;
    wire clk_1Hz;
    
    clk_div uut(.clk_50MHz(clk_50MHz), .rst(rst), .clk_1Hz(clk_1Hz));
    
    initial begin
        clk_50MHz = 0;
        forever #10 clk_50MHz = ~clk_50MHz;
    end
    
    initial begin
        rst = 1;
        #100 rst = 0;
        #200000000 $finish;
    end
endmodule

这个Testbench生成50MHz时钟和复位信号,并观察分频后的1Hz时钟。仿真时需要运行足够长的时间才能看到1Hz时钟的变化,因为50MHz到1Hz的分频比很大。为了加速仿真,可以暂时减小分频系数。

状态控制模块的仿真更复杂,需要验证状态转换和时序。我们可以通过波形查看器观察state信号的变化,以及main_LEDs和branch_LEDs的输出。特别要注意状态切换的边界条件,比如计数器达到阈值时是否立即切换状态。

调试技巧方面,我强烈建议使用Quartus的Signal Tap逻辑分析仪。它可以在FPGA运行时实时捕捉内部信号的变化,非常有助于排查硬件问题。如果仿真正确但实际运行不正常,很可能是引脚分配错误、时钟约束问题或者物理连接问题。

9. 实际部署与优化建议

设计通过仿真验证后,就可以编译并烧录到FPGA开发板上了。在Quartus中,编译过程包括综合、布局布线、生成编程文件等步骤。编译成功后,通过Programmer工具将sof文件烧录到FPGA中。

实际部署时可能会遇到一些仿真时没有发现的问题。比如数码管显示闪烁、LED亮度不均、按钮抖动等。对于数码管显示,如果切换频率太低,会出现闪烁;太高则可能导致亮度不足。一般1kHz左右的刷新频率比较合适。

按钮抖动是常见问题,机械按钮在按下和释放时会产生多个脉冲。可以通过添加消抖模块来解决,通常采用采样滤波或者计数器方式。例如,连续检测到多次相同状态才认为按钮状态稳定。

系统还可以进一步优化。比如添加紧急模式(所有方向红灯)、夜间模式(黄灯闪烁)、倒计时调整功能等。这些功能可以通过添加输入按钮和扩展状态机来实现。另外,可以考虑使用参数化设计,让红绿灯时间更容易调整。

代码优化方面,可以尝试使用二进制编码代替一位热码编码状态机,以节省资源。对于分频模块,可以考虑使用PLL代替计数器分频,提高时钟质量。显示驱动模块可以添加亮度调节功能,通过PWM控制数码管亮度。

最后,记得编写详细的文档和注释。好的注释不仅方便自己后期维护,也方便他人理解你的设计。特别是状态机的状态定义和模块接口说明,一定要清晰明了。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐