Register abstraction layer¶
Driving a register map as raw bus writes — co_await apb.write(0x04, 0x7) —
encodes offsets and bit positions into every sequence, and every one of them
has to change when the map does. The register abstraction layer (RAL) replaces
those literals with typed handles, so the same access reads as
co_await regs.control.enable.write(1): the handle knows its own offset,
fields, and reset value, and tracks what the DUT’s registers should hold.
RAL is an optional component in cpptb_vc. It turns a register contract into
typed C++ handles, tracks expected register state, and sends explicit accesses
through a protocol-neutral master or a user-supplied backdoor.
RAL is not part of the core scheduler or DUT signal API. A test that only needs
dut.path.signal.get(), set(), clocks, and triggers does not include or pay
for it. The component does not start clocks, reset the DUT, advance time, or
perform a transaction unless the test calls an operation that requires one.
Architecture at a glance¶
SystemRDL or IP-XACT
|
v
PeakRDL cpptb exporter ---> generated typed C++ model
|
test sequence ---> register/field handles
| |
| +--> RegisterBackdoor --> DUT hierarchy
+--> MemoryMappedMaster --> APB, AXI-Lite, Wishbone, ...
The generated header contains register and field names, addresses, widths,
access policies, reset metadata, and typed handles. It contains no protocol
driver and no simulator binding. The authored test chooses the concrete
MemoryMappedMaster, optional RegisterBackdoor, base address, clock, reset,
and scheduling policy.
Authored by the user |
Generated |
Supplied by |
|---|---|---|
SystemRDL/IP-XACT contract |
Typed block, register, field, and memory members |
Register state and access semantics |
Bus component or custom master |
Descriptors and logical paths |
Frontdoor and backdoor interfaces |
Optional hierarchical backdoor |
Reset and access-policy metadata |
Prediction, update, mirror, and diagnostics |
Test sequences and checks |
Relocatable block construction |
Per-register access serialization |
First model¶
Describe the register block in SystemRDL. The small APB register-file example uses the contract at
examples/apb_regfile/registers.rdl.Follow Generate a register model to produce a C++ header under the build directory.
Construct the generated model with a
MemoryMappedMasterand optional base address or backdoor.Use the typed members from an ordinary test sequence:
#include "generated/registers.hpp"
Task<void> configure_device(Dut dut, TestContext& test) {
ApbMaster master{ApbBus{dut.clk, dut.psel, dut.penable, dut.pwrite,
dut.paddr, dut.pwdata, dut.prdata,
dut.pready, dut.pslverr}};
generated_registers::RegModel<decltype(master)> regs{
test, master, 0x4000'0000};
regs.control.enable.stage(1);
regs.control.mode.stage(3);
const auto update = co_await regs.control.update();
test.require_eq("control update", update.transport.status,
MemoryStatus::Okay);
const auto status = co_await regs.status.read();
test.require_eq("status read", status.transport.status,
MemoryStatus::Okay);
test.expect_eq("sampled", regs.status.sampled.mirrored(),
status.data >> 8u & 0xffu);
}
The exact constructor type and member names come from the generated header. The APB register-file provides the smallest authored contract and bus comparison. The secworks AES oracle is the complete end-to-end generated-model integration against an open-source core.
Frontdoor and backdoor operations¶
Assume regs was constructed with both a bus master and the
generated backdoor adapter. Each operation
selects its own access path; the model does not have a global “frontdoor mode”
or “backdoor mode.” The staged and mirrored vocabulary used below is defined
in Register model semantics.
Register frontdoor¶
write() sends the exact register value over the bus. read() samples it
through the same bus and returns the complete logical register value:
co_await regs.control.write(0x0000'0007);
const auto control = co_await regs.control.read();
test.expect_eq("control", control.data, 0x0000'0007u);
Use staged state when the model should encode access policy such as W1C, W1S, or toggle fields before conditionally writing the register:
regs.status.pending.stage(0x03);
co_await regs.status.update();
Field frontdoor¶
A field can be written and read directly. write() applies staged state
through the parent register’s update() and issues a whole-register bus write
when needed. read() performs a whole-register bus read and extracts the named
field:
co_await regs.control.enable.write(0);
const auto enable = co_await regs.control.enable.read();
test.expect_eq("enable", enable.data, 0u);
Set several fields first and call the parent register’s update() once to
combine them into one conditional register write:
regs.control.enable.stage(1);
regs.control.mode.stage(generated_registers::mode_e::ACTIVE);
co_await regs.control.update();
A field read is a whole-register frontdoor read followed by field extraction. It therefore predicts read side effects on readable sibling fields as well:
const auto enabled = co_await regs.control.enable.read();
// Any read-clear siblings in regs.control have now been predicted as cleared.
Register and field backdoor¶
peek() and poke() inspect or deposit the complete register through its HDL
path without a bus transaction or implicit delay:
regs.control.poke(0x0000'0007);
test.expect_eq("control storage", regs.control.peek(), 0x0000'0007u);
Field backdoor access is an explicit read-modify-write of the parent register. This keeps one clear storage operation and preserves adjacent fields:
constexpr uint64_t kPendingMask = 0xffull;
auto status = regs.status.peek();
status = (status & ~kPendingMask) | 0x03ull;
regs.status.poke(status);
test.expect_eq("pending mirror", regs.status.pending.mirrored(), 3u);
Mix frontdoor and backdoor operations¶
The same model can choose a different path for every operation. This is useful for configuring through the real bus, checking storage directly, or preparing an otherwise expensive DUT state before returning to normal bus traffic:
co_await regs.control.write(0x0000'0007); // Frontdoor setup.
test.expect_eq("stored control", regs.control.peek(), 7u); // Backdoor check.
regs.status.poke(0x0000'0080); // Backdoor setup.
const auto status = co_await regs.status.read(); // Frontdoor sample.
test.expect_eq("visible status", status.data, 0x80u);
read(), write(), update(), and mirror() are frontdoor operations.
peek() and poke() are backdoor operations. stage(), predict(),
and reset() only change model state. Successful frontdoor and backdoor
operations update the same staged and mirrored state, so they may be
interleaved without maintaining separate models.
The two paths also have different simulation timing. A frontdoor operation
drives bus pins, and those drives queue and flush at the ReadWrite point like
every port set() under the write model. A
backdoor peek() or poke() is an immediate hierarchy deposit — it takes
effect the instant it is called, with no queue and no phase. The interleave
above is safe because each frontdoor co_await completes the whole bus
transaction before the next line runs.
Register model semantics¶
The operations above update model state as well as the DUT. This section defines that state and the rules the model applies to it.
Three values to keep straight¶
The RAL follows the useful distinction in the
Accellera UVM register layer
between requested state, predicted state, and hardware state. These values are
intentionally named rather than hidden behind an ambiguous get():
Value |
Meaning |
Changes when |
|---|---|---|
Staged |
State the test wants writable fields to reach |
|
Mirrored |
State the model currently predicts is in the DUT |
Successful reads/writes, |
DUT |
Actual hardware storage |
RTL behavior, frontdoor transactions, or a backdoor operation |
stage() changes only model state. It performs no bus access. update()
does nothing when staged and mirrored values already match; otherwise it
encodes the write needed by each field policy and performs a frontdoor write.
mirror() performs a frontdoor read, optionally checks the previous mirror,
then updates prediction. This is the same conceptual separation described by
UVM RAL, but cpptb uses explicit staged() and mirrored() names.
Known and unknown model bits¶
A numeric model value is accompanied by a validity mask. A 1 means that bit
has a known predicted value; a 0 means the model has not established its
value yet:
const auto value = regs.status.mirrored();
const auto known = regs.status.mirrored_valid_mask();
test.expect_eq("known reset bits", known & 0x0000'ffffu, 0x0000'ffffu);
test.expect_eq("known value", value & known, expected & known);
staged_valid_mask() and mirrored_valid_mask() are available on both
register and field handles. Field masks are shifted down to the field width, so
an eight-bit field reports 0xff when every field bit is known.
At construction and after reset(), validity comes from the generated
SystemRDL reset mask. A field with no reset is unknown; it is not silently
treated as zero. Successful frontdoor reads, ordinary writes, direct
prediction, and peek()/poke() establish validity for the bits they predict.
Read and write side effects propagate validity bit by bit. A user-defined
ruser or wuser effect leaves the resulting bits unknown until a custom
prediction policy is supplied.
mirror(MirrorCheck::Enabled) compares only readable, nonvolatile bits that
were valid before the read. The first mirror of a register with no reset learns
the value without reporting a false mismatch. A later mirror checks that learned
prediction. stage() marks only the selected writable register or field
bits as valid. update() rejects a full-register transaction if another
writable bit still has unknown staged state, with a path-qualified diagnostic;
read, predict, or set the complete writable value first.
Choosing an operation¶
The right operation follows from the intent of the test:
Intent |
Operation |
DUT access |
Model effect |
|---|---|---|---|
Send an exact bus write |
|
Frontdoor write |
Predicts write effects after success |
Read through the bus |
|
Frontdoor read |
Predicts sampled value and read effects |
Request a future state |
None |
Changes staged writable fields only |
|
Apply requested state if needed |
Conditional frontdoor write |
Converges staged and mirrored state |
|
Read and compare with prediction |
Frontdoor read |
Checks then updates the mirror |
|
Account for an observed transaction |
None |
Applies direct, read, or write prediction |
|
Inspect or deposit through hierarchy |
|
Backdoor |
Updates the mirror immediately |
Restore model reset state |
None |
Restores reset values, validity masks, and write-once state |
Frontdoor operations return a small aggregate response. data is the complete
logical register value, transport is the last bus response,
transfers_completed counts successful transfers, and failed_address names
the first failed transfer when present. okay() is true only when every
required transfer completed. The test decides whether a timeout or bus error
is fatal, nonfatal, or expected. Model-only operations do not advance
simulation time.
Register policy examples¶
Generated member names stay consistent across access policies. The policy controls which operations are legal and how a successful access updates the mirror:
reg {
field { sw = rw; hw = r; reset = 0; } enable[0:0];
field { sw = rw; hw = r; onwrite = woclr; } pending[15:8];
field { sw = r; hw = w; volatile = true; } level[23:16];
field { sw = r; hw = w; onread = rclr; } events[31:24];
} status @ 0x00;
reg {
field { sw = rw1; hw = r; } key[31:0];
} unlock @ 0x04;
// Ordinary RW state and a W1C field are combined into one bus update.
regs.status.enable.stage(1);
regs.status.pending.stage(0);
co_await regs.status.update();
// Volatile RO state is sampled but excluded from mirror comparisons.
const auto level = co_await regs.status.level.read();
test.expect("level in range", level.data <= 0xffu);
// A field read also predicts read-clear behavior in the parent register.
const auto events = co_await regs.status.events.read();
test.expect_eq("events cleared", regs.status.events.mirrored(), 0u);
// A second successful write to this rw1 field is rejected with its full path.
co_await regs.unlock.key.write(0x51f1'5eadu);
The model tracks staged() and mirrored() values and validity, reset values,
volatile fields, read-clear/read-set behavior, write-one/zero set, clear, and
toggle behavior, and write-once access. Concurrent frontdoor accesses to one
register are serialized. mirror() compares known, readable, nonvolatile
fields before applying read side effects; write-only fields are neither sampled
nor compared.
update() warns when a requested state cannot be reached through a field’s
write policy. For example, a write-one-set field cannot clear an already-set
bit. The transaction still occurs, and staged state converges to the predicted
hardware state so repeated update() calls do not loop forever.
Encoded fields use generated C++ enums, split registers issue ordered
frontdoor transfers, and registers wider than 64 bits use Bits<Width>. See
Typed field enumerations,
Split frontdoor accesses, and
Arbitrary-width registers for those value forms.
Split frontdoor accesses¶
regwidth is the logical register width and accesswidth is the width of each
bus transfer. A 32-bit register with a 16-bit access width therefore performs
two explicit transactions:
const auto write = co_await regs.accumulator.write(0x1122'3344);
test.require("complete split write", write.okay());
test.expect_eq("write transfers", write.transfers_completed, 2u);
const auto read = co_await regs.accumulator.read();
test.require("complete split read", read.okay());
test.expect_eq("assembled value", read.data, 0x1122'3344u);
For little-endian frontdoors, the low-address transfer carries the least
significant register word. For big-endian frontdoors, it carries the most
significant word. The generated setting is selected once with
--register-endianness; every generated descriptor, explicit frontdoor
operation, and passive-predictor transfer uses the same order.
Split operations stop at the first failed transport response. Successful
write chunks are predicted immediately, while unissued or failed chunks retain
their previous value and validity. A partially successful read returns the
assembled successful chunks in data and marks them in valid_mask; mirror
comparison and prediction cover only those valid chunks. The framework does
not retry, roll back, or insert a delay.
Arbitrary-width registers¶
Generated registers wider than 64 bits use Bits<Width> and retain the same
frontdoor vocabulary. The logical value is divided into explicit bus-width
transfers; authored code still reads and writes one register value:
Bits<256> command;
command.set_bit(3, true);
command.set_bit(192, true);
const auto write = co_await regs.command.write(command);
test.require("complete wide write", write.okay());
const auto read = co_await regs.command.read();
test.expect_eq("wide command", read.data, command);
Bits<17> opcode;
opcode.set_bit(16, true);
regs.command.opcode.stage(opcode);
co_await regs.command.update();
WideRegisterHandle<Width, Master> and its generated typed field handles
support reset, staged and mirrored values, validity masks, read/write effects,
frontdoor read(), write(), update(), and mirror(). Reset values and
masks are emitted as complete word arrays, so reset state is not truncated at
64 bits. Each individual frontdoor transfer must still be byte aligned, divide
the register width, be at most 64 bits, and fit the transport’s data type.
Generated HDL backdoors, passive prediction, and register-backed memory
elements use the same Bits<Width> representation, so no part of the logical
value is truncated to the transport width. A model that contains wide
registers exposes typed for_each_register() traversal but does not expose the
homogeneous narrow-only register_handles() span.
Multiple address maps and custom frontdoors¶
A generated handle has one logical staged/mirrored state but can be accessed
through several named bus views. Construct a RegisterAddressMap with the
master and base for that view, then pass it explicitly to the operation:
RegisterAddressMap primary{"primary", apb, 0x4000'0000};
RegisterAddressMap debug{"debug", debug_apb, 0x8000'0000};
debug.route(regs.control.descriptor(), 0x40); // Alias in the debug view.
debug.route(regs.buffer.descriptor(), 0x200);
co_await regs.control.write(0x1, primary); // Generated primary offset.
co_await regs.control.write(0x2, debug); // 0x8000'0040.
co_await regs.buffer.write(4, words, debug);
The map may use another instance of the same master type, so primary and debug
traffic can travel through different components. Register and field
read/write/update/mirror, narrow or wide memory scalar/bulk operations, and
address conversion accept an explicit map. Omitting it uses the handle’s
original master and generated address. Backdoor operations never take a map:
they name physical RTL storage, not a software address view.
An exceptional access procedure can replace the normal master call for one
descriptor. Implement RegisterFrontdoor<Master> or
RegisterMemoryFrontdoor<Master> and attach it to that map route:
class UnlockFrontdoor : public RegisterFrontdoor<Master> {
public:
Task<write_response_type> write(
Master& bus, const RegisterDescriptor& reg,
write_request_type request) override {
co_await bus.write(unlock_request());
co_return co_await bus.write(request);
}
Task<read_response_type> read(
Master& bus, const RegisterDescriptor& reg,
read_request_type request) override {
co_return co_await bus.read(request);
}
};
UnlockFrontdoor unlock;
debug.route(regs.protected_control.descriptor(), 0x80, &unlock);
co_await regs.protected_control.write(value, debug);
The custom frontdoor receives the descriptor and fully resolved request, so it can perform an unlock, indirect-index, mailbox, or other project-specific procedure without changing the generated handle API. Map names and complete logical paths are included in routing and overflow diagnostics.
Typed field enumerations¶
A SystemRDL field with an encode property is generated as a C++ enum class
and a typed field handle. Normal testbench code uses the symbolic value:
enum mode_e {
IDLE = 3'b000;
ACTIVE = 3'b011;
DIAGNOSTIC = 3'b111;
};
field {
sw = rw;
encode = mode_e;
} mode[2:0];
regs.control.mode.stage(registers::mode_e::ACTIVE);
co_await regs.control.update();
const auto sampled = co_await regs.control.mode.read();
test.expect_eq("active mode", sampled.data, registers::mode_e::ACTIVE);
The generated enum participates in failure-only diagnostic formatting, so a
failed comparison names mode_e::ACTIVE instead of printing only 3.
Hardware can still return a reserved encoding. The typed handle preserves that
underlying value rather than throwing; an unknown value formats numerically.
Use the explicit raw handle for protocol-negative tests or reserved values:
co_await regs.control.mode.raw().write(0x5);
const auto reserved = co_await regs.control.mode.raw().read();
test.expect_eq("reserved encoding", reserved.data, 0x5u);
raw() is an escape hatch, not a second model: typed and raw operations share
the same staged value, mirror, validity masks, access policy, frontdoor, and
lock. Encoded fields are currently limited to 64 bits because C++ enum
underlying types cannot represent a wider value.
User-defined read and write effects¶
SystemRDL ruser and wuser effects deliberately require project policy.
Implement RegisterUserEffectPolicy once and pass it to the generated model:
class DeviceEffects : public RegisterUserEffectPolicy {
public:
bool encode_write(const RegisterUserEffectBitContext& bit) override {
return bit.previous_valid ? bit.previous != bit.value : bit.value;
}
RegisterUserEffectBitResult predict_write(
const RegisterUserEffectBitContext& bit) override {
return {.value = bit.previous != bit.value,
.valid = bit.previous_valid};
}
RegisterUserEffectBitResult predict_read(
const RegisterUserEffectBitContext& bit) override {
return {.value = !bit.value, .valid = true};
}
// Preferred packed overrides for fields up to 64 bits.
uint64_t encode_write_field(
const RegisterUserEffectFieldContext& field) override {
return field.previous ^ field.value;
}
RegisterUserEffectFieldResult predict_write_field(
const RegisterUserEffectFieldContext& field) override {
return {.value = field.previous ^ field.value,
.valid_mask = field.previous_valid_mask};
}
RegisterUserEffectFieldResult predict_read_field(
const RegisterUserEffectFieldContext& field) override {
const uint64_t mask = field.field_descriptor.width == 64
? ~uint64_t{0}
: (uint64_t{1} <<
field.field_descriptor.width) - 1;
return {.value = ~field.value & mask, .valid_mask = mask};
}
};
DeviceEffects effects;
generated_registers::RegModel regs{test, apb, effects, 0x4000'0000};
The packed field callbacks are the normal high-throughput path for fields up to
64 bits. Their context contains the complete register and field descriptors,
previous value and validity mask, and requested or sampled value. The default
packed implementations call the bit callbacks, preserving existing policies
and supporting effects that genuinely differ per bit. encode_write_field()
controls the bus value used by update(). predict_write_field() and
predict_read_field() control the resulting mirrored value and validity.
Ordinary fields do not invoke the virtual policy. If a generated user effect
has no policy, its affected bits become explicitly unknown instead of silently
assuming hardware behavior.
Hierarchical backdoor access¶
When the SystemRDL contract supplies standard hdl_path or
hdl_path_slice properties, the exporter emits an optional typed adapter:
auto backdoor = peripheral_regs::make_backdoor<decltype(master)>(dut);
peripheral_regs::RegModel regs{test, master, base_address, &backdoor};
regs.control.poke(0xa5a5'5a5a);
test.expect_eq("control storage", regs.control.peek(), 0xa5a5'5a5au);
The adapter calls the generated Dut::cpptb_signal<"path">() lookup at compile
time. There is no runtime string search, and hierarchy discovery emits only the
get and deposit operations instantiated by the adapter. A frontdoor-only
test that does not use the adapter adds no backdoor transport hooks.
peek() and poke() do not add a delay. poke() is an HDL deposit, not a
persistent force; RTL may overwrite it on a later evaluation. Both operations
immediately predict the returned or deposited value. Tests that need to
observe subsequent RTL behavior must author the corresponding ReadOnly{},
edge, or Delay{...} explicitly.
A register-level path maps the whole register. Field-level slices are assembled
into their logical bit positions. One hdl_path_slice entry maps an entire
field; multiple entries must provide exactly one path per field bit in
MSB-to-LSB order. Ambiguous lists fail generation. Missing adapters and
incomplete paths produce diagnostics containing the full logical register
path.
RegisterBackdoor<Data> remains replaceable for projects that need a custom
simulator mechanism or nonstandard storage policy.
Passive prediction and access coverage¶
Both facilities below consume the same passive bus monitor: a predictor keeps the model coherent under traffic the handles did not issue, and an opt-in subscriber records access coverage.
Passive bus prediction¶
Frontdoor methods predict transactions issued through the register handle. A passive predictor keeps the same model coherent when another processor, DMA engine, debug port, or testbench component accesses the bus:
ApbMonitor monitor{test, bus};
RegisterPredictor predictor{test, regs.register_handles()};
regs.set_auto_predict(false);
auto prediction_connection = monitor.observed().connect(predictor);
co_await Join{
monitor.run(expected_transfer_count),
processor_sequence(dut),
};
test.expect_eq("predicted reads", predictor.reads(), expected_reads);
test.expect_eq("predicted writes", predictor.writes(), expected_writes);
register_handles() is generated with the model; users do not author an
address table. Construction sorts the handles once and rejects overlapping
address ranges. Each successful observed read or write updates the handle
matching that transfer address with the same read/write-effect and validity
rules as an explicit frontdoor
operation. Failed and unmapped transactions do not change model state and are
available through failed() and unmapped() counters.
Disable automatic prediction when a passive monitor owns prediction for traffic issued through these handles; otherwise that frontdoor transaction would be predicted twice. Leave automatic prediction enabled when the model is driven directly without a passive predictor.
An alternate address map can feed the same logical mirror:
RegisterAddressMap debug{"debug", apb, 0x8000};
debug.route(regs.control.descriptor(), 0x40);
predictor.add_alias(regs.control, debug);
Write byte enables are applied per byte. Disabled bytes preserve their prior
value and validity, including W1C/W1S and toggle fields. Strobe bits outside the
selected register width are ignored and produce a warning containing the full
register path; invalid_byte_enables() counts those transactions. The
predictor is synchronous and does not advance simulation time. The monitor owns
sampling phase and publication timing.
Register access coverage¶
RegisterAccessCoverage is an opt-in analysis subscriber. Connect the same
passive bus monitor used by prediction, then snapshot coverage for registers,
fields, and register-backed memories:
RegisterAccessCoverage accesses{regs.descriptor(), peripheral_base,
"peripheral_access"};
auto access_connection = monitor.observed().connect(accesses);
// Raw hierarchy operations are invisible on the bus, so sample them where
// the test intentionally performs them.
regs.status.peek();
accesses.sample_register(regs.status, MemoryOperation::Read,
AccessPath::Backdoor);
const auto result = accesses.snapshot();
test.expect_eq("access coverage", result.coverage_percent(), 100.0);
const auto* buffer = result.find("peripheral.buffer");
test.expect("all buffer entries written",
buffer && buffer->unique_written_indices == regs.buffer.size());
Frontdoor transactions are classified by address, byte enable, operation, and
access policy. Failed and unmapped transactions are counted separately.
Backdoor sampling is explicit because a raw hierarchy access emits no bus
transaction. Snapshot allocation occurs only when requested; a testbench that
does not construct RegisterAccessCoverage executes no coverage code.
Register-backed memories¶
A generated memory handle uses a logical entry index. In
regs.buffer.read(7), 7 means entry seven; it is not a byte address. The
frontdoor address is:
model base + SystemRDL memory offset + index * element bytes
For a 32-bit buffer @ 0x100 in a model based at 0x4000'0000, entry 7
therefore accesses byte address 0x4000'011c.
Entry, offset, and absolute coordinates¶
The handle names all three useful coordinate systems explicitly:
Coordinate |
Meaning |
Scalar method |
Chunk method |
|---|---|---|---|
Entry index |
Element number relative to this memory |
|
|
Byte offset |
Bytes relative to this memory’s effective start |
|
|
Absolute address |
Effective frontdoor bus byte address |
|
|
write, peek, and poke provide the same entry, offset, and absolute forms.
For the 32-bit memory above, these select the same four entries:
std::array<uint32_t, 4> words{};
co_await regs.buffer.read(8, words);
co_await regs.buffer.read_offset(0x20, words);
co_await regs.buffer.read_absolute(0x4000'0120, words);
The destination size supplies the chunk length, so no separate count argument
is needed. Offsets and absolute addresses must be aligned to
element_bytes(), and the complete chunk must remain inside the generated
memory. Diagnostics include the logical path and offending coordinate.
Useful mapping metadata is available without accessing the DUT:
test.expect_eq("memory start", regs.buffer.base_address(), 0x4000'0100u);
test.expect_eq("memory end", regs.buffer.end_address(), 0x4000'0500u);
test.expect_eq("entry bytes", regs.buffer.element_bytes(), 4u);
test.expect_eq("entry", regs.buffer.index_from_absolute(0x4000'0120), 8u);
test.expect_eq("entry", regs.buffer.index_from_offset(0x20), 8u);
end_address() is exclusive. contains_absolute(address) accepts only aligned
entry addresses inside [base_address(), end_address()).
These absolute methods remain scoped to the generated memory. They reject an address outside that memory instead of silently issuing an unrelated bus transaction. Use the protocol master directly when a sequence intentionally accesses an arbitrary system address that is not represented by this register model:
const auto response = co_await master.read(
decltype(master)::read_request_type{absolute_address});
Scalar frontdoor and backdoor¶
Frontdoor is the default. A test can select Backdoor for any individual
operation without constructing a second model:
using cpptb::vc::AccessPath;
const auto write = co_await regs.buffer.write(7, 0x1234'5678);
test.require("frontdoor write", write.okay());
test.expect_eq("frontdoor transfers", write.transfers_completed, 1u);
const auto frontdoor = co_await regs.buffer.read(7);
test.require_eq("frontdoor status", frontdoor.transport.status,
MemoryStatus::Okay);
test.expect_eq("frontdoor value", frontdoor.data, 0x1234'5678u);
co_await regs.buffer.write(7, 0xcafe'babe, AccessPath::Backdoor);
const auto backdoor =
co_await regs.buffer.read(7, AccessPath::Backdoor);
test.expect_eq("backdoor value", backdoor.data, 0xcafe'babeu);
Both paths return Task and the same response type. Backdoor operations do not
advance simulation time, but the common asynchronous signature lets a helper
choose its path at runtime:
Task<uint32_t> read_word(auto& memory, uint64_t index, AccessPath path) {
const auto response = co_await memory.read(index, path);
if (!response.okay()) throw std::runtime_error{"memory read failed"};
co_return response.data;
}
Semantic read() and write() enforce the SystemRDL sw policy on both
paths. A generated read-only memory can be read frontdoor or backdoor but
cannot be written through these methods.
Every semantic coordinate form accepts AccessPath as its final optional
argument. Frontdoor remains the default:
co_await regs.buffer.write_offset(0x20, words); // Frontdoor.
co_await regs.buffer.write_absolute(
0x4000'0120, words, AccessPath::Backdoor);
Raw hierarchy access¶
peek() and poke() are synchronous, unconditional HDL operations. They
bypass the sw policy and are appropriate for initialization, fault setup,
or inspecting storage software cannot access:
regs.boot_rom.poke(12, 0x0000'0013); // Raw deposit, even when sw = r.
test.expect_eq("patched opcode", regs.boot_rom.peek(12), 0x13u);
They do not add a delay, predict a memory mirror, or make the deposit persist. The test authors any later edge or observation phase explicitly. Direct DUT hierarchy remains available as the lowest-level escape hatch:
dut.u_core.instruction_ram[12].deposit(0x0000'0013);
const auto opcode = dut.u_core.instruction_ram[12].get();
The generated handle adds logical SystemRDL mapping, bounds checks, access-path selection, and contextual diagnostics around the same storage.
Bulk operations¶
Span operations consume caller-owned storage and do not allocate. They fit packet buffers, descriptor rings, lookup tables, and parsed firmware words:
std::array<uint32_t, 4> descriptor{
0x1000'0000, 0x0000'0200, 0x0000'0040, 0x0000'0001};
const auto loaded = co_await regs.descriptors.write(
8, std::span<const uint32_t>{descriptor}, AccessPath::Backdoor);
test.require_eq("descriptor words", loaded.transfers_completed,
descriptor.size());
std::array<uint32_t, 4> sampled{};
const auto read = co_await regs.descriptors.read(
8, std::span<uint32_t>{sampled}); // Frontdoor by default.
test.require("descriptor read", read.okay());
test.expect_eq("descriptor", sampled, descriptor);
Frontdoor blocks issue ordered scalar bus transfers and stop at the first
failed response. For ordinary memories, transfers_completed counts successful
entries because each entry uses one transfer. For wide memories it counts
frontdoor bus transfers, while a backdoor bulk operation counts logical
entries. failed_index names the first logical entry that failed:
const auto result = co_await regs.packet_ram.write(
first, std::span<const uint32_t>{words});
if (!result.okay()) {
test.require_eq("prefix written", result.transfers_completed,
*result.failed_index - first);
}
Raw bulk poke(first, span) and peek_into(first, span) use the generated
memory backdoor directly and remain synchronous:
regs.coefficients.poke(
0, std::span<const uint32_t>{coefficients});
regs.coefficients.peek_into(0, std::span<uint32_t>{readback});
Raw offset and absolute variants use the same mapping while continuing to bypass software access policy:
regs.coefficients.poke_offset(0x20, coefficients);
regs.coefficients.peek_absolute_into(0x4000'0120, readback);
Use slice(first, count) when several operations target the same window. The
view stores only a handle pointer, first index, and count; it does not copy or
allocate storage. Scalar indices on the view are relative to the window:
auto ring = regs.descriptors.slice(8, descriptor.size());
co_await ring.write(descriptor); // Frontdoor.
co_await ring.read_into(sampled); // Frontdoor.
co_await ring.write(descriptor, AccessPath::Backdoor);
const uint32_t third =
(co_await ring.read(2, AccessPath::Backdoor)).data; // Entry 10.
The span size must equal the slice size. A mismatch or relative index outside
the slice reports the complete logical memory path. The original
write(first, span) and read(first, span) calls remain the lowest-level bulk
primitives and have identical transport behavior. read_into(first, span) is
an equivalent compatibility spelling. std::array,
std::vector, C arrays, and other contiguous caller-owned storage convert to
the span parameters without copying.
Semantic backdoor read(), write(), and read_into() are also completed
inline. They retain the same co_await spelling as frontdoor operations so a
helper can choose its path at runtime, but they do not allocate or schedule a
child coroutine. Frontdoor operations remain ordinary asynchronous bus
transactions.
For a generated one-dimensional HDL memory up to 64 bits wide, a bulk backdoor operation uses standard DPI packed-vector exports carrying as many as four adjacent elements per crossing. Longer spans are divided into four-entry blocks and a final partial block. The generated adapter accounts for nonzero HDL array bounds, masks subword element widths, performs no allocation, and adds no simulation delay. The public API above is unchanged; unsupported shapes use the scalar backdoor fallback.
This batching is generated only when the compiled testbench accesses that
memory with get or deposit. A design that does not use the memory gets no
wrapper functions or runtime cost.
SystemRDL memory contracts¶
Different element widths and software policies generate the same API using the
master’s unsigned integral data_type:
external mem {
mementries = 256;
memwidth = 32;
sw = rw;
hdl_path_slice = '{"packet_ram"};
} packet_ram @ 0x1000;
external mem {
mementries = 1024;
memwidth = 8;
sw = r;
hdl_path_slice = '{"boot_rom"};
} boot_rom @ 0x2000;
external mem {
mementries = 64;
memwidth = 64;
sw = rw;
hdl_path_slice = '{"descriptor_ram"};
} descriptors @ 0x4000;
One memory hdl_path_slice entry names the complete one-dimensional unpacked
HDL array. Generated mapping accounts for the HDL array’s declared low bound,
so logical entry zero maps correctly to an array declared [15:8]. A missing
memory path is valid for a frontdoor-only model; selecting Backdoor or using
peek/poke then reports the complete logical memory path. Multiple slice paths
are rejected because split memory-element backdoors are not yet represented.
ELF, Intel HEX, and binary parsing policy remains outside the handle. Parse a
file into the project’s chosen word container, then pass a span to write() or
poke(). SparseMemory is a separate byte-addressed expected memory and is
not an automatic mirror of a generated DUT memory.
Introspection and traversal¶
Generated handles expose paths and dimensions directly without a runtime name
lookup. path() is the logical SystemRDL path. hdl_path() is the elaborated
RTL storage path when the object maps to one HDL object:
std::cout << regs.control.path() << '\n'; // peripheral.control
if (const auto path = regs.control.hdl_path()) {
std::cout << *path << '\n'; // u_regs.control_q
}
test.expect_eq("field address", regs.control.enable.address(),
regs.control.address());
test.expect_eq("memory entries", regs.buffer.size(), 256u);
hdl_path() returns std::optional<std::string_view> because a frontdoor-only
object may have no HDL mapping and a split register or field may use several
objects. Use hdl_slices() for the complete mapping. Each slice supplies its
HDL path, logical register LSB, and width:
for (const auto& slice : regs.status.hdl_slices()) {
std::cout << slice.hdl_path() << '\n';
inspect_mapping(slice.register_lsb, slice.width);
}
These strings and spans point at generated constant metadata. Calling the accessors performs no allocation, hierarchy discovery, simulator crossing, or string search. Available metadata is summarized below:
Object |
Direct metadata |
|---|---|
Model |
|
Register |
|
Field |
|
Memory |
|
Memory slice |
parent paths plus |
Generated-handle surface at a glance¶
The public vocabulary groups by handle kind. Operations named read, write,
update, or mirror are semantic model operations. peek and poke are raw
synchronous hierarchy operations. Model-state and metadata operations do not
access the DUT or advance simulation time.
Handle |
Semantic operations |
Raw backdoor |
Model state and metadata |
|---|---|---|---|
Model |
— |
|
|
Register |
|
|
|
Field |
|
explicit parent read-modify-write |
|
Memory |
scalar and chunk |
|
|
Semantic frontdoor operations and address(index) also accept a final
RegisterAddressMap&; raw peek/poke operations always use physical HDL
storage and therefore do not accept a software map. Full signatures and
response types are documented in the
register library reference.
Typed traversal keeps generated members and enum field types intact:
regs.control.for_each_field([&](const auto& field) {
inspect(field.path(), field.staged(), field.mirrored());
});
regs.for_each_register([&](const auto& reg) {
inspect(reg.path(), reg.address(), reg.width());
});
regs.for_each_memory([&](const auto& memory) {
inspect(memory.path(), memory.size(), memory.width());
});
The traversal callbacks are synchronous and are intended for metadata,
prediction, reset, diagnostics, and other model-only operations. Frontdoor bus
access remains explicitly asynchronous. Use update_all() or mirror_all()
for address-ordered whole-model traffic, or write an ordinary coroutine when a
test needs custom ordering, filtering, or error policy.
Register arrays retain concrete generated types through compile-time indexing.
for_each() visits every element; for_each_slice<First, Count>() visits a
compile-time subrange without constructing a runtime container:
regs.lane_control.for_each([](auto& reg) { reg.reset(); });
regs.lane_control.for_each_slice<2, 4>([](const auto& reg) {
inspect(reg.path(), reg.address());
});
There is intentionally no bulk read(span) across arbitrary registers. Unlike
memory entries, adjacent registers may have different widths, access effects,
volatility, and transport failure policy. Explicit register traffic and the
policy-aware whole-model operations preserve those semantics.
Generated register-file hierarchy follows the SystemRDL structure:
regs.security.key.key.stage(0x1234);
regs.bank.at<1>().control.value.stage(0xa55a);
at<Index>() is compile-time indexed so each element retains its concrete
generated type and named fields. Existing flattened nested members remain
available for source compatibility, but hierarchical names are the primary
API.
Generated blocks also provide address-ordered whole-model operations:
regs.reset_all();
co_await regs.update_all();
co_await regs.mirror_all(MirrorCheck::Enabled);
reset_all() changes only model state. It does not drive a DUT reset or advance
simulation time. update_all() issues only the writes required by each
register’s staged state. mirror_all() skips write-only registers and checks
all other registers sequentially in ascending address order. The sequential
ordering is intentional: it is deterministic and does not assume that the
frontdoor can accept concurrent transfers. Reusable reset-check, access, and
bit-bash policies are provided by the optional
standard register sequences
layer. They use generated asynchronous traversal without adding policy to the
core register handles.
Logical register values may be arbitrary width, while each accesswidth
transfer must be byte aligned, divide regwidth, be no more than 64 bits, and
fit the master’s unsigned integral bus-data type. Invalid combinations produce
a path-qualified diagnostic when the handle is used. Generated wide HDL
backdoors, passive prediction, and register-backed memories preserve the
complete Bits<Width> value; sparse byte memory itself also has no 64-bit
width limit.
Generate from SystemRDL, IP-XACT, or RgGen¶
The typed model used throughout this page is generated, not handwritten. The complete generation guide covers the source/build/generated-file boundary, naming controls, the CLI reference, IP-XACT import, native RgGen input, build integration, validation, and current limitations. The shortest native SystemRDL flow is:
uv sync --extra peakrdl
uv run --frozen --extra peakrdl peakrdl cpptb registers.rdl \
-o build/generated/registers.hpp \
--namespace generated_registers
The exporter is a normal PeakRDL plugin, so the input may instead be an
IP-XACT component. Native RgGen YAML, JSON, and TOML contracts use the
companion cpptb-rggen command directly, without an IP-XACT conversion. Both
flows, including every naming and endianness option, are documented in the
generation guide.
Generated models expose typed registers, named fields, memories, complete
logical paths, regwidth and accesswidth, access policies, reset metadata,
and a relocatable base. SystemRDL is the lossless semantic source; retain the
original RDL when an IP-XACT round-trip would drop properties such as nested
mem nodes.
The runnable APB register-file example includes
examples/apb_regfile/registers.rdl as a small real contract. The
secworks AES register-model oracle
provides the larger ground-truth workflow. It generates named handles from a
SystemRDL description, programs pinned open-source RTL, and requires its full
720-event register-bus transcript and all result words to match the unchanged
upstream top-level testbench.
One register workflow in four frameworks¶
These tabs compare authored verification code, not hidden bus or generator plumbing. The APB register-file example supplies the shared contract and runnable bus sequence; generated-handle behavior is covered by the code-generation regression and the complete secworks AES oracle. Cocotb core does not include a register abstraction layer, so its model-oriented tabs use an asynchronous package generated by PeakRDL-python. The UVM tabs use the native UVM register abstraction layer.
Selecting a framework in one tab group selects it in all of the groups below.
Frontdoor read and write¶
Each version writes register zero through APB, reads it back, and checks the returned value or status.
const auto write = co_await regs.register_0.write(0x1234'5678);
test.expect_eq("register write", write.transport.status, MemoryStatus::Okay);
const auto read = co_await regs.register_0.read();
test.expect_eq("register read", read.transport.status, MemoryStatus::Okay);
test.expect_eq("register value", read.data, 0x1234'5678u);
await regs.register_0.write(0x1234_5678)
actual = await regs.register_0.read()
assert actual == 0x1234_5678
logic [31:0] actual;
logic error;
int unsigned wait_cycles;
apb_write_word(8'h00, 32'h1234_5678, error, wait_cycles);
expect_eq("register write", error, 0);
apb_read_word(8'h00, actual, error, wait_cycles);
expect_eq("register read", error, 0);
expect_eq("register value", actual, 32'h1234_5678);
uvm_status_e status;
uvm_reg_data_t actual;
regs.register_0.write(status, 'h1234_5678, .path(UVM_FRONTDOOR));
`uvm_info("REG", $sformatf("write status=%s", status.name()), UVM_LOW)
regs.register_0.read(status, actual, .path(UVM_FRONTDOOR));
if (status != UVM_IS_OK || actual != 'h1234_5678)
`uvm_error("REG", $sformatf("read status=%s value=0x%0h",
status.name(), actual))
Staged state, update, and mirror¶
cpptb and UVM retain staged and mirrored state. Cocotb and pure SV show the small amount of explicit shadow bookkeeping needed when no RAL supplies it.
regs.register_0.value.stage(0x0000'002a);
if (regs.register_0.needs_update()) {
co_await regs.register_0.update();
}
co_await regs.register_0.mirror(MirrorCheck::Enabled);
test.expect_eq("staged converged", regs.register_0.staged(),
regs.register_0.mirrored());
staged = 0x2A
mirrored = 0
if staged != mirrored:
await regs.register_0.write(staged)
mirrored = staged
actual = await regs.register_0.read()
assert actual == mirrored
mirrored = actual
logic [31:0] staged = 32'h2a;
logic [31:0] mirrored = 32'h0;
if (staged != mirrored) begin
apb_write_word(8'h00, staged, error, wait_cycles);
expect_eq("update status", error, 0);
mirrored = staged;
end
apb_read_word(8'h00, actual, error, wait_cycles);
expect_eq("mirror status", error, 0);
expect_eq("mirror value", actual, mirrored);
mirrored = actual;
uvm_status_e status;
regs.register_0.value.set('h2a);
if (regs.register_0.needs_update())
regs.register_0.update(status, .path(UVM_FRONTDOOR));
regs.register_0.mirror(status, UVM_CHECK, UVM_FRONTDOOR);
if (status != UVM_IS_OK)
`uvm_error("REG", "register mirror failed")
Write and read side effects¶
This richer contract assumes status.pending is write-one-to-clear and
events.cause is read-clear. The cpptb implementation is covered by the
register-model unit regression even though the small APB example uses ordinary
read/write fields.
// Hardware currently reports pending bits 0 and 2.
regs.status.predict(0x05, RegisterPrediction::Direct);
regs.status.pending.stage(0x04);
co_await regs.status.update(); // Encodes a bus write of 0x01.
const auto cause = co_await regs.events.cause.read();
test.expect_eq("event cause", cause.data, 0x03u);
test.expect_eq("read-clear mirror", regs.events.cause.mirrored(), 0u);
# PeakRDL-python exposes the field policy, while hardware applies the effect.
await regs.status.pending.write(0x01) # Clear pending bit zero.
cause = await regs.events.cause.read()
assert cause == 0x03
assert await regs.events.cause.read() == 0 # Read-clear took effect.
// W1C: writing one clears that bit; zero leaves other bits unchanged.
apb_write_word(STATUS_ADDR, 32'h0000_0001, error, wait_cycles);
expect_eq("W1C write", error, 0);
apb_read_word(EVENTS_ADDR, actual, error, wait_cycles);
expect_eq("event cause", actual, 32'h0000_0003);
apb_read_word(EVENTS_ADDR, actual, error, wait_cycles);
expect_eq("read-clear", actual, 0);
uvm_status_e status;
uvm_reg_data_t cause;
regs.status.predict('h05, .kind(UVM_PREDICT_DIRECT));
regs.status.pending.write(status, 'h01, .path(UVM_FRONTDOOR));
regs.events.cause.read(status, cause, .path(UVM_FRONTDOOR));
if (cause != 'h03 || regs.events.cause.get_mirrored_value() != 0)
`uvm_error("REG", "read-clear prediction failed")
Backdoor access¶
Backdoor operations bypass APB timing. A generated cpptb RegisterBackdoor adapter and
UVM HDL path retain model state. Cocotb directly deposits through a simulator
handle; that deposit may be overwritten by the RTL on its next assignment.
Pure SV uses standard hierarchical force and release, whose persistent
force semantics are intentionally visible in the authored code.
auto backdoor = generated_regs::make_backdoor<decltype(master)>(dut);
generated_regs::RegModel regs{test, master, 0, &backdoor};
regs.register_0.poke(0xa5a5'5a5a);
const auto actual = regs.register_0.peek();
test.expect_eq("backdoor value", actual, 0xa5a5'5a5au);
test.expect_eq("backdoor mirror", regs.register_0.mirrored(), actual);
from cocotb.triggers import ReadOnly
dut.registers[0].value = 0xA5A5_5A5A # Simulator deposit.
await ReadOnly()
actual = int(dut.registers[0].value)
assert actual == 0xA5A5_5A5A
force i_dut.registers[0] = 32'ha5a5_5a5a;
expect_eq("forced value", i_dut.registers[0], 32'ha5a5_5a5a);
release i_dut.registers[0];
uvm_status_e status;
uvm_reg_data_t actual;
regs.register_0.poke(status, 'ha5a5_5a5a);
regs.register_0.peek(status, actual);
if (status != UVM_IS_OK || actual != 'ha5a5_5a5a)
`uvm_error("REG", "backdoor access failed")
Generate and connect the model¶
SystemRDL can remain the common contract for cpptb, Cocotb, and UVM. Pure SV can consume generated address constants or use the addresses directly. Driver or adapter construction is kept outside the generated model in every case.
// Generate first:
// uv run --frozen --extra peakrdl peakrdl cpptb registers.rdl
// -o build/generated/registers.hpp --namespace generated_registers
#include "generated/registers.hpp"
generated_registers::RegModel<Master> regs{
test, master, 0x4000'0000, &backdoor};
# Generation step: PythonExporter.export(spec, "generated", asyncoutput=True)
from generated.reg_model import RegModel
from generated.lib import AsyncCallbackSet
async def read_register(addr, width, accesswidth):
response = await apb.read(addr)
return response.data
async def write_register(addr, width, accesswidth, data):
await apb.write(addr, data)
regs = RegModel(callbacks=AsyncCallbackSet(
read_callback=read_register,
write_callback=write_register,
))
package register_map_pkg;
localparam logic [7:0] REGISTER_0_ADDR = 8'h00;
localparam logic [7:0] IDENTIFICATION_ADDR = 8'h10;
endpackage
import register_map_pkg::*;
apb_write_word(REGISTER_0_ADDR, value, error, wait_cycles);
// Generate first:
// peakrdl uvm registers.rdl -o generated/registers_uvm_pkg.sv
// The generated block type name is selected by the exporter/input model.
apb_registers_block regs;
regs = new("regs");
regs.build();
regs.lock_model();
regs.default_map.set_sequencer(apb.sequencer, reg_adapter);
Relationship to UVM and Cocotb¶
The model follows the useful UVM RAL behavior without requiring UVM’s factory, configuration database, phase hierarchy, or adapter/predictor class tree:
Intent |
cpptb-vc |
UVM RAL |
|---|---|---|
Literal frontdoor transaction |
|
|
Read and predict |
|
|
Change staged state only |
||
Update only when needed |
||
Check against mirror |
||
Raw backdoor access |
|
|
Passive bus prediction |
|
Cocotb intentionally provides simulator handles and scheduling rather than a
built-in register model. Generated Cocotb ecosystems commonly use callback
sets for frontdoor reads and writes; MemoryMappedMaster and
RegisterBackdoor are the corresponding replaceable boundaries here.
References:
Performance qualification¶
The secworks AES oracle is the scalable RAL qualification workload. Its generated model and exact pure-SystemVerilog peer program the same 3,600 AES cases, and its one-suite run must match the unchanged upstream bench’s complete 720-event register trace and checksum.
make secworks-aes-regmodel-equivalence
make secworks-aes-regmodel-benchmark
Controlled decomposition measured the generated register layer at about 2.6%
over direct use of the same master. The larger remaining gap to pure SV comes
from simulator-boundary scheduling rather than lookup or register prediction.
See Performance and the benchmark’s PROFILE.md for the
current result and profiling method.
The memory_model feature pair extends the APB component benchmark workload
with sparse byte storage and passive read/write prediction; the August 8,
2026 admitted run certified the pair under the 1.10x hard gate at 1.0247x.
Sparse expected-memory benchmarks are documented separately in
Sparse expected memory.