Architecture¶
This page is for contributors and for anyone embedding cpptb in their own build or regression system. It describes how the runtime is layered, where the simulator boundary sits, and which ownership rules the design depends on. Nothing here is needed to write a testbench.
The repository contains a reusable framework and an optional reference harness. The framework has four separable layers:
coro_runtime.hppowns tasks, processes, waits, timers, events, queues, and scheduler ordering.test_api.hppowns the compiled test catalog, one-test selection, assertions, process lifetime, and exception attribution.dpi_runtime.hpptranslates simulator callbacks into scheduler steps and batches changed values across the DPI boundary.cpptb-codegenelaborates RTL and emits a typed DUT plus transport wrapper — the pipeline How a build works walks.
The reference harness has two layers:
The public
cpptbproject layer resolves source conventions orcpptb.toml, owns the two-pass build, and caches simulator artifacts.The runner discovers tests and starts one fresh simulator process per selection;
cpptb-runexposes that lower-level executable protocol alone.
test_result.hpp and test_reporting.hpp sit beside these layers as an
embeddable result contract. A higher-level harness can call
registered_tests<Dut>() and run_registered_test(...), receive ResultSink
callbacks, or consume versioned JSON without using the command-line launcher.
The lifecycle contract and its current limitations are documented in
Framework test lifecycle.
The generated SystemVerilog wrapper owns clocks and simulator callback timing (Clocking covers the user-facing contract). The C++ scheduler owns coroutine readiness and cancellation. Signal reads and writes use generated IDs and typed bindings; no runtime hierarchical-name lookup is required on the optimized DPI path.
Every registered test invocation creates one shared lifecycle state. Processes
started through its TestContext are retained as test-owned work. Normal root
completion, a fatal requirement, or an uncaught child exception cancels any
remaining owned processes before the result becomes terminal. Generic
low-level scheduler roots still abort on an unobserved exception, so exceptions
cannot disappear silently outside the test lifecycle boundary.
Scheduler, Testbench, TestContext, and their Process handles are
confined to the simulator thread that owns the testbench. They are not
thread-safe and must not be copied to worker OS threads. This matches the
single-threaded scheduler and simulator-callback contract and lets lifecycle
ownership use non-atomic reference counting on the process hot path. Parallel
host work must return its result through a simulator-thread integration point
rather than calling framework APIs directly.
Coroutine frames are cached in one process-wide pool under that ownership
contract. This avoids a thread-local lookup for every task creation and
reclamation. An embedding that runs independent simulator runtimes on separate
OS threads may define CPPTB_CORO_THREAD_LOCAL_FRAME_POOL to give each thread
its own pool; framework objects and callbacks must still remain on their owning
simulator thread.
Simulator-phase waits have a backend boundary below the public API; the user-facing selection and status table is Timing backend support. The portable path registers standard VPI callbacks for read/write synchronization, read-only synchronization, and the next timestep. When cpptb owns Verilator’s host loop, its direct backend polls the same pending phase state at those exact host-loop locations and dispatches the generated DPI phase task without allocating one-shot VPI callback records. The VPI path remains available for other simulators and Verilator builds that require external VPI integration. Both supported modes continue to use generated DPI for signal transport; only their simulator-phase dispatch mechanism differs.
Generated pure-DPI timing paths are retained as experiments, not supported backends. An NBA-barrier phase pump reproduces the timing contract but is slower than portable VPI. A centralized generated calendar instead gives one SystemVerilog process ownership of compile-time-discovered clocks, framework timers, and phase dispatch; it passes conformance and the exact performance guard under Verilator. External and DUT-driven clocks remain thin observers.
The calendar only knows generated clocks, framework timers, and explicitly observed signals. Standard DPI has no simulator-wide next-event query, so arbitrary internal delayed DUT activity still requires generated observation or a simulator callback API. Cross-simulator validation is required before the calendar can become a supported portable backend.
The resulting backend policy is:
Use direct timing dispatch when cpptb owns the Verilator host loop.
Use standard VPI callbacks as the supported portable fallback.
Keep the generated SV-DPI calendar feature-gated until its restricted
NextTimeStepsemantics and cross-simulator behavior are acceptable.
The framework keeps clock-cycle waits and absolute delays separate. Any signal configured as an observable clock can drive edge waits, while the persistent timer owner supports clockless and arbitrary multi-clock designs.
Reusable DPI runtime¶
include/cpptb/dpi_runtime.hpp owns the design-independent DPI host behavior:
compact directional input/output transport and driven-signal tracking;
typed signal
get()/set()callbacks and dirty-output detection;generated internal-probe
get()/deposit()access for packed variables and fixed memories;scheduler construction, edge dispatch, and delay deadlines;
falling-edge interest and precision-aware time transport;
timeout invocation, elapsed wall time, completion, and result reporting;
the standard init, step, output-pull, deadline, and edge-interest C exports expected by the generated wrapper.
include/cpptb/test_result.hpp keeps status, check counts, timing, and
structured failure records independent of DPI. test_reporting.hpp writes the
versioned JSON result consumed by the optional launcher, so user-facing
fixtures and embedding harnesses do not include simulator transport headers.
A design supplies a small DpiAdapter containing its DUT and result types,
generated signal metadata, binding call, testbench registration call, result
name, and timeout policy. CPPTB_DEFINE_DPI_RUNTIME(Adapter) provides the C
entry points. No design transport needs to copy open arrays, decode events, or
format a result line.
The hot scheduler step receives only the compact observed-word array. Driven
words are fetched through a separate idempotent output-pull export on
initialization or after STEP_OUTPUTS_CHANGED, so unchanged steps do not carry
an output argument through the simulator ABI.
deposit() performs the underlying SystemVerilog blocking assignment
immediately. A port set(), by contrast, queues in the scheduler’s
deferred-write buffer and flushes at the timestep’s ReadWrite dispatch — the
scheduler owns that queue and its flush point, and the transport only ever
carries flushed words. Neither operation inserts a scheduler delay or
observation phase; testbench code uses an explicit co_await ReadOnly{} or
Delay{...} when downstream RTL must evaluate before observation. See
The write model.
Current scope¶
The end-to-end test suite currently targets Verilator. Scalar signal values use
uint32_t; packed values use uint64_t through 64 bits and Bits<W> above 64
bits. Generated DPI bindings support fixed multidimensional unpacked arrays,
packed enum and struct views, wide values, fixed-point helpers, and generated
hierarchical probes with read, deposit, force, and release operations.
Four-state X/Z propagation remains deferred. The generated
transport uses standard SystemVerilog DPI, but additional simulator backends
have not yet passed the conformance suite.
The Authoring Core sources currently present under
benchmarks/authoring_core/ exercise typed tasks, cycle waits, edge timeouts,
predicate waits, events, bounded queues, locks, semaphores, wide packed
signals, fixed-point arithmetic, fixed unpacked arrays, and a synchronous
memory front door. Its C++ DPI testbench is
benchmarks/authoring_core/testbenches/cpp_dpi/testbench.cpp, the corresponding pure-SV
source is benchmarks/authoring_core/testbenches/systemverilog/authoring_core_sv_tb.sv, and the
shared workload contract is benchmarks/authoring_core/workload.py. Runtime
API tests are in tests/unit/coro_runtime_test.cpp.