Generate a register model¶
cpptb generates typed C++ register handles from a register contract. SystemRDL
and IP-XACT use the PeakRDL exporter plugin; native RgGen YAML, JSON, and TOML
use the companion cpptb-rggen command. Generation is a build step: the
generated header belongs under build/, while the contract remains authored
source.
This facility is part of the optional cpptb_vc package. It does not alter DUT
signal generation and is not needed by testbenches that use only direct
hierarchical access.
What the generator consumes¶
SystemRDL is the preferred source because it represents register widths, access widths, fields, reset values, software access, read/write side effects, volatility, hierarchy, arrays, and memories directly. For example:
addrmap peripheral {
hdl_path = "u_regs";
default regwidth = 32;
default accesswidth = 32;
reg {
hdl_path = "control_q";
field { sw = rw; hw = r; reset = 0; } enable[0:0];
field { sw = rw; hw = r; reset = 0; } mode[3:1];
} control @ 0x00;
reg {
field {
sw = rw;
hw = r;
onwrite = woclr;
reset = 8'hff;
} pending[7:0];
field {
sw = r;
hw = w;
onread = rclr;
} sampled[15:8];
} status @ 0x04;
external mem {
mementries = 16;
memwidth = 32;
sw = rw;
hdl_path_slice = '{"buffer_storage"};
} buffer @ 0x100;
};
The source describes the hardware contract, not a test sequence. Clocks, bus timing, reset, test policy, and checks remain in authored C++.
hdl_path and hdl_path_slice are standard SystemRDL verification properties.
When present, cpptb uses them to generate typed backdoor access; no separate
path configuration file or handwritten dispatch table is required.
Install and generate¶
Install the optional PeakRDL dependencies and invoke the cpptb exporter:
uv sync --extra peakrdl
uv run --frozen --extra peakrdl peakrdl cpptb registers.rdl \
-o build/generated/peripheral_regs.hpp \
--namespace peripheral_regs
Split transfers default to little-endian CPU byte order. Select a big-endian frontdoor explicitly when that is the bus contract:
uv run --frozen --extra peakrdl peakrdl cpptb registers.rdl \
-o build/generated/peripheral_regs.hpp \
--register-endianness big
This option controls the ordering of accesswidth chunks within every
regwidth; it does not reverse field bit numbering or alter HDL backdoor
paths.
Registers wider than 64 bits are generated as typed Bits<Width> handles.
Their complete reset values and reset-valid masks are emitted as word arrays,
so no additional source annotation or handwritten packing code is required.
accesswidth selects the width of each frontdoor transfer and must be no more
than 64 bits and fit the selected transport data type.
SystemRDL encode declarations generate a C++ enum class and make the field
member typed. Enum declaration names and members are sanitized only when needed
to form valid C++ identifiers. Reusing one SystemRDL enum reuses one generated
C++ type. The generated field’s .raw() member remains available for reserved
encodings and negative tests; both views share one register-model state.
Every generated top-level model exposes reset_all(), update_all(), and
mirror_all() in ascending address order. These are model/frontdoor operations:
reset_all() does not drive a reset signal, and none of them inserts an
implicit delay. mirror_all() omits registers that are entirely write-only.
Generated handles also expose allocation-free metadata and traversal:
regs.for_each_register([](const auto& reg) {
inspect(reg.path(), reg.address(), reg.width());
});
regs.control.for_each_field([](const auto& field) {
inspect(field.path(), field.lsb(), field.width());
});
regs.for_each_memory([](const auto& memory) {
inspect(memory.path(), memory.size());
});
path() is the logical SystemRDL hierarchy. hdl_path() returns the generated
RTL path when there is exactly one mapping; hdl_slices() represents split
register and field storage without flattening it into a misleading string.
Model-wide descriptor iteration is available through regs.descriptor().
See Introspection and traversal
for memory slices, register arrays, path printing, and the frontdoor/backdoor
usage rules.
Generated memories support entry indices, byte offsets relative to the memory, and absolute bus addresses. Scalar and caller-owned chunk operations use the same vocabulary:
std::array<uint32_t, 16> words{};
co_await regs.buffer.read(8, words); // Entry 8.
co_await regs.buffer.read_offset(0x20, words); // 0x20 from buffer.
co_await regs.buffer.read_absolute(0x4000'0120, words); // Bus address.
The complete chunk must remain within regs.buffer; offsets and addresses must
be element-aligned. Every form accepts an optional final AccessPath argument,
and matching write, raw peek, and raw poke forms are available. See
Register-backed memories
for the complete API table and frontdoor/backdoor examples.
Naming the generated model¶
No filename or C++ type name is forced to be registers. Five independent
naming layers are involved:
Layer |
Selected by |
What it changes |
|---|---|---|
Address-map type/top |
SystemRDL declaration and |
Which |
Top instance |
SystemRDL instance name or |
Logical descriptor-path root and the default C++ namespace |
Header path and filename |
|
Where the generated file lives and how authored C++ includes it |
C++ namespace |
|
C++ qualification only; it does not change logical SystemRDL paths |
C++ model class |
|
Generated C++ class identifier only; default |
For this source:
addrmap uart {
reg { field { sw = rw; } enable[0:0]; } control @ 0x00;
};
the command below generates soc_uart_regs.hpp, a C++ type named
soc_registers::UartRegisters, and descriptor paths rooted at
uart0.control:
uv run --frozen --extra peakrdl peakrdl cpptb uart.rdl \
--top uart \
--rename uart0 \
-o build/generated/soc_uart_regs.hpp \
--namespace soc_registers \
--class-name UartRegisters
The corresponding C++ spelling is independent of the logical descriptor root:
#include "soc_uart_regs.hpp"
soc_registers::UartRegisters<Master> uart0_regs{test, master, uart0_base};
co_await uart0_regs.control.write(1u);
Without --rename, the instantiated top name defaults to the selected
component name. Without --namespace, that instance name is sanitized and
used as the C++ namespace. Use --top whenever the inputs expose more than one
possible address map; otherwise PeakRDL selects the last address map defined.
Generated register members retain the elaborated hierarchy below the top.
Scalar top-level registers remain direct members. Register files become nested
views, and arrays use compile-time indexed at<Index>() access:
regs.control.enable.stage(1);
regs.security.key.key.stage(0x1234);
regs.bank.at<1>().control.value.stage(0xa55a);
regs.lane_control.at<0>().value.stage(0x5a);
Array views also provide deterministic for_each() and
for_each_slice<First, Count>(), while the block provides
for_each_register() in ascending address order. Compile-time indexing keeps
the concrete generated handle type and therefore preserves named fields.
Flattened nested members such as security_key are retained as compatibility
storage aliases for existing generated-model users; new code should use the
hierarchical view. Non-identifier characters are replaced, C++ keywords
receive a trailing underscore, and collisions receive a stable numeric suffix.
Logical descriptor paths always preserve the elaborated SystemRDL hierarchy.
Actual core: secworks AES¶
The pinned secworks integration deliberately uses names specific to the core,
not generic generated/registers.hpp naming. Its contract begins with:
addrmap secworks_aes {
default regwidth = 32;
default accesswidth = 32;
// control, config, key0..key7, block0..block3, result0..result3
};
Its Makefile selects an output named aes_regs.hpp and a descriptive C++
namespace:
GENERATED_HEADER := $(BUILD_DIR)/generated/aes_regs.hpp
$(GENERATED_HEADER): $(RDL)
uv run --frozen --extra peakrdl peakrdl cpptb $(RDL) \
-o $@ --namespace secworks_aes_regs
The testbench include, type, and generated member names match that command:
#include "aes_regs.hpp"
using RegModel = secworks_aes_regs::RegModel<AesMaster>;
RegModel regs{test, master};
co_await regs.key0.write(key[0]);
co_await regs.config.write(aes256 ? 2u : 0u);
co_await regs.control.write(1u);
The project adds only the containing generated directory to
testbench.include_dirs. The filename comes from -o, the namespace comes
from --namespace, the class keeps its default RegModel, and members come
from the RDL instances. This complete source, command, configuration, and C++
usage is runnable under benchmarks/regmodel_ground_truth/secworks_aes/.
Generator CLI reference¶
Run the installed command for the authoritative help associated with that version:
peakrdl cpptb --help
peakrdl --plugins
The cpptb subcommand accepts PeakRDL’s compilation and importer options plus
the exporter-specific C++ names:
Argument |
Purpose |
|---|---|
|
One or more SystemRDL, IP-XACT, or supported PeakRDL input files |
|
Required generated header path and filename |
|
Select the top-level address-map component |
|
Override the elaborated top instance name and logical path root |
|
Override only the generated C++ namespace |
|
Override only the generated C++ class name |
|
Select split-transfer CPU byte order; default |
|
Add a SystemRDL include search directory |
|
Define a SystemRDL preprocessor macro |
|
Override a top-level SystemRDL parameter |
|
Select an IP-XACT memory-remap state |
|
Read additional command-line arguments from a file |
|
Use a PeakRDL configuration TOML file |
peakrdl globals INPUT... lists globally accessible component types when the
correct --top is unclear. The CLI rejects a missing -o and prints these
options through standard --help without running generation.
Generated wide registers and register-backed memories support typed
Bits<Width> frontdoors, generated HDL backdoors, passive prediction, reset,
update, mirror, and bulk memory operations. A logical value may be wider than
the bus while each individual transport transfer remains at most 64 bits.
Keep generation in the build¶
The generated header starts with a tool-version banner and Do not edit.
Regenerate it whenever the contract or generator changes. A minimal Make rule
looks like this:
RDL := rtl/registers.rdl
REGS_HPP := build/generated/peripheral_regs.hpp
$(REGS_HPP): $(RDL) pyproject.toml uv.lock
uv run --frozen --extra peakrdl peakrdl cpptb $(RDL) \
-o $@ --namespace peripheral_regs
build/cpptb/my_testbench: $(REGS_HPP)
Expose the generated directory to the testbench compiler:
[testbench]
sources = ["testbench.cpp"]
include_dirs = ["build/generated"]
cpptb build currently generates DUT signal bindings from RTL, but it does not
infer arbitrary register-contract sources. Keep the explicit register-header
dependency in the project build so generation is deterministic and visible.
The complete secworks example uses this pattern in
benchmarks/regmodel_ground_truth/secworks_aes/Makefile.
What the header contains¶
For each elaborated address map, the exporter emits:
constexpr block, register, field, and memory descriptors;
typed register members and named field members;
generated reset masks that preserve unspecified reset bits as unknown model state;
absolute contract offsets plus a constructor-time relocatable base address;
regwidth,accesswidth, reset value/mask, software access, side-effect, frontdoor endianness, and volatility metadata; andcomplete logical SystemRDL paths for diagnostics;
standard register slices and memory-array paths for typed RTL backdoors; and
an optional typed
DutBackdoorplusmake_backdoor<Master>(dut)factory.
Nested register files and arrays are elaborated and unrolled by PeakRDL. Their
logical paths are retained in descriptors and exposed as typed nested views.
For example, peripheral.security.key is available as regs.security.key, and
peripheral.bank[1].control is available as
regs.bank.at<1>().control. Compatibility flattened storage remains visible
for now, but authored tests should use the hierarchy.
Generated code includes only cpptb_vc/register_model.hpp. The backdoor is a
template and does not include a specific DUT binding, APB component, clock, or
simulator adapter. It is instantiated only when authored C++ passes a generated
Dut to make_backdoor.
Construct and connect¶
Include the generated header and provide any type satisfying the
MemoryMappedMaster concept:
#include "generated/peripheral_regs.hpp"
ApbMaster master{bus};
peripheral_regs::RegModel<decltype(master)> regs{
test, master, 0x4000'0000};
const auto write = co_await regs.control.write(0x0000'0005);
test.require_eq("control write", write.transport.status, MemoryStatus::Okay);
The base address is added to every generated contract offset. This lets one header represent the same IP at different subsystem or SoC addresses. Pass a fourth constructor argument when the model also needs generated backdoor access:
auto backdoor = peripheral_regs::make_backdoor<decltype(master)>(dut);
peripheral_regs::RegModel<decltype(master)> regs{
test, master, 0x4000'0000, &backdoor};
Generation does not choose a bus protocol. The frontdoor remains an authored
MemoryMappedMaster; the default backdoor comes from standard paths in the
register contract. The generated adapter implements both RegisterBackdoor
and RegisterMemoryBackdoor. Projects can instead supply custom adapters; a
separate memory adapter may be passed as the fifth model-constructor argument.
HDL path mapping¶
The complete path is the dot-joined sequence of hdl_path properties from the
selected address map through its register file and register. Paths are relative
to the generated cpptb Dut root:
addrmap peripheral {
hdl_path = "u_regs";
reg {
hdl_path = "control_q";
field { sw = rw; } enable[0:0];
} control @ 0;
};
This maps control to dut.u_regs.control_q. Packed ranges are accepted, such
as control_q[15:0]. If a register is stored in separate field signals, omit
its register-level path and add field slices:
field {
hdl_path_slice = '{"pending_q"};
} pending[7:0];
field {
hdl_path_slice = '{"state[1]", "state[0]"};
} state[1:0];
A single entry represents the complete field. Multiple entries must contain one path per bit, ordered from the field MSB to LSB; other lengths are ambiguous and rejected. The generated descriptor retains each complete path, logical LSB, and width for diagnostics and tooling.
A memory uses one hdl_path_slice entry naming the complete one-dimensional
unpacked HDL array:
external mem {
mementries = 256;
memwidth = 32;
sw = rw;
hdl_path_slice = '{"packet_ram"};
} packet_ram @ 0x1000;
The generated adapter maps logical memory entry zero to the array’s declared low bound, including arrays whose HDL range does not begin at zero. Omit the property for a frontdoor-only memory. Backdoor use then fails with the complete logical memory path instead of falling back to runtime string lookup.
The generated adapter uses deposits. It does not force storage and does not
advance simulation time. peek() and poke() apply immediately — unlike
a frontdoor operation, whose pin drives queue to the ReadWrite point like any
port set() — and any wait needed for RTL to react remains explicit in the
testbench. Hierarchy
discovery adds only the typed get and deposit hooks actually instantiated
by the testbench, so frontdoor-only users pay no backdoor transport cost.
IP-XACT input¶
PeakRDL can import an IP-XACT component before invoking the same exporter:
uv run --frozen --extra peakrdl peakrdl cpptb component.xml \
-o build/generated/peripheral_regs.hpp \
--namespace peripheral_regs
IP-XACT is useful when it is the existing integration contract, but conversion is limited to properties the PeakRDL importer can map into SystemRDL semantics. Retain SystemRDL as the source of truth when read/write effects, nested memories, or project-specific properties would be lost in an interchange round-trip.
The runnable IP-XACT register-model example starts from a checked-in 1685-2014 component and covers fields, an enum, register arrays, and a native memory address block. Its generated model is compiled against both a fake memory-mapped master and a real APB4 DUT, with a matching pure-SystemVerilog sequence. Unknown vendor extensions require an explicit PeakRDL import mapping; cpptb cannot preserve properties the importer does not expose.
Native RgGen input¶
An existing RgGen YAML, JSON, or TOML contract can be consumed directly. No IP-XACT conversion or user-authored cpptb mapping file is required:
uv run --frozen cpptb-rggen rtl/uart_csr.yml \
-o build/generated/uart_regs.hpp \
--namespace uart_regs \
--class-name UartRegisters
The command accepts RgGen’s mapping-style and sequence-style metadata. It
preserves register blocks, register files, arrays, field sequences, offsets,
resets, common built-in access/trigger field types, and field-qualified
diagnostics. Select one block from a multi-block input with --block NAME.
The output filename, C++ namespace, and model class are independent, exactly as
they are for the PeakRDL exporter.
Optional cpptb_hdl_path properties on a register, field, or external memory
enable generated hierarchy backdoors. This is a cpptb extension because native
RgGen metadata does not define a portable C++ simulator-binding property.
Omitting it produces a frontdoor-only model without backdoor wrapper code.
Run the installed command for its complete versioned interface:
uv run --frozen cpptb-rggen --help
Argument |
Purpose |
|---|---|
|
One native |
|
Required generated header path and filename |
|
Select one register block when the input contains several |
|
Override the generated C++ namespace |
|
Override the generated model class; default |
The direct importer currently rejects indirect registers and plugin-specific field types it cannot map to standard RAL semantics. The error includes the complete RgGen logical path. External register arrays without fields are modeled as register-backed memories; more specialized RgGen plugins should continue to generate their RTL normally and either provide a standard SystemRDL/IP-XACT contract or extend the importer explicitly.
Validation and diagnostics¶
A generation job should be followed by compilation and at least one model execution test. The cpptb regression performs all three levels:
Generate from native SystemRDL and syntax-check the C++ header.
Exercise generated handles against a fake
MemoryMappedMasterto verify addresses, data, statuses, mirrors, and side effects without a simulator.Run the generated model against the pinned secworks AES core, including an exact transaction-oracle comparison with its unchanged upstream bench.
Generation failures include the complete SystemRDL or RgGen node path. Current explicit limitations are:
a memory frontdoor transfer must be byte-aligned, at most 64 bits, and fit the master’s integral data type;
register transfer widths must be byte aligned, divide the logical register width, be at most 64 bits, and fit the master’s integral data type;
referenced reset values must elaborate to constants;
SystemRDL register aliases currently require an explicit
RegisterAddressMapplusRegisterPredictor::add_alias()so every address view shares one logical mirror; anduser-defined SystemRDL
ruser/wusereffects require an authoredRegisterUserEffectPolicyto define prediction and update encoding.
These checks happen at generation or first use rather than silently truncating an access.