UVM
Reusable Verification Architecture
Building self-contained, configurable, position-independent agents and environments — config objects, the factory, and block-to-SoC composition — so verification IP reuses everywhere.
UVM Testbench Architecture · Module 3 · Page 3.5
The Engineering Problem
Reuse is the entire reason UVM exists (Module 1.9), and you now have the pieces — a named hierarchy (3.3) and a wired data-flow graph (3.4). But reuse does not happen automatically because you used UVM. An agent or environment is reusable only if it was designed to be: self-contained, configurable, and dependent on nothing specific to where it currently sits. Build it with one hard-coded assumption — an absolute path, a fixed mode, a baked-in type — and it works beautifully standalone and breaks the moment it is reused somewhere else.
This is the gap between "I used UVM" and "my verification IP is reusable." A block-level environment that verifies a UART perfectly is worthless at the SoC level if it assumes it is the top of the tree, or hard-codes the one configuration it was built for. The discipline of reusable architecture is a set of concrete rules — configure from above, depend on nothing absolute, parametrise, substitute via the factory — that make a unit position-independent and context-independent, so it drops into the next block, the SoC, and the next project unchanged.
What design discipline makes an agent or environment genuinely reusable — across blocks, SoC integration, and projects — rather than only working in the one place it was first built?
Motivation — why reuse is a design property, not a freebie
Reusability has to be engineered in; the payoff and the cost of skipping it are both large:
- Reuse is the dominant cost lever. A verified agent reused across ten blocks is ten environments you didn't rebuild; an env reused at SoC is an integration you didn't re-verify from scratch. The economics of verification (Module 1.3) are dominated by what you can reuse — and that is decided by architecture.
- "Works standalone" is not "reusable." The most expensive surprises happen at integration: a block env that passed in isolation breaks when nested in an SoC because it assumed it was the top. Designing for reuse moves that failure from integration day (expensive) to never.
- Configurability is what makes one unit serve many contexts. The same agent must drive at block level and merely observe at SoC; the same env must run with different sizes, modes, and protocols. Configuration — from the top, via config objects and the config database — is the mechanism that lets one component behave differently without being edited.
- Position-independence is what makes composition possible. An env reused inside another env sits at a different path. Only a unit that makes no absolute-path assumptions can be relocated in the tree, which is exactly what block→SoC composition requires.
The motivation, in one line: reuse is the whole point of UVM but it is a design property — earned by building self-contained, configurable, position-independent units, and lost by a single hard-coded assumption.
Mental Model
Hold reusable IP as a sealed appliance with a settings panel:
A reusable unit is a sealed appliance: it exposes a settings panel and a power plug, and assumes nothing about the house it's installed in. Inside, it is self-contained — it does not reach out and grab wires from the walls, hard-code the address of the room it's in, or assume it's the only appliance. Everything it needs to adapt — its mode (active/passive), its interface, its sizes and types — comes in through the settings panel (configuration from above), not by reaching into its surroundings. Because it makes no assumptions about its location or neighbours, you can install it in this house (block), that house (another block), or a mansion full of other appliances (SoC) without rewiring it. The moment an appliance hard-codes "I am in the kitchen at 123 Main Street," it can only ever work in that one kitchen.
So the test of reusability is always: does this unit reach out and assume something specific, or does everything it needs arrive through its settings panel? Configure-from-above and assume-nothing-absolute are the two halves of the same discipline.
Visual Explanation — the levels of reuse
Reuse is not one thing; it happens at every level of the architecture, each unit reusable in a wider context than the one below it.
The levels share one property: each is a self-contained, configurable unit reusable in a wider scope. The transaction is reused by every test and sequence. The agent is the workhorse of reuse — package an interface's sequencer, driver, and monitor once, and it drops into any environment needing that interface. The environment is reused twice over: standalone to verify a block, and nested as a sub-env when that block becomes part of an SoC. And the SoC environment is composition — many reusable envs and agents assembled, coordinated from above. Designing each level to be self-contained is what lets the level above simply compose it rather than rebuild it.
RTL / Simulation Perspective — a reusable agent
The discipline is concrete in code: a reusable agent takes everything it needs through a config object delivered from above, and assumes nothing — its mode, its interface, and its parameters all arrive via configuration.
class agent_cfg extends uvm_object; // config object: all the agent needs, in one place
`uvm_object_utils(agent_cfg)
uvm_active_passive_enum is_active = UVM_ACTIVE; // mode — set per context
virtual my_if vif; // interface — supplied from the top
int unsigned max_len = 16; // a parameter — not hard-coded inside
endclass
class my_agent extends uvm_agent;
`uvm_component_utils(my_agent)
agent_cfg cfg; my_sequencer sqr; my_driver drv; my_monitor mon;
function void build_phase(uvm_phase phase);
if (!uvm_config_db#(agent_cfg)::get(this, "", "cfg", cfg)) // everything arrives via config
`uvm_fatal("NOCFG", "agent_cfg not provided from above")
mon = my_monitor::type_id::create("mon", this); // monitor: always (observe always)
if (cfg.is_active == UVM_ACTIVE) begin // driver/sequencer: configurable
sqr = my_sequencer::type_id::create("sqr", this);
drv = my_driver ::type_id::create("drv", this);
end
endfunction
endclassEvery reuse principle is visible. The agent depends on nothing specific: it doesn't hard-code its mode, its interface, or max_len — they come from agent_cfg, set by whatever instantiates the agent. It is configurable from above: a block-level test sets is_active = UVM_ACTIVE; an SoC-level test reuses the same agent with is_active = UVM_PASSIVE, and the agent builds the driver only when active. It uses relative config scoping (get(this, "", "cfg", ...) is relative to the agent itself), so it works at any path. Bundling everything into one agent_cfg object is the pattern: the settings panel is a single object handed in from the top, and the agent reads it rather than reaching out for anything.
Verification Perspective — configuration and the factory make units adaptable
Two mechanisms turn a self-contained unit into an adaptable one without editing it: configuration (the config database carrying config objects from the top down) and the factory (substituting a derived type for a base type across the whole environment). Together they let one architecture serve many contexts.
These two mechanisms are how one unit serves many situations. Configuration adapts behaviour by data: the same agent runs active or passive, with a 16-beat or 256-beat max, depending on the config object handed to it — no code change. The factory adapts behaviour by type: a test can register an override so that everywhere the environment creates a my_driver, it instead creates an error_injecting_driver — specialising the environment for an error test without editing the env at all. A reusable unit is built to expose itself to both: it reads its config in build_phase and is created through the factory (type_id::create). That exposure is what lets the unchanged unit be reconfigured (data) and re-specialised (type) from the top for each new context.
Runtime / Execution Flow — block-to-SoC composition
The headline reuse is block→SoC: the same environment that verified a block standalone is nested as a sub-env inside an SoC environment, alongside other block envs, and coordinated from above. This only works if each block env is self-contained and position-independent.
This composition is the reuse payoff made concrete, and it is entirely dependent on the block envs being self-contained. The SoC env instantiates block A's env and block B's env exactly as written, reconfigures them for the SoC context (typically making their agents passive where actual RTL now drives those interfaces, and adjusting sizes/modes via config), and adds a virtual sequence on top to coordinate stimulus across them. Nothing inside the block envs is edited. But this only works because each block env reads its configuration from above and makes no absolute-path or top-of-tree assumptions — relocate a unit that hard-codes its position and the composition breaks, which is exactly the DebugLab below.
Waveform Perspective — one agent, two roles by configuration
The clearest runtime signature of reuse is the same agent serving opposite roles purely by configuration: active (it drives and observes) at block level, passive (it only observes) at SoC level, where real RTL drives the interface.
The same agent, reused active then passive — configuration alone changes its role
10 cyclesThe contrast between agent_drives (on in the active context, off in the passive) and agent_observes (on in both) is reuse made visible. The same agent code, the same monitor, the same transaction model — only the is_active field of the config object differs between the two contexts, and that single setting decides whether the driver is built at all. This is the concrete realisation of "configure from above": a reusable unit exposes its variability as configuration, and the context (block test vs SoC test) supplies the values, so one unit covers both roles without an edit. Observation is constant because you always need it; driving is configurable because it is contextual.
DebugLab — the env that passed alone but broke inside the SoC
A block env that worked standalone and failed on integration — hard-coded absolute paths
A UART block environment verified the UART perfectly in standalone regressions for months. When it was reused as a sub-environment inside the SoC environment — with no change to the block env's code — its agents stopped receiving their configuration: the virtual interface and mode settings silently didn't apply, drivers came up unconfigured, and lookups returned null. The exact same env, reused one level deeper, broke.
Hard-coded absolute hierarchical paths. The block env (and its test) configured its agents using absolute paths that were only correct when the env sat at the top. Standalone, the agent's path was uvm_test_top.env.agent; nested in the SoC, the same env became a sub-env and its agent moved to uvm_test_top.soc_env.uart_env.agent — so every absolute-path config silently missed:
standalone path: uvm_test_top.env.agent ← absolute config scope matched here
SoC-nested path: uvm_test_top.soc_env.uart_env.agent ← path is now deeper
config used: set(null, "uvm_test_top.env.agent", "cfg", cfg); ← ABSOLUTE, hard-coded
result: scope no longer matches the relocated agent → config silently not applied
fix: set relative to `this`: set(this, "agent", "cfg", cfg); ← position-independentNothing in the block env changed — but its assumption about its position in the tree was wrong the moment it was nested. The unit was not position-independent, so it could only ever work as the top.
The tell is a unit that works standalone and breaks when nested — almost always a position assumption. Diagnose reuse failures by hunting absolute dependencies:
- Search for absolute hierarchical paths. Any config scope or
lookupthat namesuvm_test_top...or a fixed full path is position-dependent and breaks under nesting. Reusable units scope relative tothis. - Compare
get_full_name()standalone vs nested. Print the component's path in both contexts; if config was scoped to the standalone path, the deeper nested path won't match — that's the silent miss. - Look for top-of-tree assumptions. Anything that assumes it is directly under
uvm_test_top, or that there is exactly one of it, breaks when composed. Self-contained units assume neither their depth nor their multiplicity.
A reusable unit must be position-independent and assume nothing absolute:
- Scope configuration relative to
this. Useset(this, "relative.path", ...)andget(this, "", ...)so a unit's configuration follows it wherever it is nested. Never hard-code an absolute hierarchical path. - Pass everything through config objects from above. The interface, mode, and parameters arrive via a config object the parent supplies — the unit reaches out for nothing, so it doesn't matter where it sits.
- Test reuse early. Instantiate the env both standalone and nested (under an extra dummy parent) during development; a unit that only works at the top reveals itself immediately rather than on integration day.
The one-sentence lesson: a unit that hard-codes its absolute position works standalone and breaks when reused one level deeper — keep configuration relative to this and supply everything from above, so the unit is position-independent and composes anywhere.
Common Mistakes
- Hard-coding absolute hierarchical paths. Config scopes or lookups naming a fixed full path break the instant the unit is nested. Scope relative to
thisso the unit is position-independent. - Baking in configuration instead of taking it from above. A unit that hard-codes its mode, interface, or sizes can serve only the one context it was built for. Take everything through a config object supplied by the parent.
- Assuming top-of-tree or singularity. Code that assumes it is directly under
uvm_test_top, or that there is only one instance, breaks under composition. Reusable units assume neither their depth nor their count. - Reaching out instead of being given. A component that hard-references a sibling or grabs a wire by hierarchical path couples itself to one structure. It should receive handles/interfaces through configuration, not reach for them.
- Not designing the agent as the reuse unit. Spreading an interface's logic across the env instead of encapsulating it in a self-contained, active/passive agent prevents reuse of that interface elsewhere. The agent is the unit; keep it whole and configurable.
- Skipping factory-friendliness. Creating components with
newinstead oftype_id::createblocks factory overrides, so the env can't be specialised (e.g., an error-injecting driver) without editing it. Always create via the factory.
Senior Design Review Notes
Interview Insights
Three properties, all designed in. First, it must be self-contained: it encapsulates everything for its job (an agent bundles the sequencer, driver, and monitor for one interface) and doesn't spread its logic across the surrounding environment. Second, it must be configurable from above: everything that varies by context — mode (active/passive), the virtual interface, sizes, parameters, even component types — arrives through a config object and the config database supplied by the parent, rather than being hard-coded inside. Third, it must be position-independent: it makes no absolute-path or top-of-tree assumptions, scoping its configuration relative to this so it works at any depth in the tree. A unit with these properties drops into a new block, into an SoC as a sub-env, or into the next project unchanged. A unit missing any of them — one hard-coded path, one baked-in mode, one assumption that it's the top — works standalone and breaks the moment it's reused, which is why reusability is a design property rather than an automatic benefit of using UVM.
Exercises
- Spot the reuse blocker. For each, say whether it harms reuse and how to fix it: (a)
uvm_config_db::set(null, "uvm_test_top.env.agent", "cfg", cfg); (b) an agent that hard-codesis_active = UVM_ACTIVE; (c) a driver created withnew("drv", this); (d) a scoreboard that looks up a sibling by relative path. - Design the config. Write (in prose) the config object for a reusable bus agent: list the fields it should carry so the agent needs to assume nothing, and explain who sets it and when.
- Compose to SoC. Describe how you would reuse two block envs inside an SoC env: what you instantiate, what you reconfigure (and to what), and what you add on top — and state the one property of the block envs that makes this possible.
- Make it position-independent. Given an env whose test configures its agent with an absolute path, rewrite the configuration to be relative to
this, and explain why this fixes the standalone-passes-but-nested-fails bug.
Summary
- Reuse is a design property, not an automatic benefit of UVM. A unit is reusable only if it is self-contained, configurable from above, and position-independent — and a single hard-coded assumption makes it work standalone yet break on reuse.
- Reuse happens at every level: the transaction (across tests), the agent (the fundamental unit — self-contained and active/passive configurable), and the environment (reused block→SoC as a sub-env in an env-of-envs).
- Configuration (config objects via the config database, scoped relative to
this) adapts a unit by data, and the factory (type_id::create+ overrides) adapts it by type — together letting one unchanged unit serve many contexts. - Block→SoC composition reuses block envs unchanged as sub-envs, reconfigured (often active→passive) and coordinated by a virtual sequence — possible only because each env is self-contained and makes no absolute-path or top-of-tree assumptions.
- The durable rule of thumb: build sealed appliances with a settings panel — configure from above, scope relative to
this, create via the factory, and assume nothing about where you sit — and test reuse by nesting, because "works standalone" is not "reusable."
Next — Layered Testbenches: reusable units compose into layers. The next chapter looks at how a UVM testbench is organised into abstraction layers — signal, command/transaction, functional, and scenario/test — and how each layer builds on the one below, so stimulus and checking are expressed at the right altitude.