A chip-level test needs to load 4,096 coefficient words before the first frame, and the frontdoor takes 40 microseconds of simulated time per word through two bridges. A backdoor load takes zero. So the team writes HDL paths for every register, and it works on the RTL. Then the IP vendor delivers the encrypted netlist, every path lookup fails, and the regression goes red overnight. The register model was right; the way it reached the hardware was not portable.
A backdoor access reads or writes a register's storage directly: no bus transfer, no adapter, no simulated time. It is the maintenance hatch at the back of a building, used for jobs the front desk would make slow: preloading memories, checking a value without disturbing bus timing, reading a register that has side effects on a frontdoor read, or setting up a state the frontdoor cannot reach quickly.
There are two ways to build the hatch. HDL paths give each register the string name of its storage in the design, and the library reads and deposits through the uvm_hdl_* DPI routines. A user backdoor is a class derived from uvm_reg_backdoor that you write yourself, reaching the storage any way you like: a bound interface, a C model, a memory's own access task. Both end the same way: the result is predicted into the mirror.
With HDL paths, the block gets a root and each register gets one or more slices: a signal name, the bit offset where it lands in the register, and its width. A register stored in two places (say bits 15:0 in one flop and 31:16 in another) gets two slices. For the DMA it would look like this:
// snippet: HDL paths for the DMA (not used by the bench, see below)
// In the env, before regmodel.build(): the block's root in the design.
regmodel.configure(null, "top.dut"); // or regmodel.add_hdl_path("top.dut")
// In dma_reg_block::build(), one slice per stored signal: name, offset, size.
key.add_hdl_path_slice("key", 0, 32);
len.add_hdl_path_slice("len", 0, 16);
count.add_hdl_path_slice("count", 0, 16);
// STATUS is three flops in one register: three slices.
status.add_hdl_path_slice("busy", 0, 1);
status.add_hdl_path_slice("done", 1, 1);
status.add_hdl_path_slice("err", 2, 1);The full path is the concatenation of every level's path: block roots, register files, then the slice name, so top.dut.key. A kind argument (default "RTL") lets one model carry several path sets, for example "GATES" for the netlist, chosen per access. uvm_reg::backdoor_read() and backdoor_write() then call uvm_hdl_read and uvm_hdl_deposit for each slice. Two more DPI routines exist for tests rather than the model: uvm_hdl_force and uvm_hdl_release hold a value against the design's own drivers.
Three things break HDL paths, and each is a reason the bench does something else:
No DPI. The course's Verilator runs compile with UVM_NO_DPI, which turns every uvm_hdl_* routine into a fatal stub ("uvm_hdl DPI routines are compiled off"). Commercial simulators also need signal visibility switched on, and each lookup by string costs time.
Encrypted or synthesised RTL. Internal names are hidden or renamed, as in the opening story.
Storage that is not a plain register. A memory with ECC stores data plus check bits: a raw deposit of the data alone leaves the check bits wrong, and the first frontdoor read reports an ECC error. Scrambled or compressed storage has the same problem.
The DMA bench binds a small read-only interface into every dma_dut, and the backdoor class reads through it. bind attaches the interface inside the DUT's scope without editing the design, so it sees key, len and count by their local names:
// snippet: from the course DMA bench
// A read-only window into the DMA's internal registers, bound into every
// dma_dut. The register model's backdoor reads through it.
interface dma_bd_if (input logic [31:0] key, input logic [15:0] len, input logic [15:0] count);
endinterface// snippet: the bind statement in dma_ral_tb.sv
bind dma_dut dma_bd_if bd (.key(key), .len(len), .count(count));// snippet: from the course DMA bench
// A user-defined backdoor: reads KEY, LEN and COUNT straight from the DUT
// through the bound dma_bd_if, with no bus traffic and no simulation time.
// It needs no HDL-path DPI, so it works on every simulator. Writes are refused.
class dma_reg_backdoor extends uvm_reg_backdoor;
virtual dma_bd_if vif;
function new(string name = "dma_reg_backdoor");
super.new(name);
endfunction
virtual function void read_func(uvm_reg_item rw);
uvm_reg r;
if (!$cast(r, rw.element)) begin
rw.status = UVM_NOT_OK;
return;
end
rw.status = UVM_IS_OK;
case (r.get_name())
"key": rw.value[0] = vif.key;
"len": rw.value[0] = vif.len;
"count": rw.value[0] = vif.count;
default: rw.status = UVM_NOT_OK;
endcase
endfunction
virtual task write(uvm_reg_item rw);
`uvm_warning("BD_RO", "this backdoor is read-only")
rw.status = UVM_NOT_OK;
endtask
endclassThree choices in it are worth copying. It overrides read_func(), the zero-time function the library's read() task calls, so it never consumes time. It sets status on every path, including UVM_NOT_OK for a register it does not know. And it refuses writes with a warning, because the bound interface only has inputs: an honest backdoor says what it cannot do. The env attaches it to the three registers that have storage the interface can see:
// snippet: from dma_ral_env in the course DMA bench (dma_ral_tb.sv)
bd = new("bd");
bd.vif = bd_vif;
regmodel.key.set_backdoor(bd);
regmodel.len.set_backdoor(bd);
regmodel.count.set_backdoor(bd);The same pattern solves the other two problems. For encrypted IP, the vendor usually provides a debug port or a bound probe; a user backdoor wraps it. For an ECC memory, the backdoor's write computes the check bits before depositing and its read checks and corrects them, so the model sees data and the storage stays consistent. uvm_reg_backdoor also has is_auto_updated() and wait_for_change(), which let a backdoor watch storage and update the mirror whenever the hardware changes it; that is the one way to keep a volatile field's mirror live.
Call | Reaches the DUT | Applies field policies? | Prediction afterwards |
|---|---|---|---|
| Reads | No: an RC field is not cleared | Mirror and desired = value read |
| Writes v as is | No: a W1C bit gets the raw 1 | Mirror and desired = v |
| Reads, then writes back if a read side effect applies | Yes | As a frontdoor read |
| Reads, computes the policy result, writes it | Yes | As a frontdoor write |
Both peek() and poke() predict. In the 2020 library both skip the prediction when the status is UVM_NOT_OK, with a PREDICT_NOK warning; UVM 1.2's do_predict has no such check. Lesson 1's log shows the bench's backdoor refusing a poke() that way. Lesson 5 showed the cost of the prediction: a peek before a check overwrites the evidence.
The bench's job test ends by comparing KEY's mirror, built from the bus traffic, with a backdoor peek of the flip-flop:
+UVM_TESTNAME=dma_ral_job_test
UVM_INFO dma_ral_tb.sv(346) @ 425000: uvm_test_top [RAL] STATUS=2: done=0 err=0
UVM_INFO dma_ral_tb.sv(355) @ 545000: uvm_test_top [RAL] KEY mirror=cafef00d, backdoor peek=cafef00d, status=UVM_IS_OK
UVM_INFO dma_tb.sv(411) @ 645000: uvm_test_top.env.sb [SB] 6 words matched, 0 mismatched
UVM_ERROR : 0
UVM_FATAL : 0backdoor peek=cafef00d, status=UVM_IS_OK: the user backdoor read the DUT's KEY through dma_bd_if with no APB transfer, at the same simulation time as the line before it. This is the cheapest end-to-end check the register layer offers: if the frontdoor path (adapter, address map, predictor) had any error, the mirror and the peek would disagree. Note the order in the test: it saves the mirror into exp_key before the peek, and checks st too. peek() predicts, so a compare against get_mirrored_value() after the peek would compare the peeked value with itself and could never fail.
Backdoor accesses take no time, and that is exactly what makes them race the clock. A scratch test for this lesson writes KEY through the frontdoor and peeks it on the very next line:
scratch run: +UVM_TESTNAME=s_bd_race_test
UVM_INFO sacc_tb.sv(434) @ 75000: uvm_test_top [RACE] peek right after write() returns: 00000000 (mirror now 00000000)
UVM_INFO sacc_tb.sv(437) @ 75000: uvm_test_top [RACE] peek after #0: 00000000
UVM_INFO sacc_tb.sv(439) @ 76000: uvm_test_top [RACE] 1ns later, before peeking: mirror cafe0001
UVM_INFO sacc_tb.sv(441) @ 76000: uvm_test_top [RACE] peek 1ns later: cafe0001 (mirror now cafe0001)The write had returned, yet the peek read 0. The APB driver returns on the clock edge where the DUT samples the write, and the DUT's key <= pwdata is a nonblocking assignment: it lands in the NBA region of that time step, after the test's next statements have run. #0 does not help, because it only moves to the inactive region, still before NBA. One nanosecond later the flop holds the new value. The peek also predicted the stale 0 into the mirror; the explicit predictor overwrote it moments later, but code that read the mirror in between would have seen 0.
Writes race the same way in reverse. A poke() that deposits on an active clock edge can be overwritten by the design's own flop update in the same time step, so the deposit seems to vanish. The rules: do backdoor accesses away from the active edge (after the edge, once NBAs have settled, or on the opposite edge), never immediately after a frontdoor access to the same register, and use force and release (not deposit) for a value the design must not change.
write(st, 1, UVM_BACKDOOR) to clear it. What does each do to the DUT and the mirror?The poke() deposits the raw 1, so the bit is set in the DUT, and the mirror predicts 1. The backdoor write() applies the W1C policy: it reads 1, computes 1 & ~1 = 0 and deposits 0, clearing the bit, and the mirror predicts 0. The poke is the only way to set a W1C bit from software.
Add a second path set with kind = "GATES" (the netlist's names) and select it per access, or attach a user backdoor that reaches the storage another way (a bound probe or the vendor's debug port). Both keep the model; only the route changes.
read_func() and not read()?read() is a task that calls do_pre_read(), read_func() and do_post_read(). Overriding the zero-time function keeps the pre- and post-read callbacks working and guarantees that a backdoor read never consumes simulation time.
Next: the backdoor reached registers without the bus. User-Defined Frontdoors reaches registers the bus cannot address directly: indirect tables, paged banks and serial devices.