Layers and modules¶
Tero is structured as a stack of CMake static libraries with strict,
unidirectional dependencies. This page enumerates each layer, what lives
in it, what it must not depend on, and — crucially — why each
boundary exists. The rules here are the prose expansion of
CLAUDE.md rule 5:
Respect the module boundaries.
tero_coredoes not depend ontero_peripherals.tero_busdoes not depend ontero_core. Dependencies flow: interfaces ← core, bus ← peripherals ← runtime ← app. The translation stack layers as interfaces ← ir ← {arch/sparc, jit} ← runtime;tero_irdoes not depend ontero_core(it works on the opaqueGuestStateblob), whiletero_coredoes depend ontero_ir—CpuState's integer state IS aGuestStateblob (state unification); the SPARC frontend just translates SPARC → IR (sparc_frontend).
Authority
The authoritative dependency edges are the target_link_libraries
calls in each src/*/CMakeLists.txt. Every edge in the diagram and
table below is taken directly from those files. If the build graph
and this page ever disagree, the CMakeLists.txt files win — fix
this page.
Dependency graph¶
flowchart TD
tero_app[tero_app<br/>CLI executable] --> tero_runtime
tero_app --> tero_compose
demo_dma[demo_dma_device<br/>examples] --> tero_runtime
tero_compose[tero_compose<br/>Machine, kits, .tero scripts, dlopen loader] --> tero_runtime
tero_compose --> tero_peripherals
tero_runtime[tero_runtime<br/>Emulator facade, Soc, ExecutionEngine, DebugServer] --> tero_defaults
tero_runtime --> tero_peripherals
tero_runtime --> tero_arch_sparc
tero_runtime --> tero_jit
tero_runtime --> tero_core
tero_runtime --> tero_bus
tero_defaults[tero_defaults<br/>Loggers, CharDevices] -.-> tero_interfaces
tero_defaults --> fmt
tero_peripherals[tero_peripherals<br/>IrqMP, IrqAMP, GPTimer, ApbUart, MemCtrl, GRGPIO] --> tero_bus
tero_peripherals -.-> tero_interfaces
tero_bus[tero_bus<br/>SystemBus, RAM] -.-> tero_interfaces
tero_arch_sparc[tero_arch_sparc<br/>SPARC → IR frontend] --> tero_ir
tero_arch_sparc --> tero_core
tero_jit[tero_jit<br/>IR → native, LLVM ORCv2] --> tero_ir
tero_jit --> LLVM[(LLVM ≥ 18)]
tero_ir[tero_ir<br/>arch-neutral IR, interpreter] -.-> tero_interfaces
tero_core[tero_core<br/>SPARC V8 ISA, CpuState, Decoder] --> tero_ir
tero_core --> softfloat3e[(SoftFloat 3e)]
tero_core -.-> tero_interfaces
tero_interfaces[tero_interfaces<br/>Header-only API] --> tl_expected[(tl::expected)]
Solid arrows are link-time dependencies; dotted arrows are header-only
contracts (tero_interfaces is an INTERFACE target — it produces no
.a file, only #include paths and the tl::expected dependency).
Round nodes are external dependencies fetched/located by CMake.
The graph is acyclic by construction — tero_core does not know
that peripherals exist, tero_bus does not know that there is a CPU,
and tero_interfaces knows nothing about anybody.
tero_core links tero_ir (state unification)
A historically surprising edge: tero_core PUBLIC-links
tero::ir (src/core/CMakeLists.txt:23-28). This is the
state-unification design: CpuState's integer register file is an
ir::GuestState byte blob, so the Switch interpreter and the IR
engine read and write the same bytes with no per-fallback sync.
tero_core still does not depend on tero_arch_sparc or
tero_jit — it knows the opaque blob layout, not the IR ops or
LLVM. The SPARC↔IR bridge (mode_ctx_of, translate_block) lives
in tero_arch_sparc, which is the only module that links both
tero_core and tero_ir.
What goes where¶
| Module | CMake target | Owns | Links (PUBLIC / PRIVATE) |
|---|---|---|---|
| Interfaces | tero_interfaces (INTERFACE) |
Strong types, Result<T>, all I* contracts, PeripheralContext, AddressRange |
tl::expected |
| Core | tero_core |
CpuState, decoder, ISA handlers, step, trap dispatch, FP via SoftFloat |
PUBLIC interfaces, ir · PRIVATE warnings, softfloat3e |
| Bus | tero_bus |
Ram, SystemBus (BE encode/decode, MMIO routing, IBusMaster) |
PUBLIC interfaces · PRIVATE warnings |
| Peripherals | tero_peripherals |
IrqMP, IrqAMP, GPTimer, ApbUart, MemCtrl, Prom, GRGPIO, SignalPort |
PUBLIC interfaces, bus · PRIVATE warnings |
| IR | tero_ir |
IrBlock/IrOp/BlockExit, GuestState, BlockCache, IR interpreter, IArchitecture/IArchFrontend seam |
PUBLIC interfaces · PRIVATE warnings |
| SPARC frontend | tero_arch_sparc |
SparcArchitecture (ir::IArchitecture impl), translate_block (SPARC → IR), sparc_layout |
PUBLIC interfaces, ir, core · PRIVATE warnings |
| JIT | tero_jit |
IrJit (IR → native via LLVM ORCv2 LLJIT), TieredJit (baseline/optimised) |
PUBLIC interfaces, ir · PRIVATE LLVM libs, warnings |
| Runtime | tero_runtime |
Emulator (facade), Soc, ExecutionEngine, DebugServer, CPU entities, EmulatorConfig, EventScheduler, ElfLoader, CpuBusBridge, GdbStub, architecture factory, PnP table, config validator |
PUBLIC interfaces, bus, peripherals, jit · PRIVATE core, defaults, arch_sparc, warnings |
| Compose | tero_compose |
Machine, the GR712RC/GR740 kits, .tero script front-end, component registry + dlopen loader |
PUBLIC runtime, interfaces · PRIVATE peripherals, defaults, dl, warnings |
| Defaults | tero_defaults |
StdoutLogger, StdoutCharDevice, NullFaultInjector, DebugPublisher |
PUBLIC interfaces · PRIVATE warnings, fmt |
| App | tero_app (executable) |
CLI, argument parsing, post-mortem dump | PRIVATE runtime, core, compose, defaults, warnings, fmt |
| Demo | demo_dma_device (example) |
A custom DMA + IRQ peripheral | interfaces, runtime (test binary only) |
PUBLIC vs PRIVATE matters for the public header
emulator.hpp includes tero/jit/tiered_jit.hpp, so tero_jit is
a PUBLIC link of tero_runtime (src/runtime/CMakeLists.txt:52)
— any consumer of the runtime (the app, tests) transitively gets the
JIT headers. By contrast tero_core, tero_arch_sparc, and
tero_defaults are PRIVATE (src/runtime/CMakeLists.txt:44-46):
the runtime's public headers forward-declare core::CpuState instead
of including it, so neutral consumers do not transitively link the
SPARC core. A consumer that reaches for SPARC registers (the CLI's
post-mortem dump) links tero::core itself
(src/app/CMakeLists.txt:14-18).
Why the layers exist¶
Each boundary buys a concrete, testable property. The boundaries are not aesthetic — they each prevent a specific class of coupling bug.
tero_interfacesexists so any module can depend on a contract without pulling in an implementation. It is header-only, contains no logic, and is the single place strong types (PhysAddr,SimTimeNs, …) andResult<T>are defined (src/interfaces/include/tero/types.hpp). Every other module links it; nothing else may define a vocabulary type. This is what makes "twoEmulators in one process" possible — the types carry no global state.tero_coreis independent of bus and peripherals so the CPU can be unit-tested with hand-crafted instruction streams against a fake bus (tests/support/test_bus). It reaches the world only through the injectedICpuBusinterface (step(CpuState&, ICpuBus&),src/core/include/tero/core/step.hpp:41) — it never namesSystemBus, an IRQ controller, or a peripheral.tero_busdoes not know about CPUs — it is a pure physical-address router. This lets us reuse the same bus instance as the DMA target for peripherals (it implementsIBusMaster) without a circular dependency: a peripheral DMAs through exactly the memory map the CPU sees. (Decision 1: the bus is non-movable because peripherals cache raw pointers into it — see decisions.)tero_irknows no guest ISA. It operates on an opaqueGuestStatebyte blob (Decision 49), so it depends only ontero_interfaces. The IR interpreter and the JIT backend are shared across architectures: a new guest ISA (ARM is on an experimental branch) is a new frontend, not a new core. This is the multi-arch seam described in Adding a frontend.tero_jitis isolated and owns LLVM. It depends only ontero_ir(+ LLVM), so the JIT can be unit-tested under sanitisers without pulling intero_core, and the heavyweight LLVM dependency stays out of every lower layer. The LLVM headers are markedSYSTEMso the project's strict-Werrorwarning set applies to Tero code but not to LLVM's templates (src/jit/CMakeLists.txt). LLVM ≥ 18 is a mandatory build requirement (ADR-002, ADR-003): the JIT usesllvm::CodeGenOptLevel, which LLVM 18 introduced.tero_arch_sparcis the only module that bridges core and IR. It is the single place that knows bothCpuState(SPARC architectural state) and the neutral IR, keeping that knowledge out of bothtero_irandtero_jit. Becausetero_corealready exposes its integer state as theGuestStateblob, the "sync" is mostly a no-op — the frontend resolves window-relative register offsets at translate time from the block'sModeCtx(Decision 50).tero_runtimeis the only orchestrator. It composes everything; no other module instantiatesEmulator,EventScheduler, orElfLoader. It is allowed to see every lower layer because assembling the SoC is its job — but the dependency graph terminates here before reaching the app, sotero_appis a thin CLI shell over a fully usable library.- Two axes, not one. Only
tero_core(the SPARC V8 ISA) andtero_arch_sparc(the frontend) are legitimately CPU-ISA-specific. The GRLIB peripherals, the PnP table, and thegr712rc/gr740recipes carry SPARC-family vocabulary too, but on a separate board/fabric axis (AMBA / IRQMP / GPTIMER), correctly isolated as data — not an ISA leak. The arch-decoupling pass (plans/arch-decoupling.md) made the neutral run loop honour this: theExecutionEngineno longer namesarch::sparcat all — the nPC/PC delay-slot-boundary reads route through the SPARC lens accessor (sparc->npc()) instead oflayout::NpcOffblob offsets, and interrupt acknowledgement passes an opaqueInterruptDecision::ack_maskformed by the arch (the GRLIB1u << levelidentity lives in the SPARC arch, not the engine). tero::coreis a PRIVATE link oftero_runtime(src/runtime/CMakeLists.txt:38-44). Thecores_relocation is complete: theExecutionEngineowns arch-neutral per-coreir::GuestStateblobs +CoreControlrun latches and drives the architecture by core index (ir::IArchitecture::attach_cores,src/ir/include/tero/ir/architecture.hpp:158); the SPARCCpuStatelens lives inside the SPARC architecture. The runtime.cppfiles still usecore::for the SPARC oracle and inspection paths, but the public headers only forward-declare it.- The
ExecutionEngineimplementation is split into cohesive translation units —engine_run_loop.cpp(scheduling rounds),engine_translate.cpp(JIT / IR-interpret quanta),engine_irq_time.cpp(interrupt sampling, time base),engine_mt.cpp(MultiThread workers),engine_oracle.cpp(lockstep validation),execution_engine.cpp(lifecycle). See Runtime decomposition.
- Two axes, not one. Only
tero_composesits above the runtime and owns assembly only. It turns a board description (C++Machinecalls, a.teroscript, or a kit) into a validatedEmulatorConfigand stops — execution and the runtime entity graph belong to theSocinsidetero_runtime. The GR712RC/GR740 kits (tero::compose::gr712rc_config()/gr740_config(),src/compose/src/kits.cpp) live here, not in the runtime. Seetero_compose.tero_defaultsis intentionally restricted to swappable services. Everything an external simulation wrapper (e.g. an SMP2 wrapper) would replace — logger, character device, fault injector, publisher — lives here, behind interfaces, so a downstream consumer can drop the whole library and substitute their own implementations via theEmulator::set_*injection points (Decision 23).
How the boundaries are enforced¶
There is no automated layering linter; the boundaries hold by three mechanisms:
- CMake link edges. A module can only name symbols from libraries
it links.
tero_corelinks neithertero_busnortero_peripherals, so a#include "tero/bus/..."in core would fail to find the header (the include path is not on core'starget_include_directories). -Werrorwith a strict warning set (tero::warnings, top-levelCMakeLists.txt:51-73):-Wall -Wextra -Wpedantic -Werrorplus-Wshadow -Wold-style-cast -Wconversion -Wsign-conversion, etc. The whole tree builds with zero warnings under this set (Decision 6).- Code review against
CLAUDE.mdrule 5. New edges that would violate the flow are rejected; the layering invariant is part of the frozen architectural contract.
Module-by-module narrative¶
For deeper module documentation see the dedicated pages under
Modules; the translation stack
(tero_ir, tero_arch_sparc, tero_jit) is documented in
IR and LLVM JIT and Adding a frontend:
tero_interfacestero_coretero_bustero_peripheralstero_runtimetero_composetero_defaultstero_apptero_ir,tero_arch_sparc,tero_jit→ IR and LLVM JIT
See also¶
- Architecture overview — how the pieces fit together
- Design principles — the invariants the layering serves
- Execution model — what the runtime drives