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 知识体系结构图
下面这张图,把整个验证环境的搭建流程串起来了。你看一眼,心里就有数了:
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