A modern SoC talks to the outside world through several buses at once - a register bus for configuration, a reset or control bus, and a high-speed data link. A real test has to drive all of them in the right order, and a virtual sequence lets one test coordinate traffic across every interface from a single place.
A virtual sequence is a container that starts multiple sequences on different sequencers in the environment. It is usually executed on a virtual sequencer which holds handles to the real sequencers. You need one whenever different sequences must run on different agents in a controlled order.
For example, an SoC design might have multiple interfaces, each driven by its own set of sequences on its own sequencer. The best way to start and control all of these sequences is from a single virtual sequence.
8 min read | Intermediate Level
What You'll Learn
- What a virtual sequence is and why multi-agent testbenches need one
- Write a virtual sequence that runs on a virtual sequencer using
p_sequencer - Write a virtual sequence that works without a virtual sequencer
- Choose between the two approaches and avoid common start-up errors
What is a Virtual Sequence?
A regular sequence generates sequence items (transactions) and sends them to one sequencer, which passes them to one driver. A virtual sequence does not generate items itself. Its body() only creates other sequences and starts them on the correct sequencers.
It is called "virtual" because it is not tied to any particular transaction type. It extends uvm_sequence with the default item type, and it never sends an item of its own to a driver.
Think of it as the test plan written in code. A typical SoC bring-up plan might look like this:
- Step 1: Run wb_reset_seq on the Wishbone agent and wait for it to finish
- Step 2: Run apb_rd_wr_seq on the APB agent to program configuration registers
- Step 3: Run pcie_gen_seq on the PCIe agent while APB traffic continues in parallel
Each step uses a different agent. No single agent's sequence can express this ordering, but a virtual sequence can, using ordinary SystemVerilog fork/join and sequential statements.
Where are Virtual Sequences Used?
Virtual sequences are standard in SoC and subsystem-level testbenches where several protocol agents (APB, AXI, PCIe, Ethernet, interrupts) must be coordinated. A common example is a boot-flow test that releases reset, loads configuration over a register bus, and only then starts data traffic on high-speed links.
Syntax
Basic Form:
class VSEQ_NAME extends uvm_sequence;
`uvm_object_utils (VSEQ_NAME)
`uvm_declare_p_sequencer (VIRTUAL_SEQR_TYPE) // Optional - only with a virtual sequencer
virtual task body ();
CHILD_SEQ.start (TARGET_SEQUENCER, this); // Start each child on its own sequencer
endtask
endclass
Key parts:
uvm_sequence- the base class, with no item type parameter needed (required)`uvm_declare_p_sequencer- declares a typed handle p_sequencer to the virtual sequencer (optional)start()- runs a child sequence on the sequencer passed as the first argument (required)
There are two common ways to give the virtual sequence access to the real sequencers. Both are shown below.
Use of a virtual sequencer
In this approach, the environment contains a virtual sequencer that holds handles to every real sequencer. The virtual sequence runs on it and reaches the real sequencers through p_sequencer.
class my_virtual_seq extends uvm_sequence;
`uvm_object_utils (my_virtual_seq)
`uvm_declare_p_sequencer (my_virtual_sequencer)
function new (string name = "my_virtual_seq");
super.new (name);
endfunction
apb_rd_wr_seq m_apb_rd_wr_seq;
wb_reset_seq m_wb_reset_seq;
pcie_gen_seq m_pcie_gen_seq;
task pre_body();
m_apb_rd_wr_seq = apb_rd_wr_seq::type_id::create ("m_apb_rd_wr_seq");
m_wb_reset_seq = wb_reset_seq::type_id::create ("m_wb_reset_seq");
m_pcie_gen_seq = pcie_gen_seq::type_id::create ("m_pcie_gen_seq");
endtask
task body();
...
m_apb_rd_wr_seq.start (p_sequencer.m_apb_seqr);
fork
m_wb_reset_seq.start (p_sequencer.m_wb_seqr);
m_pcie_gen_seq.start (p_sequencer.m_pcie_seqr);
join
...
endtask
endclass
Note the following from the example above:
- my_virtual_seq is derived from
uvm_sequencejust like any other sequence - The macro
`uvm_declare_p_sequencercreates a handle called p_sequencer of type my_virtual_sequencer - Child sequences are created in
pre_body()and started inbody() - Each child sequence is started on its own sequencer using
start(), reached through p_sequencer - The
fork/joinruns the Wishbone and PCIe sequences in parallel and waits for both to finish
When the virtual sequence starts, UVM casts its internal sequencer handle to the type given in the macro and stores it in p_sequencer. This is why the virtual sequence must be started on an object of exactly that sequencer type (or a subclass of it).
pre_body() is called only when start() runs with its call_pre_post argument set to 1, which is the default. If the virtual sequence is ever started with call_pre_post = 0, the child sequences are never created. Creating them at the top of body() avoids this dependency.
Pass this as the second argument of start(), for example m_wb_reset_seq.start (p_sequencer.m_wb_seqr, this). This makes the virtual sequence the parent, so the child appears under it in the sequence hierarchy and in report messages, which makes debug much easier.
Once a virtual sequence is defined, you can start it in your test as shown below.
class my_test extends uvm_test;
`uvm_component_utils (my_test)
my_env m_env;
...
task run_phase (uvm_phase phase);
my_virtual_seq m_vseq = my_virtual_seq::type_id::create ("m_vseq");
phase.raise_objection (this);
m_vseq.start (m_env.m_virtual_seqr);
phase.drop_objection (this);
endtask
endclass
The test only needs to know about one sequencer, m_virtual_seqr. The handles inside the virtual sequencer are assigned by the environment, usually in its connect_phase.
Quick Check
Q: In the example above, does m_apb_rd_wr_seq run before, after, or alongside m_pcie_gen_seq?
Show Answer
Before. start() is a blocking task, so the APB sequence must complete before the fork begins. Only the Wishbone and PCIe sequences run in parallel.
Without a virtual sequencer
You can also put handles to the individual sequencers directly inside the virtual sequence. In that case, you do not need a virtual sequencer at all.
class my_virtual_seq extends uvm_sequence;
`uvm_object_utils (my_virtual_seq)
...
apb_sequencer m_apb_seqr;
reg_sequencer m_reg_seqr;
wb_sequencer m_wb_seqr;
apb_rd_wr_seq m_apb_rd_wr_seq;
wb_reset_seq m_wb_reset_seq;
pcie_gen_seq m_pcie_gen_seq;
virtual task pre_body();
m_apb_rd_wr_seq = apb_rd_wr_seq::type_id::create ("m_apb_rd_wr_seq");
...
// Instantiate other sequences here
endtask
virtual task body ();
m_apb_rd_wr_seq.start (m_apb_seqr);
...
endtask
endclass
The sequencer handles inside the sequence must be assigned before the virtual sequence is started. The test does this because it can see the whole environment hierarchy.
class my_test extends uvm_test;
...
virtual task run_phase (uvm_phase phase);
my_virtual_seq m_vseq = my_virtual_seq::type_id::create ("m_vseq");
phase.raise_objection (this);
// Assign all sequencer handles
m_vseq.m_apb_seqr = m_env.m_apb_agent.m_apb_seqr;
...
m_vseq.start (null);
phase.drop_objection (this);
endtask
endclass
The virtual sequence is started with null because it does not need a sequencer of its own. Its body() still runs normally and starts each child on the handle assigned by the test.
If you prefer, you can split the creation of the sequence and the assignment of its handles into the build_phase and connect_phase of my_test. Handles can also be passed through the UVM config database instead of being assigned directly.
Which Approach Should You Use?
| Aspect | With virtual sequencer | Without virtual sequencer |
|---|---|---|
| Extra component | Yes, a virtual sequencer in the env | No |
| Who assigns sequencer handles | Environment, once in connect_phase | Every test that starts the sequence |
| How the sequence is started | start(m_env.m_virtual_seqr) | start(null) |
| Access to real sequencers | Through p_sequencer | Through handles in the sequence |
| Best suited for | Large, reusable environments with many tests | Small testbenches and quick experiments |
Where is Each Approach Used?
Large SoC environments shared by many engineers often use a virtual sequencer so the handle wiring lives in one place inside the environment. Smaller block-level benches frequently skip it and let the test assign handles directly, since there are only one or two agents to wire up.
Common Mistakes
Mistake #1: Starting the virtual sequence on the wrong sequencer
Wrong: m_vseq.start (m_env.m_apb_agent.m_apb_seqr);
Right: m_vseq.start (m_env.m_virtual_seqr);
Why? With `uvm_declare_p_sequencer, UVM casts the sequencer passed to start() into the declared type. If the types do not match, the cast fails and the simulation stops with a UVM_FATAL before body() runs.
Mistake #2: Leaving sequencer handles unassigned
If the environment forgets to assign a handle inside the virtual sequencer, or the test forgets to assign a handle inside the virtual sequence, start() receives a null sequencer for that child. The child then has no driver to send items to, and the test either fails with a null access error or produces no traffic. Check every handle in connect_phase or right before starting the sequence.
Mistake #3: Generating sequence items inside the virtual sequence
Wrong: calling start_item() on an APB item directly inside the virtual sequence's body()
Right: wrap the APB items in an APB sequence and start that sequence on the APB sequencer
Why? A virtual sequencer has no driver connected to it, and a virtual sequence started with null has no sequencer at all. Items sent this way never reach a DUT interface. A virtual sequence should only start other sequences.
Practice Exercise
Challenge: Write a Boot-Flow Virtual Sequence
Write a virtual sequence soc_boot_vseq that runs on my_virtual_sequencer. It must run wb_reset_seq first and wait for it to finish. After that, it must run apb_rd_wr_seq and pcie_gen_seq in parallel. Make the virtual sequence the parent of every child sequence.
Hint
A plain start() call blocks until the child finishes, so put the reset sequence before a fork/join. Pass this as the second argument of every start() call.
Solution
class soc_boot_vseq extends uvm_sequence;
`uvm_object_utils (soc_boot_vseq)
`uvm_declare_p_sequencer (my_virtual_sequencer) // Gives typed access to real sequencers
wb_reset_seq m_wb_reset_seq;
apb_rd_wr_seq m_apb_rd_wr_seq;
pcie_gen_seq m_pcie_gen_seq;
function new (string name = "soc_boot_vseq");
super.new (name);
endfunction
virtual task body ();
// Create children in body() so they exist even if pre_body() is skipped
m_wb_reset_seq = wb_reset_seq::type_id::create ("m_wb_reset_seq");
m_apb_rd_wr_seq = apb_rd_wr_seq::type_id::create ("m_apb_rd_wr_seq");
m_pcie_gen_seq = pcie_gen_seq::type_id::create ("m_pcie_gen_seq");
// Step 1: reset blocks until it completes
m_wb_reset_seq.start (p_sequencer.m_wb_seqr, this);
// Step 2: configuration and data traffic run in parallel
fork
m_apb_rd_wr_seq.start (p_sequencer.m_apb_seqr, this);
m_pcie_gen_seq.start (p_sequencer.m_pcie_seqr, this);
join // Wait for both to finish before body() returns
endtask
endclass
Because body() returns only after both forked sequences complete, the test's objection is held for the whole boot flow.
Summary
In this tutorial, you learned:
- A virtual sequence coordinates sequences across multiple agents and never generates items itself
- It extends
uvm_sequenceand is not tied to any transaction type - With a virtual sequencer,
`uvm_declare_p_sequencerprovides typed access to real sequencers through p_sequencer - Without a virtual sequencer, the test assigns sequencer handles and starts the sequence with
null - Ordering is controlled with blocking
start()calls andfork/join - Passing
thisas the parent argument keeps the sequence hierarchy clear for debug