Verification components¶
When a testbench outgrows driving pins by hand, cpptb_vc supplies the pieces
you would otherwise write yourself: typed transaction endpoints, analysis
fan-out, in-order and keyed scoreboards, ready/valid stream helpers, and APB
components.
It is an optional layer, kept outside the core include tree and namespace so that a testbench pays for it only by asking for it:
#include "cpptb/cpptb.hpp" // scheduler, signals, tests
#include "cpptb_vc/cpptb_vc.hpp" // optional verification components
using namespace cpptb::vc;
The core umbrella does not include cpptb_vc. In CMake, consumers can make
the dependency equally explicit:
target_link_libraries(my_testbench PRIVATE cpptb::cpptb cpptb::vc)
cpptb::vc is currently shipped from the same repository, but it is a
separate interface target whose only dependency is the public cpptb API.
The component headers do not include generated DUT bindings, a simulator DPI
adapter, or project-specific RTL. That boundary allows the component library
to move into a separately versioned package later without changing its public
namespace or include paths.
Package layout¶
Header |
Responsibility |
|---|---|
|
Put/get endpoints, analysis fan-out, and explicit analysis buffering |
|
In-order and keyed scoreboards plus reference-model publication |
|
Stream concepts and ready/valid source, sink, and monitor components |
|
Protocol-neutral requests, responses, transactions, statuses, and master concept |
|
Sparse expected memory, regions, callbacks, images, and passive prediction |
|
Typed registers, fields, memories, mirrors, and frontdoor/backdoor adapters |
|
Optional register, field, and register-memory access coverage |
|
Reusable reset-check, access-check, and policy-aware bit-bash sequences |
|
APB master, passive monitor, and protocol checker |
|
Convenience umbrella for the complete optional package |
The old cpptb/components.hpp header remains a monorepo compatibility shim for
the first endpoint APIs. New code should import cpptb_vc directly. The shim
is deprecated for new integrations: before cpptb_vc becomes a separately
versioned distribution, it will move with that distribution or be removed so
the core package never acquires a reverse dependency on optional components.
Component guides¶
Keep core testbenches on cpptb::cpptb and add only the optional component
layers the environment needs:
Guide |
Use it for |
|---|---|
Record monitor-derived typed transactions to memory or JSON Lines |
|
Images, expected byte storage, byte enables, permissions, and passive prediction |
|
Typed registers and fields, staged/mirrored state, frontdoor/backdoor access, and side effects |
|
SystemRDL, IP-XACT, or native RgGen input, build dependencies, naming, and limitations |
|
Reset checks, frontdoor/backdoor agreement, bit-bash policy, summaries, and performance peer |
These components depend on scheduler tasks and generic transaction interfaces; the scheduler and generated DUT API do not depend on them. This packaging follows the same useful boundary described by Cocotb’s extension guidance: the simulation framework remains small while reusable verification IP can be installed, documented, and versioned separately.
Transaction endpoints and analysis fan-out¶
Use the optional cpptb_vc package when a sequence, driver, monitor, or
scoreboard should depend on a typed endpoint instead of concrete storage.
It is not included by cpptb/cpptb.hpp:
#include "cpptb_vc/cpptb_vc.hpp"
using namespace cpptb::vc;
PutPort<T> and GetPort<T> are borrowed views: constructing one allocates
nothing, and the connected backend must outlive the port and its active calls.
A Queue<T> works directly, and another backend can implement the same
put/put_nowait or get/get_nowait methods. A custom blocking put must
accept T by value so ownership remains in its coroutine frame; the
PutPort constructor enforces that signature.
Queue<Packet> storage{8};
PutPort<Packet> input{storage};
GetPort<Packet> output{storage};
co_await input.put(packet);
Packet next = co_await output.get();
AnalysisPort<T> publishes one const transaction synchronously to every live
subscriber in connection order. write() is a zero-time C++ call: it neither
suspends nor advances the simulator. Keep the returned move-only connection
for as long as the subscription should remain active; destroying or explicitly
disconnecting it removes that subscriber.
AnalysisPort<Packet> observed;
InOrderScoreboard<Packet> scoreboard{test, "packet"};
AnalysisBuffer<Packet> audit{8, AnalysisOverflowPolicy::Error};
auto score_connection = observed.connect(scoreboard.actual());
auto audit_connection = observed.connect(audit);
observed.write(packet); // immediate fan-out; no hidden await or delay
Subscribers are borrowed and must outlive their connections. A subscriber’s
write(const T&) must not wait. Connect AnalysisBuffer<T> when a coroutine
needs to consume observations asynchronously. Its required overflow policy is
explicit: DropNewest, DropOldest, or Error. The two drop policies do not
block or interrupt later subscribers. Error throws synchronously and stops
that publication, so connect an Error-policy buffer after subscribers that
must always observe the transaction. Other subscriber exceptions follow the
same connection-order rule. output() exposes the buffer’s consumer side as
a GetPort<T>.
InOrderScoreboard<T> accepts expected and actual transactions in either
arrival order, compares pairs with nonfatal expect_eq(), and reports any
unpaired values from finalize(). It is intentionally noncopyable and
nonmovable because its typed inputs refer back to the owning scoreboard.
ReadyValidDriver and
ReadyValidMonitor translate transactions to pin activity. Their constructor
requires the sampling delay, and the monitor also requires the sample edge;
they do not reset signals, start clocks, or spawn themselves. send() drives
one transfer and deasserts valid before returning; repeated calls therefore
include an idle cycle rather than implying a maximum-throughput burst driver.
The complete component FIFO example shows these objects alongside the direct FIFO scoreboard. Both are runnable, and each has an exact pure-SystemVerilog twin. Protocol-neutral memory transactions, keyed scoreboards, reference models, and APB components are described below.
Protocol-neutral transactions¶
A sequence can depend on the MemoryMappedMaster concept instead of APB:
template <MemoryMappedMaster Master>
Task<void> program_control(Master& bus, TestContext& test) {
const auto write = co_await bus.write(0x10u, 0x0000'0003u);
test.require_eq("control write", write.status, MemoryStatus::Okay);
const auto read = co_await bus.read(0x10u);
test.expect_eq("control readback", read.data, 0x0000'0003u);
}
The concrete master supplies its address, data, byte-enable, request, and
response types. MemoryWriteResponse and MemoryReadResponse<T> retain the
protocol-independent status and number of wait cycles. A timeout is returned
as MemoryStatus::Timeout; it is not silently converted into a test failure.
The calling sequence decides whether that response is expected, nonfatal, or
fatal.
StreamSource and StreamSink provide the corresponding compile-time
contracts for packet and word streams. ReadyValidDriver and
ReadyValidSink implement those contracts, while ReadyValidMonitor remains
passive and publishes accepted transfers through an AnalysisPort.
APB components¶
Create a typed bus from generated signals and pass it to each component:
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};
ApbMaster master{bus};
ApbMonitor monitor{test, bus};
ApbProtocolChecker checker{test, bus};
The APB components carry both write models,
though deferred_writes = true
is the documented configuration and the one the examples use. Under it they
take the cocotb shape: drives anchor on the rising edge and apply after that
edge’s own updates, and the master’s completion loop, the monitor, and the
protocol checker observe at the pre-evaluation resume – the values the
design sampled at the edge. Their sample_delay knob is compiled out in this
mode – pass nothing, as above and as every example does; the knob only
affects legacy immediate-write builds, where the components keep falling-edge
drive points and post-edge sampling. The ready-valid stream components are
different: the driver, sink, and monitor keep their explicit sample-edge and
post-edge sample_delay shapes in both models, mirroring their pure-SV
twins. One counting consequence is user-visible on the APB side:
pre-evaluation observation reports a wait state for every access cycle
with PREADY low, where post-edge sampling skipped the first one; calibrated
wait-cycle expectations differ between the modes accordingly.
Pass a tenth signal to ApbBus when PSTRB is present. Without it, requests
still carry a byte-enable value so a generic sequence has one stable shape;
an APB3-style master simply cannot apply partial writes.
For APB4, ApbMaster::all_bytes() derives the full-write mask from the HDL
width of PSTRB, even when the generated C++ storage type is wider. The master
drives PSTRB to zero during reads as required by APB. Monitored read
transactions remain protocol-neutral and report all_bytes(); the physical
read strobe is a bus control rule, not a partial-read request.
ApbMaster serializes concurrent callers with a cancellation-safe lock. Its
coroutines explicitly drive setup, drive access, wait on PREADY, sample the
response, and return the bus to idle. It does not start a clock, reset the
DUT, spawn itself, or add an unrequested sampling delay.
ApbMonitor never drives a signal. Its observed() analysis port publishes
completed reads and writes as timed TransactionObservation<MemoryTransaction>
values, including byte enables, response status, and wait cycles. Scoreboards,
predictors, and register coverage accept completed observations directly.
ApbProtocolChecker independently reports malformed setup/access
transitions, a nonzero read PSTRB, and control changes while waiting for
PREADY. PWDATA stability is checked only for writes because it is not a
read control signal.
Connect a recorder when a persistent or in-memory transaction timeline is useful. The monitor performs protocol decoding once and fans the same typed observation out to every consumer:
TransactionRecorder recorder;
InMemoryTransactionSink trace;
auto trace_sink = recorder.connect(trace);
auto& stream = recorder.stream<Transaction>("apb0.observed");
auto checking = monitor.observed().connect(scoreboard.actual());
auto recording = monitor.observed().connect(stream);
co_await monitor.run(expected_transfer_count);
See Transaction recording and the runnable APB trace example.
Prediction and checking¶
Use an in-order scoreboard when responses preserve request order. Use a keyed scoreboard when transactions can return in another order:
const auto transaction_id = [](const Response& response) {
return response.id;
};
KeyedScoreboard<Response, decltype(transaction_id)> scoreboard{
test, "response", transaction_id};
auto expected_connection = predicted.connect(scoreboard.expected());
auto actual_connection = observed.connect(scoreboard.actual());
Duplicate keys are retained in FIFO order. finalize() reports unmatched
expected and actual values as nonfatal checks. Transaction text is formatted
only after a mismatch, so successful comparisons do not allocate diagnostic
strings.
ReferenceModelAdapter<Input> turns an ordinary callable into an analysis
subscriber and publishes its return value:
auto predictor = make_reference_model<Request>(
[](const Request& request) { return model(request); });
auto connection = predictor.predicted.connect(scoreboard.expected());
auto request_connection = requests.connect(predictor);
The complete APB register-file example connects a master, monitor, checker, scoreboard, and coverage model. The component FIFO demonstrates the stream and analysis components.
The same APB monitor can feed a protocol-independent sparse memory predictor. Use the register abstraction layer when the environment also needs typed fields, staged/mirrored state, or generated SystemRDL access.
One component in four frameworks¶
The following tabs show the same component boundary in four familiar styles. Each monitor waits for a ready/valid transfer, samples the 32-bit payload, and publishes it to downstream checking code. The examples intentionally use each ecosystem’s native communication mechanism instead of forcing identical class shapes onto different languages.
template <typename Clock, typename Valid, typename Ready, typename Data>
class ReadyValidMonitor {
public:
using value_type = typename Data::value_type;
ReadyValidMonitor(Clock clock, Valid valid, Ready ready, Data data,
ReadyValidSampleEdge sample_edge,
coro::SimTime sample_delay)
: clock_(clock), valid_(valid), ready_(ready), data_(data),
sample_edge_(sample_edge), sample_delay_(sample_delay) {}
coro::Task<void> run(AnalysisPort<value_type>& observed,
std::size_t transaction_count) {
std::size_t received = 0;
while (received < transaction_count) {
if (sample_edge_ == ReadyValidSampleEdge::Rising) {
co_await coro::RisingEdge{static_cast<coro::Signal>(clock_)};
} else {
co_await coro::FallingEdge{static_cast<coro::Signal>(clock_)};
}
if (sample_delay_.in_femtoseconds() != 0) {
co_await coro::Delay{sample_delay_};
}
if (valid_.get() == 0 || ready_.get() == 0) continue;
observed.write(data_.get());
++received;
}
}
private:
Clock clock_;
Valid valid_;
Ready ready_;
Data data_;
ReadyValidSampleEdge sample_edge_;
coro::SimTime sample_delay_;
};
from cocotb.queue import Queue
from cocotb.triggers import ReadOnly, RisingEdge
class ReadyValidMonitor:
def __init__(self, clock, valid, ready, data):
self.clock = clock
self.valid = valid
self.ready = ready
self.data = data
self.observed = Queue()
async def run(self, transaction_count):
received = 0
while received < transaction_count:
await RisingEdge(self.clock)
await ReadOnly()
if not self.valid.value or not self.ready.value:
continue
self.observed.put_nowait(int(self.data.value))
received += 1
interface ready_valid_if(input logic clk);
logic valid;
logic ready;
logic [31:0] data;
endinterface
class ready_valid_monitor;
virtual ready_valid_if vif;
mailbox #(logic [31:0]) observed;
function new(virtual ready_valid_if vif,
mailbox #(logic [31:0]) observed);
this.vif = vif;
this.observed = observed;
endfunction
task run(int unsigned transaction_count);
int unsigned received = 0;
while (received < transaction_count) begin
@(posedge vif.clk);
#1ps;
if (!vif.valid || !vif.ready) continue;
observed.put(vif.data);
received++;
end
endtask
endclass
class ready_valid_transaction extends uvm_sequence_item;
`uvm_object_utils(ready_valid_transaction)
logic [31:0] data;
function new(string name = "ready_valid_transaction");
super.new(name);
endfunction
endclass
class ready_valid_monitor extends uvm_monitor;
`uvm_component_utils(ready_valid_monitor)
virtual ready_valid_if vif;
uvm_analysis_port #(ready_valid_transaction) observed;
function new(string name, uvm_component parent);
super.new(name, parent);
observed = new("observed", this);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db #(virtual ready_valid_if)::get(
this, "", "vif", vif)) begin
`uvm_fatal("NO_VIF", "ready_valid_if was not configured")
end
endfunction
task run_phase(uvm_phase phase);
forever begin
ready_valid_transaction transaction;
@(posedge vif.clk);
#1ps;
if (!vif.valid || !vif.ready) continue;
transaction = ready_valid_transaction::type_id::create("transaction");
transaction.data = vif.data;
observed.write(transaction);
end
endtask
endclass
The structural correspondence is more important than identical spelling:
Concern |
cpptb-vc |
Cocotb |
Pure SystemVerilog |
UVM |
|---|---|---|---|---|
Component lifecycle |
An ordinary |
An |
A |
A component |
Transaction output |
Fan-out |
|
|
Fan-out |
DUT connection |
Generated typed signal handles |
Simulator hierarchy handles |
|
A |
Packaging |
Optional |
Python module or add-on package |
SystemVerilog package/interface files |
UVM package and agent hierarchy |
cpptb-vc deliberately adopts the lightweight transaction-connectivity idea
from UVM without requiring UVM’s factory, phase, objection, or configuration
machinery. Cocotb similarly keeps scheduling and DUT handles in its core while
reusable components can live in ordinary Python or separate packages. See the
official Cocotb documentation for Queue
and the timing model, and
the Accellera UVM 1.2 User’s Guide
for monitors and analysis ports.
The cpptb-vc and pure-SystemVerilog forms have complete runnable equivalents in the component FIFO example. The Cocotb and UVM tabs above are concise architectural references, not additional performance measurements.
Performance qualification¶
The exact apb_component benchmark performs the same APB pin sequence,
monitor publication, protocol checking, scoreboard comparisons, response
checks, and checksum calculation in C++ DPI and pure SystemVerilog:
make feature-test FEATURE=apb_component
make feature-benchmark FEATURE=apb_component
The August 8, 2026 admitted run certified the pair under the standard 1.10x
hard guard at 1.0412x C++ DPI over pure SystemVerilog at 100,000 write/read
pairs. Performance records the run
history, including the superseded July 17 baseline.