Skip to content

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.

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.

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:

  1. No dynamic allocation in the HAL or port layer — malloc/calloc/ realloc are forbidden. All memory is static or on the stack.
  2. No external dependencies — not even a vendor SDK, except where architecturally unavoidable (ESP-IDF for esp32).
  3. No global state beyond what the port strictly needs, and every global must be documented and justified.
  4. No #ifdef soup — platform differences are resolved by the build system selecting a port directory. Shared code never contains #ifdef TARGET.
  5. No speculative features — every line earns its place through a concrete use case.
┌─────────────────────────────────┐
│ 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 only lpl_* 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.

On the host, the three core peripherals are modeled in software:

  • GPIO — a static uint8_t array 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>), opened O_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.
  • Timerclock_gettime(CLOCK_MONOTONIC). The reference tick is captured during lpl_system_init; all timestamps are deltas from that zero.

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).

A root Makefile delegates target-specific configuration via include:

TARGET ?= host
include make/$(TARGET).mk
SRCS := 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.

  • 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.