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
staticmutable variables anywhere. - No singleton classes.
- Every dependency is owned by
Emulatoror 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 classis 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:
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.