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
- Gang of Four: The Bridge Pattern
- UVM Implementation: uvm_reg as the Abstraction
- Backdoor Access: Mechanism and Constraints
- Custom Frontdoors and Custom Backdoors
- Advanced: Path Mixing for Verification
- Quick Reference
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_writecall 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 callingimpl.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:
| Aspect | Adapter | Bridge |
|---|---|---|
| Intent | Make two existing, incompatible classes work together | Avoid coupling an abstraction to a specific implementation in the first place |
| Timing | Applied after the fact — classes already exist with incompatible interfaces | Applied up front — you design the abstraction and implementation as separate hierarchies from day one |
| What varies | One side (the adaptee) is fixed; the adapter shapes it for the client | Both sides can vary independently; new abstractions and new implementations both compose freely |
| UVM example | uvm_reg_adapter converts uvm_reg_bus_op to your protocol's transaction | uvm_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 Role | UVM Class |
|---|---|
| Abstraction | uvm_reg (and its containers, uvm_reg_block, uvm_reg_map) |
| RefinedAbstraction | Any subclass of uvm_reg you write — your specific register classes |
| Implementor | The abstract concept of "an access mechanism" |
| ConcreteImplementorA | uvm_reg_frontdoor — routes the access through the bus via the adapter |
| ConcreteImplementorB | uvm_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 defaultUVM_BACKDOOR— force this access through HDL paths, no matter the defaultUVM_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 atpathuvm_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
| Scenario | Why It Works |
|---|---|
| Initial config write at test start | The DUT is in reset or quiescent; no side-effects can fire; just need the storage element loaded |
| Observation in a scoreboard | Read-only, no DUT state change, just need to know "what does the storage element currently hold?" |
| Compare-by-read sanity checks | After a frontdoor write, read backdoor to confirm the value actually landed in the flop |
| Loading large memory arrays | One DPI call per word vs hundreds of bus cycles; same value lands either way |
| Setting up an error injection state | Forcing a register into a value the bus protocol won't normally allow |
When Backdoor Access Is Dangerous
| Scenario | Why 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 configurations | Backdoor writes the shadow but doesn't trigger the commit pulse; the live register stays stale |
| Registers gated by clock or power | Backdoor touches the storage flop but the surrounding logic isn't running; downstream state stays inconsistent |
| Registers whose access is meant to be authentication-gated | Backdoor 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 / Property | Defined In | Purpose | Notes |
|---|---|---|---|
read(status, value, path, ...) | uvm_reg | Read a register through the selected path | path defaults to UVM_DEFAULT_PATH |
write(status, value, path, ...) | uvm_reg | Write a register through the selected path | Same defaulting rules as read |
set_default_path(uvm_path_e path) | uvm_reg_block | Set the block-wide default access path | Applies to every register that uses UVM_DEFAULT_PATH |
set_frontdoor(uvm_reg_frontdoor fd, ...) | uvm_reg | Attach a custom frontdoor implementor to one register | Replaces the default map-driven frontdoor |
set_backdoor(uvm_reg_backdoor bd) | uvm_reg | Attach a custom backdoor implementor to one register | Replaces the default HDL-slice backdoor |
add_hdl_path_slice(path, offset, size) | uvm_reg | Tell the default backdoor where the storage element lives | Required before any backdoor access works |
set_hdl_path_root(root) | uvm_reg_block | Common HDL prefix for every register in the block | One line at the top of the block keeps the slices terse |
Common Mistakes
| Mistake | Fix |
|---|---|
| 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 work | The 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 thinking | Bridge 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 transactions | The 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 register | Infinite 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 predictor | Predictors 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:
| Aspect | Adapter | Bridge | Strategy |
|---|---|---|---|
| What it decouples | Two incompatible existing interfaces | An abstraction from its implementation | A client from a family of algorithms |
| When it's chosen | After the fact, to make two things work together | Up front, to plan for varying implementations | Up front, to plan for varying algorithms |
| Number of hierarchies | One adapter per pair | Two parallel hierarchies (abstraction + implementor) | One client, many strategy classes |
| Switchable at runtime? | Usually no — chosen at build time | Yes — the implementor reference can be swapped | Yes — the strategy reference can be swapped |
| UVM example | uvm_reg_adapter | uvm_reg + uvm_reg_frontdoor / uvm_reg_backdoor | Pluggable 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
Comments (0)
Leave a Comment