2. 硬件描述语言基础:组合逻辑与时序逻辑、模块化设计、Testbench编写基础

好,咱们直接进入正题。硬件描述语言,说白了就是用来“画”电路的代码。Verilog 和 VHDL 是两大主流,我个人更习惯用 Verilog,因为它语法更灵活,写起来快。不过核心思想都一样——描述硬件,而不是写软件。

你想想看,写 C 语言是告诉 CPU 一步步做什么,但写 Verilog 是在描述一堆逻辑门和触发器怎么连。这个思维转变,是新手最容易卡住的地方。

2.1 组合逻辑 vs 时序逻辑

这是 FPGA 设计的根基。我见过不少新手把这两者混在一起,结果仿真对,上板就错。

2.1.1 组合逻辑

组合逻辑的输出只取决于当前输入。没有记忆,没有时钟。说白了就是一堆与门、或门、非门搭出来的东西。

举个例子,一个简单的加法器:

module adder (
    input  [3:0] a, b,
    output [3:0] sum
);
    assign sum = a + b;
endmodule

这里 assign 就是组合逻辑。a 和 b 一变,sum 立刻变。没有时钟,没有延迟(理想情况下)。

关键点:组合逻辑里绝对不能出现寄存器(reg)的赋值依赖时钟。否则综合工具会给你报一堆警告。

2.1.2 时序逻辑

时序逻辑就不一样了。它依赖时钟沿,有记忆功能。寄存器(Flip-Flop)是它的核心。

我在项目中遇到过一个问题:一个计数器,用组合逻辑写,结果仿真波形乱跳。后来改成时序逻辑,用 always @(posedge clk) 就稳了。

module counter (
    input  clk,
    input  rst_n,
    output reg [3:0] count
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 4'b0;
        else
            count <= count + 1'b1;
    end
endmodule

注意这里用了 <=(非阻塞赋值),而不是 =。这是时序逻辑的标配。为什么?因为非阻塞赋值能模拟寄存器在时钟沿同时更新的行为。你用 = 试试,仿真结果可能对,但综合出来的电路会多出很多奇怪的 latch。

避坑指南:我曾经在一个项目里,把组合逻辑和时序逻辑混在同一个 always 块里,用了阻塞赋值。结果仿真通过,上板后数据总是不对。查了两天才发现是赋值方式的问题。记住:时序逻辑用 <=,组合逻辑用 =,别混用。

2.2 模块化设计

FPGA 设计不是写一个巨大的模块。你想想看,一个复杂的交易系统,如果所有逻辑都塞在一个文件里,调试起来会疯掉。模块化设计就是把大系统拆成小模块,每个模块干一件事。

我个人习惯把模块分成三层:

  • 顶层模块:负责连接各个子模块,不写具体逻辑。
  • 功能模块:比如行情解析、订单生成、风险控制,每个模块独立。
  • 基础模块:比如 FIFO、计数器、加法器,这些可以复用。

举个例子,一个简单的交易信号处理模块:

module signal_processor (
    input  clk,
    input  rst_n,
    input  [31:0] price_in,
    output reg [31:0] signal_out
);
    // 内部模块实例化
    wire [31:0] filtered_price;
    lowpass_filter u_filter (
        .clk(clk),
        .rst_n(rst_n),
        .data_in(price_in),
        .data_out(filtered_price)
    );

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            signal_out <= 32'b0;
        else if (filtered_price > 32'd1000)
            signal_out <= 32'd1;  // 买入信号
        else
            signal_out <= 32'd0;
    end
endmodule

这里 lowpass_filter 是一个独立的模块。如果以后想换滤波器,直接换模块就行,不用动顶层逻辑。这就是模块化的好处。

小技巧:写模块时,接口尽量简单。输入输出信号用 wirereg 定义清楚。我习惯在模块顶部用注释写明每个信号的作用,这样半年后自己回来看代码还能看懂。

2.3 Testbench 编写基础

写完了代码,怎么验证对不对?靠 Testbench。说白了,Testbench 就是给设计模块喂激励信号,然后看输出对不对。

我刚开始做 FPGA 时,觉得仿真浪费时间,直接上板调。结果烧坏了两块板子……从那以后,我再也不敢跳过仿真了。

一个简单的 Testbench 结构:

`timescale 1ns / 1ps

module tb_counter;
    reg  clk;
    reg  rst_n;
    wire [3:0] count;

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

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

    // 生成复位和测试激励
    initial begin
        rst_n = 0;
        #20;
        rst_n = 1;
        #100;
        $display("Count = %d", count);
        #200;
        $finish;
    end

    // 监控输出
    initial begin
        $monitor("Time = %t, count = %d", $time, count);
    end
endmodule

这里有几个要点:

  • `timescale 定义时间单位和精度。我一般用 1ns/1ps。
  • initial 块用来生成激励。时钟用 forever 循环,复位用延迟。
  • $monitor$display 是系统函数,用来打印信息。调试时很有用。
核心原则:Testbench 要覆盖正常情况和边界情况。比如计数器,要测复位、计数到最大值、溢出等场景。我曾经漏测了复位释放后的第一个时钟沿,结果上板后数据少采了一个周期。

2.4 本章知识体系

为了让你更直观地理解,我画了一张图,展示组合逻辑、时序逻辑、模块化设计和 Testbench 之间的关系:

FPGA 设计核心知识体系 组合逻辑 assign / always @(*) 输出只依赖当前输入 无记忆、无时钟 时序逻辑 always @(posedge clk) 依赖时钟沿 有记忆、非阻塞赋值 模块化设计 顶层 + 功能 + 基础 接口清晰、可复用 易于调试和维护 Testbench 激励生成 + 结果验证 覆盖正常/边界情况 $monitor / $display 组合逻辑和时序逻辑是基础,模块化设计组织代码,Testbench 验证功能

这张图把本章的核心内容串起来了。组合逻辑和时序逻辑是两大基石,模块化设计帮你组织代码,Testbench 则是验证的保障。四者缺一不可。

我的建议:刚开始学的时候,别急着写复杂模块。先拿计数器、加法器练手,写一个模块就写一个对应的 Testbench。等熟练了,再尝试把多个模块拼起来。这样基础打得牢,后面做交易系统才不会手忙脚乱。

好了,这一章就到这里。记住:硬件描述语言不是写软件,是在画电路。多仿真、多上板,慢慢就有感觉了。

专注资料整理