3. 验证环境搭建:SystemVerilog/UVM环境搭建、Makefile与仿真脚本编写

好,咱们进入正题。验证环境搭建,说白了就是给DUT(待测设计)搭个台子,让它能跑起来、能被测。很多新人一上来就急着写testcase,结果环境没搭好,跑一步卡三步。我个人习惯是,先把地基打牢,后面才省心。

这一章,我带你手把手搭一个可复用的UVM验证环境,再配上顺手的Makefile和仿真脚本。嗯,都是我在项目里踩过坑之后总结出来的套路。

3.1 UVM环境的核心骨架

一个标准的UVM环境,长什么样?说白了就是几个组件拼起来:

  • test:测试用例的入口,控制整个验证流程
  • env:环境容器,把agent、scoreboard、coverage都装进去
  • agent:包含driver、monitor、sequencer,负责和DUT打交道
  • driver:把sequence产生的transaction转成DUT能吃的信号
  • monitor:偷听DUT的接口信号,转成transaction送给scoreboard
  • scoreboard:比对预期结果和实际结果,发现bug就报
  • coverage:记录哪些功能点被测到了,哪些还没测到

我刚开始搭环境时,总想把所有东西塞进一个文件里。后来发现,维护起来简直是噩梦。你想想看,一个文件几千行,找个信号定义都要翻半天。所以我建议,每个组件一个文件,命名清晰,目录结构规整。

核心原则:一个文件只做一件事。driver只管驱动,monitor只管监听,别混在一起。

3.2 目录结构设计

这是我个人比较喜欢的一套目录结构,用了好几年,分享给你:

project_root/
├── rtl/                # DUT的RTL代码
│   └── dut.v
├── tb/                 # 验证环境
│   ├── test/           # testcase
│   │   ├── base_test.sv
│   │   └── test_smoke.sv
│   ├── env/            # 环境组件
│   │   ├── my_env.sv
│   │   ├── my_agent.sv
│   │   ├── my_driver.sv
│   │   ├── my_monitor.sv
│   │   └── my_sequencer.sv
│   ├── scoreboard/     # 比对逻辑
│   │   └── my_scoreboard.sv
│   ├── coverage/       # 覆盖率收集
│   │   └── my_coverage.sv
│   └── sequences/      # 激励序列
│       └── my_sequence.sv
├── sim/                # 仿真目录
│   ├── Makefile
│   └── run_sim.sh
└── waves/              # 波形文件
    └── dump.vcd

为什么要这么分?我曾经在一个项目里,把所有文件都扔在同一个目录下。结果项目做到一半,想找一个特定的sequence文件,翻了半天没找到。后来花了半天时间重构目录,从那以后,我再也不敢偷懒了。

3.3 核心组件代码示例

咱们来看几个关键组件的代码骨架。先看driver:

class my_driver extends uvm_driver #(my_transaction);
  `uvm_component_utils(my_driver)

  virtual dut_if vif;

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction

  function void build_phase(uvm_phase phase);
    if(!uvm_config_db #(virtual dut_if)::get(this, "", "vif", vif))
      `uvm_fatal("NOVIF", "vif not set!")
  endfunction

  task run_phase(uvm_phase phase);
    my_transaction tr;
    forever begin
      seq_item_port.get_next_item(tr);
      // 把transaction转成DUT接口信号
      drive_transaction(tr);
      seq_item_port.item_done();
    end
  endtask

  task drive_transaction(my_transaction tr);
    @(posedge vif.clk);
    vif.data <= tr.data;
    vif.valid <= 1'b1;
    @(posedge vif.clk);
    vif.valid <= 1'b0;
  endtask
endclass

再看monitor,它和driver是镜像关系:

class my_monitor extends uvm_monitor;
  `uvm_component_utils(my_monitor)

  virtual dut_if vif;
  uvm_analysis_port #(my_transaction) ap;

  function void build_phase(uvm_phase phase);
    if(!uvm_config_db #(virtual dut_if)::get(this, "", "vif", vif))
      `uvm_fatal("NOVIF", "vif not set!")
    ap = new("ap", this);
  endfunction

  task run_phase(uvm_phase phase);
    my_transaction tr;
    forever begin
      @(posedge vif.clk);
      if(vif.valid) begin
        tr = my_transaction::type_id::create("tr");
        tr.data = vif.data;
        ap.write(tr);
      end
    end
  endtask
endclass

小技巧:driver和monitor的接口信号定义最好放在同一个interface里,这样两边保持一致,不容易出错。我见过有人driver用一套信号名,monitor用另一套,结果比对时对不上,查了半天才发现是信号名写错了。

3.4 Makefile编写实战

Makefile这东西,说白了就是帮我们自动化跑仿真。不用每次敲一长串命令。我见过有些团队用脚本生成Makefile,我个人觉得没必要,手写一个简洁的Makefile反而更可控。

下面这个是我常用的模板,你直接拿去改改就能用:

# 工具路径
VCS      = vcs
VERDI    = verdi
SIMV     = ./simv

# 文件列表
RTL_FILES = ../rtl/dut.v
TB_FILES  = ../tb/test/base_test.sv \
            ../tb/env/my_env.sv \
            ../tb/env/my_agent.sv \
            ../tb/env/my_driver.sv \
            ../tb/env/my_monitor.sv \
            ../tb/env/my_sequencer.sv \
            ../tb/scoreboard/my_scoreboard.sv \
            ../tb/coverage/my_coverage.sv \
            ../tb/sequences/my_sequence.sv

# 编译选项
VCS_OPTS = -sverilog +v2k -debug_acc+all \
           -timescale=1ns/1ps \
           -l compile.log

# 仿真选项
SIM_OPTS = -l sim.log +UVM_TESTNAME=$(TEST)

# 默认目标
all: compile

# 编译
compile:
	$(VCS) $(VCS_OPTS) $(RTL_FILES) $(TB_FILES) -o $(SIMV)

# 运行仿真
run: compile
	$(SIMV) $(SIM_OPTS)

# 查看波形
verdi:
	$(VERDI) -ssf waves.fsdb &

# 清理
clean:
	rm -rf $(SIMV) csrc *.log *.key *.vpd waves.fsdb verdiLog

# 帮助
help:
	@echo "Usage:"
	@echo "  make compile   - 编译环境"
	@echo "  make run TEST=test_smoke - 运行测试用例"
	@echo "  make verdi     - 打开波形"
	@echo "  make clean     - 清理中间文件"

注意:Makefile里的缩进必须用Tab,不能用空格。我刚开始用Makefile时,被这个坑了好几次。明明看着没问题,一跑就报错。后来才发现是编辑器自动把Tab转成了空格。建议你在编辑器里设置一下,让Tab键直接输出Tab字符。

3.5 仿真脚本编写

除了Makefile,我还会写一个shell脚本,用来处理一些更复杂的场景。比如批量跑多个testcase、收集覆盖率、生成报告等。

#!/bin/bash
# run_sim.sh - 仿真运行脚本

# 设置环境变量
export UVM_HOME=/tools/synopsys/vcs/UVM-1.2
export TEST_NAME=${1:-test_smoke}
export SEED=${2:-random}

echo "=========================================="
echo "  开始仿真: $TEST_NAME"
echo "  随机种子: $SEED"
echo "=========================================="

# 编译
make compile 2>&1 | tee compile_${TEST_NAME}.log
if [ $? -ne 0 ]; then
    echo "编译失败,请检查 compile_${TEST_NAME}.log"
    exit 1
fi

# 运行仿真
make run TEST=$TEST_NAME 2>&1 | tee sim_${TEST_NAME}.log
if [ $? -ne 0 ]; then
    echo "仿真失败,请检查 sim_${TEST_NAME}.log"
    exit 1
fi

# 检查结果
grep -q "UVM_TEST_DONE" sim_${TEST_NAME}.log
if [ $? -eq 0 ]; then
    echo "测试通过: $TEST_NAME"
else
    echo "测试失败: $TEST_NAME"
    exit 1
fi

echo "=========================================="
echo "  仿真完成"
echo "=========================================="

这个脚本我用了很久,基本逻辑就是:编译 → 仿真 → 检查结果。每一步失败了都会停下来报错,不会继续往下跑。你想想看,如果编译都失败了,还继续跑仿真,那不是浪费时间吗?

3.6 知识体系结构图

下面这张图,把整个验证环境的搭建流程串起来了。你看一眼,心里就有数了:

验证环境搭建知识体系 RTL代码 UVM验证组件 Makefile/脚本 编译阶段 (vcs + UVM) 仿真运行 (simv + testcase) 仿真日志 (sim.log) 波形文件 (waves.fsdb) 覆盖率报告 输入 → 编译 → 仿真 → 输出,环环相扣 Makefile串联整个流程,一键完成

3.7 避坑指南

最后,分享几个我踩过的坑,你遇到了可以少走弯路:

  • 接口传递:UVM里用uvm_config_db传递virtual interface时,路径一定要写对。我曾经因为少写了一个层级,查了整整一天。建议在build_phase里加个打印,确认interface有没有成功get到。
  • 时间尺度:编译时别忘了加-timescale=1ns/1ps。我见过有人没加,结果仿真时间单位默认是1s,跑一个时钟周期等了一万年。
  • 随机种子:调试时固定种子,回归时随机种子。这个习惯一定要养成。我刚开始做验证时,每次跑都随机种子,结果bug复现不了,急得团团转。
  • 波形dump:建议在test的build_phase里加个开关,控制是否dump波形。调试时开,回归时关,省磁盘空间也省时间。

我的习惯:每次新建一个项目,先把Makefile和目录结构搭好,然后跑一个最简单的smoke test,确保整个链路通了,再开始写复杂的testcase。这样后面出问题了,至少知道不是环境的问题。

好了,这一章的内容就到这。环境搭好了,下一章咱们就可以开始写testcase了。记住,环境搭得越稳,后面验证越顺。


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