Transaction recording¶
Transaction recording presents protocol operations as timed, structured data. It complements signal waveforms rather than replacing them. For example, an APB waveform can be summarized as:
120 ns - 150 ns apb.write address=0x24 data=0x08 status=okay
Reading that line beats reading the waveform that produced it, and unlike a waveform it can be diffed, filtered, or fed to another tool.
Note
Recording lives in the optional cpptb_vc package, so no protocol knowledge or
recording policy reaches the core scheduler. The shipped slice covers typed
completed observations, APB monitoring, in-memory retention, and JSON Lines
output. The design-decision record and the deliberately deferred extensions are
tracked on the roadmap.
The record comes from a passive protocol monitor that observes DUT signals and reconstructs what actually completed. A recorder subscribes to the monitor’s typed output; the test does not manually repeat transaction or field names. Observed transactions are the verification ground truth. Driver-side intent recording — what the test asked a driver to do, kept as a separately named stream — is a deferred extension, and the current API records observed traffic only and does not correlate streams.
Architecture at a glance¶
The solid path is the default: the passive monitor reconstructs completed transactions from DUT signals and publishes one typed observation. Scoreboards, coverage, models, and recording consume that same publication. The dashed path is optional driver intent; it would reach the recorder through a separately named stream and is not automatically correlated with the observed traffic.
Layer |
Responsibility |
|---|---|
Protocol monitor |
Observe signals, identify boundaries, and construct typed transactions |
Fan one observation out to checking, coverage, models, and recording |
|
Transaction metadata |
Begin/end sample time, completion disposition, named stream, per-stream sequence, and available provenance |
Typed recording stream |
Establish type erasure and explicit component identity once at connection time |
Recorder |
Apply global or per-stream enable policy and route records to sinks |
Sink |
Serialize JSON, retain an in-memory trace, or integrate with a simulator database |
User experience¶
For a standard protocol, the VC defines the transaction type and decoding rules. The user connects generated signals to the monitor, then connects the monitor to any consumers:
const auto bus = ApbBus{
dut.clk, dut.apb_select, dut.apb_enable,
dut.apb_write, dut.apb_address, dut.apb_write_data,
dut.apb_read_data, dut.apb_ready, dut.apb_error};
ApbMonitor monitor{test, bus};
using Transaction = typename decltype(monitor)::transaction_type;
TransactionRecorder recorder;
JsonLinesTransactionSink json{"transactions.jsonl"};
InOrderScoreboard<Transaction> scoreboard{
test, "APB transaction"};
auto json_output = recorder.connect(json);
auto recording = monitor.observed().connect(
recorder.stream<Transaction>("apb0.observed"));
auto checking = monitor.observed().connect(scoreboard.actual());
co_await Join{
monitor.run(kExpectedTransfers),
stimulus(dut, test),
};
json.finalize();
The important properties are:
the monitor attaches to typed signals and understands APB timing;
observed()publishesTransactionObservation<Transaction>;existing consumers receive its
.valuethrough constrained overloads, so one typed publication can feed recording, scoreboards, coverage, and models;recording does not drive signals, advance time, or affect pass/fail results;
transaction and field names are not authored in the test; and
the explicit stream string names a component instance for debug output; it does not define transaction fields or protocol behavior.
The runnable APB transaction trace example records and checks the same 256 operations in C++ and pure SystemVerilog with this setup.
The observation envelope¶
The user should not define APB fields repeatedly. cpptb_vc already has the
protocol-neutral shape needed for APB observations:
template <typename Address, typename Data, typename ByteEnable>
struct MemoryTransaction {
MemoryOperation operation;
Address address;
Data data;
ByteEnable byte_enable;
MemoryStatus status;
uint32_t wait_cycles;
};
The monitor publishes an envelope whose type is fixed by the API:
enum class TransactionDisposition {
Completed,
Aborted,
Incomplete,
};
template <typename T>
struct TransactionObservation {
coro::SimTime begin_time{};
coro::SimTime end_time{};
TransactionDisposition disposition =
TransactionDisposition::Completed;
T value{};
};
ApbMonitor::observed() therefore returns an
AnalysisPort<TransactionObservation<transaction_type>>&. Scoreboard,
reference-model, and coverage inputs gain constrained write(observation)
overloads that forward observation.value to their existing write(value)
implementation, so one monitor port serves recording and checking alike.
Those compatibility overloads forward only Completed observations. An
Aborted or Incomplete observation remains available to recorders and
diagnostic subscribers but is not silently compared as a completed value.
Timestamps¶
At completion, ApbMonitor publishes one observation equivalent to:
this->publish(setup_time_, transaction_type{
.operation = write ? MemoryOperation::Write
: MemoryOperation::Read,
.address = bus_.address.get(),
.data = write ? bus_.write_data.get() : bus_.read_data.get(),
.byte_enable = write ? strobe() : all_bytes(),
.status = error ? MemoryStatus::SlaveError : MemoryStatus::Okay,
.wait_cycles = wait_cycles_,
});
Times represent the instants at which the monitor sampled the protocol. Under the standard write model that is the awaited clock edge itself — the pre-evaluation resume, where the monitor reads the values the design sampled at that edge. In a legacy immediate-write build the times fall after the monitor’s explicit sampling delay instead.
Completed records and ordering¶
Publishing a completed record with explicit begin and end times avoids keeping
an open recorder handle. It also has an important limitation: a transfer that
never completes produces no completed observation, so a slave that never
asserts PREADY, a reset in the middle of a transfer, or monitor cancellation
can hide the operation most relevant to a hang. The current APB monitor
publishes completed records only and emits only Completed. If PSEL drops
before completion, it discards the unfinished setup and resynchronizes at the
next setup phase; it never combines fields from two transfers. Aborted and
Incomplete are valid envelope dispositions for custom publishers and sinks;
automatic monitor flush and cancellation emission are deferred.
Monitors publish records in completion order. Overlapping and out-of-order transactions remain self-contained, so their begin times need not be monotonic. The recorder preserves publication order and assigns a monotonically increasing sequence number within each named stream. Sinks receive that sequence unchanged. A viewer may sort by begin time, but consumers must not assume the sink has done so.
The monitor base¶
Generated DUT bindings can discover hierarchy, signal names, dimensions, and types, but they cannot infer protocol semantics: which fields form one operation, when a transfer completes, or how phases, wait states, and responses relate. The recorder therefore never subscribes to arbitrary signals. A protocol monitor decodes signals into typed transactions, and the recorder subscribes to that typed output. Known VCs make this automatic; a custom protocol requires a custom monitor or decoder.
TransactionMonitor<T> is the reusable plumbing beneath every such monitor,
not a second protocol layer. It owns observed(), anchors the sampling loop,
and provides four protected operations:
Operation |
Meaning |
|---|---|
|
Wait for the next rising edge — the pre-evaluation observation point under the standard write model. In a legacy immediate-write build it additionally awaits the configured sampling delay |
|
Repeat |
Return the current simulation time |
|
|
Add the completion time and publish one typed observation |
The protocol rules stay visible in the derived monitor: the APB setup, access,
wait-state, response, and error handling remain explicit in ApbMonitor (shown
in full in the comparison below).
The base only removes timestamps, output-port ownership, and the sampling loop
that every passive synchronous monitor otherwise repeats. It uses static
templates rather than virtual dispatch.
Custom protocol monitors¶
Custom components use the same typed API, not a string field builder:
struct CommandTransaction {
uint8_t opcode;
Bits<48> payload;
bool accepted;
};
class CommandMonitor : public TransactionMonitor<CommandTransaction> {
public:
CommandMonitor(TestContext test, CommandBus bus)
: TransactionMonitor{std::move(test)}, bus_{bus} {}
Task<void> run() {
while (true) {
co_await this->sample(bus_.clock);
if (bus_.valid.get() == 0 || bus_.ready.get() == 0) continue;
const auto sampled = this->now();
this->publish(sampled, CommandTransaction{
.opcode = bus_.opcode.get(),
.payload = bus_.payload.get(),
.accepted = true,
});
}
}
private:
CommandBus bus_;
};
Serialization for CommandTransaction is taught once by a static descriptor
authored beside the component, described under
the JSON Lines schema below.
Recorder, streams, and sinks¶
API |
Purpose |
|---|---|
|
Create one uniquely named typed recording endpoint |
|
Route every enabled record to a synchronously called sink |
|
Disable or enable publication without disconnecting the graph |
|
Publish an explicitly timed |
|
Inspect the next per-stream sequence number |
|
Read retained records in publication order |
|
Release all records retained by an in-memory sink |
|
Flush and close JSON Lines output with checked errors |
TransactionRecorder is neither copyable nor movable. Its stream references
remain valid for the recorder’s lifetime, and connections, streams, and sinks
must all remain alive while observations are published. stream_count() and
sink_count() are available for setup diagnostics; they are not simulation
work counters.
The recorder owns each stream label, rejects every duplicate label even when
the value type matches, and copies the label once at stream creation. Code that
needs the endpoint more than once stores the returned reference. A misspelling
changes a debug label; it cannot change the decoded fields or protocol
behavior. Because the publishing process identifies the passive monitor rather
than the stimulus process that caused the traffic, a named stream such as
"apb0.observed" is the primary component identity in recorded output.
The recorder can write to one or more optional sinks:
JsonLinesTransactionSink json{"transactions.jsonl"};
auto json_output = recorder.connect(json);
// A future simulator adapter could use the same typed records.
// auto waveform_output = recorder.connect(simulator_transaction_database);
Connections follow the existing RAII analysis-port convention. A sink must
outlive its connection, and dropping the returned connection disconnects it.
finalize() flushes output and reports errors on a normal test exit; the sink
destructor performs only best-effort cleanup. JSON Lines keeps every completed
line independently readable if simulation terminates before finalization.
Filtering that depends on T belongs in a typed analysis subscriber adapter,
not behind the recorder’s erased interface; the recorder itself provides only
cheap global and per-stream enables. A filtered adapter must be stored in a
named variable for at least as long as its analysis connection; connecting a
temporary would violate the existing raw-subscriber lifetime contract.
The JSON Lines schema¶
The recorder associates each C++ value type with a static descriptor. Export
names such as operation, address, and status exist once in that
descriptor; they are not passed through test code as free-form strings.
cpptb_vc supplies a partial descriptor specialization for templated
MemoryTransaction<Address, Data, ByteEnable> values, so APB recording needs
no descriptor authoring at all.
C++20 cannot reflect aggregate member names, so a custom type such as the
CommandTransaction above needs one static descriptor beside its definition.
Member pointers keep fields typed and refactorable; strings are export labels
only:
CPPTB_VC_DESCRIBE_TRANSACTION(
CommandTransaction, "command",
transaction_field<&CommandTransaction::opcode>("opcode"),
transaction_field<&CommandTransaction::payload>("payload"),
transaction_field<&CommandTransaction::accepted>("accepted"));
The macro defines an argument-dependent descriptor function. The equivalent explicit form is available when a component does not want to use a macro:
constexpr auto cpptb_transaction_descriptor(
std::type_identity<CommandTransaction>) {
return describe_transaction<CommandTransaction>(
"command",
transaction_field<&CommandTransaction::opcode>("opcode"),
transaction_field<&CommandTransaction::payload>("payload"),
transaction_field<&CommandTransaction::accepted>("accepted"));
}
TransactionRecorder::stream<T>(name) is the type-erasure boundary. It owns
the stream name and installs one per-type JSON writer when the stream is
created. Each write() constructs a non-owning record view on the stack and
invokes connected sinks synchronously. The view and its referenced transaction
remain valid only for that call. This avoids converting every transaction into
a heap-allocated string-to-value map before a sink asks for serialization.
Built-in value encoders cover integers, finite floating-point values, symbolic
enums, booleans, strings, Bits, LogicBits, arrays, and nested described
types. Floating-point values use round-trip precision and reject NaN or
infinity, which JSON cannot represent. Arrays and nested descriptors remain
structured JSON; packed and four-state values use the same stable textual form
as diagnostics. Descriptor fields must be direct data members. A field without
a supported encoder fails at the enabled sink with an actionable exception
rather than silently losing data.
The same APB observation in other frameworks¶
The examples below focus on the protocol-decoding boundary for the same 32-bit APB interface. Common clock, reset, construction, and error handling are abbreviated. The production cpptb-vc component derives its address, data, and byte-enable types from the generated signal types; the fixed aliases below keep the comparison compact.
// ApbTransaction<Bus> derives its field widths from Bus's signal types.
template <typename Bus>
class ApbMonitor : public TransactionMonitor<ApbTransaction<Bus>> {
using Base = TransactionMonitor<ApbTransaction<Bus>>;
public:
using typename Base::transaction_type;
ApbMonitor(TestContext test, Bus bus, SimTime sample_delay = {})
: Base{test, sample_delay}, bus_{bus} {}
Task<void> run(std::size_t transaction_count) {
while (transaction_count-- != 0) co_await observe_one();
}
private:
Task<void> observe_one() {
while (true) {
co_await this->sample_until(bus_.clock, [&] {
return bus_.select.get() != 0 && bus_.enable.get() == 0;
});
SimTime started{};
bool write = false;
transaction_type transaction{};
const auto capture_setup = [&] {
started = this->now();
write = bus_.write.get() != 0;
transaction = transaction_type{
.operation = write ? MemoryOperation::Write
: MemoryOperation::Read,
.address = bus_.address.get(),
.data = bus_.write_data.get(),
.byte_enable = uint8_t{0x0f},
};
};
capture_setup();
while (true) {
co_await this->sample(bus_.clock);
if (bus_.select.get() == 0) break; // resynchronize
if (bus_.enable.get() == 0) {
capture_setup(); // a repeated setup replaces the old one
continue;
}
if (bus_.ready.get() == 0) {
++transaction.wait_cycles;
continue;
}
if (!write) transaction.data = bus_.read_data.get();
transaction.status = bus_.error.get() == 0
? MemoryStatus::Okay
: MemoryStatus::SlaveError;
this->publish(started, std::move(transaction));
co_return;
}
}
}
Bus bus_;
};
typedef struct {
bit write;
logic [31:0] address;
logic [31:0] data;
int unsigned wait_cycles;
bit error;
time begin_time;
time end_time;
} apb_transaction_t;
task automatic monitor_apb();
apb_transaction_t transaction;
bit active = 0;
forever begin
@(posedge apb.PCLK);
#1ps;
if (!active && apb.PSEL && !apb.PENABLE) begin
transaction = '{default: '0};
transaction.write = apb.PWRITE;
transaction.address = apb.PADDR;
transaction.data = apb.PWDATA;
transaction.begin_time = $time;
active = 1;
end
if (active && apb.PSEL && apb.PENABLE) begin
if (!apb.PREADY) begin
transaction.wait_cycles++;
end else begin
if (!transaction.write) transaction.data = apb.PRDATA;
transaction.error = apb.PSLVERR;
transaction.end_time = $time;
observed.put(transaction);
transaction_log.write(transaction);
active = 0;
end
end
end
endtask
class apb_monitor extends uvm_monitor;
`uvm_component_utils(apb_monitor)
virtual apb_if.monitor vif;
uvm_analysis_port #(apb_item) observed;
task run_phase(uvm_phase phase);
forever begin
apb_item item = apb_item::type_id::create("item");
do begin
@(posedge vif.PCLK);
#1ps;
end while (!(vif.PSEL && !vif.PENABLE));
item.write = vif.PWRITE;
item.address = vif.PADDR;
item.data = vif.PWDATA;
void'(begin_tr(item, "apb.observed"));
do begin
@(posedge vif.PCLK);
#1ps;
if (vif.PENABLE && !vif.PREADY) item.wait_cycles++;
end while (!(vif.PENABLE && vif.PREADY));
if (!item.write) item.data = vif.PRDATA;
item.error = vif.PSLVERR;
end_tr(item);
observed.write(item);
end
endtask
endclass
@dataclass(frozen=True)
class ApbTransaction:
write: bool
address: int
data: int
wait_cycles: int
error: bool
begin_time_ns: float
end_time_ns: float
class ApbMonitor:
async def run(self):
while True:
await RisingEdge(self.bus.PCLK)
await ReadOnly()
if not (self.bus.PSEL.value and not self.bus.PENABLE.value):
continue
started = get_sim_time("ns")
write = bool(self.bus.PWRITE.value)
address = int(self.bus.PADDR.value)
data = int(self.bus.PWDATA.value)
waits = 0
while True:
await RisingEdge(self.bus.PCLK)
await ReadOnly()
if self.bus.PENABLE.value and self.bus.PREADY.value:
break
if self.bus.PENABLE.value:
waits += 1
if not write:
data = int(self.bus.PRDATA.value)
self.observed(ApbTransaction(
write, address, data, waits,
bool(self.bus.PSLVERR.value),
started, get_sim_time("ns")))
In all four versions, a protocol-aware monitor reconstructs an operation from
signals. UVM adds an explicit transaction-database lifecycle around its
sequence item; the environment and simulator must also enable transaction
recording for begin_tr() and end_tr() to produce database output. Pure SV
and Cocotb need an authored sink or simulator-specific integration if a
persistent transaction timeline is desired.
Performance¶
Qualification uses four distinct comparisons:
The existing
apb_componentC++/pure-SV pair is the disabled-recorder guard: no recorder is constructed or connected, so it measures the observation-envelope and monitor helper overhead paid by users who do not record transactions. The disabled path performs no record formatting, heap allocation, file I/O, or additional simulator crossings.The runnable
apb_traceC++/pure-SV pair performs 256 identical transfers, including 128 inserted wait cycles, retains equivalent record metadata and JSON payloads, and checks the same decoded transactions. Both report 649 checks and 898 simulated cycles.The
transaction_recordingauthoring-core pair scales that workload to 100,000 write/read pairs and retains 200,000 equivalent records in each implementation. It is the enabled-recorder1.10xhard gate, certified at1.0414xin the August 8, 2026 admitted run, and it counts every analysis operation with exact work counters rather than assigning totals afterward.JSON Lines output is measured separately. It becomes a hard comparison only if the SV peer performs equivalent formatting and file writes with the same schema; filesystem variation must not hide framework overhead.
Both guarded pairs run under the repository’s usual load gate, paired and
independent samples, and 1.10x policy.
Design notes¶
The decision record behind this API — why the monitor owns its analysis output
and the recorder subscribes like any other consumer, why string-based dynamic
recording was rejected as the documented default, and why intent-to-observation
correlation is deliberately excluded — is kept with the deliberately deferred
extensions (intent recording, live event streaming, automatic aborted-record
emission, and others) on the
roadmap. One behavioral
consequence worth knowing today: the existing ready/valid monitor still
publishes its bare value_type payload rather than an observation envelope;
its migration is among the deferred items.
References¶
The Accellera UVM reference implementation defines
uvm_transaction, recorder, stream, and component transaction APIs.Transaction Recording Anywhere Anytime demonstrates signal-attached SystemVerilog monitors and recommends keeping UVM recording usage simple.
UVM Transaction Recording Enhancements discusses missing monitor automation and the value of component provenance and parent/child relationships.
Cocotb’s testbench tools documentation describes drivers translating transactions to pins and monitors translating pin activity back into transactions.