UVM anti-patterns I see in almost every new project
DESIGN VERIFICATION · ARTICLE 02 OF 06
UVM anti-patterns I see in almost every new
project
UVM is powerful. It’s also one of the easiest frameworks to
misuse in ways that quietly destroy your testbench maintainability for years.
Let me describe a project I’ve
seen more than once.
A new block arrives for
verification. The schedule is tight. A DV engineer — competent, experienced, and
under pressure — copies an agent from the last project, renames a few things,
and gets to work. Three months later, the testbench is running regressions. Six
months later, a second engineer joins and finds the code nearly impossible to
follow. A year later, when the next chip revision requires reuse, it turns out
the testbench can’t be integrated without a near-complete rewrite.
This is not a story about bad
engineers. It’s a story about the UVM anti-patterns that appear when the
framework’s flexibility — its strength — is used without a shared set of
conventions.
Here are the ones I see most
often, what they cost, and how to fix them.
Anti-pattern 1: Copy-paste agent development
What it looks like
The project has five interfaces. Each one gets an agent. Each agent is copied from the last one, renamed at the top, and then quietly diverges as the engineer adds interface-specific logic. By the end of the project, five agents share 70% of the same code — but that code is not shared. It’s duplicated five times, in five slightly different forms.
// Agent A
(AMBA AXI)
class
axi_driver extends uvm_driver #(axi_seq_item);
task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
drive_item(req); // copy-pasted, slightly modified
seq_item_port.item_done();
end
endtask
endclass
// Agent B
(APB) — same structure, same bug potential, zero sharing
class
apb_driver extends uvm_driver #(apb_seq_item);
task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
drive_item(req); // same boilerplate, separate maintenance
burden
seq_item_port.item_done();
end
endtask
endclass
Why it hurts
When you find a bug in the
driver run loop — or improve the protocol — you have to find and fix it in five
places. And you will miss one. Copy-paste agents create the illusion of
structure while making the codebase progressively harder to maintain.
The fix
Build a base agent class with all shared infrastructure. Derive interface-specific agents from it. Use parameterization and virtual interfaces to handle the variation. This takes longer on day one and saves enormous time from month three onward.
// Base
driver with shared infrastructure
virtual
class base_driver #(type T = uvm_sequence_item)
extends uvm_driver #(T);
task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
drive_item(req); // defined in subclass
seq_item_port.item_done();
end
endtask
pure virtual task drive_item(T item);
endclass
//
Interface-specific driver — only implements the difference
class
axi_driver extends base_driver #(axi_seq_item);
virtual task drive_item(axi_seq_item item);
// AXI-specific protocol logic here
endtask
endclass
Anti-pattern 2: Overloading the scoreboard with business logic
What it looks like
The scoreboard starts as a
comparator. Then someone adds a state machine to track outstanding
transactions. Then some protocol-specific filtering. Then a coverage model.
Then some debug logging that grew into a partial re-implementation of the
reference model. Six months later, the scoreboard is 3,000 lines long, tests
half the things it should, and nobody fully understands it.
A scoreboard that does too many checks, too little. When every
failure leads to “maybe it’s a scoreboard bug,” your scoreboard has become
noise.
Why it hurts
The scoreboard’s job is to
compare DUT output against a reference and flag mismatches. When it also
contains the reference model, the tracking logic, the coverage bins, and the
debug infrastructure, it becomes impossible to determine whether a failure represents
a DUT bug or a scoreboard bug. You are using your safety net as a circus tent.
The fix
Enforce strict separation of concerns. The scoreboard compares. The reference model predicts. Coverage goes in a separate subscriber. Debug logging hooks into the analysis port network, not the scoreboard internals.
// Clean separation: scoreboard only compares
class
my_scoreboard extends uvm_scoreboard;
uvm_analysis_imp #(my_seq_item,
my_scoreboard) dut_export;
uvm_analysis_imp #(my_seq_item,
my_scoreboard) ref_export;
function void write_dut(my_seq_item item);
dut_q.push_back(item);
compare_if_ready();
endfunction
function void write_ref(my_seq_item item);
ref_q.push_back(item);
compare_if_ready();
endfunction
// Reference model lives in a separate class,
feeds ref_export
// Coverage lives in a separate subscriber
endclass
Anti-pattern 3: Skipping factory overrides
What it looks like
Sequence items, drivers, and monitors are instantiated with new() directly. Or — slightly better but still wrong — they’re created with type_id::create(), but nobody has defined a meaningful class hierarchy, so overriding one component requires editing the original source.
// Anti-pattern: direct instantiation locks you in
function
void build_phase(uvm_phase phase);
m_driver = new("m_driver",
this); // cannot override
endfunction
// Better
but still missing hierarchy:
function
void build_phase(uvm_phase phase);
// create() works, but if my_driver has no
base class,
// you can’t override it at integration
without editing the source
m_driver = my_driver::type_id::create("m_driver", this);
endfunction
Why it hurts
The UVM factory is the
mechanism that makes testbench reuse possible. When you skip it, or use it
without a meaningful type hierarchy, you lose the ability to swap components at
the test level — which is exactly what you need when integrating a block testbench
into a subsystem or chip-level environment. You either break encapsulation or
rewrite the agent.
The fix
Use type_id::create()
everywhere, without exception. Define base types for your sequence items and
protocol components that can be extended. Write integration tests using factory
overrides to substitute chip-level sequences for block-level ones.
class base_seq_item extends uvm_sequence_item;
`uvm_object_utils(base_seq_item)
// common fields
endclass
class block_seq_item extends base_seq_item;
`uvm_object_utils(block_seq_item)
// block-specific fields and constraints
endclass
// At chip level — override without touching agent source:
// factory.set_type_override_by_type(
// block_seq_item::get_type(),
// chip_seq_item::get_type());
Anti-pattern 4: Phase synchronization done wrong
What it looks like
Objections are raised in start_of_simulation or connect_phase. Objections are dropped in a task that runs on a fixed delay after the last stimulus. The test ends before all scoreboards drain. Or it never ends, because something is holding an objection that was never dropped.
// Common race: dropping objection on a timer instead of a condition
task
run_phase(uvm_phase phase);
phase.raise_objection(this);
// ... drive stimulus ...
#1000; // hope the scoreboard is done by now
phase.drop_objection(this); // race condition waiting to happen
endtask
Why it hurts
Phase synchronization bugs are
among the hardest to debug because they are non-deterministic. A race between
objection drop and scoreboard drain can produce a test that passes 99 times and
fails on the 100th, with a different failure each time. Or it produces a test
that passes but silently misses the last N transactions.
A test that ends before the scoreboard drains is not a passing
test. It’s an incomplete one. These are not the same thing.
The fix
Drop objections based on conditions, not timers. Use drain time sparingly and only when the condition is genuinely not observable. Make scoreboard drain explicit.
// Correct:
condition-based objection management
task
run_phase(uvm_phase phase);
phase.raise_objection(this);
run_all_sequences();
wait_for_scoreboard_idle(); // explicit drain condition
phase.drop_objection(this);
endtask
// In
scoreboard: track pending transactions explicitly
function bit
is_idle();
return (dut_q.size() == 0 && ref_q.size() == 0);
endfunction
Anti-pattern 5: Coverage models that measure activity, not intent
What it looks like
The coverage model has bins for every field value, every state, every transaction type. It’s comprehensive in the sense that it’s large. But it doesn’t capture the scenarios that actually matter: the corner cases in the spec, the boundary conditions, the cross-coverage between fields that interact.
// Covers
everything and nothing meaningful
covergroup
cg_transaction;
cp_addr: coverpoint item.addr; // 32-bit field: 4B bins, all useless
cp_len:
coverpoint item.len; // same
problem
cp_type: coverpoint item.type; // 8 values, probably fine
// No crosses. No boundary bins. No illegal
coverage.
// 100% hit rate, near-zero verification value.
endgroup
Why it hurts
Coverage that hits 100% without
effort is not telling you anything. Worse, it creates false confidence. The
purpose of a coverage model is to measure the gap between what has been
exercised and what needs to be exercised. If your model is trivially satisfiable,
you’ve measured nothing.
The fix
Write coverage bins against the spec, not against the data types. Cover the boundaries explicitly. Cross the fields that interact. Make the model hard to close — if your first regression hits 60%, that’s a sign you’ve written something meaningful.
// Coverage
that reflects verification intent
covergroup
cg_transaction with function sample(my_seq_item item);
// Boundaries and interesting values — not
full range
cp_len: coverpoint item.len {
bins single = {1};
bins small[] = {[2:7]};
bins max_burst = {256};
bins wrap_boundary = {item.len inside
{[253:256]}};
}
cx_type_len: cross item.type, cp_len {
// Illegal: WRITE with max burst to
restricted range
illegal_bins write_max = binsof(item.type)
intersect {WRITE}
&&
binsof(cp_len.max_burst);
}
endgroup
A pattern in the patterns
Looking at these five
anti-patterns together, they share a common root: they are all optimizations
for day one at the expense of month six. Copy-paste is faster than abstraction.
A fat scoreboard is easier than defining clean interfaces. Skipping the factory
hierarchy saves setup time. Timer-based objections avoid having to think
through drain conditions. Simple coverage closes faster than meaningful
coverage.
The cost is deferred, but it
arrives. It arrives when the second engineer joins and can’t read the code.
When the block needs to be integrated, and the agent can’t be reused. When a
subtle race condition starts corrupting regression results intermittently. When
sign-off reveals that your coverage model was measuring activity, not intent,
and the corner cases you missed are still in silicon.
These are the patterns worth
holding.
The teams that succeed with UVM
aren’t the fastest on day one.
They’re the ones who invest early in structure, abstraction, and intent — and
benefit from it every day after.
Good verification isn’t
about writing more code.
It’s about writing code that survives change.
Next in this series:
Article 03 —
Coverage closure isn’t done when the number hits 100%
Comments
Post a Comment