Multiple clocksΒΆ

The dual-clock mailbox declares clock timing in the C++ testbench. Port names carry no timing semantics, and the project command infers the RTL and top:

uv run --frozen cpptb build --project examples/multiclock --build-dir build

The testbench imports the stable generated cpptb::Dut. Producer and consumer coroutines wait on the clock that owns their interface, then the registered test composes them with reset and a phase probe:

#include <cstdint>

#include "cpptb/cpptb.hpp"
#include "dut.hpp"

namespace cpptb::examples::dpi_multiclock {
namespace {

using cpptb::Dut;
using coro::Delay;
using coro::First;
using coro::Join;
using coro::NextTimeStep;
using coro::ReadOnly;
using coro::RisingEdge;
using coro::Task;
using namespace coro;

constexpr uint32_t kTransferCount = 16;

Task<void> reset_dut(Dut dut, TestContext& test) {
    dut.rst_n.set(0);
    dut.write_valid.set(0);
    dut.write_data.set(0);
    dut.read_ready.set(0);
    dut.probe_in.set(0);

    co_await Delay{20_ns};
    test.expect_eq("reset delay deadline",
                   static_cast<uint32_t>(test.now().in_nanoseconds()), 20u);
    dut.rst_n.set(1);
}

Task<void> wait_reset_write(Dut dut) {
    while (dut.rst_n.get() == 0) {
        co_await RisingEdge{dut.write_clk};
    }
}

Task<void> wait_reset_read(Dut dut) {
    while (dut.rst_n.get() == 0) {
        co_await RisingEdge{dut.read_clk};
    }
}

Task<void> producer(Dut dut) {
    co_await wait_reset_write(dut);

    // The cocotb driver shape: RisingEdge resumes before the design
    // evaluates that edge, so a get() reads the value the DUT is about to
    // sample, and a set() applies after this edge's updates -- in time for
    // the next one. No settling delays anywhere.
    for (uint32_t value = 0; value < kTransferCount; ++value) {
        while (true) {
            co_await RisingEdge{dut.write_clk};
            if (dut.write_ready.get() != 0) break;
        }

        dut.write_data.set((0x40u + value) & 0xffu);
        dut.write_valid.set(1);

        co_await RisingEdge{dut.write_clk};
        dut.write_valid.set(0);
    }
}

Task<void> consumer(Dut dut, TestContext& test) {
    co_await wait_reset_read(dut);

    for (uint32_t expected = 0; expected < kTransferCount; ++expected) {
        while (true) {
            co_await RisingEdge{dut.read_clk};
            if (dut.read_valid.get() != 0) break;
        }

        test.expect_eq("mailbox payload", dut.read_data.get(),
                       (0x40u + expected) & 0xffu);
        dut.read_ready.set(1);

        co_await RisingEdge{dut.read_clk};
        dut.read_ready.set(0);
    }
}

Task<void> traffic(Dut dut, TestContext& test) {
    co_await Join{producer(dut), consumer(dut, test)};
    // The consumer returns at the pre-evaluation resume of its last commit
    // edge; ReadOnly is that timestep's settled region, where the final
    // counter increment is visible.
    co_await ReadOnly{};
    test.expect_eq("write count", dut.write_count.get(), kTransferCount);
    test.expect_eq("read count", dut.read_count.get(), kTransferCount);
}

Task<void> trigger_and_phase_probe(Dut dut, TestContext& test) {
    co_await Delay{7_ns};
    test.expect_eq("independent delay",
                   static_cast<uint32_t>(test.now().in_nanoseconds()), 7u);

    const auto winner = co_await First{RisingEdge{dut.read_clk}, Delay{100_ns}};
    test.expect_eq("First chose read clock", static_cast<uint32_t>(winner), 0u);

    // First resumed at t=10, where the read and write edges coincide. In
    // cpptb an edge await registered mid-timestep catches a later dispatch
    // in the same timestep -- defined behavior, but a pure-SV @(posedge)
    // armed after the coincident toggle waits for the next one. Step to
    // the next timestep first so the anchor edge is the same in both
    // worlds.
    co_await NextTimeStep{};
    co_await RisingEdge{dut.write_clk};
    dut.probe_in.set(0xa5);
    co_await ReadOnly{};
    test.expect_eq("ReadOnly settles combinational output",
                   dut.probe_echo.get(), 0xa5u);

    co_await NextTimeStep{};
    dut.probe_in.set(0x3c);
    co_await ReadOnly{};
    test.expect_eq("successive write settles by the next ReadOnly",
                   dut.probe_echo.get(), 0x3cu);
}

Task<void> output_clock_probe(Dut dut, TestContext& test) {
    co_await RisingEdge{dut.output_clk};
    test.expect_eq("DUT output clock edge",
                   static_cast<uint32_t>(test.now().in_nanoseconds()), 22u);
}

Task<void> multiclock_test(Dut dut, TestContext& test) {
    dut.write_clk.set_now(0);
    dut.read_clk.set_now(0);
    test.start_clock(dut.write_clk, 4_ns);
    test.start_clock(dut.read_clk, 6_ns, 1_ns);

    co_await Join{reset_dut(dut, test), traffic(dut, test),
                  trigger_and_phase_probe(dut, test),
                  output_clock_probe(dut, test)};
}

CPPTB_REGISTER_TEST(multiclock_test);

}  // namespace
}  // namespace cpptb::examples::dpi_multiclock

TestContext::now() checks absolute time, while First makes trigger selection visible; settle points are ReadOnly, the settled region of the current timestep, with no sub-cycle delays anywhere. The pure SV peer uses the same kTransferCount = 16 workload. The example also awaits RisingEdge{dut.output_clk}. Because output_clk is produced by the DUT, it is observed rather than passed to start_clock().

make cpp-dpi-multiclock-run
make cpp-dpi-multiclock-sv-run