Coordinating stimulus across multiple protocol interfaces — what a virtual sequence is, the recommended sequencer-handle pattern, the legacy virtual sequencer pattern, base class and init_vseq methodology, sequential and parallel sub-sequence execution, and sequencer arbitration.
Every sequence seen so far runs on a single sequencer targeting a single driver. This is fine for a block-level testbench with one protocol interface. But most real DUTs have multiple interfaces — a control bus for register writes, a data bus for payload traffic, an interrupt line, a DMA interface, a status bus.
A single sequence cannot target two different sequencers at once — start_item() and finish_item() communicate with whichever sequencer the sequence was started on. If you want to initialise a register over the APB bus and then start data traffic over the AHB bus in a coordinated way, you need a mechanism that can operate both sequencers simultaneously.
This is what the virtual sequence solves. It is a coordinating sequence that holds handles to multiple target sequencers and starts sub-sequences on each, orchestrating the complete test scenario across all interfaces.
A virtual sequence is a regular uvm_sequence parameterised on uvm_sequence_item (the base item class) rather than a specific protocol item. This is because a virtual sequence does not itself send sequence items — it only starts sub-sequences on other sequencers. The uvm_sequence_item parameter is a placeholder.
Because it does not send items directly, a virtual sequence is started with null as the sequencer argument. The actual sub-sequences it starts each get their own real sequencer handle.
// ── Recommended typedef ─────────────────────────────────── typedef uvm_sequence #(uvm_sequence_item) uvm_virtual_sequence; // ── Virtual sequence structure ──────────────────────────── class top_vseq extends uvm_virtual_sequence; `uvm_object_utils(top_vseq) // Handles to the target sequencers — assigned before start() uvm_sequencer #(apb_seq_item) apb_seqr; uvm_sequencer #(ahb_seq_item) ahb_seqr; uvm_sequencer #(spi_seq_item) spi_seqr; function new(string name = "top_vseq"); super.new(name); endfunction task body(); apb_init_seq apb_init; ahb_burst_seq ahb_burst; spi_config_seq spi_cfg; apb_init = apb_init_seq::type_id::create("apb_init"); ahb_burst = ahb_burst_seq::type_id::create("ahb_burst"); spi_cfg = spi_config_seq::type_id::create("spi_cfg"); // ① Configure APB registers first (sequential) apb_init.start(apb_seqr, this); // ② Start AHB and SPI in parallel once APB init done fork ahb_burst.start(ahb_seqr, this); spi_cfg.start(spi_seqr, this); join endtask endclass // ── Starting a virtual sequence: start(null) ────────────── task run_phase(uvm_phase phase); top_vseq vseq; phase.raise_objection(this); vseq = top_vseq::type_id::create("vseq"); // Assign sequencer handles from component hierarchy vseq.apb_seqr = m_env.m_apb_agent.m_seqr; vseq.ahb_seqr = m_env.m_ahb_agent.m_seqr; vseq.spi_seqr = m_env.m_spi_agent.m_seqr; vseq.start(null); // null — vseq has no target sequencer of its own phase.drop_objection(this); endtask
null as the sequencer. Since a virtual sequence does not send items itself, there is no target sequencer. The sub-sequences it starts each receive their own sequencer. If you accidentally pass a real sequencer to a virtual sequence’s start(), the sequence runs but m_sequencer points to an incompatible sequencer — sub-sequence starts will fail at runtime.The recommended approach is to place sequencer handles directly inside the virtual sequence as member variables. The test assigns them before calling start(). This approach is simple, explicit, and does not require any changes to the component hierarchy.
Virtual sequence declares typed sequencer handles. Test assigns them from the component hierarchy. Handles are type-safe — wrong type causes compile error. Each virtual sequence class is self-documenting about which sequencers it needs.
A uvm_sequencer sub-class holds handles to all agent sequencers. Virtual sequence uses p_sequencer to access them. More infrastructure; useful when many virtual sequences share the same set of sequencers. Covered in the virtual sequencer section below.
// ── Base virtual sequence class — shared by all tests ───── class top_vseq_base extends uvm_sequence #(uvm_sequence_item); `uvm_object_utils(top_vseq_base) // One handle per agent sequencer in the environment uvm_sequencer #(apb_seq_item) apb_seqr; uvm_sequencer #(ahb_seq_item) ahb_seqr; uvm_sequencer #(spi_seq_item) spi_seqr; function new(string name = "top_vseq_base"); super.new(name); endfunction // body() to be overridden by derived virtual sequences virtual task body(); `uvm_info("VSEQ", "Base virtual sequence — override body()", UVM_LOW) endtask endclass
When a project has many tests all using virtual sequences, the sequencer handle assignment is repetitive and error-prone. The solution is an init_vseq() function on the base test class that encapsulates all the hierarchical path knowledge in one place. Derived tests call it before starting any virtual sequence.
// ── Base test — owns the hierarchy knowledge ────────────── class base_test extends uvm_test; `uvm_component_utils(base_test) my_env m_env; function new(string name, uvm_component parent); super.new(name, parent); endfunction function void build_phase(uvm_phase phase); super.build_phase(phase); m_env = my_env::type_id::create("m_env", this); endfunction // ── Single function encapsulates all sequencer paths ─── function void init_vseq(top_vseq_base vseq); vseq.apb_seqr = m_env.m_apb_agent.m_seqr; vseq.ahb_seqr = m_env.m_ahb_agent.m_seqr; vseq.spi_seqr = m_env.m_spi_agent.m_seqr; endfunction endclass // ── Derived test — clean: one init call, one start call ─── class gpio_test extends base_test; `uvm_component_utils(gpio_test) function new(string name, uvm_component parent); super.new(name, parent); endfunction task run_phase(uvm_phase phase); gpio_vseq vseq; phase.raise_objection(this); vseq = gpio_vseq::type_id::create("vseq"); init_vseq(vseq); // one call — all seqr handles assigned vseq.start(null); // virtual seq has no target sequencer phase.drop_objection(this); endtask endclass
A complete virtual sequence for a GPIO DUT: first configure all registers over APB (sequential), then verify configuration via a parallel read-back sequence, then run randomised data traffic:
class gpio_vseq extends top_vseq_base; `uvm_object_utils(gpio_vseq) function new(string name = "gpio_vseq"); super.new(name); endfunction task body(); apb_reg_init_seq reg_init; apb_read_back_seq readback; apb_rand_seq apb_rand; spi_rand_seq spi_rand; reg_init = apb_reg_init_seq::type_id::create("reg_init"); readback = apb_read_back_seq::type_id::create("readback"); apb_rand = apb_rand_seq::type_id::create("apb_rand"); spi_rand = spi_rand_seq::type_id::create("spi_rand"); // ① Configure DUT registers — sequential (must complete first) reg_init.start(apb_seqr, this); // ② Verify config — sequential after init readback.start(apb_seqr, this); // ③ Randomised traffic on APB and SPI in parallel fork apb_rand.start(apb_seqr, this); spi_rand.start(spi_seqr, this); join `uvm_info("VSEQ", "GPIO virtual sequence complete", UVM_LOW) endtask endclass
An older pattern uses a virtual sequencer — a uvm_sequencer component sub-class that contains handles to all agent sequencers. Virtual sequences that need access to multiple sequencers are started on this virtual sequencer and access the handles through p_sequencer.
// ── Virtual sequencer component ─────────────────────────── class top_virtual_seqr extends uvm_sequencer; `uvm_component_utils(top_virtual_seqr) // Handles to real agent sequencers — assigned in connect_phase uvm_sequencer #(apb_seq_item) apb_seqr; uvm_sequencer #(ahb_seq_item) ahb_seqr; uvm_sequencer #(spi_seq_item) spi_seqr; function new(string name, uvm_component parent); super.new(name, parent); endfunction endclass // ── In env connect_phase: assign handles ────────────────── function void connect_phase(uvm_phase phase); m_vseqr.apb_seqr = m_apb_agent.m_seqr; m_vseqr.ahb_seqr = m_ahb_agent.m_seqr; m_vseqr.spi_seqr = m_spi_agent.m_seqr; endfunction // ── Virtual sequence using p_sequencer ─────────────────── class my_vseq extends uvm_sequence #(uvm_sequence_item); `uvm_object_utils(my_vseq) `uvm_declare_p_sequencer(top_virtual_seqr) // declares typed p_sequencer function new(string name = "my_vseq"); super.new(name); endfunction task body(); apb_init_seq apb_init = apb_init_seq::type_id::create("apb_init"); // Access sequencers via p_sequencer (type-cast handle) apb_init.start(p_sequencer.apb_seqr, this); endtask endclass // ── Start on the virtual sequencer (not null) ───────────── vseq.start(m_env.m_vseqr);
`uvm_declare_p_sequencer macro. The handle pattern achieves the same result with less infrastructure. Use the virtual sequencer only if the project has a large number of virtual sequences that all share the same set of sequencers.Virtual sequences can start sub-sequences in any combination of sequential and parallel execution. The key difference from single-agent sequences: sub-sequences on different sequencers can always run in parallel safely, because they each drive separate hardware interfaces. Sub-sequences on the same sequencer must be started sequentially (or through the sequencer’s arbitration mechanism).
task body(); // ── Pattern 1: strictly sequential ─────────────────────── seq_a.start(apb_seqr, this); // A finishes seq_b.start(apb_seqr, this); // then B starts on same seqr // ── Pattern 2: parallel on different sequencers ────────── fork seq_c.start(apb_seqr, this); // APB agent seq_d.start(ahb_seqr, this); // AHB agent — different HW, safe seq_e.start(spi_seqr, this); // SPI agent — different HW, safe join // ── Pattern 3: initialise all, then stream in parallel ──── init_apb.start(apb_seqr, this); // must finish before streaming fork stream_apb.start(apb_seqr, this); stream_spi.start(spi_seqr, this); join_any wait fork; // safer than disable fork for sequences endtask
wait fork instead of disable fork when possible. disable fork immediately terminates all forked threads, which can leave a sequence mid-handshake and lock the sequencer. wait fork blocks until all forked threads complete naturally — cleaner and safer. Use join_any + wait fork when you want to proceed after the first sub-sequence completes but still wait for the rest to finish cleanly.When multiple sequences are started on the same sequencer concurrently, the sequencer decides which sequence item goes to the driver next. This is arbitration. By default, UVM uses round-robin (FIFO) arbitration — each sequence gets an item through in turn. The arbitration mode can be changed to support priority-based access.
| Arbitration mode | Behaviour | Use case |
|---|---|---|
SEQ_ARB_FIFO | Round-robin — each sequence gets one item per turn (default) | General use — fair access to driver |
SEQ_ARB_WEIGHTED | Random weighted by priority value | Probabilistic traffic shaping |
SEQ_ARB_RANDOM | Uniform random selection from all ready sequences | Stress testing |
SEQ_ARB_STRICT_FIFO | Highest-priority sequence goes first; ties broken by FIFO | Interrupt service routines |
SEQ_ARB_STRICT_RAND | Highest-priority sequence goes first; ties broken randomly | Priority with randomness |
SEQ_ARB_USER | User-defined arbitration function | Custom scheduling algorithms |
// ── Set arbitration mode in sequence or test ────────────── task body(); // Set STRICT_FIFO so highest-priority seq goes first m_sequencer.set_arbitration(SEQ_ARB_STRICT_FIFO); // Start high-priority interrupt handler in background fork isr_seq.start(m_sequencer, this, 500); // priority=500 (high) join_none // Start normal traffic at lower priority normal_seq.start(m_sequencer, this, 100); // priority=100 (low) endtask
A sequence can request exclusive access to a sequencer with grab(). While a sequence holds the grab, no other sequence can send items through that sequencer. This is used for interrupt service routines and other time-critical operations that must not be interleaved with other traffic.
task body(); // grab() — exclusive access (blocks until granted) m_sequencer.grab(this); // All items sent here have exclusive access — no other // sequence can send items until ungrab() is called start_item(req); req.randomize(); finish_item(req); // lock() is a variant — same as grab but waits in FIFO // grab() immediately pre-empts current item // ungrab() — release exclusive access m_sequencer.ungrab(this); // Other sequences can now access the sequencer endtask
| Method | Behaviour | When to use |
|---|---|---|
grab(this) | Immediately gains exclusive access — pre-empts in-progress arbitration | High-priority interrupt handler that must respond immediately |
lock(this) | Gains exclusive access after the current item completes — waits in FIFO | Critical section that should not be interrupted mid-burst |
ungrab(this) | Releases exclusive access. Other sequences resume. | End of ISR or critical section. Must always be called after grab/lock. |
| Item | Key fact |
|---|---|
| Virtual sequence purpose | Coordinates stimulus across multiple sequencers — the top of the sequence hierarchy |
| Base parameterisation | uvm_sequence #(uvm_sequence_item) — does not send items directly |
| Start syntax | vseq.start(null) — null because vseq has no target sequencer |
| Sequencer handles | Declared as member variables in virtual sequence; assigned by test before start() |
| init_vseq() purpose | Encapsulates all hierarchical path assignments in one base test function |
| Sub-sequence start | sub_seq.start(typed_seqr, this) — always pass this as parent |
| Parallel safe when | Sub-sequences run on different sequencers (different HW interfaces) |
| Virtual sequencer | Legacy: uvm_sequencer sub-class with agent sequencer handles. Less preferred than handle pattern. |
`uvm_declare_p_sequencer | Macro used with virtual sequencer pattern — declares typed p_sequencer handle |
| Default arbitration | SEQ_ARB_FIFO — round-robin, fair access |
| Priority arbitration | SEQ_ARB_STRICT_FIFO — highest priority wins; set via set_arbitration() |
| Start with priority | seq.start(seqr, parent, priority_value) |
| grab() | Immediate exclusive sequencer access — pre-empts arbitration |
| lock() | Exclusive access after current item — waits in FIFO |
| ungrab() rule | Must always be called after grab() — forgetting permanently locks sequencer |