Bridge Pattern in UVM: One Register Operation, Two Access Paths

The Adapter post showed how uvm_reg_adapter translates between the register model and the bus — that's your frontdoor. But the register model has a second life: it can also reach the same register through HDL paths, bypassing the bus entirely. That's the backdoor. Two access mechanisms, one register, one read/write API. How does reg.read() know which path to take, and how do you swap the mechanism without rewriting a single sequence?

That's the Bridge pattern, and UVM's register layer is built around it.

The Problem: Two Access Paths, Same Operation

You're verifying a control block with three hundred configuration registers. Every test does the same thing in the first few microseconds: write a default configuration into every register so the DUT enters a known state. Three hundred frontdoor APB writes at one transfer per ten cycles is three thousand cycles of bus traffic before the interesting part of the test even starts. Across a thousand regression runs that's three million wasted cycles. You'd rather just poke the storage flops directly and move on.

But you can't only use direct pokes. The test that follows still needs to exercise the bus. Frontdoor writes drive the protocol pins, hit the address decoder, exercise byte enables, walk through every layer of the register block. That coverage is the entire point of register verification. Throwing it away to save startup time defeats the purpose.

So you need both. The naive solution is two parallel APIs:

class my_reg_sequence extends uvm_reg_sequence;
  // ... constructor ...
  task body();
    uvm_reg_data_t value;
    uvm_status_e   status;

    // Fast init — backdoor
    backdoor_write(.addr(reg_model.CTRL.get_address()), .data(32'h0000_0001));
    backdoor_write(.addr(reg_model.MODE.get_address()), .data(32'h0000_0010));
    backdoor_write(.addr(reg_model.MASK.get_address()), .data(32'hFFFF_FFFF));
    // ... 297 more ...

    // Stimulate — frontdoor
    reg_model.CTRL.write(status, 32'h0000_0003);
    reg_model.CTRL.read (status, value);
  endtask
endclass

This works, and it's exactly how a lot of testbenches actually look. It also creates three problems that compound as the testbench scales:

  • Sequences become mechanism-aware. Every test has to decide whether to use the frontdoor or backdoor API for each operation. The test author needs to know which one is appropriate. Wrong choices ship as silent bugs — a "backdoor init" that secretly bypassed a write-clear logic, exposed only when someone happens to read the register afterwards.
  • You can't toggle the mechanism centrally. Three months in, your manager says "I want a regression run that exercises only frontdoor accesses, including the init phase, to find timing bugs." You have to find every backdoor_write call and change it. Search, replace, hope you got them all.
  • The register model becomes a half-truth. The bus path goes through uvm_reg's normal infrastructure — mirror updates, prediction, coverage. The backdoor API doesn't. Now you have two parallel state machines that have to be kept in sync by convention, not by code.

The shape of the problem is clear: the operation is the same (read/write a register at a given address with a given value) but the implementation of how that operation reaches the silicon varies. You need the operation interface to be invariant while the mechanism behind it can change.

There's a pattern for this — and UVM's register layer was designed around it.

Gang of Four: The Bridge Pattern

"Decouple an abstraction from its implementation so that the two can vary independently."

— Design Patterns: Elements of Reusable Object-Oriented Software (Gamma et al., 1994)

The key word is decouple. Bridge keeps two class hierarchies parallel: one for the abstraction (what callers see), one for the implementation (how the work actually gets done). They are linked by a reference, not by inheritance. Either hierarchy can extend without touching the other.

classDiagram
    class Abstraction {
        -impl : Implementor
        +operation()
    }
    class RefinedAbstraction {
        +operation()
    }
    class Implementor {
        <<interface>>
        +operationImpl()*
    }
    class ConcreteImplementorA {
        +operationImpl()
    }
    class ConcreteImplementorB {
        +operationImpl()
    }
    Abstraction <|-- RefinedAbstraction
    Implementor <|.. ConcreteImplementorA
    Implementor <|.. ConcreteImplementorB
    Abstraction o-- Implementor : bridges to

The participants:

  • Abstraction — The interface the caller uses. Holds a reference to an Implementor. Forwards work by calling impl.operationImpl(). The caller never touches the implementor directly.
  • RefinedAbstraction — Specializations of the abstraction. They add features but still delegate the heavy lifting to whatever implementor is bound.
  • Implementor — An abstract base class (or interface in SystemVerilog terms) for the implementations. Defines operationImpl() but says nothing about how it works.
  • ConcreteImplementor — The actual workers. Each one does the operation differently. Swapping the implementor changes behavior without touching any caller code.

The pattern's value lives in the fact that the o-- association is a runtime composition, not a compile-time inheritance. You can hold an Abstraction reference and swap its implementor whenever you need to. The caller doesn't know and doesn't care.

Bridge vs Adapter (and Why They're Often Confused)

Both Bridge and Adapter put a translation layer between two parties, so they get mixed up constantly. The distinction is intent and timing:

AspectAdapterBridge
IntentMake two existing, incompatible classes work togetherAvoid coupling an abstraction to a specific implementation in the first place
TimingApplied after the fact — classes already exist with incompatible interfacesApplied up front — you design the abstraction and implementation as separate hierarchies from day one
What variesOne side (the adaptee) is fixed; the adapter shapes it for the clientBoth sides can vary independently; new abstractions and new implementations both compose freely
UVM exampleuvm_reg_adapter converts uvm_reg_bus_op to your protocol's transactionuvm_reg delegates to uvm_reg_frontdoor or uvm_reg_backdoor based on a runtime path argument

Adapter is a rescue. Bridge is a design choice. You reach for Adapter when you have two existing pieces that need to talk; you reach for Bridge when you're designing the system and you already know the implementation will vary.

In the register access story, both patterns appear — and that's exactly why this post comes right after the Adapter post. The Adapter handles the translation problem inside the frontdoor. The Bridge handles the which path do we take problem at the level above.

UVM Implementation: uvm_reg as the Abstraction

Map the GoF roles directly onto the UVM register layer:

Bridge RoleUVM Class
Abstractionuvm_reg (and its containers, uvm_reg_block, uvm_reg_map)
RefinedAbstractionAny subclass of uvm_reg you write — your specific register classes
ImplementorThe abstract concept of "an access mechanism"
ConcreteImplementorAuvm_reg_frontdoor — routes the access through the bus via the adapter
ConcreteImplementorBuvm_reg_backdoor — routes the access through HDL paths

The bridge between them lives in the path argument to uvm_reg::read() and write():

// uvm_reg::read signature (simplified)
virtual task read(
    output uvm_status_e      status,
    output uvm_reg_data_t    value,
    input  uvm_path_e        path     = UVM_DEFAULT_PATH,
    input  uvm_reg_map       map      = null,
    input  uvm_sequence_base parent   = null,
    // ... other args ...
);

The uvm_path_e enum has three values you care about:

  • UVM_FRONTDOOR — force this access through the bus, no matter the default
  • UVM_BACKDOOR — force this access through HDL paths, no matter the default
  • UVM_DEFAULT_PATH — whatever the register block was configured to use

Internally, uvm_reg::read() dispatches to one of two helper paths:

sequenceDiagram
    participant Test
    participant Reg as uvm_reg
    participant FD as uvm_reg_frontdoor
    participant BD as uvm_reg_backdoor
    participant Map as uvm_reg_map
    participant DUT

    Test->>Reg: read(status, value, .path(path_arg))
    Note over Reg: resolve effective path (arg or default)

    alt path == UVM_FRONTDOOR
        Reg->>FD: do_read()
        FD->>Map: dispatch via adapter
        Map->>DUT: bus transaction (PADDR, PWRITE, ...)
        DUT-->>Map: bus response
        Map-->>FD: bus2reg conversion
        FD-->>Reg: uvm_reg_bus_op (with status, data)
    else path == UVM_BACKDOOR
        Reg->>BD: do_read()
        BD->>DUT: uvm_hdl_read(hdl_path)
        DUT-->>BD: storage value
        BD-->>Reg: uvm_reg_bus_op (with status, data)
    end

    Reg-->>Test: status, value

The diagram is the entire pattern in one picture. The caller talks only to uvm_reg. The path argument selects an implementor. The two implementors take radically different routes to the same outcome — one walks through every layer of the bus protocol, the other touches the storage element directly — but the caller's API never changes.

Enabling the Backdoor

Frontdoor access works as soon as you connect a register map to a sequencer with uvm_reg_map::set_sequencer() (covered in the Adapter post). Backdoor access requires you to tell the register where its storage element actually lives in the RTL hierarchy:

class my_reg_block extends uvm_reg_block;
  rand my_ctrl_reg   CTRL;
  rand my_mode_reg   MODE;
  rand my_status_reg STATUS;

  `uvm_object_utils(my_reg_block)

  virtual function void build();
    default_map = create_map("default_map", 0, 4, UVM_LITTLE_ENDIAN);

    CTRL = my_ctrl_reg::type_id::create("CTRL");
    CTRL.configure(this);
    CTRL.build();
    default_map.add_reg(CTRL, 'h00, "RW");

    // ... more registers ...

    // Tell each register where its storage lives in the RTL
    CTRL.add_hdl_path_slice("u_ctrl_regs.r_ctrl_q", 0, 32);
    MODE.add_hdl_path_slice("u_ctrl_regs.r_mode_q", 0, 32);
    STATUS.add_hdl_path_slice("u_ctrl_regs.r_status_q", 0, 32);

    // Enable backdoor access for the whole block
    set_hdl_path_root("tb.dut.ctrl_block");
  endfunction
endclass

set_hdl_path_root() gives every register's HDL slice a common prefix. add_hdl_path_slice() says "this register's storage is at this HDL path." Behind the scenes, the default uvm_reg_backdoor uses uvm_hdl_read() and uvm_hdl_deposit() to touch those signals directly. No simulation time elapses. No bus protocol is exercised.

Selecting the Path

Once both paths are enabled, the same register supports either access mode:

// Frontdoor — default in most blocks
reg_model.CTRL.write(status, 32'h0000_0001);

// Explicit frontdoor
reg_model.CTRL.write(status, 32'h0000_0001, .path(UVM_FRONTDOOR));

// Explicit backdoor — bypasses the bus
reg_model.CTRL.write(status, 32'h0000_0001, .path(UVM_BACKDOOR));

// Toggle the block-wide default
reg_model.set_default_path(UVM_BACKDOOR);
reg_model.CTRL.write(status, 32'h0000_0001);   // now goes backdoor

The "toggle the default" line is where Bridge pays you back. Your three-hundred-register init sequence does not need a single edit to flip from bus traffic to direct HDL pokes — you change one line in the test, and every register access from that point uses the new path. Sequences stay the same. The register model stays the same. Only the binding between abstraction and implementor changes.

Backdoor Access: Mechanism and Constraints

The backdoor is powerful and dangerous in the same breath. Knowing exactly what it does — and what it cannot do — is the difference between a useful tool and a source of silent test failures.

What Backdoor Access Actually Does

The default uvm_reg_backdoor implementation uses two SystemVerilog DPI functions:

  • uvm_hdl_read(path, value) — reads the current value of the HDL signal at path
  • uvm_hdl_deposit(path, value) — writes a value to the signal without driving it (the value persists until something else drives the signal)

Both are zero-time. Both bypass every part of the DUT except the storage element itself. That means:

  • No address decode is exercised.
  • No write-enable logic runs.
  • No byte-enable masking, no read-modify-write, no protection checks.
  • No side-effects fire — a write-1-to-clear bit is not cleared by a backdoor write of 1.
  • No clock edges are required.

This is exactly why backdoor access is so useful for initialization and observation. It's also exactly why misusing it produces tests that pass but mean nothing.

When Backdoor Access Is Safe

ScenarioWhy It Works
Initial config write at test startThe DUT is in reset or quiescent; no side-effects can fire; just need the storage element loaded
Observation in a scoreboardRead-only, no DUT state change, just need to know "what does the storage element currently hold?"
Compare-by-read sanity checksAfter a frontdoor write, read backdoor to confirm the value actually landed in the flop
Loading large memory arraysOne DPI call per word vs hundreds of bus cycles; same value lands either way
Setting up an error injection stateForcing a register into a value the bus protocol won't normally allow

When Backdoor Access Is Dangerous

ScenarioWhy It Breaks
Registers with side-effects (W1C, RW1S, write-trigger)Backdoor write skips the side-effect logic — the register changes value but the side-effect never fires
Shadow registers / double-buffered configurationsBackdoor writes the shadow but doesn't trigger the commit pulse; the live register stays stale
Registers gated by clock or powerBackdoor touches the storage flop but the surrounding logic isn't running; downstream state stays inconsistent
Registers whose access is meant to be authentication-gatedBackdoor bypasses the gate entirely; you might be verifying a path the real DUT can never reach

The rule of thumb: backdoor is safe when you're treating the register as a simple storage element. The moment the register has interesting behavior beyond "remember this value," backdoor is a footgun.

Mirror Coherency

UVM's register model keeps an internal mirror — its prediction of the current DUT value. By default, frontdoor accesses update the mirror automatically because the access goes through uvm_reg::do_read()/do_write() which call predict() after the bus operation completes. Backdoor accesses update the mirror too, but they bypass the bus prediction infrastructure, so anything that monitors the bus (like an explicit predictor) won't see them. If you've wired up an explicit predictor for coverage or scoreboard purposes, backdoor traffic is invisible to it. Either disable the predictor for backdoor-heavy tests, or call predict() manually after the backdoor access.

Custom Frontdoors and Custom Backdoors

The default frontdoor (the one provided by uvm_reg_map plus your uvm_reg_adapter) handles vanilla register access. The default backdoor handles vanilla HDL slices. Real DUTs aren't always vanilla.

Custom Frontdoor: Indirect Register Access

A common scenario: a register controller exposes a wide register file behind two CSR addresses — an index register and a data register. To read register N, you write N to IDX, then read DATA. There's no direct address that maps to register N. The default frontdoor can't model this; the bus only knows about two addresses.

class indirect_frontdoor extends uvm_reg_frontdoor;
  uvm_reg                     idx_reg;
  uvm_reg                     data_reg;
  uvm_reg_data_t              idx_value;

  function new(string name = "indirect_frontdoor",
               uvm_reg idx_reg,
               uvm_reg data_reg,
               uvm_reg_data_t idx_value);
    super.new(name);
    this.idx_reg   = idx_reg;
    this.data_reg  = data_reg;
    this.idx_value = idx_value;
  endfunction

  virtual task body();
    uvm_status_e status;

    // Step 1: write the index
    idx_reg.write(status, idx_value, .parent(this));
    if (status != UVM_IS_OK) begin
      rw_info.status = status;
      return;
    end

    // Step 2: read or write the data port
    if (rw_info.kind == UVM_WRITE)
      data_reg.write(rw_info.status, rw_info.value[0], .parent(this));
    else begin
      data_reg.read(rw_info.status, rw_info.value[0], .parent(this));
      // value[0] is now in the read item; uvm_reg infrastructure will route it
    end
  endtask
endclass

You attach this frontdoor to a specific register at build time:

// In your reg block's build()
indirect_frontdoor fd_chan0_cfg = new("fd_chan0_cfg", IDX, DATA, .idx_value(8'h10));
CHAN0_CFG.set_frontdoor(fd_chan0_cfg);

Now CHAN0_CFG.read() and CHAN0_CFG.write() execute the two-step sequence automatically. Test code stays the same as for any other register; the bridge swapped one implementor for another.

Custom Backdoor: Shadowed Storage

The default backdoor uses one HDL slice per register. What if the "logical" register actually lives in different physical flops depending on a mode select? A bank of registers might have a primary and shadow copy, and reads come from whichever one a mode bit selects.

class shadowed_backdoor extends uvm_reg_backdoor;
  string                       primary_path;
  string                       shadow_path;
  string                       mode_path;

  function new(string name,
               string primary_path,
               string shadow_path,
               string mode_path);
    super.new(name);
    this.primary_path = primary_path;
    this.shadow_path  = shadow_path;
    this.mode_path    = mode_path;
  endfunction

  virtual task read(uvm_reg_item rw);
    bit            mode;
    uvm_reg_data_t value;
    void'(uvm_hdl_read(mode_path, mode));
    if (mode)
      void'(uvm_hdl_read(shadow_path,  value));
    else
      void'(uvm_hdl_read(primary_path, value));
    rw.value[0] = value;
    rw.status   = UVM_IS_OK;
  endtask

  virtual task write(uvm_reg_item rw);
    bit mode;
    void'(uvm_hdl_read(mode_path, mode));
    // Always write both copies so the next mode flip doesn't pick up stale state
    void'(uvm_hdl_deposit(primary_path, rw.value[0]));
    void'(uvm_hdl_deposit(shadow_path,  rw.value[0]));
    rw.status = UVM_IS_OK;
  endtask
endclass

The custom backdoor encapsulates the mode-aware routing. Tests still call reg.read(.path(UVM_BACKDOOR)) and get the logically correct value — the implementor handles the mode selection.

The two examples illustrate the value of having both hierarchies be open for extension. The Bridge pattern allows new RefinedAbstractions (custom register classes, parameterized register types) and new ConcreteImplementors (custom frontdoors, custom backdoors) to be added independently. Neither hierarchy needs to know what the other one is doing.

Advanced: Path Mixing for Verification

When both implementations exist for the same abstraction, you gain a verification superpower the single-path testbench doesn't have: you can compare them. A frontdoor write followed by a backdoor read — or vice versa — turns the two paths into an oracle for each other.

Frontdoor Write, Backdoor Read

The frontdoor exercises every part of the write path: address decode, write enable, byte-enable mask, side-effect logic, write-protect gating. The backdoor reads the storage element directly. If they disagree, the write path is broken somewhere.

class write_path_check_seq extends uvm_reg_sequence;
  `uvm_object_utils(write_path_check_seq)
  // ... constructor ...

  task body();
    uvm_status_e   status;
    uvm_reg_data_t expected = 32'hA5A5_F00F;
    uvm_reg_data_t actual;

    // Drive the value through the full bus path
    reg_model.CTRL.write(status, expected, .path(UVM_FRONTDOOR));

    // Read the actual storage element directly
    reg_model.CTRL.read(status, actual, .path(UVM_BACKDOOR));

    if (actual !== expected)
      `uvm_error("PATH_CHK",
                 $sformatf("Frontdoor wrote 0x%0h but storage holds 0x%0h",
                           expected, actual))
  endtask
endclass

This catches a class of bugs that pure frontdoor verification misses entirely. A frontdoor write followed by a frontdoor read can be lied to by a broken read decode that returns the right value for the wrong reason. The backdoor read is the ground truth.

Backdoor Write, Frontdoor Read

The reverse direction tests the read path. Force a known value into the storage element, then ask the bus what it reads back. If the answer is wrong, the read decode — not the write decode — is broken.

class read_path_check_seq extends uvm_reg_sequence;
  `uvm_object_utils(read_path_check_seq)
  // ... constructor ...

  task body();
    uvm_status_e   status;
    uvm_reg_data_t injected = 32'hDEAD_BEEF;
    uvm_reg_data_t observed;

    // Force the storage element
    reg_model.STATUS.write(status, injected, .path(UVM_BACKDOOR));

    // Ask the bus what it sees
    reg_model.STATUS.read(status, observed, .path(UVM_FRONTDOOR));

    if (observed !== injected)
      `uvm_error("PATH_CHK",
                 $sformatf("Backdoor wrote 0x%0h but bus reads 0x%0h",
                           injected, observed))
  endtask
endclass

Why This Matters

Pure-frontdoor verification can pass on a DUT where the write path is correct and the read path is correct but they disagree about which physical flop is "the register." Pure-backdoor verification can pass on a DUT whose storage is fine but whose bus interface is broken. Mixing the two paths catches the asymmetric bugs that either path alone would miss.

The Bridge pattern is what makes path mixing trivial. Without it, you'd need parallel test code — one set of sequences for each mechanism. With it, the same sequence body changes meaning based on a one-word argument. The test author writes the intent once; the bridge handles the rest.

Quick Reference

Pattern Methods Reference

Method / PropertyDefined InPurposeNotes
read(status, value, path, ...)uvm_regRead a register through the selected pathpath defaults to UVM_DEFAULT_PATH
write(status, value, path, ...)uvm_regWrite a register through the selected pathSame defaulting rules as read
set_default_path(uvm_path_e path)uvm_reg_blockSet the block-wide default access pathApplies to every register that uses UVM_DEFAULT_PATH
set_frontdoor(uvm_reg_frontdoor fd, ...)uvm_regAttach a custom frontdoor implementor to one registerReplaces the default map-driven frontdoor
set_backdoor(uvm_reg_backdoor bd)uvm_regAttach a custom backdoor implementor to one registerReplaces the default HDL-slice backdoor
add_hdl_path_slice(path, offset, size)uvm_regTell the default backdoor where the storage element livesRequired before any backdoor access works
set_hdl_path_root(root)uvm_reg_blockCommon HDL prefix for every register in the blockOne line at the top of the block keeps the slices terse

Common Mistakes

MistakeFix
Backdoor-writing a register with side-effects (W1C, RW1S, write-trigger)Use frontdoor for any register whose write has consequences beyond storage. Reserve backdoor for init and observation.
Forgetting add_hdl_path_slice() and expecting backdoor access to workThe default backdoor needs explicit HDL paths. Without them, backdoor reads return 'x and silently corrupt your mirror.
Mixing path argument values inside a single sequence without thinkingBridge lets you mix, but the moment you do, the mirror coherency story gets subtle. Use one default path per phase of the test, override per-call only when you know why.
Toggling set_default_path() mid-test without flushing in-flight transactionsThe default path is read at access time. Changing it while a frontdoor write is pending creates surprising ordering. Toggle defaults at well-defined phase boundaries.
Writing a custom frontdoor that calls reg.write() on the same registerInfinite recursion. The custom frontdoor must use lower-level uvm_reg calls or different registers, not the register it's a frontdoor for.
Assuming backdoor accesses update an external predictorPredictors hook into the bus. Backdoor traffic bypasses the bus. Call predict() manually or accept that the predictor's mirror will drift.

Bridge vs Adapter vs Strategy

The three "structural translators" in this series get conflated. Here is how they actually differ:

AspectAdapterBridgeStrategy
What it decouplesTwo incompatible existing interfacesAn abstraction from its implementationA client from a family of algorithms
When it's chosenAfter the fact, to make two things work togetherUp front, to plan for varying implementationsUp front, to plan for varying algorithms
Number of hierarchiesOne adapter per pairTwo parallel hierarchies (abstraction + implementor)One client, many strategy classes
Switchable at runtime?Usually no — chosen at build timeYes — the implementor reference can be swappedYes — the strategy reference can be swapped
UVM exampleuvm_reg_adapteruvm_reg + uvm_reg_frontdoor / uvm_reg_backdoorPluggable scoreboards or response policies

Bridge is the right reach whenever you know up front that the mechanism behind an operation is going to vary — across protocol families, across access paths, across simulation phases. Adapter is the right reach when you have two existing pieces and need them to talk. They coexist comfortably in the register layer: the frontdoor itself contains an adapter; the bridge sits one level above and decides whether to use the frontdoor at all.


Previous: Adapter Pattern — Bridging the register model to your bus with uvm_reg_adapter

Next: Decorator Pattern — Adding behavior without changing interfaces

Author
Milan Kubavat
Sharing knowledge about silicon verification, hardware design, and engineering insights.

Comments (0)

Leave a Comment