Your team's DMA regression has forty tests. Each one programs KEY and LEN, sets GO, sends a packet, waits for the interrupt and clears STATUS, and each one does it in its own run_phase, copied from the smoke test. Then the designers add a rule: software must read STATUS before clearing it, or a sticky error bit is lost. You now have forty places to change, and the three tests nobody remembered keep passing with the old order. Where should the order of a DMA job live, so that it is written exactly once?
A DMA job is one story told over three interfaces. Register writes go over APB, data goes over AXI-Stream, and the end of the job arrives on the interrupt line. Each agent knows its own protocol and nothing else: the APB agent has never heard of a packet, and the stream agent does not know that GO must come before the first word. The ordering across them is knowledge that belongs to no single agent.
A virtual sequence holds that knowledge. It is an ordinary uvm_sequence, but it never calls start_item itself. It creates other sequences and starts each one on the right agent's sequencer, in the right order or in parallel. Think of an orchestra conductor: the conductor plays no instrument, but decides who comes in when. The violins (the APB agent) and the brass (the stream agent) only need to play their own parts well.
"Virtual" here only means no driver underneath. A virtual sequence has no item type of its own that reaches pins, so it can start on a virtual sequencer (a component that only holds handles to real sequencers), or, as the course bench does, on no sequencer at all.
Two kinds of ordering show up here. Sequential ordering is a plain series of start() calls: KEY before GO. Parallel ordering is a fork: the data, the polling and the interrupt handler all run at once. Neither kind can be expressed inside one agent's sequence, because each branch runs on a different sequencer.
The bench's base test already shows the problem in a small way. Its run_job task knows the whole job: the register order, the packet, the wait on the interrupt, the read and clear of STATUS. It is fine for one family of tests, but the order lives in a test class, and every new scenario (polling, two jobs back to back, an error injected mid-job) means another test that copies or overrides it.
// snippet: from the course DMA bench
class dma_base_test extends uvm_test;
`uvm_component_utils(dma_base_test)
dma_env env;
virtual irq_if irq_vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
env = dma_env::type_id::create("env", this);
if (!uvm_config_db#(virtual irq_if)::get(this, "", "irq_vif", irq_vif))
`uvm_fatal("NOVIF", "irq_if not set for the test")
endfunction
function void end_of_elaboration_phase(uvm_phase phase);
uvm_root::get().print_topology();
endfunction
// Register helpers used by every test.
task reg_write(bit [7:0] addr, bit [31:0] data);
apb_rw_seq s = apb_rw_seq::type_id::create("s");
s.addr = addr; s.write = 1; s.data = data;
s.start(env.apb.sqr);
endtask
task reg_read(bit [7:0] addr, output bit [31:0] data);
apb_rw_seq s = apb_rw_seq::type_id::create("s");
s.addr = addr; s.write = 0; s.data = 0;
s.start(env.apb.sqr);
data = s.rdata;
endtask
// One complete job: program the DMA, stream n words, wait for the interrupt,
// return STATUS and clear its DONE/ERR bits.
task run_job(dma_job_cfg job, int unsigned n_words, output bit [31:0] status);
axis_pkt_seq pkt = axis_pkt_seq::type_id::create("pkt");
reg_write(KEY, job.key);
reg_write(LEN, job.len);
reg_write(CTRL, {30'b0, job.irq_en, 1'b1});
if (!pkt.randomize() with { n == n_words; })
`uvm_fatal("RAND", "axis_pkt_seq randomize failed")
pkt.start(env.in_agt.sqr);
wait (irq_vif.irq === 1'b1);
reg_read(STATUS, status);
reg_write(STATUS, 32'h6); // write 1 to clear DONE and ERR
endtask
endclassNotice also what run_job cannot express: it runs strictly one step after another. A poller that reads COUNT while the data moves, with an interrupt handler that cuts in front of it, needs parallel branches on two sequencers and a way to stop one branch from another. That is scenario logic, and it wants its own class.
Move the job into dma_vseq (the next lessons take it apart line by line) and the test shrinks to four jobs: give the virtual sequence its sequencer handles, randomize its knobs, start it, and hold the objection while it runs.
// snippet: from the course DMA bench
// A virtual sequence drives a whole job across both agents, with background
// polling and a grab-based interrupt handler on the APB sequencer.
class dma_vseq_test extends dma_base_test;
`uvm_component_utils(dma_vseq_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
task run_phase(uvm_phase phase);
dma_vseq vseq = dma_vseq::type_id::create("vseq");
phase.raise_objection(this);
vseq.apb_sqr = env.apb.sqr;
vseq.axis_sqr = env.in_agt.sqr;
vseq.irq_vif = irq_vif;
if (!vseq.randomize()) `uvm_fatal("RAND", "dma_vseq randomize failed")
vseq.start(null);
if (vseq.status[2:1] !== 2'b01) `uvm_error("STATUS", $sformatf("expected DONE only, STATUS=%08h", vseq.status))
#100ns;
phase.drop_objection(this);
endtask
endclassThat is the thin test. It decides what runs and for how long; the virtual sequence decides how a DMA job goes. A second test that wants three jobs in a row, or a different key distribution, reuses dma_vseq unchanged, with a constraint or a loop around start. The rule from the designers' change in the opening now lives in one class.
Belongs in the test | Belongs in the virtual sequence | Belongs in an agent sequence |
|---|---|---|
Which scenario, how many times, which knobs | Order across agents: KEY before GO, data in parallel with polling | One protocol action: an APB access, a stream packet |
The objection for the whole run | Waiting for the interrupt, then reacting to it | Item fields and constraints |
Factory and config overrides | Stopping a background branch when the job ends | Nothing about other agents |
Run the bench with +UVM_TESTNAME=dma_vseq_test, and add +UVM_VERBOSITY=UVM_MEDIUM to see the interrupt handler's line. The key and length are random, so your times and poll count differ by simulator and seed (times are in ps):
UVM_INFO dma_tb.sv(572) @ 405000: uvm_test_top.env.apb.sqr@@vseq.isr [IRQ] handler has the APB sequencer
UVM_INFO dma_tb.sv(623) @ 575000: reporter@@vseq [VSEQ] job done: STATUS=00000002 after 4 polls
UVM_INFO dma_tb.sv(411) @ 675000: uvm_test_top.env.sb [SB] 6 words matched, 0 mismatched
UVM_ERROR : 0`vseq.isr` is the interrupt handler, named as a child of the virtual sequence because dma_vseq passed itself as the parent. It ran on the APB sequencer, so the message is reported through uvm_test_top.env.apb.sqr.
`reporter@@vseq`: the virtual sequence itself has no sequencer (it was started with start(null)), so its messages go through the global reporter.
STATUS=00000002 is DONE alone, which is what the test checks. 4 polls proves the poller really ran in parallel with the data.
6 words matched means the scoreboard, which knows nothing about virtual sequences, checked every word. Coordination changes stimulus, never checking.
A team writing their first two-job test called their copied job task twice in a row. The first job injected a short packet on purpose, so it ended with STATUS.ERR. The second job then misbehaved in a way that made no sense: STATUS was read before its data had moved, and the run ended with the scoreboard reporting words that never came out. The cause was one line missing from the copy. Someone had dropped the STATUS clear because "GO clears DONE anyway". It does, but it does not clear ERR, and the DMA's interrupt is a level: irq = IRQ_EN & (DONE | ERR). With ERR still set, the line was high when the second job started, so its wait (irq === 1) returned at once.
The fix was not to add the clear back in that one test. It was to move the whole job, including read STATUS, then write 1 to clear DONE and ERR, into one virtual sequence that every test reuses. When ordering rules live in exactly one class, a missing step is a bug in one place that every test exposes, instead of a silent difference between forty copies.
A loop (or two calls) around randomizing and starting dma_vseq, the handle assignments, and the objection. No register offsets, no waits on the interrupt, no STATUS handling: those live in dma_vseq, which also clears DONE and ERR before it finishes, so the second job's interrupt wait is not fooled by the first.
The APB agent would need to know about the stream sequencer and the interrupt line, which couples a reusable protocol agent to one design. At chip level, where that APB agent might talk to ten different blocks, the DMA story is meaningless. Cross-agent order belongs one level up, in a virtual sequence owned by the DMA environment.
reporter@@vseq, but the interrupt handler's line by uvm_test_top.env.apb.sqr@@vseq.isr. Why the difference?Messages from a sequence are reported through its sequencer when it has one. dma_vseq was started with start(null), so it has none and falls back to the global reporter. dma_irq_seq runs on the APB sequencer, and its name includes vseq. because the virtual sequence passed itself as the parent.
Next: a virtual sequence needs handles to real sequencers. The classic way to give it them is a component built for the purpose: Virtual Sequencer Wiring.