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:
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::createreturns aResult<std::unique_ptr<Emulator>>(Result<T>istl::expected<T, ErrorCode>). Every public method that can fail returns aResult<T>; check it. Nothing throws across the library boundary.- The lifecycle is fixed:
create → set_* (optional) → initialize → load_* → run_for/run_until. - The
Emulatoris not copyable or movable. Own it through astd::unique_ptr(whichcreatealready 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
StdoutCharDeviceon 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 :1234works at any time afterinitialize(). Set ittrueonly to inspect the very first instruction.run_forreturnsHaltReason::Breakpointwhenever the stub wants the caller to talk to GDB. Your job is to callprocess_until_resume()and branch on theResumeAction.- A guest
error_modewhile 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_forconcurrently from two threads on the same emulator, or - call
read_physicalfrom one thread while another is inrun_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.