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.

UVM virtual sequence running on a virtual sequencer and starting child sequences on APB, Wishbone and PCIe sequencers

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_sequence just like any other sequence
  • The macro `uvm_declare_p_sequencer creates a handle called p_sequencer of type my_virtual_sequencer
  • Child sequences are created in pre_body() and started in body()
  • Each child sequence is started on its own sequencer using start(), reached through p_sequencer
  • The fork/join runs 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?

AspectWith virtual sequencerWithout virtual sequencer
Extra componentYes, a virtual sequencer in the envNo
Who assigns sequencer handlesEnvironment, once in connect_phaseEvery test that starts the sequence
How the sequence is startedstart(m_env.m_virtual_seqr)start(null)
Access to real sequencersThrough p_sequencerThrough handles in the sequence
Best suited forLarge, reusable environments with many testsSmall 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_sequence and is not tied to any transaction type
  • With a virtual sequencer, `uvm_declare_p_sequencer provides 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 and fork/join
  • Passing this as the parent argument keeps the sequence hierarchy clear for debug
Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5