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:

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

Randomization

To record which cases a test actually hit

Functional coverage

Drivers, monitors, and scoreboards you don’t write yourself

Verification components

Typed handles for a register map

Register abstraction layer

To read or force signals inside the DUT

Hierarchical DUT access

Readable diagnostics from concurrent processes

Structured logging

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.