Architecture
Lapilli makes the Linux/macOS PC the primary development target. The MCU becomes a deploy destination, not a prerequisite. Two host processes talking over a Unix pipe faithfully simulate two boards talking over UART — same logic, same API, no hardware required.
Why another framework
Section titled “Why another framework”Traditional embedded development imposes a slow loop: write code → flash → observe. Every iteration depends on physical hardware. Existing frameworks address this unsatisfactorily for lightweight firmware:
- Zephyr / FreeRTOS are powerful, but their architectural weight makes bootstrap expensive — the right answer for complex systems, not light firmware.
- Vendor HALs (STM32Cube, ESP-IDF) bind code to the platform from the first line; there is no path to a host target.
- Cycle-accurate simulators (QEMU) simulate the silicon, not application behavior — debugging tools, not daily-development tools.
Lapilli is the tool you reach for when both of those feel like too much.
The minimalism manifesto
Section titled “The minimalism manifesto”Minimalism is the project’s primary constraint, not a nice-to-have. Every decision — API surface, file count, dependencies, features — must answer “is this the simplest thing that could work?”. The explicit rules:
- No dynamic allocation in the HAL or port layer —
malloc/calloc/reallocare forbidden. All memory is static or on the stack. - No external dependencies — not even a vendor SDK, except where architecturally unavoidable (ESP-IDF for esp32).
- No global state beyond what the port strictly needs, and every global must be documented and justified.
- No
#ifdefsoup — platform differences are resolved by the build system selecting a port directory. Shared code never contains#ifdef TARGET. - No speculative features — every line earns its place through a concrete use case.
Layered architecture
Section titled “Layered architecture”┌─────────────────────────────────┐│ Application / FW │ ← zero platform #ifdefs├─────────────────────────────────┤│ RTOS (optional) │ ← thin scheduler, mutex, queue, delay├─────────────────────────────────┤│ HAL │ ← hal.h: GPIO, UART, timer, system├─────────────────────────────────┤│ Port layer │ ← posix/ stm32l4/ esp32/ avr/└─────────────────────────────────┘- Application — your code. Never contains platform
#ifdefs; calls onlylpl_*symbols. Can use the optional RTOS layer or run bare-metal on the HAL. - RTOS — a thin, optional scheduler (task, mutex, queue, delay). Out of scope for now; when added it will be a build-system-selectable module.
- HAL — the public interface in
hal/hal.h. Declarations only. See the HAL API reference. - Port layer — C files implementing the HAL for one platform. The build system selects exactly one port per build. See Hardware porting.
Host simulation model
Section titled “Host simulation model”On the host, the three core peripherals are modeled in software:
- GPIO — a static
uint8_tarray in memory; one index per pin. Every read and write is also emitted on the simulation bus for external inspection and injection. - UART — Unix named pipes (FIFOs), one per port (
/tmp/lpl_uart<N>), openedO_RDWR | O_NONBLOCK. Two host processes opening the same pipe behave exactly like two boards wired over UART. This is the foundation of multi-node simulation. - Timer —
clock_gettime(CLOCK_MONOTONIC). The reference tick is captured duringlpl_system_init; all timestamps are deltas from that zero.
Multi-node model
Section titled “Multi-node model”Two or more firmware instances run as separate host processes and coordinate through the HAL abstractions: UART over named pipes, GPIO via cross-injection on the sim bus, and an independent host clock per process.
┌─────────┐ socket 0 ┌───────────┐ WS (nodeId=0/1/…) ┌──────────┐│ fw node0│───────────▶│ │◀────────────────────│ │└─────────┘ │ bridge.py │ │ frontend │┌─────────┐ socket 1 │ (async) │────────────────────▶│ (Svelte) ││ fw node1│───────────▶│ │ │ │└─────────┘ └───────────┘ └──────────┘A single bridge.py spawns the processes, discovers their sockets, reads all
sim buses in parallel (one asyncio task per socket), and multiplexes every
event onto one WebSocket. Each message carries a nodeId identifying the source
node. The uart_echo example demonstrates the model with two processes over a
named pipe; multi_node shows a heterogeneous scenario (a sensor node and a
display node on two MCU cards).
Build system
Section titled “Build system”A root Makefile delegates target-specific configuration via include:
TARGET ?= hostinclude make/$(TARGET).mkSRCS := hal/$(PORT)/hal_$(PORT).c $(PORT_EXTRA_SRCS)Each make/<target>.mk defines the toolchain, flags, and the variables the root
Makefile and example Makefiles consume. Adding a port means adding one
make/<target>.mk file — nothing else. The target selection lives entirely in
the build system; there are no #ifdef TARGET blocks in the source.
Non-goals
Section titled “Non-goals”- Not a cycle-accurate simulator — the simulation is behavioral, not architectural.
- Not a replacement for Zephyr or FreeRTOS — for certified real-time scheduling or full network stacks, use those.
- Not a do-everything framework — CAN, USB, and a thin RTOS are out of scope until a concrete use case appears.
- Not automatic portability to every MCU — portability requires writing a port layer; the framework makes that work structured and minimal, not magic.