Sparse expected memory¶
cpptb_vc::SparseMemory is a protocol-independent verification component for
expected memory contents, images, byte enables, permissions, and access
callbacks. It is separate from the register abstraction layer: use it when a
monitor or reference model needs scalable byte-addressable storage rather than
staged and mirrored register state.
The model does not drive a bus or advance simulation time. Tests can use it directly or connect it to passive transactions from APB, AXI-Lite, Wishbone, or a custom protocol.
Define regions¶
Configure only the address regions the test needs. Unwritten bytes retain the region’s fill value without allocating backing storage:
SparseMemory memory;
memory.add_region(MemoryRegionConfig{
.name = "registers",
.base = 0x0000,
.size = 0x1000,
.permission = MemoryPermission::ReadWrite,
.byte_order = MemoryByteOrder::LittleEndian,
.fill = 0,
});
memory.add_region(MemoryRegionConfig{
.name = "boot-rom",
.base = 0x8000,
.size = 0x4000,
.permission = MemoryPermission::Read,
});
memory.load_file(0x8000, "firmware.bin");
Regions may be writable, read-only, or inaccessible and may select little- or
big-endian integer interpretation. An access that is not fully contained in
one mapped region — an unmapped address or a range straddling a region
boundary — returns MemoryStatus::DecodeError, and a read or write that the
containing region’s permission forbids returns MemoryStatus::SlaveError.
add_region() rejects a region that would overlap an existing one by throwing
rather than by status.
Access data¶
Byte writes, integer word reads and writes, byte enables, load(), fill(),
inspect(), and dump_file() are explicit operations. Byte-enable bit i
always selects address lane address + i, independent of byte order. On a
big-endian 32-bit region, bit zero therefore selects the most-significant byte
of the word.
Use read_into() when a hot path already owns storage:
std::array<uint8_t, 64> cache_line;
const auto status = memory.read_into(address, cache_line);
test.require_eq("cache-line read", status, MemoryStatus::Okay);
Word reads and writes use fixed stack storage internally. Callback-free byte
writes consume caller spans directly. read_bytes() remains the convenient
owning API when a returned std::vector is useful.
Add access policy¶
A replaceable callback can observe or modify an access and can translate its result before or after storage is touched:
class PeripheralPolicy : public MemoryAccessCallback {
public:
void after_access(MemoryAccessEvent& access) override {
if (access.status == MemoryStatus::DecodeError)
access.status = MemoryStatus::SlaveError;
}
};
Attach the policy with the callback constructor or set_callback(); passing
nullptr detaches it:
PeripheralPolicy policy;
SparseMemory memory{policy}; // Attach at construction...
memory.set_callback(&policy); // ...or attach, replace, or detach later.
before_access() runs before the region and permission checks and before any
storage is touched. It can rewrite a write’s data and byte-enable lanes, and
setting a non-Okay status vetoes the access: the built-in checks and the
storage update are skipped and that status is returned. after_access() runs
once the checks and any storage update are complete — on a read, data then
holds the returned bytes — so it is the place to inspect a finished access or
translate its final status, as PeripheralPolicy does above.
Both hooks receive one MemoryAccessEvent containing:
operation—MemoryOperation::ReadorMemoryOperation::Write;address— the first accessed byte address;data— a mutable span holding the write payload or, after a read, the returned bytes;byte_enable— one entry per data byte on writes, where nonzero enables the lane; empty on reads;status— the access result, writable from either hook; andregion— the containing region’s name, empty when no single region contains the access.
The callback observes the read_bytes(), read_into(), read_word(), and
write operations. The direct load(), fill(), inspect(), and
dump_file() maintenance operations bypass it.
Keep timing in the protocol component. The callback is ordinary synchronous C++ and must not assume that a clock or delay is inserted around it.
Predict from a passive monitor¶
Connect the model to any monitor publishing a generic MemoryTransaction:
SparseMemory memory;
memory.add_region({.name = "ram", .base = 0, .size = 64 * 1024});
auto predictor = make_memory_predictor<Transaction>(
test, memory, "APB memory transaction");
auto prediction_connection = monitor.observed().connect(predictor);
co_await Join{sequence(master),
monitor.run(expected_transaction_count)};
test.expect_eq("memory mismatches", predictor.mismatches(), uint64_t{0});
The complete memory_model_apb_test in the
APB register-file example’s
examples/apb_regfile/testbench.cpp combines an APB monitor, writable
storage, a read-only image, and translated unmapped-address errors. The same
memory model has no dependency on APB.
Performance qualification¶
The exact memory_model C++ DPI and pure-SystemVerilog pair runs the same APB
pin sequence, passive prediction, byte-enable updates, checks, and checksum:
make feature-test FEATURE=memory_model
make feature-benchmark FEATURE=memory_model
The bus-free memory_model_direct pair isolates sparse storage and checking
without simulated time:
make feature-test FEATURE=memory_model_direct
make feature-benchmark FEATURE=memory_model_direct
Keep both measurements. The direct workload identifies container cost; the APB workload covers normal monitor and scheduler composition.