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
endclassIt 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
endtaskap.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();
joinBoth 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
endclassuvm_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
endclassAnd 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);
endtaskNothing 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_sequencerwith 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 ofuvm.uvm_event/uvm_event_pool::get_global(name)is UVM's cross-component rendezvous primitive — a monitortrigger()s it, a sequencewait_trigger()s it, noconnect_phasewiring required between components in different branches of the hierarchy.- A real race exists between the driver noticing
BVALIDand the monitor noticingirq, because both are driven by the same clock edge in the DUT — confirmed by instrumenting the actual simulation, not assumed. The fix:forkthe 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 asuvmch9'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?