Skip to content

Embedding as a library

tero_runtime is a static library that exposes the tero::runtime::Emulator class. Linking against it gives your application full programmatic control over the SoC: load images, advance simulated time, read and write physical memory, inspect CPU registers, install custom peripherals, and embed a GDB stub.

This page is the API-level companion to the CLI reference — everything tero-emu does, you can do from your own program. The complete, compilable examples this page draws from live in examples/hello-gr712rc, examples/hello-gr740, and examples/gdb-demo.

CMake integration

Link one target — tero::tero. It is the aggregate that pulls the runtime (Emulator + full SPARC ISA + peripherals + tiered JIT) and the default injected services (StdoutLogger, StdoutCharDevice, LabeledStdoutCharDevice, NullFaultInjector).

# Enable C as well as C++: Tero links LLVM, and LLVM's CMake package probes
# the host with C-language checks (LibEdit, terminfo, …). A CXX-only project
# fails to configure with "enable other languages".
project(my_simulator CXX C)

find_package(Tero REQUIRED)        # installed package — or FetchContent, below

add_executable(my_simulator main.cpp)
target_link_libraries(my_simulator PRIVATE tero::tero)

tero::tero transitively carries every layer (tero_runtime, tero_core, tero_bus, tero_peripherals, tero_ir, tero_arch_sparc, tero_jit, tero_interfaces, tero_defaults) plus its public dependency tl::expected — you never list them by hand. The individual tero::<layer> targets are exported too, for the rare consumer that wants just one. LLVM ≥ 18 must be discoverable on the consumer host (see Installation); fmt and tl::expected are handled for you (next section).

Three ways to consume it

All three give you the same tero::tero target.

find_package (installed). Build and install Tero once, then point any project at the prefix:

cmake -S tero -B build          # TERO_INSTALL defaults ON when top-level
cmake --build build
cmake --install build --prefix /opt/tero
# in the consumer — add /opt/tero to CMAKE_PREFIX_PATH
find_package(Tero REQUIRED)
target_link_libraries(my_app PRIVATE tero::tero)

The install is self-contained: it lays down Tero's headers and static archives, the CMake package (TeroConfig.cmake + TeroTargets.cmake), and the fetched fmt and tl::expected packages. A clean host therefore needs only LLVM ≥ 18 discoverable — TeroConfig resolves all three with find_dependency(LLVM), find_dependency(fmt), find_dependency(tl-expected).

FetchContent. No install step; Tero builds as part of your tree:

include(FetchContent)
FetchContent_Declare(tero
    GIT_REPOSITORY https://…/tero.git
    GIT_TAG        main)
FetchContent_MakeAvailable(tero)

target_link_libraries(my_app PRIVATE tero::tero)

add_subdirectory. A vendored / git-submodule copy:

add_subdirectory(third-party/tero)
target_link_libraries(my_app PRIVATE tero::tero)

For FetchContent and add_subdirectory, TERO_INSTALL defaults off (Tero detects it is not the top-level project), so Tero's own install rules never leak into yours.

Linkage

Tero builds static by default — the perf-tested configuration — and honours -DBUILD_SHARED_LIBS=ON for shared libtero*.so objects (best-effort: x86-64 Linux, no symbol-visibility map yet). Shared mode forces position-independent code on the vendored static dependencies it absorbs; the default static build keeps its non-PIC codegen untouched.

Minimal flow

#include "tero/runtime/emulator.hpp"
#include "tero/runtime/emulator_config.hpp"
#include "tero/runtime/run_result.hpp"
#include "tero/types.hpp"

int main() {
    // 1. Start from a SoC recipe and override what you need.
    auto cfg      = tero::compose::gr712rc_config();
    cfg.num_cores = 1;
    cfg.ram_size  = 16U * 1024U * 1024U;   // 16 MiB

    // 2. Create — validates the config, returns Result<unique_ptr<Emulator>>.
    auto emu_res = tero::runtime::Emulator::create(cfg);
    if (!emu_res) return 1;                 // ErrorCode::InvalidConfig, etc.
    auto emu = std::move(emu_res.value());

    // 3. (Optional) inject services BEFORE initialize() — see below.

    // 4. Initialize — wires RAM, peripherals, the bus, the GDB stub.
    if (auto r = emu->initialize();          !r) return 2;

    // 5. Load an image and run.
    if (auto r = emu->load_elf("hello.elf"); !r) return 3;

    auto result = emu->run_for(tero::SimTimeNs{5'000'000'000ULL}); // 5 s sim
    return result.reason == tero::runtime::HaltReason::HaltedMode ? 5 : 0;
}

Three things to note:

  • Emulator::create returns a Result<std::unique_ptr<Emulator>> (Result<T> is tl::expected<T, ErrorCode>). Every public method that can fail returns a Result<T>; check it. Nothing throws across the library boundary.
  • The lifecycle is fixed: create → set_* (optional) → initialize → load_* → run_for/run_until.
  • The Emulator is not copyable or movable. Own it through a std::unique_ptr (which create already gives you).

Lifecycle

The Emulator mirrors the SMP2 model states (Publish → Configure → Connect → Initialize → Run → Hold → Store → Restore). Run, Initialize, Reset, Store, and Restore are implemented today; the remaining hooks are stubs until the external SMP2 wrapper needs them.

stateDiagram-v2
    [*] --> Created : Emulator::create(cfg)
    Created --> Configured : set_logger / set_uart_character_device / set_observer
    Configured --> Initialized : initialize()
    Initialized --> Loaded : load_elf / load_binary
    Loaded --> Running : run_for / run_until
    Running --> Loaded : returns RunResult
    Running --> Halted : HaltedMode / Breakpoint
    Loaded --> Reset : reset()
    Reset --> Loaded
    Halted --> [*]

reset() brings every core, the bus, and every peripheral back to power-on state without freeing memory. It does not unload the ELF — re-load_elf() if you need a fresh image. Use it for deterministic fixtures: create + load_elf once, then reset + run_for many times.

RunResult and HaltReason

run_for() / run_until() return a RunResult:

struct RunResult {
    std::uint64_t instructions_executed{0}; // across all cores
    tero::SimTimeNs time_elapsed{0};       // simulated time consumed
    tero::runtime::HaltReason reason{};    // why it stopped
};
HaltReason Meaning What to do
DurationExpired The run_for duration was consumed normally. Call run_for again to continue.
DeadlineReached The run_until deadline was reached normally. Call run_until again with a later deadline.
HaltedMode A guest core took a trap with PSR.ET = 0 (the processor stops). Usually a deliberate guest shutdown (ta 0/_exit) or an unrecoverable fault — not an emulator failure. Inspect emu->core(0); the run is over.
ErrorMode Reserved for genuine internal emulator errors (a bug/unsupported condition). Treat as a defect; rarely seen.
Breakpoint The GDB stub wants you to talk to GDB (breakpoint, single-step, late-binding accept, Ctrl-C, or error-mode-with-client). Call gdb_stub()->process_until_resume(). See GDB stub.

Mind the five reasons

There are five HaltReason values, not four. In particular, HaltedMode (guest stopped the CPU) is distinct from ErrorMode (emulator-internal error). tero-emu returns exit code 5 on HaltedMode.

Loading images

Method Use it for
load_elf(path) A SPARC big-endian ET_EXEC ELF — or an mkprom2 PROM ELF; segment LMAs decide placement and the entry PC is set from e_entry.
load_binary(base, bytes, entry) A flat binary blob: written at base, PC set to entry. Use when the guest is raw machine code, not an ELF.
load_ram_image(addr, bytes) Write bytes at addr with no side effects on PC/SP/power-down. Any writable mapped region is a legal target (primary RAM, FTAHBRAM, flash-bank Ram entities). Additive across calls. For priming data regions at runtime.
load_ram_image_from_file(addr, path) Same, reading the file verbatim.
cfg.memory_images Declarative form of the above: raw images (path or inline blob) applied at addresses during initialize(), once every region is mounted. The flight shape — FSW binaries into flash banks. CLI: --bin <path>@<addr>.
cfg.prom_image_path / prom_image_blob Boot from a PROM image: set the field before create; initialize() loads it. This is the realistic silicon boot path.

The hello-gr740 example shows the ELF-or-flat-binary auto-detection pattern (sniff the \x7fELF magic, then load_elf or load_binary).

Single-step and time control

emu->run_for(tero::SimTimeNs{1'000'000});            // advance 1 ms sim
emu->run_until(tero::SimTimeNs{deadline_ns});        // run to an absolute time
auto t = emu->current_sim_time();                     // SimTimeNs
auto r = emu->single_step(tero::CoreId{0});          // exactly one insn on core 0

single_step(core) executes exactly one instruction on core and returns (advancing time by ns_per_insn). It does not sample interrupts or run scheduled events — for interrupt-driven progress use run_for. It returns HaltReason::Breakpoint on success (a semantic "stopped after one") and HaltReason::HaltedMode if the core is out of range, powered down, or already halted.

External memory access

The emulator publishes a curated set of bus operations for debuggers, test harnesses, and the SMP2 wrapper:

auto w = emu->read_physical_u32(tero::PhysAddr{0x40000000});  // Result<uint32_t>
emu->write_physical_u32(tero::PhysAddr{0x40000004}, 0xdeadbeef);

std::array<std::byte, 16> buf;
emu->read_physical(tero::PhysAddr{0x40000010}, buf);          // Result<void>
emu->write_physical(tero::PhysAddr{0x40000020}, some_span);

read_physical / write_physical bypass the MMU (which is a stub today — see Memory and bus).

No internal lock — drive from one thread

Today's execution model is single-threaded round-robin, so the public API carries no internal lock. You must call run_* and the memory accessors from the same thread; do not call read_physical from a second thread while another thread is inside run_for. (A runtime-gated mutex over the public API lands with MultiThread mode — ADR-001 — and is not implemented yet.)

Inspecting and priming CPU state

emu->core(idx) returns a reference to the CpuState of core idx. It is readable and writable, so you can prime registers before a run (handy for unit tests):

auto& cpu0 = emu->core(0);
auto pc  = cpu0.pc();
auto psr = cpu0.psr();
auto g7  = cpu0.read_r(7);    // %g7
emu->run_for(tero::SimTimeNs{1'000'000});

num_cores() returns the core count; config() returns the (read-only) EmulatorConfig. The full CpuState surface is in the C++ API reference under tero::core::CpuState.

Injecting services

By default initialize() wires:

  • a StdoutLogger (you typically replace this),
  • a StdoutCharDevice on the console UART (UART0); all auxiliary UARTs start with no chardev (TX dropped, RX empty),
  • a NullFaultInjector.

You replace these via setters before initialize() — each peripheral latches its service pointers at attach time, so a later swap has no effect.

Setter Replaces
set_logger(unique_ptr<ILogger>) All logging output.
set_character_device(unique_ptr<ICharacterDevice>) The console UART (UART0) chardev. Sugar for set_uart_character_device(0, ...).
set_uart_character_device(i, unique_ptr<ICharacterDevice>) The chardev for UART i.
set_observer(unique_ptr<IEmulatorObserver>) Installs a per-instruction/IRQ/trap observer (forces the Switch path).
#include "tero/defaults/stdout_logger.hpp"
#include "tests/support/capturing_char_device.hpp"   // test-only helper

emu->set_logger(std::make_unique<tero::defaults::StdoutLogger>(
    tero::LogLevel::Info));

auto cap  = std::make_unique<tero::test_support::CapturingCharDevice>();
auto* ref = cap.get();
emu->set_character_device(std::move(cap));   // console UART
emu->initialize();                            // ← latches the chardev
emu->load_elf("hello.elf");
emu->run_for(tero::SimTimeNs{2'000'000'000ULL});

std::string out = ref->captured();

See UART and console for the full chardev story (ICharacterDevice, multiple UARTs, labeled output, suppression, PTY bridging). CapturingCharDevice is deliberately in the test-support tree, not in tero_defaults, so production binaries don't link an unused collector.

Custom peripherals

Every built-in peripheral is declared as a PeripheralSpec in cfg.peripherals (the recipes populate it). You can add, remove, or replace specs before create. For a peripheral added after initialize() (REPL / test usage), there is sugar:

auto board = std::make_unique<my_company::FlightControlBoard>(
                 tero::PhysAddr{0x80001000});
auto r = emu->add_peripheral(std::move(board), tero::IrqLine{10});
// IrqLine{0} for a peripheral that does not raise interrupts.

DMA, interrupts, scheduled events, and typed ports all work out of the box. Full walkthrough: Custom peripherals. The examples/custom-board/ and examples/demo-dma/ directories are runnable, from-scratch references.

A complete example: hello-gr712rc

This mirrors examples/hello-gr712rc/main.cpp — boot a PROM image on the dual-core GR712RC, label every UART, and run in fixed slices:

#include "tero/defaults/labeled_stdout_char_device.hpp"
#include "tero/defaults/stdout_logger.hpp"
#include "tero/runtime/emulator.hpp"
#include "tero/runtime/emulator_config.hpp"
#include "tero/runtime/run_result.hpp"
#include "tero/types.hpp"

#include <fmt/format.h>
#include <memory>

int main(int argc, char** argv) {
    if (argc != 2) { return 2; }

    auto cfg            = tero::compose::gr712rc_config();  // 2 cores, 80 MHz
    cfg.ram_size        = 64U * 1024U * 1024U;               // 64 MiB
    cfg.prom_image_path = argv[1];                           // PROM boot path

    auto emu_res = tero::runtime::Emulator::create(cfg);
    if (!emu_res) { return 3; }
    auto& emu = **emu_res;

    emu.set_logger(std::make_unique<tero::defaults::StdoutLogger>(
        tero::LogLevel::Info));

    // Surface every APBUART on stdout with a per-instance tag.
    for (std::size_t i = 0; i < cfg.character_devices.size(); ++i) {
        emu.set_uart_character_device(i,
            std::make_unique<tero::defaults::LabeledStdoutCharDevice>(
                fmt::format("[apbuart{}] ", i)));
    }

    if (auto init = emu.initialize(); !init) { return 3; }

    // Run for 5 simulated seconds in 100 ms slices, reporting progress.
    const auto slice    = tero::SimTimeNs{100U * 1'000'000ULL};
    const std::uint64_t budget_ns = 5ULL * 1'000'000'000ULL;
    tero::runtime::RunResult result{};
    while (tero::to_underlying(emu.current_sim_time()) < budget_ns) {
        result = emu.run_for(slice);
        if (result.reason == tero::runtime::HaltReason::HaltedMode) { break; }
    }
    return result.reason == tero::runtime::HaltReason::HaltedMode ? 5 : 0;
}

The slice loop is the common embedding pattern: it lets you observe progress, poll the GDB stub, or react to UART output between slices. The hello-gr740 example is the same shape with the GR740 recipe and an ELF-or-flat-binary loader.

Embedding the GDB stub

Set cfg.gdb_stub_port to a non-zero TCP port and the Emulator listens for RSP connections on 127.0.0.1. The user-facing reference — late binding, RTEMS thread-awareness, supported packets, TT→signal mapping — is Debugging with GDB. The library-side resume loop (exactly what tero-emu and examples/gdb-demo do) is:

#include "tero/runtime/gdb_stub.hpp"

auto cfg                     = tero::compose::gr712rc_config();
cfg.gdb_stub_port            = 1234;
cfg.gdb_stub_wait_for_client = false;                    // late-binding
cfg.pacing                   = tero::runtime::PacingMode::Turbo;

auto emu = std::move(tero::runtime::Emulator::create(cfg).value());
emu->initialize();
emu->load_elf("hello.elf");

using tero::runtime::HaltReason;
using tero::runtime::ResumeAction;

auto r = emu->run_for(tero::SimTimeNs{5'000'000'000ULL});
while (auto* stub = emu->gdb_stub()) {
    if (r.reason != HaltReason::Breakpoint || !stub->client_connected()) {
        break;
    }
    const auto action = stub->process_until_resume();    // blocks on RSP
    if (action == ResumeAction::Detach || action == ResumeAction::Kill) {
        break;
    }
    r = emu->run_for(tero::SimTimeNs{5'000'000'000ULL});
}
  • gdb_stub_wait_for_client = false + a non-zero port enables late-binding: target remote :1234 works at any time after initialize(). Set it true only to inspect the very first instruction.
  • run_for returns HaltReason::Breakpoint whenever the stub wants the caller to talk to GDB. Your job is to call process_until_resume() and branch on the ResumeAction.
  • A guest error_mode while a client is attached is reported to GDB as a signal (signal_from_tt) instead of dumping a post-mortem.

Threading model

Emulator is single-thread-of-execution: run_for, run_until, initialize, reset, the memory accessors, and every peripheral handler run on the calling thread, with no internal lock. It is not safe to:

  • call run_for concurrently from two threads on the same emulator, or
  • call read_physical from one thread while another is in run_for.

Drive one emulator from one thread. (Two separate Emulator instances in the same process are fully independent — zero shared global state — which is a deliberate design invariant.) The EmulatorConfig::execution_mode = MultiThread setting parallelises the simulated cores across host threads internally; it does not change the single-caller contract of the public API. See Multi-core and timing.