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都定义好了。它们怎么串起来?我画个简单的流程给你看:

  1. Sequence产生一个Transaction(比如:写操作,数据0xAB)。
  2. Driver从Sequence拿到这个Transaction,解析出操作类型和数据。
  3. Driver通过接口的clocking block,把信号驱动到DUT的端口上。
  4. DUT接收到信号,执行写操作。
  5. Monitor通过接口的clocking block,采样DUT的输出信号。
  6. 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环境就是这么一层层搭起来的。

嗯,今天就到这里。你可以先动手把这三样东西写出来,跑个编译试试。有问题随时翻翻前面的内容,或者自己想想为什么。动手是最好的学习方式。