Skip to content

Design principles

These are the non-negotiable invariants that shape every line of Lince. They are frozen in CLAUDE.md; this page restates them with the reasoning behind each one.

1. Zero singletons, zero global mutable state

Test: "Can I instantiate two Emulator objects in the same process without them interfering?" — must always be YES.

Why:

  • An SMP2 wrapper may want one model per simulated SoC in the same binary.
  • Test executables instantiate dozens of bus / CPU pairs in parallel Catch2 sections.
  • Global state silently couples unrelated tests, leading to flaky CI.

How:

  • No static mutable variables anywhere.
  • No singleton classes.
  • Every dependency is owned by Emulator or injected through it.

2. Zero direct I/O from the core

No printf, no std::cout, no fopen, no sockets inside any non-lince_app translation unit. Every byte that reaches the host's console goes through ICharacterDevice; every log line goes through ILogger.

Why:

  • A simulator embedded inside a host simulation environment must redirect output through the host's logging service.
  • Unit tests need to capture or suppress output without touching code under test.
  • Replacing the implementation must be a one-line set_* call.

The only exception is src/app/main.cpp, which is itself a default implementation.

3. Time as a parameter, never wall-clock

Emulator never reads the host system clock. Time advances exclusively through:

  • EmulatorConfig::ns_per_insn (rate)
  • Emulator::run_for(SimTimeNs) (caller-driven duration)
  • Emulator::run_until(SimTimeNs) (caller-driven deadline)

Why:

  • Determinism: replays produce identical traces regardless of host load.
  • SMP2 compatibility: the simulation environment owns the clock; the model just consumes it.
  • Trace comparison against reference outputs is meaningful only if both simulators see the same notion of time.

4. Configuration by struct, not by files

Emulator::create() takes an EmulatorConfig plain C++ struct. No parsing of YAML, JSON, INI, or environment variables happens inside the core library.

Why:

  • The CLI is one of the consumers; a future SMP2 wrapper, a Python binding, or an automated test all want to skip the file format entirely.
  • It keeps the surface area small and easy to evolve.

5. Errors as values at the public API boundary

Every method on the public surface returns Result<T> (a tl::expected<T, ErrorCode>). Internal exceptions are permitted but must never cross the library boundary.

Why:

  • Embedded simulation environments often disable C++ exceptions.
  • Result<T> makes the error path syntactically explicit.
  • [[nodiscard]] enforces handling at compile time.

6. Strong types everywhere

PhysAddr, VirtAddr, CoreId, SimTimeNs, IrqLine, AccessSize are all enum class over fixed-width integers. Raw uint32_t only appears at byte-level encoding/decoding.

Why:

  • Eliminates an entire class of mix-up bugs ("I passed a virtual address where a physical was expected").
  • Costs zero runtime: enum class is a typed alias.
  • Reads correctly: read_physical(PhysAddr{0x40000000}, …) is unambiguous.

7. Interpreter only, no JIT, no decode cache

The fetch-decode-execute loop re-decodes every instruction on every fetch. There is no per-PC decode cache, no block translator, no JIT.

Why:

  • Correctness first. Every optimisation introduces a category of bugs that take days to track down.
  • ~50 MIPS aggregate throughput is sufficient for RTEMS testing.
  • A future block interpreter or JIT can be plugged in behind the same step() interface without touching anything else.

8. Round-robin single-threaded multicore

All cores execute on the host's main thread, one quantum at a time. TSO is satisfied trivially; atomics are correct by construction.

Why:

  • Determinism over parallelism, again.
  • The cost of multi-threaded host execution would be N× the bug surface for ≤ N× the throughput; on a 4-core SoC running RTEMS sptests, the host CPU is not the bottleneck.

9. SMP2-aligned lifecycle

The Emulator exposes a lifecycle that mirrors SMP2 model states even though the SMP2 wrapper itself is not part of the MVP:

Publish() → Configure() → Connect() → Initialize() → Run() → Hold() → Store() → Restore()

Run(), Initialize(), Reset() are mandatory in the MVP. The others exist as stubs for the wrapper to delegate to.

Why:

  • Aligning the API boundary now means the wrapper is a thin adapter, not a refactor.
  • The same shape can be reused by other simulation frameworks with minimal glue.

10. Trivial extensibility

Adding a custom peripheral must be one C++ file plus a single add_peripheral() call. No CMake surgery, no recompilation of the runtime library, no global registration step.

Why:

  • The customer story is "I have a proprietary IP block; let me model it next to your IRQMP". That story breaks the moment the integration cost is non-trivial.
  • The reference is examples/demo-dma/: 200 lines of C++ for a fully wired DMA-capable peripheral with an IRQ.

Custom peripheral guide