An SoC design rarely ships as a single fixed chip. The same RTL base often produces several derivatives - one variant with four CPU cores and one with two, one with a display controller and one without, one aimed at a cost-sensitive market with half the DMA channels of the flagship part. The testbench that verifies all of them can't be rebuilt from scratch for every variant, which means it has to be written to build itself differently depending on which configuration it's pointed at.
12 min read | Intermediate Level
What You'll Learn
- Why a single, fixed testbench topology breaks down once a design has more than one configuration
- How a configuration object drives which agents and environments actually get built
- How the factory lets a configuration swap out a component's implementation, not just its presence
- Why RTL and testbench configuration need one shared source of truth, and how to debug it when propagation goes wrong
- Why coverage and regression reporting need the same configuration awareness the build itself does
One RTL Base, Several Real Chips
A configuration difference between chips is rarely cosmetic. Two CPU clusters instead of one changes how many core-local agents the environment needs.
A cost-reduced SKU without a display controller means the interface that controller would have used doesn't exist on that variant at all - there's nothing there for a UVC to connect to. A part with eight DMA channels instead of four needs eight monitor instances watching real, distinct hardware, not four instances reused twice.
Hardcoding a testbench's component hierarchy against any one of these configurations means that hierarchy is wrong for every other one. The environment has to look at what it's actually verifying and build only the pieces that configuration calls for.
A Configuration Object, Not a Hardcoded Hierarchy
The usual approach is a small config object - typically a plain uvm_object with fields describing the configuration - populated once, early, and then read by every environment and agent that needs to make a build-time decision:
class soc_env_config extends uvm_object;
`uvm_object_utils(soc_env_config)
int unsigned num_cpu_cores = 2;
int unsigned num_dma_channels = 4;
bit has_display_ctrl = 1;
bit has_crypto_engine = 0;
function new(string name = "soc_env_config");
super.new(name);
endfunction
endclass
A top-level test constructs this object, sets its fields to match whichever variant is under test, and places it in the config database before the environment's build_phase runs:
class four_channel_dma_test extends uvm_test;
`uvm_component_utils(four_channel_dma_test)
virtual function void build_phase(uvm_phase phase);
soc_env_config cfg = soc_env_config::type_id::create("cfg");
cfg.num_dma_channels = 4;
cfg.has_display_ctrl = 0;
cfg.has_crypto_engine = 0;
uvm_config_db #(soc_env_config)::set(this, "*", "env_config", cfg);
super.build_phase(phase);
endfunction
endclass
The environment then reads that same object and uses its fields to decide what to build, instead of assuming a fixed number of anything:
class soc_env extends uvm_env;
`uvm_component_utils(soc_env)
soc_env_config cfg;
dma_agent dma_agents[];
display_agent disp_agent;
virtual function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db #(soc_env_config)::get(this, "", "env_config", cfg))
`uvm_fatal("NO_CFG", "soc_env_config not set")
// Build exactly as many DMA agents as this configuration has
// channels for - not a fixed number picked for the biggest chip
dma_agents = new[cfg.num_dma_channels];
foreach (dma_agents[i])
dma_agents[i] = dma_agent::type_id::create($sformatf("dma_agents[%0d]", i), this);
// A display controller UVC only gets built when there's a display
// controller in this configuration for it to connect to
if (cfg.has_display_ctrl)
disp_agent = display_agent::type_id::create("disp_agent", this);
endfunction
endclass
The pattern generalizes past a simple count or a present/absent flag. Anything that changes across configurations - address ranges, interrupt line assignments, which cores exist at all - belongs in the same config object, read the same way, rather than encoded separately in each place that happens to need it.
One Source of Truth for RTL and Testbench Configuration
The config object only describes what the testbench believes is present. Nothing enforces, by itself, that those beliefs match the actual RTL build - which is usually assembled from its own set of Verilog defines, parameters, or a separate build-configuration file entirely outside the testbench's control.
Mistake: Letting the testbench configuration and the RTL build configuration drift independently.
A testbench built expecting four DMA channels, run against RTL compiled with only two, doesn't fail cleanly.
The two extra agents either connect to nothing and hang waiting for traffic that will never arrive, or - worse, if the interface signals happen to exist but are tied off - silently observe idle behavior and report clean coverage on channels that were never actually exercised. Either failure mode is far more confusing to debug than a build that refuses to elaborate.
Teams that verify several chip derivatives from one RTL superset usually solve this by generating both sides of the build from a single master configuration source - often a script-generated file rather than something maintained by hand in two places.
That master source produces an include.v of RTL defines that strips or tie-offs unneeded logic for the RTL compile, and the same source produces (or directly feeds) the testbench's soc_env_config fields, so a channel count or a feature flag only ever has one place it's actually decided.
This doesn't remove the need for the config object described earlier - it changes where that object's field values come from. Instead of a test hand-setting cfg.num_dma_channels = 4, the test (or an outer script) reads that same value out of the shared configuration source the RTL build already used, so the two sides can't quietly disagree.
Swapping Implementations, Not Just Presence, with the Factory
Building or skipping an agent covers configurations that add or remove a whole interface. Some configuration differences are narrower than that - the interface exists on every variant, but its behavior differs enough that the same driver or monitor class shouldn't be reused as-is.
A cost-reduced SKU's crypto engine might support a smaller key size, or a low-power variant's clock controller might expose fewer configurable domains, while the surrounding bus protocol stays identical.
The UVM factory handles this without adding another branch to every agent's build_phase. Instead of instantiating a class directly, a component creates it through type_id::create(), and a configuration-scoped factory override swaps in the variant that's actually appropriate:
// In the test (or an outer configuration script) for the
// cost-reduced SKU, before the environment builds:
crypto_agent_lite::type_id::set_type_override_by_type(
crypto_agent::get_type(), crypto_agent_lite::get_type());
The environment's own code stays exactly as written for the flagship configuration - it still calls crypto_agent::type_id::create(...) - but the factory hands back a crypto_agent_lite instance wherever that override is active. The component hierarchy doesn't need to know how many implementations of a given agent exist; it only needs to ask the factory for one.
Debugging Configuration That Didn't Propagate
A configuration value that never reached the component expecting it produces the same symptom every time: that component built or behaved as if the default value were still in effect, with no error to point at why. Two tools make this easier to track down than reading through build_phase code by hand.
The +UVM_CONFIG_DB_TRACE command-line plusarg makes every set() and get() call on uvm_config_db print where it happened and whether it matched, which turns "this field has the wrong value" into a search through a handful of log lines instead of the whole build hierarchy.
Alongside it, wildcard paths in set() calls are worth double-checking directly: a set() scoped to "env.agents[0]*" only reaches instances matching that exact pattern, and a typo in the wildcard fails silently - the get() on the other end simply doesn't find a match and falls back to whatever default the field already had.
A component whose configuration didn't arrive almost never reports it as an error, since a missed get() is only a warning by default. Treating any uvm_config_db get-not-found warning as worth investigating immediately, rather than something to revisit if a test fails later, catches a propagation bug long before it disguises itself as something else.
Coverage and Regression Have to Be Configuration-Aware Too
A coverage model written against the flagship configuration's four DMA channels will, by construction, never see channels five through eight on a variant that doesn't have them - and it shouldn't be expected to. Left unguarded, that produces a permanent coverage hole for every variant smaller than the biggest one, chased in every regression against every configuration, for bins that configuration was never capable of hitting.
Gate each coverage bin's applicability by the same configuration object the environment build used. A covergroup or coverpoint can check cfg.num_dma_channels before sampling, or the bins themselves can be sized from that field at construction time, so a smaller configuration's coverage report reflects what that configuration could reach - not what the largest configuration in the family happens to have.
Regression reporting benefits from the same awareness. A test written for an eight-channel DMA scenario has no meaningful result on a variant with two channels - it isn't a failure of that configuration, it's a test that was never applicable to begin with.
Filtering a regression's pass rate and coverage rate down to only the tests and bins each configuration is actually capable of exercising gives a truer picture of how well that specific variant has been verified than a single number averaged across every configuration in the family.
Common Mistakes
Mistake: Sizing arrays and loops in the environment for the largest configuration and disabling the extras.
Building the maximum number of agents every run and leaving unused ones idle still elaborates and connects components that have nothing valid to attach to on smaller configurations, which reintroduces the same dangling-interface problem a configuration-driven build is meant to avoid. The build itself should size to the configuration, not size to the maximum and prune afterward.
Mistake: Reading the configuration object after the components that depend on it have already built.
Like any config database value, the environment's configuration object has to be set by a component higher in the hierarchy before the components that consume it run their own build_phase. A get that happens later reads a value that arrived too late to affect anything.
Mistake: Treating configurability as an afterthought added once a second chip variant shows up.
Retrofitting a testbench that was written against one fixed topology into a configuration-driven one usually touches every environment and agent in the hierarchy at once. Designing the build around a configuration object from the first variant, even when only one variant exists yet, costs little and avoids that rewrite entirely.
Mistake: Ignoring a uvm_config_db::get() that quietly returns the default.
A missed get() is a warning, not an error, and a component that silently keeps its default field value looks identical to one that was configured correctly - until its behavior stops matching the configuration under test. Chasing down every such warning as soon as it appears is far cheaper than debugging the test failure it eventually causes.
Summary
In this article, you learned:
- A single RTL base commonly produces several chip configurations, and a testbench with a fixed component hierarchy can only be correct for one of them
- A configuration object, set in the config database before the environment builds, lets agents and sub-environments size and enable themselves based on what that configuration actually contains
- Factory type overrides let a configuration swap which implementation of a component gets built, for interfaces that exist on every variant but behave differently on some of them
- RTL and testbench configuration hold up best when both are generated from one shared source instead of two hand-maintained files, and
+UVM_CONFIG_DB_TRACEplus attention to unmatchedget()calls make propagation bugs traceable - Coverage and regression reporting need to filter by what each configuration was actually capable of exercising, rather than measuring every variant against the largest one in the family