4. RTL设计与仿真

仿真,说白了就是给设计“照CT”。你写了一大堆RTL代码,到底能不能跑?功能对不对?边界情况处理了没?这些问题,光靠眼睛看代码是看不出来的。我见过太多工程师,代码写得飞起,一上板就抓瞎——最后发现是仿真没做透。

这一章,咱们就聊聊仿真那些事儿。从Testbench怎么写,到工具怎么用,再到波形怎么看、覆盖率怎么分析。嗯,都是实战中摸爬滚打出来的经验。

4.1 Testbench编写——不只是“喂激励”

很多人觉得Testbench就是给输入信号,然后看输出对不对。其实没那么简单。一个好的Testbench,应该具备三个要素:可复用、自检查、可调试。

4.1.1 基本结构

我习惯把Testbench分成几个模块:

  • 时钟与复位生成:最基础的部分,但容易出错
  • 激励生成:模拟真实场景的输入
  • 参考模型:用行为级描述实现预期行为
  • 比较器:自动比对输出与预期
  • 覆盖率收集:记录哪些场景被覆盖了

来看一个简单的例子。假设我们要测试一个同步FIFO:

module tb_fifo;

  // 参数定义
  parameter DATA_WIDTH = 8;
  parameter DEPTH = 16;

  // 信号声明
  reg clk, rst_n;
  reg wr_en, rd_en;
  reg [DATA_WIDTH-1:0] wr_data;
  wire [DATA_WIDTH-1:0] rd_data;
  wire full, empty;

  // 实例化DUT
  fifo #(
    .DATA_WIDTH(DATA_WIDTH),
    .DEPTH(DEPTH)
  ) u_fifo (
    .clk(clk),
    .rst_n(rst_n),
    .wr_en(wr_en),
    .rd_en(rd_en),
    .wr_data(wr_data),
    .rd_data(rd_data),
    .full(full),
    .empty(empty)
  );

  // 时钟生成
  initial begin
    clk = 0;
    forever #5 clk = ~clk;  // 100MHz
  end

  // 复位
  initial begin
    rst_n = 0;
    #20 rst_n = 1;
  end

  // 激励
  initial begin
    // 初始化
    wr_en = 0;
    rd_en = 0;
    wr_data = 0;

    // 等待复位释放
    @(posedge rst_n);
    #10;

    // 写数据
    repeat(10) begin
      @(posedge clk);
      wr_en = 1;
      wr_data = $random;
    end
    wr_en = 0;

    // 读数据
    #20;
    repeat(10) begin
      @(posedge clk);
      rd_en = 1;
    end
    rd_en = 0;

    #100;
    $finish;
  end

  // 自检查
  initial begin
    // 这里可以加入比对逻辑
    // 比如用队列记录写入的数据,读出时比对
  end

endmodule
我的习惯:用`$random`生成随机激励,但一定要加种子。否则每次仿真都一样,测不出边界情况。我曾经因为没加种子,漏掉了一个深度满时的写操作bug。

4.1.2 自检查机制

手动看波形?那是小项目才做的事。真正的工程化开发,必须让Testbench自己判断对错。我常用的方法有两种:

  • 队列比对法:把写入的数据存到一个队列里,读出时逐个比对
  • 参考模型法:写一个行为级的“黄金模型”,输出与DUT实时比对

举个例子,用队列比对FIFO:

// 在Testbench中
reg [DATA_WIDTH-1:0] expected_queue [0:255];
int wr_ptr, rd_ptr;

// 写操作时记录
always @(posedge clk) begin
  if (wr_en && !full) begin
    expected_queue[wr_ptr] = wr_data;
    wr_ptr++;
  end
end

// 读操作时比对
always @(posedge clk) begin
  if (rd_en && !empty) begin
    if (rd_data !== expected_queue[rd_ptr]) begin
      $error("Mismatch at time %0t: expected %h, got %h",
             $time, expected_queue[rd_ptr], rd_data);
    end
    rd_ptr++;
  end
end
注意:自检查不是万能的。它只能检查你写进去的比对逻辑。如果参考模型本身就有bug,那比对结果就是错的。我建议至少做一次“人工波形确认”,确保参考模型正确。

4.2 ModelSim与Vivado Simulator使用

工具嘛,说白了就是顺手就行。ModelSim老牌稳定,Vivado Simulator跟Vivado集成度高。我个人更习惯用Vivado Simulator做综合后仿真,因为时序信息更准。

4.2.1 ModelSim快速上手

ModelSim的常用命令,我整理了一张表:

命令 作用 我的用法
vlib work 创建库 每次新建项目第一步
vlog *.v 编译Verilog文件 可以用通配符,但小心同名文件
vsim work.tb_fifo 启动仿真 记得加`-voptargs=+acc`保留所有信号
add wave * 添加波形 我习惯先加顶层,再逐层深入
run 1us 运行指定时间 配合`run -all`使用

一个小技巧:用do文件(脚本)来跑仿真。这样每次改完代码,双击一下do文件就全自动了。我团队里,谁要是还在手动点按钮跑仿真,是要被笑话的。

4.2.2 Vivado Simulator特色

Vivado Simulator有个好处——可以直接用XDC约束文件里的时序信息。做后仿真的时,它能自动反标SDF(标准延迟格式)文件,模拟出真实的门级延迟。

启动方式也简单:

# 在Vivado Tcl Console中
launch_simulation -mode post-implementation -type functional

嗯,这里要注意:后仿真比前仿真慢得多。我一般只在关键模块或流片前才跑后仿。平时用前仿真就够了,省时间。

4.3 波形调试技巧——别在波形里“大海捞针”

波形调试,是仿真里最耗时的环节。我见过有人盯着波形看半小时,就为了找一根信号什么时候跳变的。其实有更好的方法。

4.3.1 设置标记与光标

ModelSim和Vivado都支持标记(Marker)和光标(Cursor)。我的习惯是:

  • 先找到出错的时刻(比如$error报出的时间点)
  • 在那个位置加一个光标
  • 然后往前推,看关键信号的变化

比如,FIFO读数据出错,我会先看读使能(rd_en)和空标志(empty)的时序。是不是在空的时候读了?还是读地址跳错了?

4.3.2 使用波形分组与颜色

信号多了,波形图就是一团乱麻。我习惯把信号分组:

  • 控制信号:用蓝色,比如使能、复位
  • 数据信号:用绿色,比如数据总线
  • 状态信号:用红色,比如满、空标志

这样一眼就能看出问题在哪。Vivado里右键信号就能改颜色,ModelSim里用`wave color`命令也行。

4.3.3 利用$display和$monitor

有时候波形太密,不如直接看打印信息。我常用:

// 在Testbench中
always @(posedge clk) begin
  if (wr_en) begin
    $display("[%0t] Write: addr=%0d, data=%h", $time, wr_addr, wr_data);
  end
  if (rd_en) begin
    $display("[%0t] Read: addr=%0d, data=%h", $time, rd_addr, rd_data);
  end
end

这样,仿真一跑完,看打印日志就能定位问题。比在波形里翻来翻去快多了。

避坑指南:我曾经在一个项目里,波形看起来完全正常,但输出就是不对。折腾了两天,最后发现是Testbench里一个信号声明成了wire,忘了加reg。从那以后,我每次写Testbench都会检查一遍信号类型。

4.4 覆盖率分析——你测全了吗?

覆盖率,说白了就是“你测了多少”。没有覆盖率分析,你根本不知道自己的仿真够不够。我见过有人跑了一万次仿真,结果只覆盖了20%的代码——那跟没跑有什么区别?

4.4.1 覆盖率的种类

类型 含义 我的关注点
代码覆盖率 每行代码是否被执行 必须100%,否则有死代码
翻转覆盖率 每个信号是否0→1和1→0都发生过 重点关注控制信号
条件覆盖率 每个分支条件是否都被评估过 if-else、case语句容易漏
功能覆盖率 用户自定义的场景是否被覆盖 需要手动写covergroup

4.4.2 如何收集覆盖率

在ModelSim里,加个命令就行:

vsim -coverage work.tb_fifo
add coverage -r /*
run -all
coverage report -file coverage.rpt

Vivado Simulator也类似,在仿真设置里勾上“Enable Coverage”就行。

4.4.3 功能覆盖率的实战

代码覆盖率只能告诉你“代码跑了没”,但跑的对不对,得靠功能覆盖率。比如FIFO,我想测“写满后继续写”的场景:

covergroup fifo_cg @(posedge clk);
  // 写满后继续写
  coverpoint {full, wr_en} {
    bins full_write = {2'b11};
  }
  // 读空后继续读
  coverpoint {empty, rd_en} {
    bins empty_read = {2'b11};
  }
  // 同时读写
  coverpoint {wr_en, rd_en} {
    bins both = {2'b11};
  }
endgroup

fifo_cg cg_inst = new();

这样,仿真结束后,我就能看到这些边界场景有没有被覆盖到。如果某个bin是0%,那就得补测试用例。

我的经验:覆盖率不是越高越好。有些场景可能永远达不到100%(比如某些异常路径)。我一般定个目标:代码覆盖率95%以上,功能覆盖率90%以上。剩下的5%,要么是冗余代码,要么是硬件不可能出现的状态。

4.5 本章小结

仿真这件事,说难不难,说简单也不简单。关键是要形成一套自己的流程:

  • Testbench要自检查,别靠眼睛看
  • 工具要会用脚本,别手动点
  • 波形调试要有方法,别大海捞针
  • 覆盖率要分析,别盲目跑