UVM-29: Advanced Topics — VLSI Trainers
VLSI Trainers UVM Series · UVM-29
UVM Series · UVM-29

Advanced Topics

Four advanced UVM techniques for real-world testbenches — sequence layering for multi-protocol stacks, signal wait methods on config objects, interrupt handling sequences, and parameterised tests for DUT configuration sweeps.

📋 Sequence Layering Overview

Many protocols have hierarchical definitions — a transaction layer on top of a transport layer on top of a physical layer. USB, PCIe, MIPI, and Ethernet all follow this model. Testing at the transaction layer requires that high-level items (USB transfers) be decomposed into low-level items (USB packets, USB HS tokens) and driven on the physical interface.

A layering component sits between the test’s sequence-level interface and the physical agent’s driver. It runs a translator sequence on each layer boundary — a sequence that pulls items from the upstream sequencer and breaks them into items for the downstream sequencer.

Sequence Layering — Three-Level ABC Protocol Stack Test A_sequencer A_items ↓ AtoB_seq pulls A_items, sends B_items B_sequencer B_items ↓ BtoC_seq pulls B_items, sends C_items ABC_layering component C_agent (leaf) C_sequencer C_driver C_monitor DUT Analysis path C_monitor.ap C2B_monitor B2A_monitor vlsitrainers.com
Figure 1 — Three-level sequence layering. The ABC_layering component (dashed orange boundary) contains A_sequencer and B_sequencer. Translator sequences (AtoB_seq, BtoC_seq) run on the downstream sequencer and pull items from the upstream sequencer. The leaf C_agent drives the actual hardware. The analysis path runs reconstruction monitors in reverse.

📋 Layering Architecture

The layering component holds all intermediate sequencers, starts all translator sequences in its run_phase, and exposes the top-level sequencer for test sequences to target. It also manages the leaf-level protocol agent — either owning it internally or holding an external handle.

// ── Layering component with internal C_agent ─────────────
class ABC_layering extends uvm_component;
  `uvm_component_utils(ABC_layering)

  // ── Intermediate sequencers ───────────────────────────
  A_sequencer a_seqr;   // tests target this level
  B_sequencer b_seqr;   // internal translation level

  // ── Leaf agent (internal) ─────────────────────────────
  C_agent c_agent;

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction

  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    a_seqr  = A_sequencer::type_id::create("a_seqr",  this);
    b_seqr  = B_sequencer::type_id::create("b_seqr",  this);
    c_agent = C_agent::type_id::create("c_agent", this);
  endfunction

  task run_phase(uvm_phase phase);
    AtoB_seq a2b;
    BtoC_seq b2c;

    a2b = AtoB_seq::type_id::create("a2b");
    b2c = BtoC_seq::type_id::create("b2c");

    // Connect translator sequences to their upstream sequencers
    a2b.up_seqr = a_seqr;
    b2c.up_seqr = b_seqr;

    // Start translator sequences on downstream sequencers — run forever
    fork
      a2b.start(b_seqr);              // A→B translator on B sequencer
      b2c.start(c_agent.m_seqr);      // B→C translator on C sequencer
    join_none
  endtask
endclass

📋 Translator Sequence

A translator sequence runs on the downstream sequencer but holds a handle to the upstream sequencer. It calls get_next_item() directly on the upstream sequencer to pull high-level items, decomposes each into one or more downstream items, drives them via start_item()/finish_item(), then calls item_done() on the upstream sequencer when fully translated.

class BtoC_seq extends uvm_sequence #(C_item);
  `uvm_object_utils(BtoC_seq)

  // Handle to the upstream (B) sequencer — set by layering component
  uvm_sequencer #(B_item) up_seqr;

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

  task body();
    B_item b_req;
    C_item c_item;

    forever begin
      // ① Pull a B_item from the upstream sequencer
      up_seqr.get_next_item(b_req);

      // ② Decompose: one B_item → multiple C_items
      // (e.g. one packet → multiple flits)
      foreach (b_req.payload[i]) begin
        c_item = C_item::type_id::create("c_item");
        // Drive C_item on the C sequencer (m_sequencer = C_seqr)
        start_item(c_item);
        c_item.flit_data = b_req.payload[i];
        c_item.is_last   = (i == b_req.payload.size()-1);
        finish_item(c_item);
      end

      // ③ Signal upstream sequencer that B_item is fully translated
      up_seqr.item_done();
    end
  endtask
endclass
item_done() on the upstream sequencer must be called after ALL downstream items for that upstream item have been sent. Calling item_done() too early unblocks the sequence that generated the B_item, which may immediately generate the next B_item before the translation of the current one is complete. This creates ordering violations in the physical layer.

📋 Reconstruction Monitor

The analysis path of a layering mirrors the stimulus path in reverse. A reconstruction monitor subscribes to the leaf-level monitor’s analysis port, reassembles the low-level items into higher-level items, and broadcasts the reassembled items on its own analysis port for scoreboards and coverage at the higher level.

// ── Reconstruction monitor: C items → B items ────────────
class C2B_monitor extends uvm_subscriber #(C_item);
  `uvm_component_utils(C2B_monitor)

  // Output: broadcasts reconstructed B items
  uvm_analysis_port #(B_item) ap;

  // Internal accumulation buffer
  B_item  m_pending;
  int     m_flit_count = 0;

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction

  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    ap = new("ap", this);
  endfunction

  // write() called for every C_item observed by the leaf monitor
  function void write(C_item c);
    if (m_flit_count == 0)
      m_pending = B_item::type_id::create("b_recon");

    m_pending.payload[m_flit_count] = c.flit_data;
    m_flit_count++;

    if (c.is_last) begin
      // Last flit — B_item complete, broadcast it
      ap.write(m_pending);
      m_flit_count = 0;
    end
  endfunction
endclass

// ── In layering connect_phase ─────────────────────────────
function void connect_phase(uvm_phase phase);
  c_agent.ap.connect(m_c2b_mon.analysis_export);  // leaf → reconstructor
  m_c2b_mon.ap.connect(m_b2a_mon.analysis_export);  // chain upward
endfunction

📋 Signal Wait Methods on Config Objects

Drivers and monitors synchronise to hardware clocks directly via their virtual interface. But sometimes a sequence or a coverage component also needs to wait on a hardware signal — for example, waiting for N clock cycles between transactions, or gating coverage collection during reset.

The recommended pattern is to add timing methods to the agent config object. Since the config object holds the virtual interface handle, it can implement tasks that wait on interface signals. Sequences retrieve the config object via the sequencer and call these methods.

// ── Add timing tasks to the config object ────────────────
class bus_agent_cfg extends uvm_object;
  `uvm_object_utils(bus_agent_cfg)

  virtual bus_if vif;   // virtual interface with all signals

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

  // ── Hardware synchronisation tasks ────────────────────
  task wait_for_clock(int n = 1);
    repeat(n) @(posedge vif.clk);
  endtask

  task wait_for_reset();
    @(posedge vif.resetn);   // wait for reset to deassert
  endtask

  task wait_for_interrupt();
    @(posedge vif.irq);
  endtask

  task wait_until_idle();
    @(posedge vif.clk);
    while (vif.busy) @(posedge vif.clk);
  endtask
endclass

// ── Sequence using timing tasks ───────────────────────────
class spaced_write_seq extends uvm_sequence #(bus_seq_item);
  `uvm_object_utils(spaced_write_seq)

  bus_agent_cfg m_cfg;
  rand int gap_cycles = 4;

  task body();
    bus_seq_item item;

    // Get config from sequencer (not component hierarchy)
    if (!uvm_config_db #(bus_agent_cfg)::get(
          m_sequencer, "", "cfg", m_cfg))
      `uvm_fatal("SEQ", "No config!")

    m_cfg.wait_for_reset();     // wait for reset before driving

    repeat(10) begin
      item = bus_seq_item::type_id::create("item");
      start_item(item);
      item.randomize();
      finish_item(item);
      m_cfg.wait_for_clock(gap_cycles);   // inter-transfer gap
    end
  endtask
endclass
Use uvm_config_db::get(m_sequencer, ...) from sequences to retrieve the config object. Sequences are not in the component hierarchy, so they cannot call get(this, ...). Use m_sequencer as the starting point — the config_db lookup walks up from the sequencer’s position in the hierarchy, which finds the same config that was set for the agent.

📋 Interrupt Handling Sequences

Hardware interrupt handling in a UVM testbench follows a consistent pattern: a background thread monitors the interrupt signal via the config object’s timing tasks, and when an interrupt fires, it starts a high-priority ISR sequence that grabs the sequencer, services the interrupt, and releases.

class interrupt_controller_seq extends uvm_sequence #(apb_seq_item);
  `uvm_object_utils(interrupt_controller_seq)

  apb_agent_cfg m_cfg;

  task body();
    apb_background_seq bg_seq;
    apb_isr_seq        isr_seq;

    if (!uvm_config_db #(apb_agent_cfg)::get(
          m_sequencer, "", "cfg", m_cfg))
      `uvm_fatal("SEQ", "No config!")

    m_sequencer.set_arbitration(SEQ_ARB_STRICT_FIFO);

    fork
      // ── Background traffic thread (normal priority) ───────
      begin
        bg_seq = apb_background_seq::type_id::create("bg");
        bg_seq.start(m_sequencer, this, 100);
      end

      // ── Interrupt monitor thread ──────────────────────────
      begin
        forever begin
          m_cfg.wait_for_interrupt();    // blocks until IRQ rises
          `uvm_info("SEQ", "IRQ detected — starting ISR", UVM_MEDIUM)

          isr_seq = apb_isr_seq::type_id::create("isr");
          // ISR sequence grabs the sequencer inside its own body()
          isr_seq.start(m_sequencer, this, 500);  // high priority
        end
      end
    join_any
    wait fork;
  endtask
endclass

// ── ISR sequence: grabs sequencer, services interrupt ─────
class apb_isr_seq extends uvm_sequence #(apb_seq_item);
  `uvm_object_utils(apb_isr_seq)

  task body();
    apb_seq_item item;

    m_sequencer.grab(this);   // exclusive access

    // Read interrupt status register
    item = apb_seq_item::type_id::create("isr_rd");
    start_item(item);
    item.read_not_write = 1; item.addr = 12'h00C;
    finish_item(item);

    // Clear interrupt by writing W1C bits
    item = apb_seq_item::type_id::create("isr_wr");
    start_item(item);
    item.read_not_write = 0;
    item.addr           = 12'h00C;
    item.write_data     = item.read_data;  // W1C: write back what was read
    finish_item(item);

    m_sequencer.ungrab(this);  // release — background resumes
  endtask
endclass

📋 Parameterised Tests

When the DUT is parameterised (data width, address width, FIFO depth), the testbench must match. The recommended approach is a test parameters package that defines all shared parameters in one place, imported by both the DUT interfaces and the testbench classes.

// ── test_params_pkg.sv — shared by DUT and testbench ─────
package test_params_pkg;
  parameter int DATA_WIDTH  = 32;
  parameter int ADDR_WIDTH  = 12;
  parameter int FIFO_DEPTH  = 16;
  parameter int NUM_MASTERS = 2;
endpackage : test_params_pkg

// ── Interface uses parameters from package ────────────────
import test_params_pkg::*;
interface apb_if #(
  parameter int AW = ADDR_WIDTH,
  parameter int DW = DATA_WIDTH
) (input logic PCLK, PRESETn);
  logic [AW-1:0] PADDR;
  logic [DW-1:0] PWDATA, PRDATA;
  // ...
endinterface

// ── Parameterised test class ──────────────────────────────
// Factory cannot register parameterised classes with the standard
// macro. Use a typedef to create a concrete specialisation.
import test_params_pkg::*;

class base_test_t #(
  parameter int DW = DATA_WIDTH,
  parameter int AW = ADDR_WIDTH
) extends uvm_test;
  // No `uvm_component_utils here — parameterised classes
  // cannot be registered with the standard macro

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction

  // ... env, config, run_phase ...
endclass

// ── Create a concrete registered specialisation ───────────
typedef base_test_t #(32, 12) base_test;
// Now base_test is a regular class — can be registered and run

// ── tb_top: import the params package ────────────────────
module tb_top;
  import test_params_pkg::*;
  import test_pkg::*;
  import uvm_pkg::*;
  `include "uvm_macros.svh"

  apb_if #(.AW(ADDR_WIDTH), .DW(DATA_WIDTH)) APB(.PCLK, .PRESETn);
  my_dut #(.AW(ADDR_WIDTH), .DW(DATA_WIDTH)) dut(.APB);

  initial run_test();
endmodule
Parameterised classes cannot use `uvm_component_utils directly. The factory registration macros are not compatible with parameterised class templates. The standard approach is to define the parameterised class without the macro, then create a concrete typedef specialisation, and register the typedef using a specialised registration macro like `uvm_component_utils(base_test) on the typedef. This limits run_test() to a fixed parameter set per compilation, which is the intended behaviour.

📋 Stopping Sequences Cleanly

Sequences sometimes need to stop before they naturally complete — when a kill signal fires, when a timeout expires, or when an error condition is detected. The recommended approach uses a shared flag and a fork/join_any structure.

// ── Shared stop event — set by external controller ────────
class stoppable_seq extends uvm_sequence #(apb_seq_item);
  `uvm_object_utils(stoppable_seq)

  bit stop_flag = 0;   // external code sets this to stop

  task body();
    apb_seq_item item;

    fork
      // Main stimulus thread
      begin
        while (!stop_flag) begin
          item = apb_seq_item::type_id::create("item");
          start_item(item);
          item.randomize();
          finish_item(item);
        end
      end
      // Stop watchdog — waits for flag then exits
      begin
        wait(stop_flag == 1);
      end
    join_any
    // Both threads have exited cleanly — no mid-handshake kill
  endtask
endtask

// ── External controller: virtual sequence stops it ────────
task body();
  stoppable_seq seq = stoppable_seq::type_id::create("seq");
  fork
    seq.start(m_sequencer, this);
  join_none

  // ... other work ...
  #500ns;
  seq.stop_flag = 1;  // signals sequence to stop after current item
  wait(seq.stopped);   // wait for clean exit
endtask

📋 Quick Reference

TopicKey fact
Layering purposeDecomposes high-level protocol items into low-level physical items for multi-layer protocol stacks
Layering component containsIntermediate sequencers + translator sequences started in run_phase + leaf protocol agent
Translator sequence runs onDownstream sequencer — but holds handle to upstream sequencer
Translator sequence flowup_seqr.get_next_item() → decompose → start_item/finish_item × N → up_seqr.item_done()
item_done() timing in translatorMust be called AFTER all downstream items for that upstream item are sent
Reconstruction monitorSubscribes to leaf monitor; reassembles high-level items from low-level; broadcasts via analysis port
Signal wait patternAdd wait_for_clock/reset/interrupt/idle tasks to the config object — sequences call via m_cfg
Config in sequencesuvm_config_db::get(m_sequencer, "", "cfg", m_cfg) — use m_sequencer not this
ISR sequence patternBackground traffic fork + interrupt monitor fork + ISR sequence with grab/ungrab
ISR arbitration modeSEQ_ARB_STRICT_FIFO — ISR sequence has higher priority (500 vs 100)
Parameterised testsUse test_params_pkg for shared DUT+TB parameters. Parameterised class → typedef → registered concrete class.
Stopping sequencesstop_flag + while(!stop_flag) loop — allows current item to complete cleanly before stopping
External vs internal agent in layeringInternal: layering extends uvm_component, creates C_agent. External: layering extends uvm_subscriber #(C_item).
Scroll to Top