3. 硬件描述语言基础(Verilog):模块与端口、组合逻辑与时序逻辑、always块与assign语句、仿真初探

好,咱们进入正题。Verilog 这东西,说白了就是硬件工程师的“普通话”。你想想看,我们要设计一个网络加速器,总不能拿烙铁一个个焊门电路吧?Verilog 就是用来描述硬件行为的语言。这一章,我带你把最核心的几个概念捋清楚。

3.1 模块与端口:硬件的“黑盒子”

模块(module)是 Verilog 的基本单元。你可以把它想象成一个黑盒子——有输入、有输出,内部具体怎么实现的,外面不用管。我刚开始学的时候,总觉得模块就是 C 语言的函数,后来发现完全不是一回事。函数是顺序执行的,而模块是并行存在的硬件实体。

看个最简单的例子:

module and_gate (
    input  wire a,      // 输入端口 a
    input  wire b,      // 输入端口 b
    output wire y       // 输出端口 y
);

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

endmodule

这里有几个关键点:

  • inputoutput 定义了端口方向。注意,没有 inout 的话,端口就是单向的。
  • wire 表示连线型变量。组合逻辑输出一般用 wire。
  • 模块名和文件名最好保持一致。这是我的血泪教训——有一次项目里模块名和文件名对不上,仿真跑了一天才发现是编译顺序的问题。
小技巧:端口列表里,我习惯把输入写在前面,输出写在后面。这样代码可读性会好很多。尤其是团队协作时,大家一眼就能看出信号流向。

3.2 组合逻辑 vs 时序逻辑:一个“即时响应”,一个“听时钟的”

这两个概念,是数字电路设计的基石。我见过不少新手在这上面栽跟头。

组合逻辑:输出只取决于当前输入。就像你按开关,灯立刻就亮。没有记忆,没有延迟(理想情况下)。

时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。说白了,它有“记忆”。这个记忆是靠时钟沿触发的触发器(Flip-Flop)实现的。

为什么网络加速器里大量使用时序逻辑?因为我们需要流水线、需要状态机、需要缓存数据包。纯组合逻辑搞不定这些。

我画了一张图,帮你理清关系:

组合逻辑 输出 = f(当前输入) 无记忆,即时响应 时序逻辑 输出 = f(当前输入, 历史状态) 有记忆,时钟沿触发 时钟 clk 组合逻辑 + 触发器 = 时序逻辑
核心区别一句话:组合逻辑是“线”,时序逻辑是“沿”。组合逻辑用 assign 或 always @(*) 描述,时序逻辑用 always @(posedge clk) 描述。

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

Verilog 里描述硬件,主要有两种“武器”:assignalways

3.3.1 assign 语句——组合逻辑的“直通车”

assign 就是连续赋值。只要右边表达式变了,左边立刻更新。适合描述简单的组合逻辑。

// 用 assign 实现一个 2 选 1 多路选择器
module mux2to1 (
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire       sel,
    output wire [3:0] y
);

    assign y = sel ? b : a;

endmodule

嗯,这里要注意:assign 的左边必须是 wire 类型。你不能把 reg 放在 assign 左边,这是语法规定。

3.3.2 always 块——更强大的描述方式

always 块可以描述组合逻辑,也可以描述时序逻辑。关键看敏感列表怎么写。

组合逻辑的 always 块:

always @(*) begin
    if (sel)
        y = b;
    else
        y = a;
end

这里的 @(*) 表示“对所有输入信号敏感”。只要 a、b、sel 任何一个变了,这个块就会重新计算。我个人习惯用 @(*) 而不是手动列出所有信号,因为手动列容易漏掉。我曾经因为漏了一个信号,仿真和综合结果不一致,查了两天才发现。

时序逻辑的 always 块:

// 一个带同步复位的 D 触发器
always @(posedge clk) begin
    if (rst)
        q <= 1'b0;
    else
        q <= d;
end

注意这里用的是 非阻塞赋值 <=。这是时序逻辑的标配。为什么?因为非阻塞赋值能保证多个触发器在同一个时钟沿同时更新,不会出现竞争。我刚开始用阻塞赋值写时序逻辑,仿真结果总是怪怪的,后来才明白这个道理。

避坑指南:我曾经在一个项目里,把组合逻辑和时序逻辑混写在同一个 always 块里,结果综合出来的电路多了一堆不必要的锁存器(latch)。记住:组合逻辑用阻塞赋值 =,时序逻辑用非阻塞赋值 <=。别混着用!

3.4 仿真初探:让代码“跑”起来

写完了代码,怎么知道对不对?仿真。说白了,就是给设计加激励,看输出是否符合预期。

一个最简单的 testbench 长这样:

`timescale 1ns / 1ps

module tb_and_gate;

    reg  a, b;
    wire y;

    // 实例化被测试模块
    and_gate uut (
        .a(a),
        .b(b),
        .y(y)
    );

    // 产生激励
    initial begin
        a = 0; b = 0;
        #10;
        a = 0; b = 1;
        #10;
        a = 1; b = 0;
        #10;
        a = 1; b = 1;
        #10;
        $finish;
    end

    // 监视输出
    initial begin
        $monitor("Time=%0t, a=%b, b=%b, y=%b", $time, a, b, y);
    end

endmodule

这里有几个要点:

  • `timescale 定义了时间单位和精度。1ns/1ps 表示时间单位是 1ns,精度是 1ps。
  • initial 块 只执行一次。适合用来产生激励信号。
  • #10 表示延迟 10 个时间单位(这里是 10ns)。
  • $monitor 是系统任务,只要信号变化就打印一行。

你想想看,如果没有仿真,直接去烧录 FPGA,出了问题找原因多痛苦?仿真就是你的“安全网”。我每次写代码,都会先写一个简单的 testbench 跑一下,确认基本功能没问题,再去做综合。

我的习惯:仿真时,我会把时钟周期设成 10ns(100MHz),这样时间好算。另外,我会在 testbench 里加一些随机延迟,模拟真实世界的抖动。这样能发现一些边界情况。

3.5 本章小结

这一章,我们聊了 Verilog 的四个核心概念:

  • 模块与端口:硬件的封装方式,输入输出要分清。
  • 组合逻辑 vs 时序逻辑:一个即时响应,一个靠时钟记忆。
  • assign 与 always:两种描述方式,各有各的用法。
  • 仿真初探:写 testbench 是验证的第一步,别偷懒。

嗯,这些基础打牢了,后面讲状态机、流水线、AXI 总线的时候,你才不会觉得吃力。记住,硬件设计和软件不一样——你写的每一行代码,最终都会变成实实在在的电路。所以,想清楚再写。


公众号:蓝海资料掘金营,微信deep3321