UVM Virtual Sequence and Virtual Sequencer Explained
A UVM virtual sequence is the conductor of a multi-interface testbench. An ordinary sequence runs on a single sequencer and drives a single agent. But a real chip has several interfaces at once — a register bus, a data bus, an interrupt line — and a meaningful test must touch them in a coordinated order: configure over APB, then stream over AXI, then check an interrupt. A virtual sequence is the one place that cross-agent choreography lives, and a virtual sequencer is the object that gives it reach into every agent’s sequencer.
A note on running the UVM code below: every block that uses the uvm_* base classes depends on the UVM library, which the free open-source simulators cannot provide. Those blocks are shown as syntax references for a UVM-capable simulator such as industry-standard simulation tools, VCS, or industry-standard simulators (or EDA Playground in the browser). The one plain-SystemVerilog example at the end carries no UVM dependency, so it actually compiles and runs, and its real simulation log is attached.
Why an ordinary sequence is not enough
Picture an SoC with two agents: an APB agent for register programming and an AXI agent for data movement. An APB sequence knows how to write registers; an AXI sequence knows how to move burst data. Neither knows the other exists, and that isolation is deliberate — it keeps both reusable. But then no single sequence can say “program the mode register, and only once that lands, start the data burst.” That ordering decision belongs to the test, not to either agent, and the virtual sequence is where you express it.
The virtual sequencer: handles, not drivers
A virtual sequencer is a uvm_sequencer that drives nothing itself. Its only job is to hold handles to the real sub-sequencers inside each agent so a virtual sequence can reach them.
class soc_virtual_sequencer extends uvm_sequencer;
`uvm_component_utils(soc_virtual_sequencer)
// Handles to the REAL sub-sequencers that live inside each agent.
// The virtual sequencer owns no driver of its own; it only points
// at the sequencers it will coordinate.
apb_sequencer apb_sqr;
axi_sequencer axi_sqr;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
endclassThere is no uvm_driver connected to this sequencer — it is a pure coordination point. The two fields apb_sqr and axi_sqr are the levers the virtual sequence will pull.
p_sequencer: how the virtual sequence sees those handles
When any sequence starts, UVM stores the sequencer it runs on in a base-class handle called m_sequencer. The trouble is that m_sequencer is typed as a generic uvm_sequencer, so m_sequencer.apb_sqr will not compile — the base class has no such field. The macro `uvm_declare_p_sequencer solves this by generating a second, strongly typed handle named p_sequencer that is cast to your actual virtual-sequencer class. Through p_sequencer you can finally reach apb_sqr and axi_sqr.
class cfg_then_traffic_vseq extends uvm_sequence;
`uvm_object_utils(cfg_then_traffic_vseq)
// This macro builds a typed handle called p_sequencer that points at
// the soc_virtual_sequencer this sequence is started on. Without it,
// the base handle m_sequencer is only a plain uvm_sequencer and cannot
// see apb_sqr / axi_sqr.
`uvm_declare_p_sequencer(soc_virtual_sequencer)
function new(string name = "cfg_then_traffic_vseq");
super.new(name);
endfunction
task body();
apb_config_seq cfg_seq;
axi_write_seq wr_seq;
axi_read_seq rd_seq;
cfg_seq = apb_config_seq::type_id::create("cfg_seq");
wr_seq = axi_write_seq::type_id::create("wr_seq");
rd_seq = axi_read_seq::type_id::create("rd_seq");
// Phase 1: program the DUT registers over APB.
cfg_seq.start(p_sequencer.apb_sqr);
// Phase 2: only AFTER config completes, run a write then a read
// over AXI. Because start() blocks until a sequence finishes, the
// three calls below execute strictly in order.
wr_seq.start(p_sequencer.axi_sqr);
rd_seq.start(p_sequencer.axi_sqr);
endtask
endclassThree things in that body() deserve attention:
`uvm_declare_p_sequencer(soc_virtual_sequencer)is what makesp_sequencer.apb_sqrlegal. Forgetting it is the single most common virtual-sequence mistake.seq.start(sequencer)is the general way to run any sequence on any sequencer. The virtual sequence creates sub-sequences with the factory and starts each one on the appropriate sub-sequencer.- Ordering is free.
start()blocks until the sequence it launched has finished, so writing the three calls one after another guarantees config completes before the write, which completes before the read.
Wiring the handles in the environment
The virtual sequencer’s apb_sqr and axi_sqr are null until something assigns them. That assignment is a one-line job in the environment’s connect_phase, after every agent has been built.
class soc_env extends uvm_env;
`uvm_component_utils(soc_env)
apb_agent apb_agt;
axi_agent axi_agt;
soc_virtual_sequencer v_sqr;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
apb_agt = apb_agent::type_id::create("apb_agt", this);
axi_agt = axi_agent::type_id::create("axi_agt", this);
v_sqr = soc_virtual_sequencer::type_id::create("v_sqr", this);
endfunction
// The crucial wiring: point the virtual sequencer's handles at the
// concrete sequencers inside each agent. Do this in connect_phase,
// after all components have been built.
function void connect_phase(uvm_phase phase);
super.connect_phase(phase);
v_sqr.apb_sqr = apb_agt.sequencer;
v_sqr.axi_sqr = axi_agt.sequencer;
endfunction
endclassThis is the join between structure and stimulus: the env builds the agents and the virtual sequencer, then points the virtual sequencer at each agent’s real sequencer. From then on, any virtual sequence started on v_sqr can drive both interfaces.
Running sequences on several interfaces in order — or at once
Because start() is blocking, sequential ordering is the default, as shown above. When you instead want two interfaces exercised concurrently — say background AXI traffic while APB reconfigures — wrap the calls in fork...join.
task body();
apb_config_seq cfg_seq = apb_config_seq::type_id::create("cfg_seq");
axi_write_seq wr_seq = axi_write_seq::type_id::create("wr_seq");
// Run APB configuration and AXI traffic at the SAME time. fork..join
// launches both, then waits for both to finish before body() returns.
fork
cfg_seq.start(p_sequencer.apb_sqr);
wr_seq.start(p_sequencer.axi_sqr);
join
endtaskSwap join for join_any or join_none to change how long body() waits. Blocking calls plus fork blocks are the whole vocabulary you need for almost any multi-interface scenario: strictly ordered, fully parallel, or a pipeline of the two.
Grabbing a sub-sequencer for exclusive access
Sometimes a critical burst must not be interrupted by other sequences sharing the same sequencer. A virtual sequence can take exclusive control with grab() and release it with ungrab().
task body();
axi_write_seq hp_seq = axi_write_seq::type_id::create("hp_seq");
// Take exclusive ownership of the AXI sub-sequencer. While grabbed,
// no other sequence can push items onto axi_sqr, so this burst is
// guaranteed to go out back-to-back with nothing interleaved.
grab(p_sequencer.axi_sqr);
hp_seq.start(p_sequencer.axi_sqr);
ungrab(p_sequencer.axi_sqr);
endtaskWhile the AXI sequencer is grabbed, UVM blocks every other sequence from arbitrating onto it, so the high-priority burst goes out uninterrupted. Use lock()/unlock() for the gentler variant that waits its turn in the arbitration queue instead of jumping ahead, and always release what you grab, or the sequencer will deadlock.
A runnable illustration of the same idea
The example below strips UVM away and keeps only the concept: a coordinator object holding two sequencer handles, running one sub-sequence fully on the first interface and then another on the second. It is the plain-SystemVerilog skeleton of everything above, and because it uses no library classes it runs anywhere.
// Plain SystemVerilog model of a virtual sequence coordinating two
// sub-sequencers in a fixed order. No UVM library is required, so this
// compiles and runs on any SystemVerilog simulator.
// A transaction item handed to a sequencer.
class item;
int value;
function new(int value);
this.value = value;
endfunction
endclass
// A miniature "sequencer": a driver would pull from it; here it just
// logs the order in which items reach the interface.
class sequencer;
string name;
int count;
function new(string name);
this.name = name;
endfunction
task send(item it);
count++;
$display("[t=%0t] %s drives item #%0d (value=%0d)",
$time, name, count, it.value);
#5; // model one bus transfer taking time
endtask
endclass
// A "sub-sequence": streams n items onto ONE sequencer, like a real
// uvm_sequence running on one agent's sequencer.
class sub_seq;
string id;
int n;
function new(string id, int n);
this.id = id;
this.n = n;
endfunction
task body(sequencer sqr);
item it;
for (int i = 0; i < n; i++) begin
it = new((i + 1) * 10);
sqr.send(it);
end
$display(" -> sub_seq %s complete on %s", id, sqr.name);
endtask
endclass
// The "virtual sequence": holds handles to both sequencers (its
// p_sequencer) and runs the two sub-sequences in a defined order.
class virtual_seq;
sequencer apb_sqr; // config interface
sequencer axi_sqr; // data interface
task body();
sub_seq cfg = new("CONFIG", 2);
sub_seq data = new("DATA", 3);
$display("Virtual sequence: phase 1 = program config over APB");
cfg.body(apb_sqr); // runs fully before phase 2 begins
$display("Virtual sequence: phase 2 = stream data over AXI");
data.body(axi_sqr);
endtask
endclass
module tb;
virtual_seq vseq;
initial begin
vseq = new();
vseq.apb_sqr = new("apb_sqr");
vseq.axi_sqr = new("axi_sqr");
vseq.body();
$display("All interfaces exercised in the intended order.");
$finish;
end
endmoduleVirtual sequence: phase 1 = program config over APB [t=0] apb_sqr drives item #1 (value=10) [t=5] apb_sqr drives item #2 (value=20) -> sub_seq CONFIG complete on apb_sqr Virtual sequence: phase 2 = stream data over AXI [t=10] axi_sqr drives item #1 (value=10) [t=15] axi_sqr drives item #2 (value=20) [t=20] axi_sqr drives item #3 (value=30) -> sub_seq DATA complete on axi_sqr All interfaces exercised in the intended order. /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpocbp74_w/tb.sv:71: $finish called at 25 (1s)
The log shows the two CONFIG items reaching apb_sqr before any of the three DATA items reach axi_sqr — the exact ordering a real virtual sequence enforces with blocking start() calls. Wrap the two body() calls in fork...join and the streams interleave, mirroring the UVM fork version.
Key takeaways
- A virtual sequencer holds handles to real sub-sequencers; a virtual sequence runs on it and choreographs them.
`uvm_declare_p_sequencergives the sequence a typedp_sequencerhandle so it can reach those sub-sequencer fields.- Assign the handles once, in the environment’s
connect_phase. - Blocking
start()gives ordering for free;fork...joingives concurrency;grab()/ungrab()give exclusivity.
Quick check
What does `uvm_declare_p_sequencer actually create, and why do you need it?
It generates a strongly typed handle named p_sequencer that points at the specific virtual-sequencer class the sequence runs on. The base-class m_sequencer is only a generic uvm_sequencer, so without p_sequencer you cannot reach custom fields such as apb_sqr or axi_sqr.
Where are a virtual sequencer’s sub-sequencer handles connected to the real agent sequencers?
In the environment’s connect_phase, after build_phase has created the agents and the virtual sequencer, for example v_sqr.axi_sqr = axi_agt.sequencer. They are null until then.
How do you guarantee that an APB config sequence finishes before an AXI data sequence starts?
Call them sequentially with blocking start(): cfg_seq.start(p_sequencer.apb_sqr) then wr_seq.start(p_sequencer.axi_sqr). Since start() blocks until the sequence completes, the second cannot begin until the first has finished.
When would you grab a sub-sequencer instead of just starting a sequence on it?
When a burst must run without any other sequence interleaving items on the same sequencer. grab() takes exclusive control immediately (lock() waits its turn), and you must ungrab()/unlock() afterwards or the sequencer deadlocks.
Share your question in comments or talk to our mentor team for batch guidance.
Compile and simulate this in the browser — no install. Edit the code and re-run instantly.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses