UVM Basics

Chapter 3 of 10

Build and Run Your First UVM Test

Reuse the SV Basics capstone's mux2 DUT, wrap it in a minimal UVM skeleton, and actually run it — a near-empty driver, a test, and the config_db handoff that gets a virtual interface from the module domain into the UVM domain.

Chapter 2 covered uvm_component, phasing, objections, and reporting — all concepts, nothing running yet. This chapter fixes that: the same mux2 DUT you drove by hand in the SV Basics capstone (chapter 16), now driven by an actual UVM skeleton, actually running. Mirrors the role SV ch3 (first-simulation) played in that track — get something running before going deep.

Before anything else: pick a UVM-capable simulator

SV ch3 told you any free EDA Playground simulator, including Icarus Verilog, covers that whole track. That stops being true here — Icarus Verilog doesn't support the UVM class library, so running this chapter's example on it fails with an opaque "uvm_pkg not found"-style error, not a helpful one.

On EDA Playground's Tools & Simulators panel, pick a simulator that lists UVM support (for example, Aldec Riviera-PRO, which EDA Playground has historically offered without requiring your own commercial license — check the current list, since simulator offerings there do change over time) and, if the simulator's options expose a UVM library version, select one (UVM 1.2 is a safe default). Every example from here on assumes this.

The DUT and interface: unchanged from the capstone

Same mux2 and mux2_if as chapter 16's capstone — nothing new here, just a reminder of what they look like:

interface mux2_if;
  logic sel, a, b, y;
 
  modport dut_mp (input sel, a, b, output y);
  modport tb_mp  (output sel, a, b, input y);
endinterface
 
module mux2 (mux2_if.dut_mp bus);
  always_comb begin
    bus.y = bus.sel ? bus.b : bus.a;
  end
endmodule

The driver: a near-empty uvm_component

The capstone's driver class held a virtual mux2_if.tb_mp vif, assigned through its constructor. This driver holds the same handle, but gets it a different way — through uvm_config_db, UVM's standard way to hand a virtual interface from the plain-SystemVerilog module domain into the UVM class domain. The full explanation of why this works waits until chapter 5; for now, treat it as the standard pattern to copy:

class my_driver extends uvm_component;
  `uvm_component_utils(my_driver)
 
  virtual mux2_if.tb_mp vif;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    if (!uvm_config_db#(virtual mux2_if.tb_mp)::get(this, "", "vif", vif))
      `uvm_fatal("DRV", "no virtual interface set for vif -- check the config_db::set() call in the top module")
  endfunction
 
  task run_phase(uvm_phase phase);
    phase.raise_objection(this);
 
    vif.sel = 0; vif.a = 1; vif.b = 0;
    #1;
    `uvm_info("DRV", $sformatf("sel=%0b a=%0b b=%0b -> y=%0b", vif.sel, vif.a, vif.b, vif.y), UVM_MEDIUM)
 
    vif.sel = 1; vif.a = 1; vif.b = 0;
    #1;
    `uvm_info("DRV", $sformatf("sel=%0b a=%0b b=%0b -> y=%0b", vif.sel, vif.a, vif.b, vif.y), UVM_MEDIUM)
 
    phase.drop_objection(this);
  endtask
endclass

Two familiar things and one new pattern:

  • super.build_phase(phase) first, then the config_db::get() call — exactly the habit chapter 2 covered. get() returns a bit: 1 on success, 0 if nothing was ever set() for this path, which is why it's wrapped in if (!...) with a `uvm_fatal — a driver that silently drove garbage because its virtual interface was never assigned would be a much worse failure mode than stopping immediately with a clear message.
  • run_phase does exactly two things, wrapped in raise_objection/drop_objection (chapter 2 again): drive a sel/a/b combination, wait a time unit for the combinational logic to settle (same #1 simplification the capstone used), and `uvm_info the result.

The test: constructs the driver, nothing else

class my_test extends uvm_test;
  `uvm_component_utils(my_test)
 
  my_driver drv;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    drv = my_driver::type_id::create("drv", this);
  endfunction
endclass

uvm_test is a uvm_component subclass UVM provides as a conventional marker for the top of the hierarchy — nothing about it behaves differently from any uvm_component you already know from chapter 2. my_driver::type_id::create("drv", this) builds the driver instead of a plain new(...) call — this is the UVM factory at work, and why it's spelled that way instead of new waits until chapter 6. For now, the rule to copy is simple: inside build_phase, construct child components with SomeClass::type_id::create("instance_name", this), not new(...).

Wiring it up: the top-level module

`include "uvm_macros.svh"
import uvm_pkg::*;
 
module tb_top;
  mux2_if bus_if();
  mux2 dut (bus_if);
 
  initial begin
    uvm_config_db#(virtual mux2_if.tb_mp)::set(null, "*", "vif", bus_if);
    run_test("my_test");
  end
endmodule

Every file that uses UVM needs `include "uvm_macros.svh" and import uvm_pkg::*; before anything else — that's what makes `uvm_info, uvm_component, uvm_config_db, and everything else UVM-provided available at all.

The initial block does two things, in order: uvm_config_db#(virtual mux2_if.tb_mp)::set(null, "*", "vif", bus_if) publishes the real bus_if instance under the field name "vif", visible to any component that asks (the "*" means "any instance path"); then run_test("my_test") starts the phase engine, constructs exactly one my_test as the root of the component tree (conventionally called uvm_test_top), and runs every phase in order. set() has to happen before run_test() — chapter 2's "build_phase runs top-down" material is exactly why: by the time my_test's build_phase constructs drv, and drv's own build_phase calls get(), the value is already there waiting.

What this leaves unexplained (exactly two things)

  • `uvm_component_utils/type_id::create() — this is the UVM factory; chapter 6 covers what it actually does and why it matters.
  • uvm_config_db#(...)::set()/get() — chapter 5 covers the full mechanics: what the context/path/field-name arguments mean, and why build_phase's ordering guarantees it works.

Everything else — `uvm_info, run_phase, raise_objection/drop_objection, super.build_phase — is already-covered chapter 2 material put to use.

Run it, and the log should show build_phase/connect_phase/run_phase activity followed by two `uvm_info lines from DRV, each reporting a sel/a/b/y combination — the same result the capstone's hand-written driver produced, now coming from an actual UVM component tree.

Summary

  • Icarus Verilog doesn't support UVM — pick a UVM-capable simulator on EDA Playground (e.g. Riviera-PRO) before running any UVM example.
  • The DUT and interface are unchanged from the SV Basics capstone; what's new is a uvm_component-based driver that gets its virtual interface through uvm_config_db::get() instead of a constructor argument.
  • config_db::get() returns 0 on failure — check it and `uvm_fatal rather than silently continuing with an unassigned handle.
  • my_driver::type_id::create("drv", this) builds child components inside build_phase, instead of new(...) — the factory mechanism behind why is chapter 6's material.
  • uvm_config_db#(...)::set(...) (in the top-level module, before run_test()) publishes a value that any component's matching get() can retrieve — the full mechanics are chapter 5's material.
  • run_test("my_test") starts the phase engine and constructs exactly one instance of the named class as the root of the component tree.
  • Every UVM file starts with `include "uvm_macros.svh" and import uvm_pkg::*;.

In tb_top's initial block, why does uvm_config_db#(...)::set(...) have to happen before run_test(...)?

If uvm_config_db#(virtual mux2_if.tb_mp)::get(...) fails inside the driver's build_phase (returns 0) and the `uvm_fatal check were removed, what would actually happen?

Why does my_test construct the driver with my_driver::type_id::create('drv', this) instead of my_driver drv = new('drv', this)?