3. 验证环境搭建:UVM基础架构、Testbench组件、DUT封装与接口
好,咱们进入第三章。这一章是真正动手干活儿的地方。
前面两章聊了概念和策略,现在我们要把袖子撸起来,搭一个能跑起来的验证环境。说白了,就是给DUT(待测设计)搭个台子,让它能唱戏。
我个人习惯,搭环境之前先想清楚三件事:用什么框架、需要哪些零件、DUT怎么接进来。这三点搞定了,环境就稳了。
3.1 UVM基础架构:为什么选它?
UVM,全称Universal Verification Methodology。你想想看,如果没有UVM,每个人写验证环境都是野路子,今天张三用C写个driver,明天李四用SystemVerilog写个monitor,项目交接的时候简直灾难。
UVM说白了就是一套标准化打法。它把验证环境拆成几个固定的组件,每个组件有固定的职责和通信方式。我最早接触UVM是在一个AI加速器项目里,当时团队里有人觉得UVM太重,想自己搞一套轻量级的。结果呢?三个月后,那哥们儿自己默默把UVM捡回来了——因为维护成本太高了。
UVM的核心思想: 用面向对象的方式,把验证环境拆成可复用的组件。每个组件只干一件事,组件之间通过TLM(Transaction Level Modeling)通信。
UVM的架构,从上到下大概是这样:
- test:最顶层,控制整个验证流程。你可以在test里配置参数、启动sequence。
- env:环境层,把所有的agent、scoreboard、coverage collector组装在一起。
- agent:代理层,里面包含了driver、monitor、sequencer。一个agent对应一种接口协议。
- driver:驱动层,负责把transaction转成DUT能识别的信号时序。
- monitor:监视层,负责抓取DUT的输入输出信号,转成transaction。
- sequencer:序列器,负责把sequence里的transaction发给driver。
- scoreboard:计分板,负责比对期望值和实际值。
嗯,这里要注意:UVM的组件都是uvm_component的子类,它们有生命周期——build、connect、run、report。你写代码的时候,一定要在正确的phase里做正确的事。
我的小技巧: 刚开始学UVM,别急着把所有组件都塞进去。先搭一个最简单的环境——一个driver、一个monitor、一个scoreboard就够了。跑通了再往上加东西。我曾经见过有人一上来就搭了十几个agent,结果连编译都过不了,debug了一周。
3.2 Testbench组件:每个零件怎么用?
咱们一个一个来看。Testbench里的组件,说白了就是验证环境的“零件”。每个零件都有它该待的位置。
3.2.1 Driver:给DUT喂数据
Driver的职责很简单:从sequencer拿到transaction,然后按照接口时序,把数据打到DUT的输入端口上。
举个例子,假设DUT是一个简单的加法器,输入是a和b,输出是sum。Driver要做的就是把a和b的值,在时钟上升沿打出去。
class my_driver extends uvm_driver #(my_transaction);
`uvm_component_utils(my_driver)
virtual my_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 my_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转成信号
@(posedge vif.clk);
vif.a <= tr.a;
vif.b <= tr.b;
seq_item_port.item_done();
end
endtask
endclass
你看,代码其实不长。关键点在于:seq_item_port.get_next_item和seq_item_port.item_done。这两个调用必须成对出现,否则sequencer会卡住。
我曾经踩过的坑: 有一次我忘了调item_done(),结果sequence发了一个transaction之后,driver就再也不拿下一个了。整个仿真卡在那里,我还以为是DUT的问题,debug了两天才发现是driver少写了一行代码。所以,get_next_item和item_done一定要配对。
3.2.2 Monitor:监听DUT的行为
Monitor和Driver正好相反。Driver是往外发数据,Monitor是往里收数据。Monitor把DUT的输入输出信号抓下来,打包成transaction,然后发给scoreboard。
class my_monitor extends uvm_monitor;
`uvm_component_utils(my_monitor)
virtual my_if vif;
uvm_analysis_port #(my_transaction) ap;
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
task run_phase(uvm_phase phase);
my_transaction tr;
forever begin
@(posedge vif.clk);
tr = my_transaction::type_id::create("tr");
tr.a = vif.a;
tr.b = vif.b;
tr.sum = vif.sum;
ap.write(tr); // 通过analysis port发出去
end
endtask
endclass
Monitor里有个重要的东西叫uvm_analysis_port。它是个广播端口,可以连接多个接收者。比如你可以同时连到scoreboard和coverage collector。
3.2.3 Scoreboard:比对结果
Scoreboard是验证环境的“裁判”。它从monitor拿到实际结果,然后和期望值做比对。
class my_scoreboard extends uvm_scoreboard;
`uvm_component_utils(my_scoreboard)
uvm_analysis_imp #(my_transaction, my_scoreboard) imp;
function new(string name, uvm_component parent);
super.new(name, parent);
imp = new("imp", this);
endfunction
function void write(my_transaction tr);
int expected_sum;
expected_sum = tr.a + tr.b;
if(tr.sum !== expected_sum) begin
`uvm_error("MISMATCH", $sformatf("a=%0d, b=%0d, expected=%0d, got=%0d",
tr.a, tr.b, expected_sum, tr.sum))
end
endfunction
endclass
这里要注意:Scoreboard用的是uvm_analysis_imp,它和monitor的uvm_analysis_port是一对。一个发,一个收。
3.2.4 Sequence和Sequencer:控制数据流
Sequence是数据的“剧本”,Sequencer是“导演”。Sequence定义了一组transaction怎么产生,Sequencer负责把它们调度给Driver。
class my_sequence extends uvm_sequence #(my_transaction);
`uvm_object_utils(my_sequence)
function new(string name = "my_sequence");
super.new(name);
endfunction
task body();
my_transaction tr;
repeat(10) begin
tr = my_transaction::type_id::create("tr");
tr.randomize();
start_item(tr);
finish_item(tr);
end
endtask
endclass
嗯,这里有个细节:start_item和finish_item之间,你可以插入一些延迟或者等待条件。我习惯在start_item之后加一个#1ns,模拟真实场景中的时序不确定性。
3.3 DUT封装与接口:怎么把DUT接进来?
DUT封装,说白了就是给DUT穿件“外套”。这件外套叫wrapper或者interface。它的作用是让UVM环境能和DUT通信。
我一般用SystemVerilog的interface来做这件事。Interface里定义了时钟、复位、数据信号,以及一些控制信号。
interface my_if(input clk, input rst_n);
logic [7:0] a;
logic [7:0] b;
logic [8:0] sum;
// 时钟块,控制时序
clocking cb @(posedge clk);
output a, b;
input sum;
endclocking
// 驱动时序
task drive(input [7:0] a_val, input [7:0] b_val);
@(posedge clk);
a <= a_val;
b <= b_val;
endtask
// 监视时序
task monitor(output [7:0] a_val, output [7:0] b_val, output [8:0] sum_val);
@(posedge clk);
a_val = a;
b_val = b;
sum_val = sum;
endtask
endinterface
Interface写好后,怎么把它传给UVM组件呢?答案是uvm_config_db。在test层,我们把interface set进去,然后在driver和monitor里get出来。
// 在test的build_phase里
function void build_phase(uvm_phase phase);
uvm_config_db #(virtual my_if)::set(this, "env.agent.driver", "vif", vif);
uvm_config_db #(virtual my_if)::set(this, "env.agent.monitor", "vif", vif);
endfunction
关键点: uvm_config_db的路径必须和组件的层次路径完全一致。路径写错了,组件就拿不到interface。我见过最离谱的bug,就是路径里多了一个点或者少了一个斜杠,结果仿真跑起来全是X态。
DUT本身怎么封装?通常我们在top层例化DUT和interface,然后把interface的端口和DUT的端口连起来。
module top;
reg clk, rst_n;
my_if if_inst(clk, rst_n);
my_dut dut_inst(
.clk(clk),
.rst_n(rst_n),
.a(if_inst.a),
.b(if_inst.b),
.sum(if_inst.sum)
);
initial begin
clk = 0;
forever #5 clk = ~clk;
end
initial begin
rst_n = 0;
#20 rst_n = 1;
end
initial begin
run_test("my_test");
end
endmodule
你看,整个环境就串起来了。Top层负责生成时钟和复位,例化DUT和interface,然后调用run_test启动UVM环境。
我的建议: 刚开始搭环境,别用太复杂的DUT。找一个简单的模块,比如加法器、计数器,先把UVM的流程跑通。我当年第一个UVM环境就是给一个8位加法器搭的,虽然简单,但让我彻底理解了UVM的组件交互方式。后来做AI芯片的验证,那些复杂的DMA、MAC单元,本质上也是这套框架。
3.4 环境搭建的常见问题
最后,我总结几个我踩过的坑,你们遇到类似问题可以少走弯路。
| 问题 | 原因 | 解决办法 |
|---|---|---|
| 仿真卡住不动 | driver忘了调item_done() |
检查driver的run_phase,确保get_next_item和item_done成对出现 |
| interface信号全是X态 | uvm_config_db路径写错了 |
打印config_db的路径,和组件的层次路径对比 |
| scoreboard收不到数据 | analysis_port和analysis_imp没连上 | 在env的connect_phase里检查port连接 |
| sequence不执行 | 忘了在test里启动sequence | 在test的run_phase里调sequence.start() |
好了,这一章的内容就到这儿。UVM环境搭建,说白了就是搭积木——把driver、monitor、scoreboard、sequencer这些组件拼起来,然后用interface把DUT接进来。框架是固定的,但每个项目都有自己的细节。多动手,多debug,慢慢就熟了。
下一章,咱们聊聊验证计划怎么写。那玩意儿看着简单,但写好了能省一半的验证时间。