一、Verilog 模块化设计:把大问题拆成小积木

做 FPGA 设计,说白了就是搭积木。

你想想看,一个交易系统里,有行情解析、订单管理、风控检查、撮合引擎……如果全写在一个文件里,那代码量轻松上千行。我刚开始做项目时就这么干过,结果调试起来简直想哭——改一个地方,不知道会影响哪里。

模块化设计就是来解决这个问题的。

1.1 模块是什么?

模块(module)是 Verilog 的基本单元。每个模块有输入、输出,内部实现具体功能。模块之间通过端口连接,就像乐高积木一样拼起来。

// 一个简单的 D 触发器模块
module d_flip_flop (
    input  wire clk,    // 时钟
    input  wire rst_n,  // 复位,低有效
    input  wire d,      // 数据输入
    output reg q        // 数据输出
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end
endmodule

嗯,这里要注意:模块名最好用下划线分隔,比如 d_flip_flop,别用驼峰。我个人习惯用全小写,这样在 Linux 下不会出大小写问题。

1.2 模块实例化:把积木拼起来

写好模块后,怎么用?实例化。

module top (
    input  wire clk,
    input  wire rst_n,
    input  wire data_in,
    output wire data_out
);
    wire q_mid;  // 中间连线

    // 实例化第一个 D 触发器
    d_flip_flop u_dff0 (
        .clk  (clk),
        .rst_n(rst_n),
        .d    (data_in),
        .q    (q_mid)
    );

    // 实例化第二个 D 触发器
    d_flip_flop u_dff1 (
        .clk  (clk),
        .rst_n(rst_n),
        .d    (q_mid),
        .q    (data_out)
    );
endmodule

看到没?u_dff0u_dff1 就是实例名。我建议实例名用 u_ 开头,这样一眼就能看出是模块实例,不是变量。

我的小技巧:端口连接用 .端口名(连线名) 的格式,别用位置连接。位置连接一旦端口顺序变了,代码就全废了。我在项目中吃过这个亏,后来再也不敢用了。

二、组合逻辑与时序逻辑:一个管现在,一个管未来

这两个概念,是 FPGA 设计的根基。搞不懂它们,后面寸步难行。

2.1 组合逻辑

组合逻辑的输出只取决于当前输入。说白了,输入一变,输出立刻变,没有记忆功能。

// 组合逻辑:与门
assign y = a & b;

这里 assign 是连续赋值语句。只要 ab 变了,y 马上跟着变。

2.2 时序逻辑

时序逻辑的输出不仅取决于当前输入,还取决于之前的状态。它需要时钟来驱动,有记忆功能。

// 时序逻辑:计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 8'd0;
    else if (cnt == 8'd255)
        cnt <= 8'd0;
    else
        cnt <= cnt + 1'b1;
end

注意看,这里用的是 <=(非阻塞赋值),不是 =。为什么?因为时序逻辑里,多个赋值要同时发生,不能有先后顺序。

我曾经踩过的坑:在 always 块里混用阻塞赋值和非阻塞赋值。结果仿真没问题,上板子就跑飞了。后来查了三天才发现,是赋值方式用错了。记住:时序逻辑用 <=,组合逻辑用 =,别混着来。

三、always 块与 assign 语句:两种描述方式

Verilog 里描述电路,主要就这两种方式。我刚开始学的时候,总觉得它们差不多,后来才发现各有各的用处。

3.1 assign 语句

assign 用来描述组合逻辑。它相当于一根导线,左边是输出,右边是输入表达式。

assign sum = a + b;
assign carry = a & b;

简单、直接、好理解。适合做简单的组合逻辑。

3.2 always 块

always 块更灵活,既能描述组合逻辑,也能描述时序逻辑。关键看敏感列表怎么写。

// 组合逻辑的 always 块
always @(*) begin
    if (sel)
        out = a;
    else
        out = b;
end

// 时序逻辑的 always 块
always @(posedge clk) begin
    q <= d;
end

看到区别了吗?组合逻辑的敏感列表是 @(*),表示所有输入信号变化都触发。时序逻辑的敏感列表是 @(posedge clk),只在时钟上升沿触发。

核心要点:
  • 简单组合逻辑 → 用 assign
  • 复杂组合逻辑(if-else、case) → 用 always @(*)
  • 时序逻辑 → 用 always @(posedge clk)

四、仿真与 Testbench 编写:不仿真就上板,那是找死

我见过太多人,写完代码直接烧到板子上。结果呢?灯不亮、数据不对、系统死机……然后开始怀疑人生。

仿真,是 FPGA 设计的必备环节。说白了,就是在电脑上模拟你的电路,看看它跑起来对不对。

4.1 什么是 Testbench?

Testbench 就是测试平台。它不是一个可综合的模块,而是用来给被测试模块(DUT)提供激励信号,并检查输出是否正确。

`timescale 1ns / 1ps  // 时间单位/精度

module tb_d_flip_flop;

    reg  clk;
    reg  rst_n;
    reg  d;
    wire q;

    // 实例化被测试模块
    d_flip_flop u_dut (
        .clk  (clk),
        .rst_n(rst_n),
        .d    (d),
        .q    (q)
    );

    // 生成时钟:周期 10ns
    always #5 clk = ~clk;

    // 测试过程
    initial begin
        // 初始化
        clk   = 0;
        rst_n = 0;
        d     = 0;

        // 复位
        #20 rst_n = 1;

        // 测试数据
        #10 d = 1;
        #10 d = 0;
        #10 d = 1;
        #10 d = 1;

        // 结束仿真
        #50 $finish;
    end

    // 监控输出
    initial begin
        $monitor("time=%0t, clk=%b, rst_n=%b, d=%b, q=%b", 
                 $time, clk, rst_n, d, q);
    end

endmodule

4.2 仿真流程

  1. 编写 Testbench:用 initial 块生成激励,用 always 块生成时钟
  2. 编译:把 DUT 和 Testbench 一起编译
  3. 运行仿真:观察波形或打印日志
  4. 检查结果:看输出是否符合预期
我的习惯:每个模块都写一个 Testbench。哪怕只是简单的计数器,也要仿真一下。因为很多 bug 在仿真阶段就能发现,等上了板子再查,成本高十倍不止。

五、本章知识体系

下面这张图,帮你理清本章的核心脉络:

Verilog 基础:四大核心模块 模块化设计 • 模块定义:module/endmodule • 端口声明:input/output/inout • 实例化:模块复用 • 层次化设计:top → sub → leaf 组合逻辑 • 输出只取决于当前输入 • 无记忆功能,无时钟 • 描述方式:assign / always @(*) • 示例:加法器、多路选择器 时序逻辑 • 输出取决于当前输入+历史状态 • 需要时钟驱动,有记忆功能 • 描述方式:always @(posedge clk) • 示例:计数器、移位寄存器 仿真与 Testbench • Testbench:测试平台 • 激励生成:initial / always • 波形观察:$monitor / gtkwave • 仿真 vs 上板:先仿真再上板 四大核心模块相互关联,共同构成 Verilog 设计的基础

六、避坑指南:我踩过的那些坑

最后,分享几个我亲身经历过的教训:

  • 忘记初始化寄存器:FPGA 上电后,寄存器值是随机的。如果不复位,仿真可能对,但上板就错。我有个项目因此多花了两周调试。
  • 组合逻辑环路组合逻辑的输出又连回输入,形成环路。这会导致仿真卡死,综合报错。检查代码时,多留意有没有 assign a = a & b; 这种写法。
  • 敏感列表不全:在 always @(a or b) 里漏了信号,综合时会生成锁存器。现在我都用 @(*),省心。
  • Testbench 写得太简单:只测正常情况,不测边界。结果上板后,遇到极端数据就崩了。现在我的 Testbench 都会覆盖正常、边界、异常三种情况。

好了,这一章的内容就到这里。记住:模块化是骨架,组合逻辑和时序逻辑是血肉,仿真验证是灵魂。把这四个点吃透了,后面的路就好走了。


专注资料整理
专注资料整理