第二章 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、always块与assign语句、Testbench编写基础

各位同学,欢迎来到第二章。这一章我们聊聊Verilog,这是SoC设计的“普通话”。你想想看,没有它,你拿什么跟工具沟通?拿什么描述你的硬件想法?

我个人习惯把Verilog看作一种“画电路图的文字版”。你画电路图时,有门、有连线、有触发器。Verilog里,assign就是连线,always就是那些带时钟的触发器。说白了,它就是让你用代码把电路“写”出来。

2.1 模块化设计:搭积木的艺术

做SoC,没人会写一个几百万门的单文件。那太疯狂了。我们讲“模块化”,就是把大系统拆成小模块,每个模块干一件事。

我在项目中遇到过最头疼的事,就是接手一个同事写的“超级模块”——一个文件上万行,什么功能都揉在一起。想改个计数器,得翻半天。从那以后,我坚持每个模块只做一件事,接口要清晰。

一个标准的Verilog模块长这样:

module counter (
    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'd0;
        else if (en)
            count <= count + 1'b1;
    end

endmodule

注意看,端口列表里我用了wirereg。嗯,这里要注意:wire一般用在assign赋值,或者模块的输入;reg用在always块里。但别死记硬背,后面我们慢慢就熟了。

小技巧: 模块名最好跟文件名一致。比如这个模块叫 counter,文件就叫 counter.v。这是行业惯例,工具也喜欢这样。

2.2 组合逻辑 vs 时序逻辑

这是新手最容易搞混的地方。我简单解释一下:

  • 组合逻辑:输出只取决于当前输入。没有记忆,没有时钟。比如一个加法器,输入1+1,输出就是2。
  • 时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。说白了,它有“记忆”。比如计数器,它记得自己数到哪了。

为什么会这样?因为时序逻辑里有触发器(Flip-Flop),而组合逻辑只有门电路。

我曾经犯过一个低级错误:在组合逻辑里试图“记住”一个值。结果仿真时数据乱跳,查了半天才发现是忘了加时钟。从那以后,我写代码前会先问自己:这个信号需要记忆吗?需要,就用时序逻辑。

2.3 always块与assign语句

这两个是Verilog里最常用的描述方式。我习惯这么区分:

语句 用途 典型场景
assign 组合逻辑 数据选择器、加法器、译码器
always @(*) 组合逻辑 复杂组合逻辑、状态机中的下一状态
always @(posedge clk) 时序逻辑 计数器、寄存器、状态机中的状态更新

举个例子,一个2选1数据选择器:

// 用assign实现
assign out = sel ? b : a;

// 用always实现
always @(*) begin
    if (sel)
        out = b;
    else
        out = a;
end

两种写法功能一样。我个人习惯:简单的组合逻辑用assign,复杂的用always @(*)。为什么?因为always块里可以写if-elsecase,可读性更好。

避坑指南:always @(posedge clk) 里,赋值要用 <= (非阻塞赋值)。在组合逻辑的 always @(*) 里,要用 =(阻塞赋值)。混用了,仿真结果会跟你预期不一样。我曾经因为这个,调了整整一个下午。

2.4 Testbench编写基础

写完了模块,怎么知道它对不对?靠Testbench。说白了,Testbench就是给设计模块“喂”激励,然后看它输出对不对。

一个最简单的Testbench结构:

`timescale 1ns / 1ps

module tb_counter;

    // 信号声明
    reg        clk;
    reg        rst_n;
    reg        en;
    wire [7:0] count;

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

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

    // 测试激励
    initial begin
        // 初始化
        rst_n = 0;
        en    = 0;
        #20;
        rst_n = 1;
        #10;
        en = 1;
        #100;
        en = 0;
        #50;
        $finish;
    end

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

endmodule

你看,Testbench里没有端口,因为它不是要综合的硬件。它只是一个仿真环境。我习惯把Testbench分成三部分:

  1. 信号声明和模块实例化:就像搭积木,把被测模块放进来。
  2. 激励生成:用initial块产生时钟、复位、数据。
  3. 结果检查:可以用$monitor打印,也可以用if判断输出对不对。
重要提醒: 写Testbench时,别忘了加 `timescale。没有它,工具不知道你的 #5 是5纳秒还是5皮秒。我刚开始学时就吃过这个亏,仿真结果全是乱的。

嗯,这一章的内容就这些。记住,Verilog不是编程语言,它是硬件描述语言。你写的是电路,不是软件。多写、多仿真、多犯错,慢慢就找到感觉了。

下一章,我们聊聊如何用Verilog搭建一个简单的SoC子系统。到时候,你会看到这些基础模块是怎么组合成系统的。