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

cpptb_vc/ports.hpp

Put/get endpoints, analysis fan-out, and explicit analysis buffering

cpptb_vc/scoreboards.hpp

In-order and keyed scoreboards plus reference-model publication

cpptb_vc/stream.hpp

Stream concepts and ready/valid source, sink, and monitor components

cpptb_vc/memory_mapped.hpp

Protocol-neutral requests, responses, transactions, statuses, and master concept

cpptb_vc/memory_model.hpp

Sparse expected memory, regions, callbacks, images, and passive prediction

cpptb_vc/register_model.hpp

Typed registers, fields, memories, mirrors, and frontdoor/backdoor adapters

cpptb_vc/register_coverage.hpp

Optional register, field, and register-memory access coverage

cpptb_vc/register_sequences.hpp

Reusable reset-check, access-check, and policy-aware bit-bash sequences

cpptb_vc/apb.hpp

APB master, passive monitor, and protocol checker

cpptb_vc/cpptb_vc.hpp

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

Transaction recording

Record monitor-derived typed transactions to memory or JSON Lines

Sparse expected memory

Images, expected byte storage, byte enables, permissions, and passive prediction

Register abstraction layer

Typed registers and fields, staged/mirrored state, frontdoor/backdoor access, and side effects

Generate register models

SystemRDL, IP-XACT, or native RgGen input, build dependencies, naming, and limitations

Standard register sequences

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.

cpptb-vc (C++ DPI)
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_;
};
Cocotb
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
Pure SystemVerilog
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
UVM
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 Task joined or spawned by the test

An async method started by the test

A task started with fork or from the test module

A component run_phase

Transaction output

Fan-out AnalysisPort<T>

Queue or package-defined callbacks

mailbox or authored callbacks

Fan-out uvm_analysis_port<T>

DUT connection

Generated typed signal handles

Simulator hierarchy handles

virtual interface

A virtual interface, commonly supplied through uvm_config_db

Packaging

Optional cpptb_vc CMake target and headers

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.