A block-level register model only ever needs to know about itself. A UART's reg_block declares a handful of registers starting at address zero, because on that bench the UART is the only thing with an address map. An SoC has dozens of blocks, sometimes several copies of the very same block, all sharing one physical address space - and the register model has to represent that whole space as one coherent thing, without touching a single line of any block's own register definitions.
16 min read | Intermediate Level
What You'll Learn
- Why a block-level register model has to stay relative to its own base address, never absolute
- How
add_submap()and a base address assemble several block-level register models into one SoC-level map - What breaks when the same IP block is instantiated more than once, and how to handle it correctly
- How the register model's mirror value stays in sync with real bus traffic, including writes issued by software
- Why the address map itself needs to come from the same source of truth as the RTL's address decode, C header files, and coverage all at once
Block-Level Registers Stay Relative
A well-written block-level reg_block never hardcodes where it will eventually sit in a larger system. Its registers are built at offsets relative to its own base - 0x00 for a control register, 0x04 for status, and so on - and that block's own reg_map is configured with a base address of zero:
class uart_reg_block extends uvm_reg_block;
`uvm_object_utils(uart_reg_block)
rand uvm_reg ctrl;
rand uvm_reg status;
rand uvm_reg baud_div;
virtual function void build();
ctrl = uart_ctrl_reg::type_id::create("ctrl");
status = uart_status_reg::type_id::create("status");
baud_div = uart_baud_div_reg::type_id::create("baud_div");
// ... each register's configure() and build() calls omitted ...
default_map = create_map("default_map", 0, 4, UVM_LITTLE_ENDIAN);
default_map.add_reg(ctrl, 'h00, "RW");
default_map.add_reg(status, 'h04, "RO");
default_map.add_reg(baud_div, 'h08, "RW");
endfunction
endclass
Nothing in this class knows, or should know, whether it ends up at 0x8000_0000 in one chip and 0x9000_2000 in another - or whether the SoC integrates one instance of it or four. That decision belongs entirely to whoever assembles the SoC-level model, which is exactly what keeps the block-level model reusable across every product it ships in.
Assembling the SoC-Level Map with add_submap()
An SoC-level register model is itself a reg_block, but instead of adding individual registers to its map, it adds other blocks' maps as submaps, each anchored at the address that block actually lives at in this chip:
class soc_reg_block extends uvm_reg_block;
`uvm_object_utils(soc_reg_block)
uart_reg_block uart0;
timer_reg_block timer0;
virtual function void build();
default_map = create_map("default_map", 0, 4, UVM_LITTLE_ENDIAN);
uart0 = uart_reg_block::type_id::create("uart0");
uart0.configure(this);
uart0.build();
default_map.add_submap(uart0.default_map, 'h8000_0000);
timer0 = timer_reg_block::type_id::create("timer0");
timer0.configure(this);
timer0.build();
default_map.add_submap(timer0.default_map, 'h8001_0000);
endfunction
endclass
A frontdoor access to uart0.ctrl now resolves to 0x8000_0000, and a frontdoor access to timer0.ctrl resolves to 0x8001_0000 + 0x00, purely from the base address handed to add_submap() - the UART's own register definitions never changed. If the next chip in the family wires the UART to a different base address, only that one add_submap() call needs to change.
uvm_reg_map also offers set_base_addr() to reposition an already-built map, and m_set_reg_offset() for repositioning one register at a time within a map. add_submap() with a base address at construction time covers the common case; the other two exist for cases that need adjusting after the fact.
When the SoC Has More Than One Copy of the Same Block
Plenty of SoCs instantiate the same IP more than once - two UARTs, four DMA channels built from one channel design, a CPU cluster with several identical cores. The block-level register model is still a single class, but the SoC-level model needs one independent register-model instance per hardware instance, each with its own base address.
Rather than naming each one by hand, the usual approach is an array of register-model handles, sized and populated in a loop - the same way an environment sizes its agent array from a configuration object:
uart_reg_block uart[];
virtual function void build();
default_map = create_map("default_map", 0, 4, UVM_LITTLE_ENDIAN);
// One register-model instance per physical UART, at the base
// address that instance actually decodes to on this chip
uart = new[cfg.num_uarts];
foreach (uart[i]) begin
uart[i] = uart_reg_block::type_id::create($sformatf("uart[%0d]", i));
uart[i].configure(this);
uart[i].build();
default_map.add_submap(uart[i].default_map, UART_BASE_ADDR[i]);
end
endfunction
UART_BASE_ADDR here is a per-instance base-address array, populated from wherever the SoC's address map actually comes from - the array itself is just a stand-in for that lookup. The point isn't the specific storage; it's that neither the number of instances nor their addresses are hardcoded into the loop.
Mistake: Reusing a single reg_block object for two hardware instances instead of constructing one per instance.
A register model is an object representing one physical set of registers. Pointing the same object at two base addresses, or trying to add its map as a submap twice under different offsets, doesn't produce two independent register mirrors - it produces one model that can't consistently represent either instance's current value.
Each hardware instance needs its own freshly constructed uart_reg_block, exactly the way the loop above constructs a distinct object for every entry in uart[].
Backdoor access needs the same per-instance treatment. Each register's hdl_path is normally set relative to its block's root, and that root - the actual RTL hierarchy path - has to be pointed at the correct instance for each copy:
foreach (uart[i]) begin
uart[i].default_map.set_base_addr(UART_BASE_ADDR[i]);
uart[i].set_hdl_path_root($sformatf("tb_top.dut.uart_inst%0d", i));
end
Without a distinct hdl_path_root per array entry, a backdoor read or write issued through uart[1]'s register model could resolve to uart_inst0's actual signals in the RTL hierarchy - a mismatch that a frontdoor-only test would never surface, since it only shows up the first time someone reaches for a backdoor peek or poke on an instance past the first.
Keeping the Mirror Honest: Prediction from the Reused Monitor
Every register object carries a mirror value - the register model's own record of what it currently believes is in that register.
Every access issued through uvm_reg::write() or uvm_reg::read() updates that mirror automatically, but at SoC level plenty of the interesting register writes don't come from the SV sequence at all - they come from software running on the CPU, through a passive agent that's only watching the bus, not driving it. Left alone, the register model's mirror would drift out of sync the moment a C test writes a register directly.
The fix reuses exactly the passive-monitor setup from earlier in this chapter. A uvm_reg_predictor subscribes to the same agent's analysis port, translates each observed bus transaction into the register or field it corresponds to using the map, and updates that register's mirror value to match - without ever driving anything itself:
class soc_env extends uvm_env;
`uvm_component_utils(soc_env)
soc_reg_block regmodel;
ahb_agent ahb_agt;
uvm_reg_predictor #(ahb_bus_item) ahb_predictor;
ahb_reg_adapter ahb_adapter;
virtual function void build_phase(uvm_phase phase);
super.build_phase(phase);
// ... regmodel and ahb_agt construction omitted ...
ahb_predictor = uvm_reg_predictor #(ahb_bus_item)::type_id::create("ahb_predictor", this);
ahb_adapter = ahb_reg_adapter::type_id::create("ahb_adapter");
endfunction
virtual function void connect_phase(uvm_phase phase);
// Feed the predictor from the same passive monitor already
// watching this bus for protocol checking and coverage
ahb_predictor.map = regmodel.default_map;
ahb_predictor.adapter = ahb_adapter;
ahb_agt.mon.item_collected_port.connect(ahb_predictor.bus_in);
endfunction
endclass
The ahb_reg_adapter here is the same uvm_reg_adapter subtype a frontdoor sequence already needs to translate a register-level operation into a bus-level one, and vice versa. Wiring it into the predictor as well means writing the translation logic once and using it for both directions - sequences driving traffic, and traffic updating the mirror.
With the predictor connected, a scoreboard or a later sequence step can read regmodel.uart[0].ctrl.get_mirrored_value() and trust that it reflects the last access this environment actually observed on the bus, regardless of whether a C test or an SV sequence issued it.
What Happens at an Address With No Register
Not every address in a block's decoded range necessarily has a register mapped to it - gaps are common, and a bug that computes the wrong address should be caught, not silently accepted. uvm_reg_map::get_reg_by_offset() looks up whatever register, if any, is mapped at a given address and returns null when nothing is there:
uvm_reg accessed_reg = regmodel.default_map.get_reg_by_offset(observed_addr);
if (accessed_reg == null) begin
`uvm_error("UNMAPPED_ACCESS",
$sformatf("Bus access at 0x%0h did not hit any known register", observed_addr))
end
Mistake: Two submaps quietly overlapping because of a typo in a base address.
An address-map source with a copy-paste error - two peripherals both anchored at the same base, or ranges that overlap by a few bytes - doesn't always fail the RTL build, since address decode logic can tolerate overlaps that simply favor whichever block wins arbitration.
The register model, though, will resolve an access in that overlapping range to whichever submap was added first, silently mispredicting for the other one. Checking the address map for overlapping ranges before it ever reaches the register model catches this long before it shows up as a confusing mirror mismatch.
Registers That Change Underneath the Mirror
Some registers don't wait for software to touch them. An interrupt-pending bit set by real hardware the moment an interrupt condition occurs, or a status field that clears itself the instant it's read, changes value independently of any access the register model initiated. uvm_reg_cbs callbacks - post_read(), pre_write(), and their counterparts - are the mechanism for teaching the register model about that kind of side effect, so a read-to-clear status register's mirror reflects the clear instead of assuming the value it read is still sitting there afterward.
This matters more at SoC level than it did at block level, precisely because an interrupt-pending bit set by hardware is no longer a scripted stimulus event - it happens whenever the real cross-block condition that causes it actually occurs, on its own schedule.
Where the Address Map Itself Comes From
Hand-transcribing every block's base address into the SoC-level register model's build function works until the address map changes - and on any SoC with more than a couple of derivative chips, it changes often.
Most teams instead generate the SoC-level register model, or at least the table of base addresses it consumes, from the same address-map source that also drives the RTL's own address decode logic - commonly an IP-XACT description or an internally maintained spreadsheet or YAML file that the build flow treats as the single source of truth.
IP-XACT is an IEEE standard (IEEE 1685) for describing exactly this kind of thing in one machine-readable file: a block's registers, fields, and address layout. A tool reads that one file and generates the RTL's register decode logic, the UVM register model used here, and even software header files or device drivers - so the same description feeds hardware, verification, and software instead of three people transcribing the same address map by hand into three different places.
This is the same principle a configuration-driven testbench already needs for feature flags and channel counts: whatever describes the chip's configuration to the RTL build should be the same thing that configures the testbench, not a second file that has to be kept in sync by hand. An address map is one more field that belongs in that shared source rather than typed twice.
Generating the register model also keeps the hdl_path_root values from the earlier section correct automatically - a generator that already knows an SoC instantiates uart_inst0 and uart_inst1 at specific hierarchy paths can emit both the base addresses and the backdoor paths from the same pass, instead of leaving a second place for the two to quietly drift apart.
The same shared source is usually where a C test's own register addresses come from too.
A status address a bring-up test writes to directly, as a plain volatile unsigned int *, and the base address add_submap() anchors that same register at in the SV register model, hold together only because both were generated - or at least checked - against one address map, not typed independently into a linker script and a testbench by two different people.
The same generated source can also drive which built-in coverage models get enabled per register, through uvm_reg::include_coverage() with models like UVM_CVR_REG_BITS or UVM_CVR_FIELD_VALS. Gating that the same way the configuration object gates agent construction keeps a smaller chip variant's register-access coverage scoped to registers it actually has, rather than reporting holes for registers a smaller SKU never built.
Common Mistakes
Mistake: Letting an absolute address leak into a block-level register definition.
A register built with its final chip address baked in works exactly once - for whichever chip happened to be the first one that block shipped in.
The moment a second product places that block somewhere else, or the block appears twice in the same chip, that hardcoded address is wrong, and it's wrong in a way that only surfaces once someone tries to reuse the model, not when it was written.
Mistake: Assuming one register model instance can represent every hardware instance of a block.
Each physical instance of an IP block has its own storage in the DUT, and the register model exists to mirror that storage's expected value. One model object can only mirror one instance; a second hardware instance needs a second model object, constructed and based independently, not the same object reused at a different offset.
Mistake: Maintaining the address map by hand in a second location, separate from what the RTL build actually uses.
An address map typed once into the register model and separately into whatever generates the RTL's address decode logic will eventually disagree with it - usually discovered only when a frontdoor register access lands on the wrong block. Deriving both from one shared source removes the chance for that kind of drift entirely.
Mistake: Assuming the mirror value is correct without a predictor connected to software-driven writes.
A register model that only updates its mirror through its own write() and read() calls has no way of knowing a C test just wrote that same register directly over the bus. Without a predictor observing the passive monitor's analysis port, the mirror silently falls out of sync the first time software touches a register the SV environment also cares about.
Summary
In this article, you learned:
- A block-level register model stays relative to its own base address, so the same model can be reused unmodified across chips and across multiple instances within one chip
add_submap()assembles an SoC-level register model from block-level ones, anchoring each block's map at the address it actually occupies in this chip- Multiple instances of the same IP need independently constructed register-model objects, each with its own base address and its own
hdl_path_rootpointing at the correct RTL instance - A
uvm_reg_predictorconnected to the reused passive monitor's analysis port keeps the register model's mirror in sync with every bus access, including writes software issues directly get_reg_by_offset()anduvm_reg_cbscallbacks catch unmapped or overlapping addresses and model registers that change on their own, both more likely to matter once real cross-block hardware behavior is driving the bus- The address map itself holds up best when it's generated from one shared source that also feeds the RTL's address decode logic, C-test header addresses, and register-access coverage - rather than transcribed by hand into each of those separately