2. 搭建第一个UVM环境:DUT设计、接口定义、Transaction定义
好,咱们正式开始动手了。
上一章我们聊了UVM是什么,能干什么。这一章,咱们直接撸起袖子,搭一个最小的UVM验证环境出来。别怕,这个环境很简单,但五脏俱全。
我个人习惯,搭建任何验证环境,第一步不是写代码,而是先搞清楚三件事:待测设计(DUT)长什么样?怎么跟它通信?通信的数据长什么样? 说白了,就是DUT、接口、Transaction。这三样定下来,后面的组件才有根。
2.1 先看看我们的DUT:一个简单的FIFO
为了不让大家被复杂的逻辑绕晕,我选了一个最经典的模块——同步FIFO。你想想看,FIFO几乎是所有数字芯片里都会用到的模块,用它来入门再合适不过了。
这个FIFO的规格很简单:
- 深度:16
- 数据位宽:8 bit
- 支持同时读写
- 有满标志(full)和空标志(empty)
嗯,这里要注意,虽然DUT很简单,但我们的验证环境要把它当成一个黑盒子。我们只关心它的输入输出,不关心内部怎么实现。
核心原则:验证环境看DUT,就像测试工程师看一块芯片——只看引脚,不看内部。
下面是DUT的接口定义,我用Verilog写了一下:
module fifo (
input clk,
input rst_n,
input wr_en,
input [7:0] wr_data,
input rd_en,
output [7:0] rd_data,
output full,
output empty
);
// 内部实现省略,大家知道接口就行
endmodule
这个模块有8个端口。我们的验证环境,就是要通过这8个端口,模拟各种场景去“折磨”它。
2.2 定义接口:UVM与DUT的桥梁
DUT的端口是硬件信号。但我们的验证环境跑在软件层面,怎么把软件世界的激励变成硬件世界的电平变化?
答案就是接口(interface)。
SystemVerilog的interface,说白了就是一组信号的集合。它把DUT的端口打包成一个对象,方便我们在验证环境里传递和驱动。
我在项目中遇到过很多新手,直接把DUT的端口散落在各个组件里,结果改一个信号名,满世界都要改。用interface打包一下,清爽多了。
来看看我们这个FIFO的接口怎么定义:
interface fifo_if (input clk, input rst_n);
logic wr_en;
logic [7:0] wr_data;
logic rd_en;
logic [7:0] rd_data;
logic full;
logic empty;
// 时钟块,用于驱动和采样
clocking drv_cb @(posedge clk);
default input #1step output #1step;
output wr_en, wr_data, rd_en;
input rd_data, full, empty;
endclocking
// 监控时钟块
clocking mon_cb @(posedge clk);
default input #1step;
input wr_en, wr_data, rd_en, rd_data, full, empty;
endclocking
// 驱动器的modport
modport drv_mp (clocking drv_cb, input clk, rst_n);
// 监控器的modport
modport mon_mp (clocking mon_cb, input clk, rst_n);
endinterface
我的小技巧:接口里一定要加clocking block和modport。clocking block帮你解决了时序问题,modport让不同组件只看到自己需要的信号。这俩是黄金搭档,别省。
你看,接口里我定义了两种clocking block:drv_cb用于驱动器(driver)去驱动信号,mon_cb用于监控器(monitor)去采样信号。为什么分开?因为驱动和采样的时序要求不一样。驱动器需要先输出再等待,监控器只需要采样。分开更灵活。
2.3 Transaction:数据长什么样?
接口搞定了,接下来要定义数据。在UVM里,数据被封装成Transaction。你可以把它理解成一个“数据包”,里面装着一次操作的所有信息。
对于我们的FIFO,一次操作要么是写,要么是读。所以Transaction里需要包含:
- 操作类型:写还是读
- 数据:写操作时,数据是什么
我习惯把Transaction定义成UVM的sequence_item。为什么叫sequence_item?因为它是sequence(序列)里产生的一个个“条目”。
class fifo_transaction extends uvm_sequence_item;
// 操作类型枚举
typedef enum {WRITE, READ} oper_t;
rand oper_t oper;
rand bit [7:0] data;
// 约束:写操作时数据随机,读操作时数据无关
constraint c_oper {
oper dist {WRITE := 50, READ := 50};
}
constraint c_data {
if (oper == WRITE) data inside {[0:255]};
else data == 0;
}
// UVM自动化宏
`uvm_object_utils_begin(fifo_transaction)
`uvm_field_enum(oper_t, oper, UVM_ALL_ON)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "fifo_transaction");
super.new(name);
endfunction
endclass
这里有几个点我想强调一下:
- rand关键字:让oper和data可以随机化。验证的精髓就是随机,别写死。
- 约束:我加了一个简单的约束,让写和读各占50%的概率。实际项目中,你可能需要更复杂的约束,比如连续写几次再读。
- `uvm_field_*宏:这些宏实现了copy、compare、print等常用函数。我刚开始学UVM时,总觉得这些宏是黑魔法,后来发现它们就是帮你省去写重复代码的。用就完了。
避坑指南:我曾经在一个项目里忘了加`uvm_object_utils_begin/end宏,结果跑仿真时transaction的print函数死活不打印数据。查了半天,原来是自动化宏没加。记住,所有继承自uvm_sequence_item的类,一定要加这个宏。
2.4 三者如何协作?
好了,DUT、接口、Transaction都定义好了。它们怎么串起来?我画个简单的流程给你看:
- Sequence产生一个Transaction(比如:写操作,数据0xAB)。
- Driver从Sequence拿到这个Transaction,解析出操作类型和数据。
- Driver通过接口的clocking block,把信号驱动到DUT的端口上。
- DUT接收到信号,执行写操作。
- Monitor通过接口的clocking block,采样DUT的输出信号。
- Monitor把采样到的信号打包成另一个Transaction,发给Scoreboard做比对。
你看,整个流程里,Transaction是“灵魂”,接口是“手脚”,DUT是“被测试的对象”。三者缺一不可。
我个人习惯,在定义Transaction时,会顺便把它的print函数重写一下,方便调试:
function void do_print(uvm_printer printer);
printer.print_string("oper", oper.name());
printer.print_hex("data", data, 8);
endfunction
这样在仿真日志里,一眼就能看出当前Transaction是什么操作、什么数据。调试效率高很多。
2.5 本章小结
这一章我们干了三件事:
- 定义了一个简单的FIFO作为DUT
- 用interface把DUT的端口打包,并加入了clocking block和modport
- 用Transaction定义了数据包,包含了操作类型和数据
这些东西看起来简单,但它们是整个UVM环境的基石。下一章,我们会基于这三样东西,搭建出真正的UVM组件——driver、monitor、scoreboard。到时候你就会发现,原来UVM环境就是这么一层层搭起来的。
嗯,今天就到这里。你可以先动手把这三样东西写出来,跑个编译试试。有问题随时翻翻前面的内容,或者自己想想为什么。动手是最好的学习方式。