A new virtual sequence passes its first run, then hangs on the second seed. The data has all come out, the scoreboard is happy, the interrupt was handled, and still the run phase never ends. Eventually the timeout fires. The culprit is a background poller that nobody told to stop, keeping a fork ... join open forever. How do you shape the branches of a scenario so that every one of them is guaranteed to finish?
A virtual sequence is a small concurrent program. Some steps must happen in order (program the registers, then start), some happen side by side (data, polling, the interrupt handler), and some race (the interrupt against a watchdog). SystemVerilog gives you three tools for that: plain sequential calls, fork ... join (wait for all branches), and fork ... join_any (continue when the first finishes).
The trap is the branch with no natural end. A poller reads a register forever; a responder answers forever. Think of a security guard doing rounds in a building: the rounds never end on their own. You can tell the guard the shift is over and let them finish the current round (a stop flag), or you can remove them mid-corridor (kill). The first is clean; the second is sometimes necessary and always needs cleanup.
The dashed arrow is the whole design. Without it, join waits for a branch that never ends.
apb_poll_seq checks a stop bit before every access. Whoever owns it sets the bit; the poller finishes the access in flight, sleeps its 50 ns, sees the bit, and returns. Nothing is cut off mid-item, so the sequencer and driver are never left holding half a transfer.
// snippet: from the course DMA bench
// Reads COUNT every 50 ns until told to stop: background register traffic that
// keeps the APB sequencer busy, the way firmware polling would.
class apb_poll_seq extends uvm_sequence #(apb_item);
`uvm_object_utils(apb_poll_seq)
bit stop;
int unsigned polls;
function new(string name = "apb_poll_seq");
super.new(name);
endfunction
task body();
while (!stop) begin
apb_item it = apb_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { addr == COUNT; write == 0; })
`uvm_fatal("RAND", "apb_item randomize failed")
finish_item(it);
polls++;
#50ns;
end
endtask
endclassInside dma_vseq, the fork is three lines plus the handler block. The interrupt branch is the one that knows the job is over, so it is the one that stops the poller:
// snippet: the fork inside dma_vseq::body
fork
poll.start(apb_sqr, this); // background polling
pkt.start(axis_sqr, this); // the data, in parallel
begin // the interrupt handler
wait (irq_vif.irq === 1'b1);
isr.start(apb_sqr, this);
status = isr.status;
poll.stop = 1;
end
joinTwo of the branches share the APB sequencer. That is fine: they arbitrate item by item, and the interrupt handler uses grab() to go first when it matters (lock vs grab is the deep dive). Branches on different sequencers really run in parallel; branches on the same sequencer take turns, by the rules in Inside the Sequencer.
Sometimes the first finisher decides the outcome: the interrupt arrives, or a watchdog fires. join_any continues after the first branch, and the others keep running unless you stop them. A bare disable fork kills every child of the current process, which in a virtual sequence can include branches you started earlier and still need. Wrap the race in its own fork begin ... end join so the disable fork reaches only the race.
// snippet: wait for the interrupt, or give up after 20 us
task wait_irq_or_timeout(output bit timed_out);
timed_out = 0;
fork begin // guard: disable fork stops here
fork
wait (irq_vif.irq === 1'b1);
begin #20us; timed_out = 1; end
join_any
disable fork; // stops only the losing branch
end join
endtaskWhen the loser is a sequence, killing its thread is not enough. A sequence blocked in start_item has a request queued in the sequencer, and it may hold a lock. If the thread dies without telling the sequencer, the next arbitration finds a request whose process is dead, reports SEQREQZMB and removes it; a lock held by a dead thread is never released. Call kill() on the losing sequence instead, or as well: it removes the sequence's requests and locks from the sequencer before stopping it. If the sequence might be killed with an item already in the driver, the driver must be reset-aware, which the Agents section covers.
// snippet: race two sequences, then kill the one still running
fork begin
fork
isr.start(apb_sqr, this);
watchdog.start(apb_sqr, this);
join_any
disable fork;
end join
if (isr.get_sequence_state() != UVM_FINISHED) isr.kill();
if (watchdog.get_sequence_state() != UVM_FINISHED) watchdog.kill();A scenario often is a sequence of smaller scenarios. A two-job test wants dma_vseq twice; a chip stress test wants the DMA job beside a PCIe boot. Write the bigger scenario as a virtual sequence that starts the smaller ones as children, passing the handles down and itself as the parent:
// snippet
class dma_two_job_vseq extends uvm_sequence;
`uvm_object_utils(dma_two_job_vseq)
apb_sequencer apb_sqr;
axis_sequencer axis_sqr;
virtual irq_if irq_vif;
function new(string name = "dma_two_job_vseq");
super.new(name);
endfunction
task body();
repeat (2) begin
dma_vseq job = dma_vseq::type_id::create("job");
job.apb_sqr = apb_sqr; job.axis_sqr = axis_sqr; job.irq_vif = irq_vif;
if (!job.randomize()) `uvm_fatal("RAND", "dma_vseq randomize failed")
job.start(null, this); // no sequencer, but a parent
end
endtask
endclassEach dma_vseq clears DONE and ERR before it returns, which is what makes back-to-back jobs safe. A base class that holds the handles once, instead of copying them in every vseq, is in Virtual Sequences Without a Virtual Sequencer.
Owner | Owns | On the DMA bench |
|---|---|---|
Agent package | Protocol sequences that know one interface |
|
Env package | Scenario (virtual) sequences that know the design |
|
Test | Knobs, which scenario, how many, the objection |
|
dma_irq_seq is an APB sequence, but it knows the DMA's STATUS register and its W1C bits, so it belongs with the DMA environment, not the APB agent. A good rule: if a sequence mentions a register name or another interface, it is not agent code. On objections: the test usually raises one around vseq.start(). A sequence can also hold one, with set_automatic_phase_objection(1) in UVM 1.2 and IEEE 1800.2; starting_phase is deprecated in 1.2 and removed in 1800.2. See Objections and end of test.
dma_vseq_test shows the pattern working: the job finishes, the poller has stopped, and the join returned so the vseq could print its summary. The poll count depends on the random length.
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 mismatchedIf the VSEQ line is missing and the run ends with a timeout instead, one of the three branches never returned. The poller is the first suspect.
The hang in the opening came from a refactor. Someone moved the interrupt handling into a helper task and forgot to carry poll.stop = 1 along. Data, interrupt and STATUS clear all worked; the poller kept reading COUNT every 50 ns; the join waited for it; the test's objection stayed raised. With no timeout set, a run like that burns CPU until the 9200 s default. With one set, it ends with the same fatal that dma_timeout_test produces on purpose:
UVM_FATAL uvm_phase_hopper.svh(598) @ 20000000: reporter [PH_TIMEOUT] Explicit timeout of 20000000 hit, indicating a probable testbench issueTwo habits prevent it. Give every open-ended branch an explicit exit and set it on every path that ends the scenario. And set a test timeout (uvm_root::get().set_timeout() or +UVM_TIMEOUT) sized to the scenario, so a hang costs seconds instead of a night of farm time. Run_test and timeouts has the details.
fork ... join_any, and the author expects the poller to stop when the packet ends. What actually happens?join_any returns when the packet finishes, but the poller keeps running in the background, still issuing APB reads after the vseq has moved on, and possibly after the test thinks the scenario is over. It needs a stop flag (or a guarded disable fork plus kill()).
apb_poll_seq check stop at the top of the loop instead of being killed by the vseq?A flag lets the current access finish: the item completes, item_done is called, and no request is left in the sequencer. Killing could interrupt it while blocked in start_item (leaving a dead request) or while its item is in the driver. The flag costs at most one extra 50 ns wait.
Next: every scenario is built from smaller sequences. Composing them, and letting the seed choose among them, is Sub-sequences and Sequence Libraries.