Core ideas¶
Getting started got a test running. This page explains the model underneath it, so the rest of the documentation reads as variations on a few ideas rather than a list of features. It should take about ten minutes and assumes nothing beyond the counter you already ran.
The picture to keep in mind¶
Two diagrams cover most of what is actually going on. At build time, cpptb build turns your files into one self-contained executable:
counter.sv testbench.cpp cpptb.toml (optional)
└──────────────┼──────────────┘
▼
cpptb build elaborate → generate → compile
│ (details: How a build works)
▼
Vdpi_counter one process: simulator, scheduler,
and every registered test
There is no separate simulator process and no interpreter between your test and the design. At run time, that one process contains three cooperating parts:
┌────────────────────────── one OS process ──────────────────────────┐
│ │
│ your tests cpptb runtime generated SV │
│ (coroutines) (C++ scheduler) wrapper │
│ │ │
│ co_await RisingEdge ──▶ suspends the task ┌─────┴─────┐ │
│ co_await ReadWrite ──▶ until the wrapper │ clocks │ │
│ reports the event ◀── │ phases │ │
│ dut.count.get() ──▶ reads a word the │ edges │ │
│ wrapper batched in └─────┬─────┘ │
│ dut.enable.set(1) ──▶ queues a write; │ │
│ flushed at ReadWrite ┌──┴──┐ │
│ after the edge ────────▶ │ DUT │ │
│ └─────┘ │
└────────────────────────────────────────────────────────────────────┘
The wrapper owns simulation time — clocks, edges, and the ReadWrite /
ReadOnly / NextTimeStep phase points. The scheduler owns your
coroutines — which are suspended, which resume on the event that just
fired. Signal values cross between them as batched words with generated
IDs, never by hierarchical name lookup. The full pipeline that produces the
wrapper is walked through in How a build works.
Everything below is a consequence of this picture.
A test is a registered coroutine¶
There is no test class to inherit and no framework object to construct. A test
is a Task<void> coroutine that receives the generated DUT and a
TestContext, and one macro that registers it:
Task<void> register_sequence(Dut dut, TestContext& test) {
dut.clk.set_now(0);
test.start_clock(dut.clk, 10_ns);
co_await reset_dut(dut); // a helper you wrote
ApbMaster apb{ApbBus{/* the DUT's APB pins */}};
const auto write = co_await apb.write(0x04, 0x1234'5678);
test.require_eq("register write", write.status, MemoryStatus::Okay);
const auto read = co_await apb.read(0x04);
test.expect_eq("register readback", read.data, 0x1234'5678u);
}
CPPTB_REGISTER_TEST(register_sequence);
CPPTB_REGISTER_TEST installs the test through a translation-unit
initializer. Register as many as you like in the same binary; each simulator
invocation selects and runs exactly one of them, in fresh simulation state, so
no test can inherit another’s DUT.
Because registration happens in an initializer, link the testbench translation unit directly into the simulator executable. If it is packaged in a static archive, link that archive with whole-archive semantics so the linker does not discard an otherwise unreferenced initializer.
Signal access is explicit, and never moves time¶
get() and set() are ordinary function calls. Neither one advances the
simulation:
dut.enable.set(1); // no time passes
const auto count = dut.count.get(); // no time passes
Time advances at exactly one kind of place — a co_await on a scheduling
primitive:
co_await RisingEdge{dut.clk}; // time passes here
co_await Delay{10_ns}; // and here
co_await clock_cycles(dut.clk, 8); // and here
This is the single most useful property of a cpptb testbench: you can find
every point where simulation time moves by searching for co_await. Nothing
is hidden in a driver or a helper. start_clock() looks like an exception but
is not — it registers a period with the simulator and returns immediately,
without suspending.
set() takes effect at the next settle point, not the instant you call it —
cocotb’s write model, selected by deferred_writes = true in cpptb.toml. So
a write made right after an awaited edge is seen by the following edge, not the
one you just waited for. set_now() is the escape hatch when you really do
need an immediate deposit, as when initializing a clock pin before
start_clock(). See The write model.
Which values you observe when a coroutine resumes is a genuinely subtle topic, and the one most likely to bite you. Scheduling is the precise answer; if you are converting a cocotb bench, read Coming from cocotb first, because the resume point differs.
Checks are values, not exceptions¶
Two flavors, differing only in what happens after a failure:
test.expect_eq("register readback", read.data, 0x1234'5678u); // records, continues
test.require_eq("register write", write.status, MemoryStatus::Okay); // records, stops
Use expect for a comparison whose failure the rest of the test can survive —
you will see every mismatch in one run instead of only the first. Use
require when continuing would be meaningless or misleading. Both record the
label, the source location, and both values into the structured result, which
is what a CI system consumes. Framework test lifecycle
covers the full model.
Concurrency is explicit too¶
Real benches need a driver, a monitor, and a scoreboard running at once.
spawn() starts a coroutine as a concurrent process owned by the test:
auto monitor = test.spawn(monitor_bus(dut));
auto driver = test.spawn(drive_packets(dut));
co_await driver; // wait for one
monitor.cancel(); // stop another
Test-owned means you do not have to clean up: when the test ends, for any reason, its remaining processes are cancelled for you, and a failure inside a spawned process is attributed to the right place.
The primitive set is deliberately small¶
Nearly every cpptb testbench is built from these:
test.start_clock(dut.clk, 10_ns)— register a periodic input clock, before the firstco_await.test.spawn(task)— run a coroutine concurrently and get a handle back.co_await RisingEdge{dut.clk},FallingEdge,Edge— simulator triggers.co_await Delay{10_ns},co_await clock_cycles(clk, n)— advance time.co_await ReadOnly{},ReadWrite{},NextTimeStep{}— settle to a specific point in the current or next timestep, as the counter example does before it samples.dut.signal.set(v)/dut.signal.get()— explicit signal access.dut.block.sub.name.get(),deposit(),force(),release()— anything in the inferred RTL hierarchy, not just ports.test.expect_eq(...)/test.require_eq(...)— checks.co_await helper_task(...)— your own reusable bus operations.
Everything else in this documentation builds on that list.
Tasks and concurrency adds Join, First,
timeouts, events, queues, locks, and semaphores.
What you can add when you need it¶
None of this is required to write a working test, and none of it changes the model above:
When you need |
Reach for |
|---|---|
Random stimulus that replays from a seed |
|
To record which cases a test actually hit |
|
Drivers, monitors, and scoreboards you don’t write yourself |
|
Typed handles for a register map |
|
To read or force signals inside the DUT |
|
Readable diagnostics from concurrent processes |
Next¶
Read Coming from cocotb if you have written cocotb benches — it is the fastest route from what you already know, and it names the three traps that cost real debugging time. Otherwise, browse the examples for a DUT shaped like yours, or go straight to Tasks and concurrency.