Configuration in UVM Sequences - Dependency Injection, Virtual Sequencers & the Null-Sequencer Trap

UVM_FATAL @ 0: reporter [CFG] Failed to get config — but only in the full SoC environment. In the block-level bench the same virtual sequence runs perfectly. Two days of diffing environments later, the truth: the block bench set the config with one context spelling, the SoC bench with another, and the sequence — started on a null sequencer, outside the component hierarchy entirely — was fishing in a string-keyed global pool for a key that was spelled differently in each world. Nothing was broken. Three pieces of code simply disagreed about a string.

This is the real subject of this post. "How do I get configuration in a sequence started on null?" is the surface question, and the 2016 version of this post answered it with four workarounds. The deeper question is how should configuration reach sequences at all — and the answer in 2026 inverts the original's recommendation order. The workarounds still work; you'll meet them in every legacy bench. But the first-choice pattern today is the one the original ranked last.

Note Originally published in 2016; rewritten in 2026. The original recommended uvm_root::get() as the context for config lookups and listed direct handle assignment as a "tight coupling OK" fallback. Modern practice — including this blog's own virtual sequences post — reverses that ranking. This rewrite explains both the how and the why.
~14 min read · Intermediate body, Advanced tail · Part of the sequences cluster: Fundamentals · Virtual Sequences · Arbitration.

The Problem: Sequences Are Objects, Not Components

uvm_config_db resolves lookups against the component hierarchy — every get() asks "what was set for something at my position in the tree?" Components have positions. Sequences don't: they're objects, and they borrow their position from the sequencer they run on, via m_sequencer. Start a virtual sequence with vseq.start(null) and there is no sequencer, no position, no path — and the lookup fails:

flowchart TD
    subgraph NORMAL["Sequence on a real sequencer"]
        N1["Sequence"] --> N2["m_sequencer"]
        N2 --> N3["Position in component tree"]
        N3 --> N4["config_db::get() resolves"]
    end

    subgraph VIRTUAL["Sequence on null"]
        V1["Virtual Sequence"] --> V2["m_sequencer == null"]
        V2 --> V3["No position, no path"]
        V3 --> V4["config_db::get() fails"]
    end

    style N4 fill:#d1fae5,stroke:#10b981
    style V4 fill:#fee2e2,stroke:#ef4444
virtual task body();
  // FAILS when started on null — m_sequencer has no position to resolve from
  if (!uvm_config_db#(my_config)::get(m_sequencer, "", "cfg", cfg))
    `uvm_fatal("CFG", "Failed to get config")
endtask

Name the Philosophies First

Every fix for this problem is one of two design patterns, and naming them turns a bag of tricks into a decision:

Service Locator (config_db)Dependency Injection (direct assignment)
WiringImplicit — global registry, string keysExplicit — the creator hands over the handle
Failure modeRuntime: get() returns 0, or worse, returns the wrong object silentlyCompile-time type check; null check at start
Refactor safetyRenames and re-parenting break string paths invisiblyCompiler finds every broken wire
DecouplingProducer and consumer never meetCreator must know both ends
Right forComponent hierarchy wiring, genuinely global knobsTest → sequence configuration — this post's subject

The 2016 original treated "tight coupling" as DI's disqualifying flaw. But look at where the wiring happens: the test. The test already knows the env, the agents, and the sequence it's about to start — that knowledge is the test's entire job. "Decoupling" a test from its own environment through a global string registry doesn't remove the coupling; it just hides it from the compiler. (This is the Service Locator critique from the software world, and it lands with full force here — see the design patterns series.)

Solution 1 (Recommended): Inject the Config Directly

class my_virtual_sequence extends uvm_sequence;
  `uvm_object_utils(my_virtual_sequence)

  my_config cfg;             // the dependency, as a plain field

  virtual task body();
    if (cfg == null)
      `uvm_fatal("CFG", "cfg must be assigned before start() — see my_test")
    // ... use cfg, start sub-sequences ...
  endtask
endclass

// The test wires it — one line, visible, compile-checked:
virtual task run_phase(uvm_phase phase);
  my_virtual_sequence vseq = my_virtual_sequence::type_id::create("vseq");
  phase.raise_objection(this);
  vseq.cfg = env.cfg;        // dependency injection
  vseq.start(null);
  phase.drop_objection(this);
endtask

Everything about the failure mode improves: a forgotten wire is an immediate, well-named fatal at sequence start (not a mystery at time 80us); a renamed config class is a compile error at every stale site; and a reader of the test sees the sequence's entire configuration in one line. The guard-plus-fatal in body() is part of the pattern — it converts "misconfigured" from a silent wrong-behavior bug into a labeled crash.

Solution 2: Give the Virtual Sequence a Real Home

The other modern answer dissolves the problem instead of solving it: stop starting virtual sequences on null. Build an actual virtual sequencer — a real component holding handles to the sub-sequencers and the config — and start the sequence on it:

class my_virtual_sequencer extends uvm_sequencer;
  `uvm_component_utils(my_virtual_sequencer)

  apb_sequencer apb_sqr;     // wired by the env in connect_phase
  eth_sequencer eth_sqr;
  my_config     cfg;         // wired by the env in build_phase

  function new(string name, uvm_component parent); super.new(name, parent); endfunction
endclass

class my_virtual_sequence extends uvm_sequence;
  `uvm_object_utils(my_virtual_sequence)
  `uvm_declare_p_sequencer(my_virtual_sequencer)

  virtual task body();
    my_config cfg = p_sequencer.cfg;          // just... works
    apb_reset_seq  rst  = apb_reset_seq::type_id::create("rst");
    eth_traffic_seq eth = eth_traffic_seq::type_id::create("eth");
    rst.start(p_sequencer.apb_sqr);
    eth.start(p_sequencer.eth_sqr);
  endtask
endclass

// Test: start on the REAL virtual sequencer — never null
vseq.start(env.v_sqr);

Now m_sequencer exists, p_sequencer is typed, the config rides the same wire as the sub-sequencer handles, and even hierarchy-based config_db lookups work again if you want them — because the sequence has a position. This is the architecture the Virtual Sequences post builds as the mediator pattern; the null-sequencer config problem is, from that vantage point, a symptom of a missing component.

Tip Choosing between Solutions 1 and 2: if the environment already has (or merits) a virtual sequencer — multi-agent coordination, arbitration, reusable vseq libraries — Solution 2, and the config rides along. For a lightweight test-specific vseq in a bench with no virtual sequencer, Solution 1 is two lines and no new component. What you should no longer reach for first is the global-pool workaround below.

Solution 3 (Legacy/Global): the uvm_root Bulletin Board

The 2016 original's "recommended" fix — context uvm_root::get() (or equivalently null, which config_db treats as the top scope; the original listed these as two separate solutions, but they're one mechanism with two spellings):

// Producer (test/env):
uvm_config_db#(my_config)::set(uvm_root::get(), "*", "cfg", my_cfg);
// Consumer (sequence on null):
if (!uvm_config_db#(my_config)::get(uvm_root::get(), "", "cfg", cfg)) ...

It works, and it has a legitimate niche: genuinely global, test-wide knobs — verbosity policies, error-injection master enables, things that are conceptually plusargs with an object's structure. The cost is everything in the Service Locator column: top-scope wildcard sets are visible to the entire environment (name collisions across VIPs sharing the well-known top scope are real), nothing checks producer and consumer ever meet, and the string key is load-bearing. Treat it as the bulletin board it is — fine for announcements, wrong for wiring.

Why get() Fails Silently: the Debug Section

Whatever pattern you adopt, you will inherit benches full of config_db — so the debugging skill matters. The four ways a lookup that "should work" returns 0, in observed frequency order:

  • Phase ordering. The set happens after the get — classically, a config set in run_phase after the virtual sequence already started and read. Sets in build_phase, reads no earlier than end_of_elaboration, is the discipline that prevents it. Remember get() is a pull at call time, not a subscription — late sets are simply missed.
  • Type parameter mismatch. set as uvm_config_db#(my_config), get as uvm_config_db#(my_derived_config) — different parameterizations are different databases. No warning, just a miss. (Same type-identity rule as parameterized classes everywhere.)
  • Context/path spelling. The cold open. The instance path in set must match the getter's actual position — after a refactor renames env.agt to env.apb_agt, every string mentioning the old name still compiles.
  • Precedence surprises. When multiple sets match one get, hierarchy position wins (a set from the test overrides a set from deeper in the env), then set order. Usually what you want; occasionally a test-level wildcard silently shadows an agent's carefully specific set.
Key Before theorizing, trace: +UVM_CONFIG_DB_TRACE logs every set and get with context, path, type, and result. The failing get and the mismatched set end up adjacent in the log, and the string or type difference is usually visible by inspection. This one plusarg replaces the two days in the cold open.

Common Mistakes

  • Ranking decoupling above debuggability for test-to-sequence wiring — the 2016 mistake this rewrite exists to correct. The test knows its environment; let the compiler see the wire.
  • Setting config in run_phase and wondering why a sequence that started at time 0 doesn't see it.
  • Reading config per-item instead of per-sequence. A config_db lookup is string matching against a global pool — do it once at the top of body(), not inside the transaction loop (measured cost in the Advanced section).
  • The unguarded get. void'(uvm_config_db#(...)::get(...)) with no check: the sequence runs with a null config and fails somewhere else, later, worse. Check the return; fatal with a message that names the missing key.
  • Two spellings of the same key. "cfg" here, "m_cfg" there, per-bench conventions drifting. One localparam string-style constant (or a config-access wrapper class) per key kills the whole bug class.

Interview Corner

Q: Why does config_db::get fail inside a sequence started on null?

A: The database resolves lookups against component-hierarchy positions, and a sequence has no position of its own — it borrows m_sequencer's. Started on null, there's no sequencer, hence no path to match set patterns against. The fixes are either giving the lookup a real position (start on an actual virtual sequencer), bypassing the hierarchy (top-scope/global context), or bypassing the database entirely (inject the handle).

Q: Service locator vs dependency injection — and which is UVM's config_db?

A: config_db is a service locator: consumers pull dependencies from a global registry by string key, producer and consumer never meet, and mismatches surface at runtime as failed lookups. Dependency injection hands the dependency over explicitly at construction/configuration time, with the compiler checking the wire. UVM needs the locator for hierarchy-crossing wiring (test to deeply-nested driver), but where injector and consumer already know each other — a test and the sequence it starts — DI is strictly less mysterious.

Q: Two set() calls match one get(). Who wins?

A: The set made from higher in the component hierarchy — a test-level set overrides an env-level one for the same key, which is what makes test-specific overrides work without editing the env. Between sets at equal precedence, the later one wins. The practical consequence: a broad wildcard set in a test can unintentionally shadow a specific set deeper down, and +UVM_CONFIG_DB_TRACE is how you see the shadowing happen.

Q: When is the uvm_root-context pattern still the right call?

A: For configuration that is genuinely global in scope — test-wide policy knobs any component might consult, set once in the test. It's the object-valued cousin of a plusarg. The moment the "global" config is actually one sequence's working configuration, the pattern is hiding a wire that DI or a virtual sequencer would make visible.

Beyond the Basics: Advanced → Expert

Level 1 — What config_db actually is: resources under the hood

uvm_config_db is a thin convenience layer over uvm_resource_db: a global pool of typed resources, each tagged with a name and a scope regex. set(ctx, "path", "key", val) writes a resource whose scope pattern is the context's full name joined with your path glob; get matches its own full name against those patterns and takes the highest-precedence hit. Once you hold this model, every behavior in this post is derivable: null-sequencer failure (no full name to match), precedence (pattern origin depth), type-mismatch misses (the pool is segregated by type parameter), and the cost model (every get is string/regex matching over a pool). You can also drop below the sugar when warranted: uvm_resource_db#(T)::read_by_name for hierarchy-free lookups that make the global-registry nature explicit rather than accidental.

Level 2 — Config architecture at scale: the object tree convention

Environments that stay debuggable at SoC scale converge on the same shape: one env-level config object containing agent config objects (composition mirroring the component tree), built and randomized in the test, distributed downward — the env passes sub-configs to agents, agents to their children, with exactly one config_db set/get pair per hierarchy boundary that genuinely needs the locator. Three conventions carry the pattern: configs are effectively immutable after build_phase (mid-test reconfiguration is a sequence's job via explicit APIs, not a config mutation); sequences receive configs by injection from whoever starts them (this post's Solution 1, applied recursively — parent sequences inject into children via `uvm_do_with-adjacent field assignment); and any sequence-local tweaking operates on a clone(), never the shared object — one aliased-handle stimulus bug pays for the clone discipline forever.

Level 3 — Reusable virtual sequences: when the vseq must not know the sequencer

Solution 2's `uvm_declare_p_sequencer has a hidden cost: the sequence now compiles against one sequencer type, welding it to one environment family. For vseq libraries reused across benches, invert it — the sequence declares what it needs (sub-sequencer handles as fields), and the starter injects them: DI again, one level up. The env-specific knowledge lives in the one place that has it (the test or a thin env-specific wrapper vseq), and the library sequence runs anywhere the handles exist. The spectrum, in coupling order: p_sequencer (typed, welded) → injected handles (portable, explicit) → layered sequences via translation sequencers (fully decoupled, heavier). Pick by how far the sequence travels.

Level 4 — The wrapper-class endgame: config access as an API

Large programs eventually stop calling config_db raw and wrap access in a policy class: class cfg_access; static function my_config get_env_cfg(uvm_component ctx); ... endfunction endclass — one place that owns the key strings (killing the two-spellings bug class), enforces the phase discipline (assert not-yet-run-phase on sets), applies defaults, and logs every access uniformly. The same wrapper is where migration lives: when a bench moves from locator-style to injection-style config, the wrapper's implementation changes while its fifty call sites don't. If you've read the Refactoring post, this is Introduce Facade applied to configuration — and it's the highest-leverage single refactor available in a config_db-saturated legacy bench.

Level 5 — Cost and audit: the pool as a measurable thing

Two expert practices close the topic. Cost: every get() is regex matching across the resource pool — irrelevant at sequence granularity, real inside per-transaction loops or convergence-critical always-style polling. The discipline from Common Mistakes (read once per body()) is worth enforcing in review for any sequence that runs thousands of items. Audit: the resource pool is introspectable — uvm_resource_pool::get().dump() prints every resource with its scope pattern, and UVM's resource machinery tracks read counts, which means end-of-test reporting can flag set-but-never-read resources: each one is a dead knob, a typo'd key, or a producer whose consumer was deleted — the config-space equivalent of the vacuous-pass audit in the SVA post. A five-line report hook that dumps unread resources converts config rot from archaeology into a regression artifact.

Key Takeaways

  • The null-sequencer config failure is structural: sequences have no hierarchy position of their own. Give them one (real virtual sequencer), hand them the object (injection), or go global deliberately (top-scope) — in that order of preference for anything non-global.
  • Name the patterns: config_db is a service locator, direct assignment is dependency injection. For test-to-sequence wiring, the coupling already exists — DI just lets the compiler see it.
  • get() failures are phase ordering, type mismatch, string spelling, or precedence — and +UVM_CONFIG_DB_TRACE shows which, in one run.
  • At scale: config object trees mirroring the hierarchy, immutable after build, cloned before tweaking, accessed through a wrapper that owns the strings.
  • The resource pool is measurable and auditable — read-once discipline in hot loops, unread-resource reports at end of test.
Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment