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

好,咱们直接进入正题。Verilog 这门语言,说白了就是用来「画」数字电路的。只不过你不用鼠标拖门电路,而是用代码来描述。我当年刚学的时候,总觉得它跟 C 语言差不多,结果写出来的东西综合出来一堆莫名其妙的 Latch……嗯,今天咱们就把这些坑一个个填上。

3.1 模块与端口:芯片的「黑盒子」

Verilog 的基本单元叫 module。你可以把它想象成一个黑盒子——外面只露出几个引脚(端口),里面藏着具体的逻辑电路。

我个人习惯,写模块时先把端口声明写好,就像画芯片先画封装一样。来看个最简单的例子:

module adder (
    input  wire [3:0] a,      // 4位输入
    input  wire [3:0] b,      // 4位输入
    output wire [4:0] sum     // 5位输出,防止溢出
);

    assign sum = a + b;

endmodule

这里有几个关键点:

  • input / output:定义数据流向。记住,端口方向是设计约束,不是建议。
  • wire / reg:wire 是线网类型,用于组合逻辑;reg 是寄存器类型,用于时序逻辑。但别被名字骗了——reg 不一定是寄存器,它只是表示「过程赋值」。
  • 位宽声明:比如 [3:0] 表示 4 位宽。位宽不匹配是我在项目中遇到最多的低级错误,仿真能过,上板就挂。
我曾经踩过的坑:端口位宽不一致。比如输入是 [3:0],输出是 [4:0],但内部计算时忘了扩展符号位,结果负数运算全错。所以,写端口时先想好最大位宽,别偷懒。

3.2 组合逻辑与时序逻辑:两个世界的分界线

数字电路就两种逻辑:组合逻辑和时序逻辑。你想想看,组合逻辑就是「输入一变,输出立刻变」,没有记忆功能。时序逻辑则依赖时钟,只在时钟沿更新状态。

我画个图帮你理清思路:

组合逻辑 时序逻辑 assign 语句 always @(*) 块 门电路、加法器 输出 = f(当前输入) 无记忆、无时钟 always @(posedge clk) 寄存器、计数器 状态机 输出 = f(当前输入 + 历史状态) 依赖时钟沿、有记忆 时钟驱动

为什么要把它们分得这么清楚?因为综合工具处理它们的方式完全不同。组合逻辑综合成门电路,时序逻辑综合成触发器和锁存器。混在一起写,工具会给你一堆警告,甚至综合出你不想要的东西。

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

Verilog 里描述逻辑就两种主要方式:assignalways。我刚开始时总搞混什么时候用哪个,后来总结了一个简单规则:

  • assign:用于组合逻辑,连续赋值。左边必须是 wire 类型。
  • always:用于组合逻辑或时序逻辑,过程赋值。左边必须是 reg 类型。

来看对比:

特性 assign always
适用场景 简单的组合逻辑 复杂的组合逻辑 / 时序逻辑
赋值对象 wire reg
触发方式 输入变化立即触发 敏感列表变化触发
典型写法 assign y = a & b; always @(posedge clk) q <= d;

举个例子,用两种方式实现同一个 2 选 1 多路选择器:

// 方式一:assign 语句
module mux2_assign (
    input  wire a, b, sel,
    output wire y
);
    assign y = sel ? b : a;
endmodule

// 方式二:always 块
module mux2_always (
    input  wire a, b, sel,
    output reg  y
);
    always @(*) begin
        if (sel) y = b;
        else     y = a;
    end
endmodule
我的习惯:简单的组合逻辑用 assign,代码更简洁。复杂的组合逻辑(比如多条件判断)用 always @(*),可读性更好。时序逻辑一律用 always @(posedge clk)。

3.4 仿真与测试平台基础:不仿真的代码都是耍流氓

写 Verilog 不仿真,就像盖楼不打地基。我见过太多人写完代码直接上板,结果 debug 到崩溃。其实写个 testbench 花不了几分钟,但能帮你省下几小时的调试时间。

测试平台(testbench)说白了就是给被测试模块(DUT)提供激励,然后观察输出对不对。它不需要端口,也不需要综合。来看个完整的例子:

`timescale 1ns / 1ps

module tb_adder;

    // 声明信号
    reg  [3:0] a, b;
    wire [4:0] sum;

    // 实例化被测试模块
    adder u_adder (
        .a   (a),
        .b   (b),
        .sum (sum)
    );

    // 产生激励
    initial begin
        a = 4'd5; b = 4'd3;
        #10;
        a = 4'd10; b = 4'd7;
        #10;
        a = 4'd15; b = 4'd1;
        #10;
        $finish;
    end

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

endmodule

这里有几个要点:

  • `timescale:定义时间单位和精度。1ns/1ps 表示单位 1ns,精度 1ps。
  • initial 块:只执行一次,用于初始化或产生激励。
  • #10:延迟 10 个时间单位。这是仿真特有的语法,不可综合。
  • $monitor:系统任务,信号变化时自动打印。
我曾经犯过的错:仿真时忘了加 `timescale,结果 #10 到底延迟多久完全看工具心情。后来我养成了习惯,每个 testbench 第一行就是 `timescale。

3.5 避坑指南:新手最容易犯的 3 个错误

  1. 组合逻辑中产生 Latch:在 always @(*) 中,如果某个分支没有给所有变量赋值,综合工具会推断出锁存器。比如 if 没有 else,case 没有 default。解决办法:要么补全所有分支,要么在 always 块开头给所有变量赋默认值。
  2. 阻塞赋值与非阻塞赋值混用:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。混用会导致仿真和综合结果不一致。我见过一个项目,就因为这里写错了,仿真没问题,上板后数据总是不对。
  3. 敏感列表不完整:always @(a or b) 只对 a 和 b 敏感,如果内部用了 c,c 变化时不会触发。现在都建议用 always @(*),让工具自动推导敏感列表。

核心总结:

  • 模块 = 端口 + 内部逻辑
  • 组合逻辑用 assign 或 always @(*),时序逻辑用 always @(posedge clk)
  • 仿真不是可选项,是必选项
  • 写代码前先想清楚:这是组合还是时序?

嗯,这一章的内容就到这里。记住,Verilog 只是工具,真正的功夫在于理解硬件电路。下一章咱们聊聊更深入的东西——不过在那之前,先把这些基础练扎实了。


专注资料整理