2. 硬件描述语言基础(Verilog/VHDL):组合逻辑与时序逻辑、模块化设计、Testbench编写、仿真工具使用

好,我们直接进入正题。硬件描述语言,说白了就是FPGA的“编程语言”。但别把它和C语言搞混了——你写的每一行代码,最终都会变成实实在在的电路。我刚开始学的时候,就犯过这个错,把Verilog当C写,结果综合出来的电路一塌糊涂。

这一章,我会带你理清两个最核心的概念:组合逻辑和时序逻辑。然后聊聊模块化设计,再手把手教你写Testbench。最后,说说仿真工具怎么用。嗯,这些都是基本功,但也是决定你项目成败的关键。

核心要点:硬件描述语言描述的是“电路结构”,而不是“执行步骤”。你是在画电路图,不是在写算法流程。

2.1 组合逻辑 vs 时序逻辑:你必须分清楚

为什么要把这两个放在一起讲?因为很多新手栽跟头,就是栽在这里。组合逻辑的输出只取决于当前输入,没有记忆功能。时序逻辑则相反,它依赖时钟边沿,能“记住”过去的状态。

我在项目中遇到过一位同事,他想实现一个计数器,结果用组合逻辑的assign语句去写,仿真看起来没问题,一上板子就乱跳。原因很简单——组合逻辑没有时钟控制,信号变化会直接透传,根本没法稳定计数。

2.1.1 组合逻辑:用assign和always@(*)

组合逻辑的实现方式有两种:assign连续赋值语句,和always@(*)过程块。我个人习惯,简单的逻辑用assign,复杂的用always。你想想看,一个多路选择器用assign写,一行搞定;但如果是复杂的译码逻辑,用always块加case语句,可读性会好很多。

// 组合逻辑示例:2选1多路选择器
module mux2to1 (
    input  wire a,
    input  wire b,
    input  wire sel,
    output wire y
);
    assign y = sel ? b : a;
endmodule

// 组合逻辑示例:用always块实现译码器
module decoder_3to8 (
    input  wire [2:0] in,
    output reg  [7:0] out
);
    always @(*) begin
        case (in)
            3'b000: out = 8'b0000_0001;
            3'b001: out = 8'b0000_0010;
            3'b010: out = 8'b0000_0100;
            3'b011: out = 8'b0000_1000;
            3'b100: out = 8'b0001_0000;
            3'b101: out = 8'b0010_0000;
            3'b110: out = 8'b0100_0000;
            3'b111: out = 8'b1000_0000;
            default: out = 8'b0000_0000;
        endcase
    end
endmodule

注意:在always@(*)中,如果某个输入信号没有出现在敏感列表里,综合工具会推断出锁存器(Latch)。这通常不是你想要的。我曾经因为漏写了一个信号,导致整个模块功能异常,排查了整整两天。

2.1.2 时序逻辑:用always@(posedge clk)

时序逻辑的核心是时钟。所有寄存器都在时钟上升沿(或下降沿)更新。记住一个原则:时序逻辑的赋值用非阻塞赋值(<=),组合逻辑用阻塞赋值(=)。这不是语法糖,这是硬件行为的要求。

// 时序逻辑示例:带同步复位的8位计数器
module counter_8bit (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       en,
    output reg  [7:0] count
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 8'b0;
        else if (en)
            count <= count + 1'b1;
        else
            count <= count;
    end
endmodule

这里有个细节:复位信号是异步的,但我们在always块里用了negedge rst_n。这样复位信号可以立即生效,不受时钟控制。嗯,这在FPGA设计里很常见,但要注意复位信号的毛刺问题。

2.2 模块化设计:把大问题拆成小模块

模块化设计,说白了就是“分而治之”。一个复杂的交易信号处理系统,如果全写在一个模块里,那调试起来简直是噩梦。我建议你按照功能划分:比如数据接收模块、信号预处理模块、策略计算模块、结果输出模块。每个模块独立设计、独立仿真,最后再拼起来。

模块化设计的好处很明显:

  • 可复用性:写好的模块可以在不同项目里重复使用
  • 可维护性:修改一个模块不影响其他模块
  • 可测试性:每个模块都可以单独仿真验证
// 顶层模块:调用两个子模块
module top_module (
    input  wire       clk,
    input  wire       rst_n,
    input  wire [7:0] data_in,
    output wire [7:0] data_out
);
    wire [7:0] mid_data;

    // 实例化预处理模块
    preprocess u_pre (
        .clk    (clk),
        .rst_n  (rst_n),
        .data_in(data_in),
        .data_out(mid_data)
    );

    // 实例化计算模块
    compute u_calc (
        .clk    (clk),
        .rst_n  (rst_n),
        .data_in(mid_data),
        .data_out(data_out)
    );
endmodule

个人经验:模块的端口尽量少,控制在10个以内。端口太多,说明这个模块功能太杂,需要进一步拆分。我在做高频交易系统时,每个模块的端口数严格控制在8个以内,这样接口清晰,调试也方便。

2.3 Testbench编写:仿真才是硬道理

写Testbench,就是给你的设计搭建一个“测试环境”。你想想看,没有Testbench,你怎么知道你的代码对不对?靠眼睛看波形?那太原始了。我建议你养成习惯:每写完一个模块,立刻写对应的Testbench。

一个标准的Testbench包含:

  • 时钟生成
  • 复位信号
  • 输入激励
  • 输出检查(可选,但强烈建议)
// Testbench示例:测试8位计数器
`timescale 1ns / 1ps

module tb_counter_8bit;
    reg        clk;
    reg        rst_n;
    reg        en;
    wire [7:0] count;

    // 实例化被测模块
    counter_8bit uut (
        .clk   (clk),
        .rst_n (rst_n),
        .en    (en),
        .count (count)
    );

    // 生成时钟:周期10ns
    initial begin
        clk = 0;
        forever #5 clk = ~clk;
    end

    // 测试激励
    initial begin
        // 初始化
        rst_n = 0;
        en    = 0;
        #20;
        rst_n = 1;
        #10;
        // 使能计数
        en = 1;
        #100;
        // 停止计数
        en = 0;
        #50;
        // 复位
        rst_n = 0;
        #20;
        rst_n = 1;
        #50;
        $finish;
    end

    // 可选:自动检查输出
    always @(posedge clk) begin
        if (rst_n && en) begin
            // 这里可以加断言检查
        end
    end
endmodule

避坑指南:我曾经在Testbench里忘记加$finish,结果仿真跑了一整天都没停。还有一次,时钟周期写错了,导致时序完全对不上。记住:仿真时间单位用`timescale指定,时钟周期要和你设计的实际频率匹配。

2.4 仿真工具使用:Vivado Simulator vs ModelSim

仿真工具的选择,其实看个人习惯和项目需求。我用过ModelSim、Vivado Simulator,还有开源的Icarus Verilog。说实话,工具只是手段,关键是你会不会用。

这里我列个对比表,帮你快速了解:

特性 Vivado Simulator ModelSim/Questa Icarus Verilog
集成度 与Vivado深度集成 独立工具 命令行工具
调试功能 波形查看、断点调试 功能强大,支持Tcl脚本 基本波形查看
性能 中等 优秀,适合大型设计 一般
成本 免费(Vivado自带) 商业授权 开源免费
适用场景 Xilinx FPGA项目 大型复杂设计 学习和小型项目

我个人习惯:日常调试用Vivado Simulator,因为它和Vivado无缝衔接,编译、仿真、看波形一键搞定。但如果是做大型的验证项目,我会用ModelSim,它的脚本化能力更强,可以批量跑回归测试。

小技巧:仿真时,不要只看波形。学会用$display$monitor打印关键信号的值。我在调试一个复杂的流水线时,就是靠打印信息定位到了数据冲突的问题。波形看的是宏观,打印看的是细节,两者结合效率最高。

2.5 本章知识体系

下面这张图,帮你梳理本章的核心逻辑。从语言基础到仿真验证,每一步都是环环相扣的。

硬件描述语言基础 - 知识体系 硬件描述语言基础 组合逻辑 时序逻辑 assign 连续赋值 always@(*) 过程块 always@(posedge clk) 非阻塞赋值 (<=) 模块化设计 实例化、端口连接 Testbench 编写 + 仿真工具使用 正确、可综合的硬件设计

这张图从语言基础出发,分支出组合逻辑和时序逻辑两大核心。组合逻辑用assign和always@(*)实现,时序逻辑用always@(posedge clk)实现。模块化设计是组织代码的关键。最后,通过Testbench和仿真工具验证设计的正确性。嗯,整个流程就是这样,环环相扣,缺一不可。

总结一下:这一章我们讲了硬件描述语言的基础。组合逻辑和时序逻辑是两大支柱,模块化设计是工程实践,Testbench和仿真工具是验证手段。记住:写代码之前先想清楚电路结构,仿真通过后再上板子。这是我在无数次失败中总结出来的经验。

专注资料整理