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.
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.
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
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
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
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
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.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
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
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.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
| Topic | Key fact |
|---|---|
| Layering purpose | Decomposes high-level protocol items into low-level physical items for multi-layer protocol stacks |
| Layering component contains | Intermediate sequencers + translator sequences started in run_phase + leaf protocol agent |
| Translator sequence runs on | Downstream sequencer — but holds handle to upstream sequencer |
| Translator sequence flow | up_seqr.get_next_item() → decompose → start_item/finish_item × N → up_seqr.item_done() |
| item_done() timing in translator | Must be called AFTER all downstream items for that upstream item are sent |
| Reconstruction monitor | Subscribes to leaf monitor; reassembles high-level items from low-level; broadcasts via analysis port |
| Signal wait pattern | Add wait_for_clock/reset/interrupt/idle tasks to the config object — sequences call via m_cfg |
| Config in sequences | uvm_config_db::get(m_sequencer, "", "cfg", m_cfg) — use m_sequencer not this |
| ISR sequence pattern | Background traffic fork + interrupt monitor fork + ISR sequence with grab/ungrab |
| ISR arbitration mode | SEQ_ARB_STRICT_FIFO — ISR sequence has higher priority (500 vs 100) |
| Parameterised tests | Use test_params_pkg for shared DUT+TB parameters. Parameterised class → typedef → registered concrete class. |
| Stopping sequences | stop_flag + while(!stop_flag) loop — allows current item to complete cleanly before stopping |
| External vs internal agent in layering | Internal: layering extends uvm_component, creates C_agent. External: layering extends uvm_subscriber #(C_item). |