A new test passes in 0 ns of simulated time. The log says UVM_ERROR : 0. A second test, written by the same engineer the same afternoon, dies with a UVM_FATAL about an objection count below zero. A third sails through but leaves the last two DMA words uncompared. Three different symptoms, one root cause: the objection count did something the author did not expect.
This lesson collects the rules of the count. Each one is short, each one is asked in interviews, and each one has burned a real regression.
An objection is a ledger, not an on/off flag. Every raise is a deposit and every drop is a withdrawal, per object. UVM checks only two things: you may not withdraw more than you deposited, and the phase may end only when the whole bank, uvm_top's total, is empty. It never checks who should have deposited. That is your job, and it is why the rules below are about ownership as much as arithmetic. The mechanics of the counts themselves, and a runnable model, are in Objection Hierarchy Propagation.
What you do | What UVM does | Severity |
|---|---|---|
Raise and drop once each, on every path | Count returns to 0; the phase may end | None |
Drop an objection that was never raised | Reports | UVM_FATAL |
Raise in build_phase or another function phase | Reports | UVM_ERROR |
Never raise in run_phase | Nothing holds the phase, so it ends at time 0 | None (a silent false pass) |
Raise, then | The drop runs at once; the phase ends while the forked work runs | None (an early end) |
| Adds 3 to the count; drops must total 3 | None |
Two of these deserve a closer look because they are the opposite of what people expect. A drop without a raise is fatal, not a warning: the simulation stops there. And a test with no raise does not hang; it ends immediately, with every forked thread still pending. If all objections drop, the phase ends after the drain time even if threads started with fork ... join_none are still running. That is an early end, not a hang.
UVM decides whether anyone objects to the run phase right at the start, in the same time step it is entered. If your run_phase waits for anything before raising, even one clock edge, the phase may already have ended.
// bad-example
task run_phase(uvm_phase phase);
@(posedge vif.clk); // blocks first ...
phase.raise_objection(this, "too late"); // ... the phase ended at time 0
// the job below never runs; check_phase sees nothing
endtaskThe bench's tests all raise on their first line. The same rule holds for runtime phases such as main_phase: raise first, then wait.
Every raise needs a drop on every path out of the task, including error returns and timeouts. The safest shape has exactly one drop, after the work, which every path reaches. Bounded waits help: wrap a wait that might never finish so it gives up with an error instead of hanging.
// snippet
task run_phase(uvm_phase phase);
phase.raise_objection(this, "TEST: job");
fork begin // isolates disable fork (see below)
fork
run_job_and_wait_irq();
begin #50us; `uvm_error("TIMEOUT", "no interrupt within 50 us") end
join_any
disable fork;
end join
phase.drop_objection(this, "TEST: job done"); // reached on every path
endtaskdisable fork kills every child of the current process, not only the fork above it. Wrapping it in its own fork begin ... end join limits it to the two threads you meant. Without the wrapper, it would also kill any thread the task forked earlier.
A sequence can own its own duration, which is useful for a long background sequence started from a virtual sequence. There are three ways, and only two are safe.
Pattern | Works? | Why |
|---|---|---|
| Yes (UVM 1.2 and 1800.2) | UVM raises before pre_start and drops after post_start, on the starting phase. |
Raise in | Yes | pre_start and post_start run on every start of the sequence. |
Raise in | Not always | pre_body and post_body are skipped when a parent starts the sequence with call_pre_post = 0, which the |
// snippet
task pre_start();
uvm_phase p = get_starting_phase();
if (p != null) p.raise_objection(this, "SEQ: bg traffic");
endtask
task post_start();
uvm_phase p = get_starting_phase();
if (p != null) p.drop_objection(this, "SEQ: bg traffic done");
endtaskget_starting_phase() returns null when the sequence was started from code that never set a phase, such as a sub-sequence started inside a parent's body. The null check makes the same sequence usable in both places.
Delete the raise_objection line from dma_smoke_test and run it. The run phase ends at time 0, before a single APB write. The scoreboard's checks are the only thing that notices:
UVM_ERROR dma_tb.sv(407) @ 0: uvm_test_top.env.sb [SB_EMPTY] no words were compared: the test checked nothing
UVM_INFO dma_tb.sv(411) @ 0: uvm_test_top.env.sb [SB] 0 words matched, 0 mismatched
UVM_ERROR : 1Without SB_EMPTY, this log would read UVM_ERROR : 0, and the test would be green. Every scoreboard that can compare zero items should fail when it does.
A team's long-running background sequence raised in pre_body and dropped in post_body. Started directly from a test, it worked. Then someone moved it into a virtual sequence that launched it with a uvm_do-style macro, and regressions started passing in a fraction of their usual time, with coverage collapsing. The trace showed no SEQ raise at all.
The macro starts the child with call_pre_post set to 0, so pre_body and post_body never ran. With no raise from the sequence and the virtual sequence finishing quickly, the test dropped and the phase ended under the background traffic. Moving the pair to pre_start and post_start, and later to set_automatic_phase_objection(1), fixed it. The lesson generalises: put an objection where the code is guaranteed to run, not where it usually runs.
return before its drop. What does the log show?The phase never ends on that path. uvm_top's total stays at 1 until the global timeout fires a PH_TIMEOUT fatal. On the success path the test passes, so the bug appears only when the error path is taken, which is exactly when you most need a clean log. One drop after the work, reached on every path, fixes it.
Not necessarily. OBJTN_ZERO means one path drops twice, or drops without raising. Deleting a drop can leave another path with a raise and no drop, which turns a fatal into a hang. Find the path that double-drops, usually an error branch that drops and then falls through to the normal drop.
join_none, and drops. How long does the test run?About zero time. join_none returns at once, so the drop follows the raise in the same time step, uvm_top's total reaches zero and the phase ends while the three sequences are still starting. Use join, or wait fork before the drop.
Next: even a perfectly paired objection can drop a moment too early, while the last word is still inside the DUT. set_drain_time Deep Dive covers the grace period after the last drop.