SystemVerilog / UVM 1.2 · beginner's notes

UVM Foundations
Architecture, Data Flow and Data Types

These notes do not walk through the syntax line by line. They answer the four questions a beginner needs settled first: what the UVM architecture looks like, which stations a transaction passes through between being generated and being checked, what type it holds at each of them, and which details will stall you if you overlook them.

Main example DUT: adder (7-bit + 7-bit -> 8-bit, 1 cycle latency) Extended example: apb_slave Simulator: VCS + -ntb_opts uvm-1.2

01Why UVM Exists

The smallest UVM testbench is thirty lines. All it does is print one line:

A minimal UVM testbenchhello_world_uvm.sv
program automatic test;

  import uvm_pkg::*;
  `include "uvm_macros.svh"        // without this line, `uvm_info / `uvm_component_utils do not exist

  class hello_world extends uvm_test;
    `uvm_component_utils(hello_world)          // register with the factory

    function new(string name = "hello_world", uvm_component parent = null);
      super.new(name, parent);
    endfunction

    virtual task run_phase(uvm_phase phase);
      phase.raise_objection(this);             // hand up: I am not finished, do not end the simulation
      #50;
      `uvm_info("TEST", "Hello World!", UVM_MEDIUM)
      phase.drop_objection(this);              // hand down: you may finish now
    endtask
  endclass

  initial begin
    run_test("hello_world");                   // start UVM and name the test to run
  end

endprogram

In the same folder, the testbench that tests one adder runs to four hundred lines across eight classes. Those four hundred lines are not there for the addition — the addition is a single line, expected = in1 + in2;. What they buy you is these four things:

1. Separation of concerns

Generating stimulus, driving pins, watching waveforms, judging right from wrong and counting coverage are split into five independent classes. Change one and the other four stay untouched.

2. Randomisation

Stimulus comes from rand, not hand-written. A different seed gives you a completely new set of vectors with no code change.

3. Automatic checking

The scoreboard computes the expected value itself and compares it against the DUT, so you are not staring at waveforms. When it disagrees it raises a UVM_ERROR.

4. Something you can measure

Coverage tells you whether you have tested enough. You can write "run until coverage reaches 50%" instead of "a hundred runs is probably enough".

In one sentence: UVM does not write your tests. It breaks the test environment into parts you can replace one at a time, and fixes the conventions between them: how they talk (TLM), when they start (phases), how they are built (factory) and how they receive settings (config_db). The next five chapters are about those four conventions.

02Interface: the boundary between class and module

This is where beginners get confused most easily. A UVM testbench actually contains two entirely separate worlds, with different lifetimes and different memory models.

 The module world (static)The class world (dynamic)
Members module top, adder dut, adder_intf intf, clock and reset generators test, env_c, agent_c, driver_c, trans_c …
When they exist Fixed at compile time, present for the whole simulation, fixed in number Created at run time with create(). You can make a thousand of them, or throw one away mid-run
Can they touch signals? Yes, wired directly No — a class has no such thing as a pin
Can they be randomised? No Yes, rand + constraint

For the class world to drive the DUT there is exactly one channel: the interface. interface itself lives in the module world (instantiated once inside top), and what the class side holds is a reference to it, of type virtual interface.

The interface and its three modportsadder_intf.sv
interface adder_intf (input logic clk, input logic rst_n);
  logic [6:0] in1;
  logic [6:0] in2;
  logic [7:0] out;

  modport dut (input in1, input in2, output out);              // DUT: takes inputs, produces an output

  modport drv (output in1, output in2, input out,              // driver: may only write in1/in2
               input clk, input rst_n);

  modport mon (input in1, input in2, input out,                // monitor: observation only, all inputs
               input clk, input rst_n);
endinterface

modport is a table of directional permissions. The same wires, seen three different ways:

Where beginners get stuck most often: the type the driver declares is not virtual adder_intf, but virtual adder_intf.drv (with the modport). That type must match the one used in the uvm_config_db#(...) call exactly, or get() returns false and you get a UVM_FATAL. Chapter 8 covers this.

03The Architecture at a Glance

UVM components form a tree. The root is uvm_test_top (run_test("test") builds it for you), and each level below creates its children inside its own build_phase.

test uvm_test → uvm_test_top env_c uvm_env agent_c uvm_agent  is_active = UVM_ACTIVE / UVM_PASSIVE scoreboard_c uvm_scoreboard cov_coll_c uvm_subscriber #(trans_c) driver_c uvm_driver sequencer uvm_sequencer monitor_c uvm_monitor ↑ class world   ↓ module world adder_intf intf (.clk, .rst_n) adder dut module top vif.drv vif.mon
The component tree, in the shape a UVM testbench almost always takes. Above the dashed line is class, below it is module; driver and monitor each hold a virtual interface so they can reach across.

What each level is responsible for

LevelResponsibilityReplaceable?
testDecides what this run does: how many transactions, the coverage goal, whether to override anythingOne test per scenario; the most frequently swapped
envAssembles agent, scoreboard and coverage, and wires them togetherReused whole; only touched when the DUT changes
agentA complete agent for one protocol interface: generate, drive, observeOne agent per protocol (an APB agent, an AXI agent, and so on)
sequencerArbitration and queueing between sequence and driverAlmost never written by hand; use uvm_sequencer#(T)
driverTranslates a transaction into signal timing (this is where the protocol details live)Must change with the protocol
monitorTurns signal timing back into a transaction and broadcasts itMust change with the protocol
scoreboardComputes the expected value, compares it against the DUT's actual output, tallies PASS/FAILMust change when the DUT's function changes (the reference model)
coverageSamples and tallies which scenarios have been exercisedChanges when the verification goal changes

Active vs. passive:uvm_agent has a built-in is_active field. When active it builds driver + sequencer + monitor; when passive it builds only the monitor (pure observation, no stimulus). You do this with a check in the agent's build and connect phases:

Deciding active or passiveagent_c
virtual function void build_phase(uvm_phase phase);
  super.build_phase(phase);
  if (is_active == UVM_ACTIVE) begin              // only an active agent needs to drive stimulus
    m_drv = driver_c::type_id::create("m_drv", this);
    m_sqr = uvm_sequencer#(trans_c)::type_id::create("m_sqr", this);
  end
  m_mon = monitor_c::type_id::create("m_mon", this);   // the monitor is always built
endfunction

virtual function void connect_phase(uvm_phase phase);
  super.connect_phase(phase);
  if (is_active == UVM_ACTIVE)
    m_drv.seq_item_port.connect(m_sqr.seq_item_export);   // do not connect what was never built, or you get a null handle
endfunction

04Datapath: following one transaction

This is the heart of these notes. What follows tracks one trans_c, from being randomised into existence, driven onto the pins, computed by the DUT, observed on the way back, to being compared — eight stations in all.

the class world (objects in memory) sequence_c body() 1 sequencer arbitrate / queue 2 driver_c run_phase 3 monitor_c run_phase 6 scoreboard_c write() compare 8 cov_coll_c covgrp.sample() finish_item get_next_item m_mon_port.write() 7 adder_intf   in1[6:0]  in2[6:0]  out[7:0]  clk  rst_n the virtual interface boundary vif.in1 <= tr.in1; vif.in2 <= tr.in2; 4 sample in1/in2/out adder (DUT) out <= in1 + in2 (next cycle) 5 in out the module world (actual wires)
The complete path of one transaction. Steps 1–3 pass an object around, 4–6 are signals moving, and 7–8 return to objects.

Station by station: where the data is and what type it holds

StationWhat happensWhat the data is at this moment
① sequence create() creates a trans_c, randomize() fills the in1/in2 trans_c object; only the input fields are meaningful, out is still meaningless
② sequencer finish_item() hands the object to the sequencer; if the driver is still busy it blocks here A handle to the same object is passed along (nothing is copied)
③ driver seq_item_port.get_next_item(m_trans) receives the handle The same object; the driver can now read its fields
④ crosses the boundary @(posedge clk), then vif.in1 <= m_trans.in1; object -> signal. From here on the data is a logic level
⑤ DUT always_ff writes, on the next posedge, in1+in2 into out Pure signal, with a one-cycle delay
⑥ monitor Remember this cycle's in1/in2 and read out only on the next cycle, so the two halves form one transaction signal -> object.create() creates a brand new trans_c
⑦ broadcast m_mon_port.write(m_trans) sends it to every subscriber at once A handle to the same object goes to N receivers
⑧ checks the scoreboard computes expected = in1+in2 and compares; coverage samples Once read, nobody holds the object and it goes to the garbage collector

The driver: one half of the conversion

Take an item from the sequencer, drive the pins, report completiondriver_c
virtual task run_phase(uvm_phase phase);
  trans_c m_trans;

  adder_vif.in1 <= '0;                    // hold the signals at zero during reset
  adder_vif.in2 <= '0;
  wait (adder_vif.rst_n === 1'b1);        // start work only once reset is released

  forever begin
    seq_item_port.get_next_item(m_trans);       // <- blocking: waits here when there is nothing
    @(posedge adder_vif.clk);                   //    line up with the clock
    adder_vif.in1 <= m_trans.in1;               //    non-blocking assignment, to avoid a race
    adder_vif.in2 <= m_trans.in2;
    `uvm_info("DRV", $sformatf("Drive in1=%0d, in2=%0d",
              m_trans.in1, m_trans.in2), UVM_LOW)
    seq_item_port.item_done();                  // <- tell the sequencer this one is finished
  end
endtask

get_next_item() and item_done() must come in pairs. Leave out item_done() and the sequencer believes the driver never finished, so the second transaction is never issued and the simulation looks hung.

The monitor: the other half of the conversion, and where timing is easiest to get wrong

Handling the one-cycle latency and the NBA racemonitor_c
virtual task run_phase(uvm_phase phase);
  trans_c m_trans;
  logic [6:0] in1_q, in2_q;      // hold this cycle's inputs
  int idx = 0;

  wait (adder_vif.rst_n === 1'b1);
  @(posedge adder_vif.clk);
    #1;                          // * let the NBA region settle before reading, dodging the sampling race

  forever begin
    in1_q = adder_vif.in1;       // cycle N: remember the inputs
    in2_q = adder_vif.in2;
    @(posedge adder_vif.clk);
    #1;
    m_trans = trans_c::type_id::create($sformatf("mon_trans_%0d", idx++), this);
    m_trans.in1 = in1_q;         // cycle N's inputs
    m_trans.in2 = in2_q;
    m_trans.out = adder_vif.out; // cycle N+1's output <- this is the matching half
    m_mon_port.write(m_trans);   // broadcast
  end
endtask

Why the #1? The DUT uses always_ff with the non-blocking assignment <= to update out, and that update happens in the same time step's NBA region. If the monitor wakes at @(posedge clk) and reads immediately, it may catch the value from before the update, and the execution order is not guaranteed to repeat (a race). Adding a #1 (or switching to a clocking block) guarantees a settled value.

Why hold in1_q/in2_q? Because the DUT has one cycle of latency: in1/in2 is on cycle N and the matching out is on cycle N+1. Sample both on the same cycle and you pair this transaction's input with the previous transaction's output, turning the whole scoreboard red.

05The Data Types

The two families: uvm_object vs. uvm_component

Every UVM class grows from uvm_object, but it forks early into two branches that behave nothing alike:

 uvm_object familyDatauvm_component familyStructure
Typified by uvm_sequence_item (a transaction)
uvm_sequence
uvm_driver, uvm_monitor, uvm_scoreboard, uvm_subscriber, uvm_agent, uvm_env, uvm_test, uvm_sequencer
Does it have a place? No. Like a letter: passed around, copied, thrown away Yes. It has a fixed path in the component tree, such as uvm_test_top.m_env.m_agt.m_drv
LifetimeShort. One per transaction, recycled after useLong. Once build_phase has built it, it exists for the whole simulation
Constructor function new(string name = "");
One argument only
function new(string name, uvm_component parent);
One extra: parent
Registration macro `uvm_object_utils(T)
or `uvm_object_utils_begin/end
`uvm_component_utils(T)
Does it have phases?NoYes: build / connect / run / report / final ...
Can it be randomised?Yes (rand + constraint)Never used that way

What using the wrong macro looks like: put `uvm_component_utils on a transaction, or `uvm_object_utils on a component, and compilation blows up on a constructor signature mismatch (the macro expands to that family's create() implementation). When you see a pile of odd new() errors, check that these two macros are paired correctly first.

The transaction: a container for data

A typical transactiontrans_c
class trans_c extends uvm_sequence_item;
  rand logic [6:0] in1;        // rand: randomize() will fill this in
  rand logic [6:0] in2;
       logic [7:0] out;        // no rand: this comes from the DUT, we do not generate it

  function new(string name = "");
    super.new(name);
  endfunction

  `uvm_object_utils_begin(trans_c)      // only the begin/end form accepts field macros
    `uvm_field_int(in1, UVM_DEFAULT)
    `uvm_field_int(in2, UVM_DEFAULT)
    `uvm_field_int(out, UVM_DEFAULT)
  `uvm_object_utils_end
endclass

Three design decisions worth noting:

`uvm_field_int gives you what

Once the fields are registered, UVM generates these methods for you:

MethodPurpose
print()Prints the whole transaction as a table (very useful when debugging)
copy() / clone()Deep-copies a transaction (essential before queueing one for comparison)
compare()Compares two, field by field
sprint() / convert2string()Turns it into a string for the log
pack() / unpack()Flattens it into a bit stream

Parameterised types: what goes inside the angle brackets

DeclarationWhat the parameter means
uvm_sequence #(trans_c)Which item this sequence produces
uvm_sequencer #(trans_c)Which item this sequencer carries
uvm_driver #(trans_c)Which item this driver receives (it sets the type of seq_item_port type)
uvm_analysis_port #(trans_c)Which item this port broadcasts
uvm_analysis_imp #(trans_c, scoreboard_c)Two parameters: which item it receives, plus who implements write()
uvm_subscriber #(trans_c)Has a built-in analysis_export, so you only override write()
uvm_tlm_analysis_fifo #(trans_c)A buffering receiver; read from it with get()
virtual adder_intf.drvA reference to the interface, with the modport

new() vs type_id::create()

Both create objects, but they mean very different things:

obj = new("name")

Calls the constructor directly. What comes out is always the class you wrote; the factory never sees it.

Use it only for things that need no override, such as TLM ports, exports and FIFOs: m_mon_port = new("m_mon_port", this);

obj = T::type_id::create("name", this)

Creates through the factory. The factory checks for an override first and, if there is one, substitutes a different class.

Every transaction, sequence and component should use this. It is the prerequisite for changing behaviour without changing the structure (see chapter 9).

The difference in arguments:create() for a component takes this as the parent (create("m_drv", this)); for an object it usually does not (create("m_trans_0")) — an object has no place in the tree.

SystemVerilog: a few things you must keep straight

DeclarationMeaningWhen it is used in UVM
= (blocking)Takes effect immediatelyArithmetic inside a class; storing a monitor's captured values into a transaction
<= (non-blocking)Queued to the NBA region before taking effectAlways use this when a driver drives a signal, so it does not fight the DUT's always_ff
==Logical comparison; returns X when it meets X or ZAvoid where you can
===Four-valued identity; X equals X, and the result is always 0 or 1Always use this for scoreboard comparisons and for waiting on reset
wait (expr)Proceeds once the condition holds (and does not wait if it already does)wait (vif.rst_n === 1'b1)
@(posedge clk)Always waits for the next rising edgeLining up with the clock
functionCannot consume timebuild/connect/report/write() are all functions
taskMay consume timerun_phase, main_phase, and a sequence's body()

06Phases: what runs first, what runs after

UVM lays out a fixed order for you. You never call these yourself; you just override the part you want to take a hand in, and UVM calls it at the right moment.

build_phase function · top-down · create() the children, config_db::get() connect_phase function · bottom-up · connect TLM, connect seq_item_port end_of_elaboration_phase function · the tree is fixed; the last chance to check it start_of_simulation_phase function · often where uvm_root::get().print_topology() goes run_phase / main_phase  * the only phase that consumes simulation time task · every component's run_phase is forked at once the phase ends only once every objection has been dropped extract / check / report_phase function · bottom-up · print the PASS/FAIL tally and the coverage figure final_phase time →
Phase order. To see it for yourself, add a `uvm_info marker to each phase and the log prints in exactly this order.

Two rules worth memorising

Rule 1: a function phase cannot delay

build_phase, connect_phase, report_phase... are all function void. Put a #10 or @(posedge clk) inside one and you get Illegal time-consuming statement.

Anything that waits belongs in run_phase / main_phase (a task).

Rule 2: build runs top-down, connect runs bottom-up

build_phase is top-down: build the test, the test builds the env, the env builds the agent... because a parent must exist before its child.

connect_phase goes the other way, bottom-up: every child must already exist before anything can be connected.

Objections: deciding when the simulation ends

UVM has no idea how long your test should run. An objection is simply a counter: while anyone still has a hand raised against finishing, the run phase continues; the moment it reaches zero, UVM packs up.

Using an objection to bracket the period where work is happeningtest::main_phase
virtual task main_phase(uvm_phase phase);
  sequence_c m_seq;
  int iter = 0;

  phase.raise_objection(this);            // <- hand up: do not finish yet

  // keep sending sequences until coverage hits the target or the iteration cap
  while (m_env.m_cov.covgrp.get_coverage() < cov_target && iter < max_iter) begin
    m_seq = sequence_c::type_id::create($sformatf("seq_%0d", iter), this);
    m_seq.start(m_env.m_agt.m_sqr);       // <- attach the sequence to the sequencer and run it
    iter++;
  end

  phase.phase_done.set_drain_time(this, 20ns);   // 20 ns of slack for whatever is still in the pipeline

  phase.drop_objection(this);             // <- hand down: we can pack up
endtask

Forget raise_objection looks like this: the simulation ends at 0 ns and the log is nearly empty — just UVM's header and the report summary. It looks like a broken build, but it is simply that nobody objected, so UVM packed up immediately.

set_drain_time is for: after the last stimulus reaches the DUT, the DUT still needs a few cycles to produce a result. Stop the instant the objection drops and the last few out never arrive: the monitor captures too few and the scoreboard compares too few. Leave a drain time so the pipeline empties.

run_phase and main_phase — what is the difference? run_phase is the master switch; UVM also divides it into twelve sub-phases: pre_reset → reset → post_reset → pre_configure → configure → post_configure → pre_main → main → post_main → pre_shutdown → shutdown → post_shutdown, which run run_phase in parallel with it. The usual convention: things that run continuously, like the driver and monitor, go in run_phase, while a test's flow with a definite beginning and end goes in main_phase.

07TLM: how components connect

UVM components never call each other's methods directly. They connect through TLM ports. The benefit is that the monitor need not know who is listening — add a coverage collector today and the monitor does not change by a single line.

A · analysis_imp monitor scoreboard m_mon_port.connect(m_scr_imp) the scoreboard implements write() itself the monitor calls write() and it executes synchronously, at once suits: outputs that can be compared immediately uvm_analysis_imp #(trans_c, scoreboard_c) B · uvm_subscriber monitor cov_coll m_mon_port.connect(m_cov.analysis_export) has a built-in analysis_export; nothing to new you only override write() suits: coverage, log recorders uvm_subscriber #(trans_c) C · analysis_fifo driver/mon FIFO scoreboard connect(m_scr.drv_fifo.analysis_export) write() stores into the FIFO first (never blocks the caller) the scoreboard reads it with get() in run_phase suits: pipelined DUTs, or any DUT with latency uvm_tlm_analysis_fifo #(trans_c)
Three ways to write the receiving end. What they share: the sending end is always uvm_analysis_port #(trans_c), and the direction of connection is always port.connect(export/imp).

connections all go in connect_phase

One port broadcasting to two receiversenv_c
virtual function void connect_phase(uvm_phase phase);
  super.connect_phase(phase);
  m_agt.m_mon.m_mon_port.connect(m_scr.m_scr_imp);          // → scoreboard
  m_agt.m_mon.m_mon_port.connect(m_cov.analysis_export);    // → coverage
endfunction

uvm_analysis_port is one-to-many: there are as many subscribers as you made connections. One call to write() runs every subscriber's write() in turn. And because write() is a function, it consumes no time, so the broadcast completes instantaneously.

What separates architecture A (imp) from architecture C (FIFO)

 A: analysis_impC: analysis_fifo
Where the data comes fromOne path only, the monitor; a single transaction carries both in and outTwo paths: the driver sends the inputs, the monitor sends the outputs
When the comparison happenswrite() — compared the instant it is calledrun_phase, once get() has data from both sides
Where it livesvirtual function void write(trans_c t)virtual task run_phase(...) (it must be able to block)
Extra work requiredNonethe driver needs an extra uvm_analysis_port, and before item_done() it must call write()
When to use itA simple DUT, where the monitor can capture in and out togetherA deeply pipelined DUT, out-of-order results, or in and out separated by many cycles
The heart of the FIFO scoreboardscoreboard_c (FIFO version)
uvm_tlm_analysis_fifo #(trans_c) drv_fifo;   // holds the inputs sent by the driver
uvm_tlm_analysis_fifo #(trans_c) mon_fifo;   // holds the outputs sent by the monitor

virtual task run_phase(uvm_phase phase);
  trans_c drv_tr, mon_tr;
  logic [7:0] expected;

  forever begin
    drv_fifo.get(drv_tr);                 // blocking: waits here when there is nothing
    mon_fifo.get(mon_tr);                 // one from each side, paired automatically
    expected = reference_model(drv_tr);   // expected = drv_tr.in1 + drv_tr.in2

    if (mon_tr.out === expected) pass_cnt++;
    else `uvm_error("SCR", $sformatf("FAIL: expected %0d, got %0d", expected, mon_tr.out))
  end
endtask

Note that get() is a task, not a function. It blocks until the FIFO has something, so it can only live inside run_phase. That is why the FIFO scoreboard has no write() — it has been replaced by run_phase.

08config_db: handing things in from outside

The driver needs a virtual interface, but the interface lives in module top. The driver is created by the factory, so there is no way to pass the interface in through its constructor. uvm_config_db solves exactly this, with a global dictionary: one side calls set, the other calls get.

The set side: in top, before run_test()module top
initial begin
  uvm_config_db#(virtual adder_intf.drv)::set(null,
      "uvm_test_top.m_env.m_agt.m_drv", "adder_vif", intf);

  uvm_config_db#(virtual adder_intf.mon)::set(null,
      "uvm_test_top.m_env.m_agt.m_mon", "adder_vif", intf);

  run_test("test");        // set must always come before run_test
end
The get side: in build_phasedriver_c
virtual adder_intf.drv adder_vif;          // the type must match the set side exactly

virtual function void build_phase(uvm_phase phase);
  super.build_phase(phase);
  if (!uvm_config_db#(virtual adder_intf.drv)::get(this, "", "adder_vif", adder_vif))
    `uvm_fatal("DRV", "Unable to get interface adder_vif (drv)")
endfunction

The four arguments, one by one

PositionSet sideGet sideNotes
1nullthis The starting context. null = start from uvm_root (a module is not in the component tree)
2"uvm_test_top.m_env.m_agt.m_drv""" The path relative to the context. The get side already passes this, so an empty path means "me"
3"adder_vif""adder_vif" The field name (a string key); it must match exactly on both sides
4intfadder_vif On set it is the value to store; on get it is the variable to store into (passed by reference)

Three ways to make get() fail, all with the same symptom: UVM_FATAL:

  1. Mismatched types — set uses virtual adder_intf while get uses virtual adder_intf.drv. As far as uvm_config_db is concerned these are two entirely different dictionaries, and what goes into A never comes out of B. This is the most common one and the hardest to spot.
  2. A wrong path — for instance m_agt typed as m_agent, or forgetting the leading uvm_test_top. (run_test() always names the test instance this).
  3. A wrong key — "adder_vif" typed as "adder_intf".

Why set twice? Because the driver and the monitor need different modports (.drv is writable, .mon is read-only), and different types need separate entries. This one is easy to miss.

The path accepts wildcards."*" matches any level, so set(null, "*", "adder_vif", intf) lets every component find it — quick, but dangerous: it hands the monitor a writable handle too.

09Factory and Override

The factory is a registry that exchanges a string for an object. It exists for exactly one reason: so you can replace one class throughout the system without touching any existing code.

Three steps

  1. Register — `uvm_object_utils(T) / `uvm_component_utils(T). A class that is not registered does not exist as far as the factory is concerned.
  2. Create with create() — rather than new(). That is the interception point.
  3. Set an override — before any create() happens.
Swapping every trans_c in the system for a constrained version, without touching the architecturetest::build_phase
// (1) derive a new transaction and add one constraint
class trans_in1_3_c extends trans_c;
  `uvm_object_utils(trans_in1_3_c)          // * must be registered, or the override does nothing

  function new(string name = "");
    super.new(name);
  endfunction

  constraint in1_eq_3 { in1 == 3; }         // pin in1 to 3
endclass

// (2) set the override at the very top of the test's build_phase
virtual function void build_phase(uvm_phase phase);
  set_type_override_by_type(
    trans_c::get_type(),                    // what was going to be created
    trans_in1_3_c::get_type()               // what actually gets created
  );

  super.build_phase(phase);                 // <- the override must come before this line
  m_env = env_c::type_id::create("m_env", this);

  uvm_factory::get().print();               // (3) print the type-override table to confirm
endfunction

Once that is set, the line sequence_c inside trans_c::type_id::create(...) has not changed by a single character, yet what comes out is a trans_in1_3_c. Every in1 in the log becomes 3, the scoreboard still passes throughout (nothing is broken), but the coverage on cp1 collapses to a single bin.

Two reasons an override does nothing:

  1. the new class has no `uvm_object_utils — the factory has never heard of the type.
  2. somewhere the code uses new() rather than create() — that site quietly creates the old type, and you get the baffling behaviour where some transactions are swapped and others are not.

Two scopes of override:

  • set_type_override_by_type(A, B) — system-wide: every A becomes a B.
  • set_inst_override_by_type(A, B, "path") — swaps only the one instance under the given path. You might swap the driver of one particular agent and leave every other alone.

10Functional coverage: have you tested enough?

The scoreboard answers "is it wrong". Coverage answers "did we ever test it". Both passing does not mean verification is done — you may simply never have sent the input that would fail.

The covergroup lives in a subscribercov_coll_c
class cov_coll_c extends uvm_subscriber #(trans_c);
  `uvm_component_utils(cov_coll_c)

  trans_c m_trans;                    // * the covergroup samples from this member variable

  covergroup covgrp;
    option.per_instance = 1;          // tally each instance separately

    cp1: coverpoint m_trans.in1 {
      bins in1_min    = {0};          // a single value
      bins in1_max    = {127};
      bins in1_low[]  = {[1:63]};     // [] = expand automatically into 63 bins
      bins in1_high[] = {[64:126]};
    }
    cp2: coverpoint m_trans.in2 { /* as above */ }
    cp3: coverpoint m_trans.out {
      bins low  = {[0:127]};
      bins high = {[128:254]};        // did we ever test a case that carries
    }
    cp1xcp2: cross cp1, cp2;          // cross: have the combinations been walked
  endgroup

  function new(string name = "", uvm_component parent);
    super.new(name, parent);
    covgrp = new();                   // * the covergroup must be constructed in new()
  endfunction

  virtual function void write(trans_c t);   // called once for every transaction the monitor broadcasts
    m_trans = t;                            // update the member variable first
    covgrp.sample();                        // then sample
  endfunction
endclass

The order matters.covgrp.sample() captures the m_trans value this member variable holds at that instant. Call sample() before m_trans = t;, and every sample is one transaction behind.

covgrp = new() belongs in the constructor. Putting it in build_phase also works, but forget it and you have a null handle that explodes on the first sample().

Using coverage as the stopping condition

The better approach is not "run N times" but "run until coverage reaches the target":

A coverage-driven looptest::main_phase
while (m_env.m_cov.covgrp.get_coverage() < cov_target && iter < max_iter) begin
  m_seq = sequence_c::type_id::create($sformatf("seq_%0d", iter), this);
  m_seq.start(m_env.m_agt.m_sqr);
  iter++;
end

if (iter >= max_iter)
  `uvm_warning("TEST", "Hit max_iter before reaching coverage target")
APIReturns
covgrp.get_coverage()The coverage of this type, overall (all instances combined)
covgrp.get_inst_coverage(covered, total)The coverage of this instance, with output arguments giving how many bins hit out of how many
covgrp.cp1.get_coverage()The coverage of a single coverpoint (to find which one is holding you back)

max_iter safety net is essential: If the constraints pin the input down too hard (fixing in1 to a single value, say), coverage will never reach the target, and without max_iter you have an infinite loop. Seeing that Hit max_iter warning usually means the constraints and the coverage goal contradict each other.

11What changes with a different protocol

Swap the DUT from an adder to an APB slave and the whole UVM architecture — test / env / agent / sequencer / TLM connections / phases / factory — does not change by one line. Only these four things change:

What changesadderAPB slave
Interface signals in1 / in2 / out psel penable pwrite paddr pwdata prdata pready pslverr
Transaction fields rand in1, in2;out rand pwrite, paddr, pwdata;prdata, pslverr, plus an address constraint
Driver timing Drive it on one clock edge and you are done Two phases: setup then access, and it must also wait for pready
Monitor sampling condition Every cycle is one transaction Only psel && penable && pready is the one cycle that counts as a transaction
clk psel penable pready SETUP ACCESS IDLE IDLE wait state ▲ the edge where psel && penable && pready all hold is a completed transfer; the monitor samples here
An APB transfer: PSEL rises into setup, PENABLE rises on the next cycle into access, and it completes only once PREADY = 1. PADDR / PWRITE / PWDATA must not change in between.
The driver: two-phase timing plus wait statesdriver_c (APB)
// setup phase: PSEL=1, PENABLE=0, present address, direction and write data
@(posedge apb_vif.clk);
apb_vif.psel    <= 1'b1;
apb_vif.penable <= 1'b0;
apb_vif.pwrite  <= m_trans.pwrite;
apb_vif.paddr   <= m_trans.paddr;
apb_vif.pwdata  <= m_trans.pwdata;

// access phase: PENABLE only rises on the next cycle
@(posedge apb_vif.clk);
apb_vif.penable <= 1'b1;

// wait states: keep waiting while PREADY=0, and do not change PADDR/PWRITE/PWDATA meanwhile
while (!apb_vif.pready)
  @(posedge apb_vif.clk);
The monitor: sample only when a transfer really completesmonitor_c (APB)
if (apb_vif.psel && apb_vif.penable && apb_vif.pready) begin
  s_pwrite = apb_vif.pwrite;      // (a) latch the stimulus fields first
  s_paddr  = apb_vif.paddr;
  s_pwdata = apb_vif.pwdata;

  @(posedge apb_vif.clk);
  #1;                             // (b) let the prdata/pslverr the DUT wrote with NBA settle

  m_trans = trans_c::type_id::create($sformatf("mon_trans_%0d", idx++), this);
  m_trans.pwrite  = s_pwrite;
  m_trans.paddr   = s_paddr;
  m_trans.pwdata  = s_pwdata;
  m_trans.prdata  = apb_vif.prdata;   // (c) then add the DUT's response
  m_trans.pslverr = apb_vif.pslverr;
end

Assertions and cover properties: a layer outside UVM

Protocol rules can live in the driver, but SVA can also pin them to the signals directly, written in module top (outside the class world). Anyone who breaks one is caught immediately — including your own driver, when you get it wrong.

A protocol assertion plus a cover propertymodule top (APB)
// assertion: PENABLE may only rise after the previous cycle was already a setup phase
property apb_penable_after_psel;
  @(posedge clk) disable iff (!rst_n)
    $rose(intf.penable) |->
      (intf.psel && $past(intf.psel && !intf.penable));
endproperty

apb_penable_after_psel_a : assert property (apb_penable_after_psel)
  else `uvm_error("APB_ASSERT", "PENABLE asserted without previous APB setup phase")

// cover property: confirm the test really walked the setup -> access path
property apb_setup_to_access;
  @(posedge clk) disable iff (!rst_n)
    (intf.psel && !intf.penable) ##1 (intf.psel && intf.penable);
endproperty

apb_setup_to_access_cp : cover property (apb_setup_to_access);

assert and cover are two sides of one coin: assert property says this must never happen, while cover property says this confirms it did happen at least once. Write only the assert and an assertion that never fires passes forever — it looks safe, but you never tested it at all.disable iff (!rst_n) exists to stop an invalid state during reset being reported as a violation.

12Traps for Beginners

Every entry below is something you will actually hit. Start here when you are stuck.

01You edited a .sv file but only ran make run, and nothing changed

Symptom
You definitely changed the code, yet the log is identical to last time.
Cause
make run only executes the existing simv; it does not recompile.
Fix
make (= compile + run), or make compile and then make run. Only seed / verbosity / TEST_NAME are runtime arguments; changing those needs no rebuild.

02The simulation ends at 0 ns with an almost empty log

Symptom
No DRV / MON / SCR messages at all; it jumps straight to the report summary.
Cause
Forget phase.raise_objection(this). Nobody objected, so UVM packed up at once.
Fix
Raise at the start of a time-consuming phase and drop at the end; the two must be paired.

03Illegal time-consuming statement

Symptom
You get this error at compile or run time.
Cause
You put a build_phase / connect_phase / write() inside function, which is a #10 or @(posedge clk).
Fix
Anything that waits belongs in run_phase / main_phase (task).

04UVM_FATAL: Unable to get interface

Symptom
A fatal on the first run; the components were never built.
Cause
One of config_db's three essentials does not match: the type (virtual adder_intf ≠ virtual adder_intf.drv),path, key string.
Fix
Put the set line and the get line side by side and compare them character by character. Nine times out of ten it is the missing modport suffix.

05The scoreboard is all red, but the DUT is clearly correct

Symptom
Every transaction fails, and fails in a very regular way (as if off by one cycle).
Cause
The monitor's sampling timing is wrong. The DUT has one cycle of latency, so capturing in1/in2 and out on the same cycle pairs this transaction's input with the previous transaction's output.
Fix
Latch the inputs first and read the output only after @(posedge clk) (see the monitor example in chapter 4).

06Results differ from run to run, or you occasionally catch a stale value

Symptom
The same seed gives a different result on a different simulator version.
Cause
A sampling race. The DUT updates in the NBA region with <=, while the monitor reads the moment it wakes at @(posedge clk), and the order between them is not guaranteed.
Fix
Add a #1 before sampling, or switch to a clocking block and let its input skew define the sampling point explicitly.

07The driver writes signals with =

Symptom
The waveform looks right, but the result is intermittent.
Cause
A blocking assignment races the DUT's always_ff for update order within the same time step.
Fix
Always drive DUT inputs with the non-blocking <=. Use =.

08Comparisons use == rather than ===

Symptom
When a signal is X, the comparison is neither PASS nor FAIL, and the if behaves strangely.
Cause
== returns X when it meets X or Z (neither 0 nor 1).
Fix
Always compare four-valued signals with ===, which is guaranteed to return 0 or 1 and compares X correctly.

09Each loop iteration produces two transactions

Symptom
The PASS count is double what you expect.
Cause
The sequence's body() uses both `uvm_do(m_trans) and a hand-written start_item/finish_item. `uvm_do already includes create + start_item + randomize + finish_item.
Fix
Pick one. Write it out by hand when you want a custom constraint or a log line; use `uvm_do.

10The simulation runs for a long time and then Hit max_iter

Symptom
It exhausts the iteration cap and coverage is still low.
Cause
The constraints have boxed in the input space (in1 < 10 or in1 == 3, for instance), so the high bins can never be hit.
Fix
This is in fact a correct and valuable signal: it tells you the constraints and the coverage goal contradict each other. Use cp1.get_coverage() to find which coverpoint is stuck.

11A factory override is set but has no effect

Symptom
uvm_factory::get().print() shows the override table, yet the behaviour is unchanged.
Cause
(a) the new class forgot `uvm_object_utils; (b) somewhere uses new() rather than type_id::create(); (c) the override was set after create(), too late to take effect.
Fix
Put the override at the very top of build_phase — the first line — before super.build_phase().

12+UVM_TESTNAME=... not found

Symptom
UVM_FATAL: Requested test from command line not found.
Cause
The name given on the command line does not match the class name registered with `uvm_component_utils(...) (a typo, or the Makefile's TEST_NAME was never updated).
Fix
The factory looks up by string, so the name must match exactly, case included.

13A message never prints

Symptom
You definitely wrote the `uvm_info, but it is nowhere in the log.
Cause
The message level is below the current verbosity threshold. A message written at UVM_HIGH does not appear under the default UVM_MEDIUM.
Fix
make run verbosity=UVM_HIGH (a runtime argument, no rebuild needed). Put important statistics at UVM_NONE, which always prints.

14Missing `include "uvm_macros.svh"

Symptom
A whole run of `uvm_info / `uvm_component_utils reported as undefined.
Cause
import uvm_pkg::*; only brings in class library; the macros come from `include.
Fix
Write both lines together at the top of the file, wrapped in an `ifndef UVM_MACROS_SVH guard so it is not included twice.

15The second transaction never goes out; it looks hung

Symptom
You see the first DRV message and then nothing.
Cause
The driver's get_next_item() has no matching item_done(). The sequencer believes the previous item is unfinished and will not hand over the next.
Fix
They come in pairs, with no return or break skipping past in between.

13Quick Reference for Running It

Make target

CommandWhat it doesEnough after editing a .sv?
make cleanDeletes simv, logs, fsdb and the rest of the build products—
make compileCompiles only, producing simvYes
make runRuns the existing simv✘ No
makecompile + runYes, and the safest
make randomCompiles, then runs with a random seed✔

Runtime arguments: these three need no rebuild

ArgumentExampleEffect
seedmake run seed=2 A different set of random stimulus. The same seed reproduces the same result — always fix the seed when debugging
verbositymake run verbosity=UVM_HIGH Adjusts how much detail the log carries
TEST_NAMEmake run TEST_NAME=test Chooses which test class runs (equivalent to +UVM_TESTNAME=)

The five verbosity levels

LevelValueWhen to use it
UVM_NONE0Must print. Final statistics, the PASS/FAIL summary
UVM_LOW100Milestones: phase markers, iteration progress
UVM_MEDIUM200The default threshold. The DRV/MON/SCR message for each transaction
UVM_HIGH300Debugging detail, such as every coverage sample
UVM_DEBUG500The most talkative

The rule: a message prints when its level is at or below the current threshold. So under the default UVM_MEDIUM, NONE/LOW/MEDIUM appears and HIGH/DEBUG is filtered out.

Tracing a log ID back to who printed it

IDSource componentSeeing it means
[SEQ]sequence_ca random stimulus was generated
[DRV]driver_cthe stimulus has reached the pins
[MON]monitor_ca complete transaction was observed back from the pins
[SCR]scoreboard_ca comparison came out PASS or FAIL
[COV]cov_coll_cone sample was taken (visible only at UVM_HIGH)
[TEST]testiteration progress, coverage summary
[PHASE]testa phase marker (one you added yourself)

A suggested debugging order: follow the SEQ → DRV → MON → SCR chain. Wherever it breaks is where the problem is:
no SEQ -> the sequence never started, or no objection was raised;
SEQ but no DRV -> the driver is stuck in get_next_item or never received the vif;
DRV but no MON -> the monitor's timing is wrong, or it is stuck at wait();
MON but no SCR -> the TLM connection was never made.

14Recap

One sentence per chapter, to be used as an index — jump back from here when you cannot recall a section:

Ch.In one sentence
01What UVM gives you is four things: reuse, randomisation, automatic checking and measurability
02A class cannot touch a signal; interface + virtual interface is the only channel, and modport decides who may write
03Components form a tree, and each level creates its children in its own build_phase
04Eight stations: object -> signal -> object, with the conversions happening in the driver and the monitor
05uvm_object is data and uvm_component is structure; their constructors and registration macros differ
06Only run / main are tasks; no other phase may consume time, and objections decide when to pack up
07Components never call each other; everything goes through port.connect(export); switch to a FIFO when the output is delayed
08config_db's type, path and key must all match exactly
09Register -> create() -> override; miss any one and nothing is swapped
10The scoreboard says whether it is wrong, coverage says whether it was ever tested
11A different protocol touches only transaction / driver / monitor / interface; the architecture is untouched
12Fifteen common mistakes; check this chapter first when you are stuck
13Editing .sv always needs a rebuild; follow SEQ→DRV→MON→SCR to find where it breaks

If you only remember five things

  1. The data flow is always sequence -> sequencer -> driver -> interface -> DUT -> interface -> monitor -> analysis port -> scoreboard / coverage. When you are stuck, first ask which station it broke at.
  2. Two families: transactions are uvm_object (new(name) + `uvm_object_utils), components are uvm_component (new(name, parent) + `uvm_component_utils).
  3. Always use type_id::create(), never new(). It is the prerequisite for a factory override.
  4. Only run_phase / main_phase can consume time; every other phase is a function.
  5. Objections decide when the simulation ends; forget to raise one and you finish at 0 ns.