2、RTL仿真基础:VCS/Verilog编译器的使用,testbench结构,initial/always块

好,咱们正式开始动手了。这一章我带你过一遍RTL仿真最核心的几个工具和概念。说白了,就是让你知道怎么把写好的代码跑起来,看看它到底对不对。

2.1 VCS/Verilog编译器:怎么把代码跑起来

VCS是Synopsys家的仿真工具,业界用的非常广。我个人习惯用命令行来跑仿真,虽然现在很多IDE(比如Verdi)也集成了图形界面,但命令行更灵活,也更容易集成到自动化流程里。

一个最基本的VCS编译命令长这样:

vcs -full64 -sverilog -R -debug_access+all \
    -f filelist.f \
    -l compile.log

我来拆解一下这几个参数:

  • -full64:64位模式编译。现在设计越来越大,不用64位很容易爆内存。
  • -sverilog:支持SystemVerilog语法。如果你只写纯Verilog,可以不加,但我建议你加上,因为SystemVerilog的很多特性(比如interface、assertion)在后仿真里非常有用。
  • -R:编译完直接运行仿真。省得你再多敲一行./simv
  • -debug_access+all:打开所有调试权限。这样你才能在Verdi里看波形、设断点。我刚开始做验证时,经常忘了加这个,结果跑完仿真发现波形dump不出来,又得重跑一遍,浪费时间。
  • -f filelist.f:指定文件列表。把所有RTL文件、testbench文件、库文件都写在一个.f文件里,方便管理。
  • -l compile.log:把编译信息输出到日志文件。出了问题方便排查。
小技巧: 我习惯在filelist.f里用+incdir+指定include路径,这样写代码时`include指令就不用写绝对路径了。比如:+incdir+../common

编译通过后,会生成一个可执行文件simv。运行它:

./simv -l run.log

嗯,这里要注意:如果仿真跑的时间很长,或者你想后台运行,可以加-batch参数。我曾经有一次跑一个大型SoC的仿真,忘了加这个,结果终端一关,跑了一天的仿真全白费了……从那以后,我只要跑长时间仿真,必加-batch

2.2 Testbench结构:给DUT搭个测试台

Testbench,说白了就是给你的设计(DUT,Design Under Test)搭一个测试环境。它没有端口,因为它就是顶层。它负责产生激励、监测输出、判断对错。

一个典型的testbench结构长这样:

module tb_top;

  // 1. 信号声明
  reg        clk;
  reg        rst_n;
  reg  [7:0] data_in;
  wire [7:0] data_out;

  // 2. DUT实例化
  my_design u_dut (
    .clk     (clk),
    .rst_n   (rst_n),
    .data_in (data_in),
    .data_out(data_out)
  );

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

  // 4. 复位与激励
  initial begin
    // 初始化
    rst_n   = 0;
    data_in = 0;

    // 复位
    #20 rst_n = 1;

    // 发送激励
    #10 data_in = 8'hA5;
    #10 data_in = 8'h5A;
    #10 data_in = 8'hFF;

    // 等待足够时间,然后结束仿真
    #100 $finish;
  end

  // 5. 监测输出(可选)
  initial begin
    $monitor("time=%0t, data_in=%h, data_out=%h", $time, data_in, data_out);
  end

endmodule

这个结构里,有几点我想强调一下:

  • 信号声明:驱动DUT输入用reg,监测DUT输出用wire。这是Verilog的规则,别搞反了。
  • DUT实例化:名字最好有意义,比如u_dutu_core。我见过有人用u0u1,后仿真debug时根本分不清哪个是哪个。
  • 时钟生成:用forever配合#delay是最常见的写法。注意时钟周期要跟设计匹配。
  • 激励发送:用#delay控制时序。这里我建议你养成好习惯:先复位,再发激励。复位时间要足够长,确保所有寄存器都复位到位。
避坑指南: 我曾经在项目中遇到一个问题:仿真跑完,波形看起来全对,但后仿真就是出错。查了半天,发现是testbench里忘了加$finish,仿真一直跑下去,最后时间戳溢出,波形乱了。所以,记得在激励发完后,加一个$finish或者$stop

2.3 Initial/Always块:仿真的两大引擎

这两个块是Verilog仿真的核心。你想想看,没有它们,代码就是一堆静态的连线,动不起来。

2.3.1 Initial块:只执行一次

initial块在仿真开始时执行,且只执行一次。它通常用来做初始化、产生激励、控制仿真流程。

initial begin
  // 初始化
  rst_n = 0;
  // 等待20ns
  #20;
  // 释放复位
  rst_n = 1;
  // 等待10ns
  #10;
  // 发送数据
  data_in = 8'hA5;
end

注意:initial块里的语句是按顺序执行的。每个#delay都会让仿真时间前进。如果你有多个initial块,它们是并发执行的,但每个块内部是顺序的。

我个人习惯把不同功能放在不同的initial块里。比如一个块专门负责时钟,一个块专门负责复位,一个块专门发激励。这样代码清晰,debug时也容易定位问题。

2.3.2 Always块:循环执行

always块会一直循环执行。它通常用来描述硬件行为,比如时钟生成、组合逻辑、时序逻辑。

常见的写法有几种:

  • 时钟生成always #5 clk = ~clk; —— 每5ns翻转一次,产生10ns周期的时钟。
  • 组合逻辑always @(*) y = a & b; —— 输入a或b变化时,重新计算y。
  • 时序逻辑always @(posedge clk or negedge rst_n) begin ... end —— 时钟上升沿或复位下降沿触发。
重点: 在testbench里,always块最常用的就是时钟生成。但要注意,如果你在always块里用了@(posedge clk),那它就会变成时序逻辑,跟DUT里的寄存器行为一致。这在后仿真里特别有用,可以用来模拟真实的时钟行为。

为什么会这样?因为always块本质上是一个无限循环。仿真器在每个时间步都会检查它的敏感列表,如果条件满足,就执行一次。然后继续等待下一个时间步。所以,它非常适合描述那些需要持续运行的硬件行为。

2.4 实战小贴士:让仿真更高效

最后,分享几个我在项目中积累的小经验:

  • $time打时间戳:在$monitor$display里加上$time,可以清楚知道每个事件发生的时间点。debug时特别有用。
  • #0避免竞争:有时候多个initial块同时赋值,会产生竞争。加一个#0可以让赋值推迟到下一个delta cycle,避免不确定行为。我刚开始学的时候,经常被这种问题坑,后来就养成了习惯。
  • force/release做临时调试:如果仿真中发现某个信号值不对,可以用force强制赋值,看看能不能绕过问题。但注意,这只是调试手段,不能用在最终代码里。
  • vcs -R -gui启动图形界面:如果你不习惯命令行,可以用-gui参数启动VCS的图形界面,里面可以看波形、设断点、单步执行。不过我个人还是喜欢命令行+后处理看波形,效率更高。

好了,这一章的内容就到这里。下一章我们会深入SystemVerilog的验证特性,比如interface、class、随机化等。这些东西能让你的testbench更强大、更灵活。咱们到时候见。