Advanced UVM

Chapter 4 of 6

Virtual Sequencer and Virtual Sequences

A uvm_sequencer with nothing to drive, coordinating axi_agent and irq_agent from one place -- uvm_event/uvm_event_pool as the cross-agent synchronization primitive, and a real same-clock-edge race between BVALID and irq, caught by actually checking simulation timestamps, not just reasoning about it.

Chapters 2 and 3 built two agents that have never once talked to each other: axi_agent drives, irq_agent only ever observes passively, and every sequence so far has run on exactly one sequencer. Chapter 10 of uvm named this gap directly — "layering sequences on top of each other" is a real problem a bigger environment runs into. This chapter closes it: one sequence, coordinating both agents, using a virtual sequencer — a uvm_sequencer with nothing attached to it at all.

Why "virtual" — a sequencer with nothing to drive

axi_sequencer (chapter 2) exists to be paired with axi_driver through seq_item_port/seq_item_export — every item it produces eventually reaches a driver that does something with it. A virtual sequencer is a uvm_sequencer that never gets a driver connected to it at all:

class regfile_vsqr extends uvm_sequencer;
  `uvm_component_utils(regfile_vsqr)
 
  axi_sequencer axi_sqr;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
endclass

It holds a handle to the real sequencer (axi_sqr, wired up in regfile_env's connect_phase below) instead. A sequence running "on" regfile_vsqr never calls start_item/finish_item against regfile_vsqr itself — there'd be nothing on the other end to receive it. Instead, it targets axi_sqr explicitly, which is the one piece of new API this chapter needs: start_item accepts an optional third argument naming which sequencer to actually use, overriding the sequence's own default.

Crossing agents: uvm_event and the uvm_event_pool

Driving axi_agent from a virtual sequence is one new argument on a familiar call. Coordinating with irq_agent is a different problem: irq_agent has no sequencer at all (chapter 3), so there's no start_item equivalent — the virtual sequence needs to find out when the monitor observes something, not send it a command.

uvm_event is UVM's answer: a rendezvous object one piece of code can trigger() and another can block on with wait_trigger(). uvm_event_pool::get_global(name) hands back the same singleton event to any caller using the same name string, anywhere in the testbench — no need to wire a handle through connect_phase between components that aren't even in the same branch of the hierarchy. irq_monitor (chapter 3) gains exactly one addition — everything else in its run_phase is unchanged:

task run_phase(uvm_phase phase);
  bit last = 1'b0;
  uvm_event irq_event = uvm_event_pool::get_global("irq_event");
  forever begin
    @(vif.cb);
    if (vif.cb.irq !== last) begin
      irq_txn txn = irq_txn::type_id::create("txn");
      txn.level = vif.cb.irq;
      ap.write(txn);
      irq_event.trigger(txn);
      last = vif.cb.irq;
    end
  end
endtask

ap.write(txn) (chapter 3) and irq_event.trigger(txn) are two different consumption mechanisms for the same observation, for two different purposes: an analysis_port broadcasts to any number of loosely-coupled observers (a future scoreboard, a coverage collector — nobody has to be listening); uvm_event is a tight rendezvous for exactly one piece of code that needs to block until this specific thing happens. trigger(txn) passes the transaction itself as trigger data, so whoever wakes up from wait_trigger() can inspect which edge just happened.

A real race, caught by checking the actual timing

The obvious way to write the virtual sequence's body is: issue the four DATA writes, then call irq_event.wait_trigger(). That's wrong, and not for a subtle theoretical reason — it's wrong because of something directly measurable. axil_regfile's always_ff block updates count_reg (which irq is derived from) and asserts s_axi_bvalid for that same write in the exact same clocked always block, so they change on the identical clock edge. Instrumenting the DUT directly confirms it:

t=175000  BVALID rose
t=175000  IRQ    rose
t=205000  BVALID rose
t=205000  IRQ    fell

Both the fourth DATA write's response and irq's assertion land at the same simulation time; same again for the IRQ_CLR write and irq's deassertion. That means the driver's item_done() (which unblocks the sequence's finish_item()) and the monitor's irq_event.trigger() call are two independent processes both woken by the same simulation time step — SystemVerilog doesn't guarantee which one runs first. If the monitor's process happens to run first, it triggers and clears the event before the virtual sequence gets around to calling wait_trigger() at all, and the sequence hangs forever waiting for a trigger that already happened.

The fix is to register the wait before the event could possibly fire, not after — fork the write together with the wait, instead of sequencing them:

fork
  write(8'h08, 32'hDD);       // the write that crosses IRQ_THRESHOLD
  irq_event.wait_trigger();
join

Both branches start at the same simulation time, before either the write or the trigger has happened — so no matter which of the two independent processes the simulator happens to schedule first once the shared clock edge arrives, the wait_trigger() call is already registered and waiting. This is the same class of ordering hazard `uvm_info's objection material and this track's own AW/W independence rule have already surfaced in different forms — two things that can happen at the same simulation time need an explicit ordering guarantee, or there isn't one.

regfile_vsqr and regfile_virtual_seq

class regfile_virtual_seq extends uvm_sequence;
  `uvm_object_utils(regfile_virtual_seq)
  `uvm_declare_p_sequencer(regfile_vsqr)
 
  function new(string name = "regfile_virtual_seq");
    super.new(name);
  endfunction
 
  task write(bit [7:0] addr, bit [31:0] data);
    axi_txn req = axi_txn::type_id::create("req");
    start_item(req, -1, p_sequencer.axi_sqr);
    req.is_write = 1'b1;
    req.addr     = addr;
    req.wdata    = data;
    finish_item(req);
  endtask
 
  task body();
    uvm_event irq_event = uvm_event_pool::get_global("irq_event");
    irq_txn   ev_txn;
 
    write(8'h00, 32'h1);   // CTRL: ENABLE=1
    write(8'h08, 32'hAA);  // DATA #1
    write(8'h08, 32'hBB);  // DATA #2
    write(8'h08, 32'hCC);  // DATA #3
 
    fork
      write(8'h08, 32'hDD);      // DATA #4 -> crosses IRQ_THRESHOLD
      irq_event.wait_trigger();
    join
 
    if ($cast(ev_txn, irq_event.get_trigger_data()) && ev_txn.level == 1'b1)
      `uvm_info("VSEQ", "confirmed: irq asserted right after the write that crossed IRQ_THRESHOLD", UVM_LOW)
    else
      `uvm_error("VSEQ", "expected irq to assert after the 4th DATA write")
 
    fork
      write(8'h00, 32'h3);       // CTRL: ENABLE=1, IRQ_CLR=1
      irq_event.wait_trigger();
    join
 
    if ($cast(ev_txn, irq_event.get_trigger_data()) && ev_txn.level == 1'b0)
      `uvm_info("VSEQ", "confirmed: irq deasserted right after IRQ_CLR", UVM_LOW)
    else
      `uvm_error("VSEQ", "expected irq to deassert after IRQ_CLR")
  endtask
endclass

uvm_declare_p_sequencer(regfile_vsqr) is the one macro this chapter adds to the vocabulary — it declares a typed p_sequencer handle so body() can reach p_sequencer.axi_sqr without a manual $cast. Everything else — start_item/finish_item, uvm_object_utils, the factory's type_id::create() — is chapters 2 and 6 of uvm, unchanged. Checking get_trigger_data()'s level against what the sequence expects at each point isn't just informative logging — it's a real correctness check with almost no extra code, the same "detect a mismatch" instinct uvm ch9's scoreboard was built around, just inline instead of in a separate component.

Wiring it into regfile_env and the test

regfile_env (chapter 3) gains the virtual sequencer alongside its two agents, wired to the real one in connect_phase:

class regfile_env extends uvm_env;
  `uvm_component_utils(regfile_env)
 
  axi_agent   axi_agt;
  irq_agent   irq_agt;
  irq_watcher irqw;
  regfile_vsqr vsqr;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    axi_agt = axi_agent::type_id::create("axi_agt", this);
    irq_agt = irq_agent::type_id::create("irq_agt", this);
    irqw    = irq_watcher::type_id::create("irqw", this);
    vsqr    = regfile_vsqr::type_id::create("vsqr", this);
  endfunction
 
  function void connect_phase(uvm_phase phase);
    super.connect_phase(phase);
    irq_agt.mon.ap.connect(irqw.imp);
    vsqr.axi_sqr = axi_agt.sqr;
  endfunction
endclass

And the test runs regfile_virtual_seq on env.vsqr instead of axi_basic_seq on env.axi_agt.sqr directly:

task run_phase(uvm_phase phase);
  regfile_virtual_seq seq = regfile_virtual_seq::type_id::create("seq");
  phase.raise_objection(this);
  seq.start(env.vsqr);
  phase.drop_objection(this);
endtask

Nothing about the top-level module, axil_regfile, axi4lite_if, or irq_if changes — same DUT, same interfaces, same config_db handoffs as chapter 3. Running it now prints two confirmations from the virtual sequence itself, not just two irq_watcher observations — the environment isn't just running two agents side by side anymore, it's coordinating them from one place.

Summary

  • A virtual sequencer is a uvm_sequencer with no driver attached — it exists purely to hold handles to the real sequencers a virtual sequence needs to reach.
  • start_item(req, -1, sequencer)'s third argument is the one new piece of API this chapter needs — everything else about issuing items is unchanged from chapters 2/6/7 of uvm.
  • uvm_event/uvm_event_pool::get_global(name) is UVM's cross-component rendezvous primitive — a monitor trigger()s it, a sequence wait_trigger()s it, no connect_phase wiring required between components in different branches of the hierarchy.
  • A real race exists between the driver noticing BVALID and the monitor noticing irq, because both are driven by the same clock edge in the DUT — confirmed by instrumenting the actual simulation, not assumed. The fix: fork the triggering write together with the wait, so the waiter is registered before the trigger could possibly fire.
  • Checking get_trigger_data() against what's expected at each synchronization point is a real, nearly-free correctness check — the same instinct as uvm ch9's scoreboard, applied inline.

Why would calling irq_event.wait_trigger() right after write(8'h08, 32'hDD) returns be unreliable, instead of forking the two together?

What is regfile_vsqr's axi_sqr field, and how does it get its value?

What is the name of the UVM class-library method that returns the same globally-shared uvm_event to any caller using the same name string?