Architecture
See PLAN.md for the full design rationale, locked decisions, and phased roadmap.
See docs/agent-guide.md for what is implemented versus still planned, and for
the steps to add an app, a HAL function, or a unit test. This file is a short
map of the codebase for orientation; keep it in sync per rule 10 in PLAN.md.
Layers
apps/ user-facing apps (shell, filemgr, settings, ota)
game/ ECS, renderer, audio, scripting, assets
modules/ dynamic .ardmod loader
drivers/ display, sensor, ... (probe/remove/open/read/write/ioctl)
fs/ unified VFS over LittleFS (flash) + FatFS (SD)
hal/ hardware abstraction interfaces (include/) + per-arch impls (arch/)
kernel/ scheduler, time, allocator
Portable code (kernel, drivers, fs, game, apps) talks to hardware only through the
hal_*.h interfaces in hal/include/. Each target — sim, esp32, esp8266, avr, rp2040 —
provides its own implementation under hal/arch/<target>/ or sim/.
flowchart TB
subgraph user["User-facing"]
apps["apps/<br/>shell, fileman, settings, pong, ..."]
game["game/<br/>ECS, renderer, audio, scripting"]
end
subgraph system["System services"]
drivers["drivers/<br/>probe/open/read/write/ioctl<br/>device registry, hotplug"]
modules["modules/<br/>.ardmod dynamic loader"]
fs["fs/<br/>VFS -> LittleFS (/flash), FatFS (/sd)<br/>config store, OTA A/B"]
end
subgraph core["Kernel"]
sched["kernel/scheduler.c<br/>cooperative, priority ready lists<br/>tickless idle"]
alloc["kernel/alloc.c<br/>TLSF allocator"]
power["kernel/power.c + power_governor.c<br/>sleep, clock, backlight cap"]
end
subgraph hw["Hardware abstraction"]
hal["hal/include/ (hal_gpio.h etc)<br/>GPIO, I2C, SPI, UART, Display, Net, Storage"]
end
subgraph impls["Implementations (one compiled in per build)"]
sim["sim/<br/>SDL2 + PortAudio + device models"]
esp32["hal/arch/esp32/"]
esp8266["hal/arch/esp8266/"]
avr["hal/arch/avr/"]
end
apps --> drivers
apps --> fs
apps --> sched
game --> sched
drivers --> modules
drivers --> hal
fs --> hal
sched --> alloc
sched --> power
power --> hal
hal -. compiled against .-> sim
hal -. compiled against .-> esp32
hal -. compiled against .-> esp8266
hal -. compiled against .-> avr
Everything above hal/ is portable C that never includes a backend header
directly - swapping sim for esp32 at build time is the only thing that
changes which arrows on the bottom row are "real".
Boot and the cooperative scheduler
ArdubotOS uses setjmp/longjmp host fibers (not OS threads) to give each
task its own stack while staying strictly cooperative - a task only gives up
control at task_yield(), task_sleep(), or task_suspend(), never
preemptively.
sequenceDiagram
participant Main as main() / sim_main loop
participant Sched as scheduler
participant Task as a task (e.g. an app)
Main->>Sched: scheduler_init()
Note over Sched: creates the idle task (TASK_PRIO_IDLE)<br/>and the main pseudo-task
Main->>Sched: task_create("myapp", entry, ...)
Note over Sched: allocates a private stack,<br/>bootstraps via host_call_on_stack + setjmp
Main->>Sched: scheduler_start()
Sched->>Sched: scheduler_step()
Sched->>Task: longjmp into highest-priority ready task
Task->>Task: runs until task_sleep() / task_yield() / return
Task->>Sched: longjmp back (switch_to_main)
loop every ~1ms of wall-clock time
Main->>Sched: scheduler_tick()
Note over Sched: wakes any task whose wake_time has arrived
Main->>Sched: scheduler_step()
end
Only one task's stack is ever "live" at a time - the sim main loop above
is the idle context, which is why idle_task() (the dedicated
TASK_PRIO_IDLE task created in scheduler_init()) never actually runs:
scheduler_step() and task_yield() both explicitly skip
TASK_PRIO_IDLE when picking the next task to switch to.
Memory: the TLSF allocator
kernel/alloc.c implements a small TLSF (Two-Level Segregated Fit) pool
allocator - O(1) malloc/free with low fragmentation, suited to a fixed-size
heap on a microcontroller. os_malloc/os_calloc/os_realloc/os_free
wrap a single static 32KB pool (g_heap_memory); tlsf_create/tlsf_malloc/
etc. are the lower-level API usable against any caller-provided buffer
(see tests/unit/test_kernel_alloc.c for direct pool tests).
flowchart LR
req["tlsf_malloc(pool, size)"] --> fl["bucket = fls(size)<br/>(32 size-class buckets, one per power of two)"]
fl --> scan["scan that bucket for a block >= size<br/>(exact bucket may hold smaller blocks)"]
scan -->|found| split{"block much bigger<br/>than needed?"}
split -->|yes| carve["block_split(): carve off the<br/>remainder, re-insert as free"]
split -->|no| use["mark used, return pointer"]
carve --> use
scan -->|none in any bucket >= fl| fail["return NULL"]
Freeing a block runs block_merge() first, coalescing with a physically
adjacent free neighbor (tracked via each block's prev_phys/next_phys,
independent of the free-list buckets) before reinserting it - this is what
keeps long-running allocate/free churn from fragmenting the pool into
unusable slivers.
Targets
Selected via CMake options in the top-level CMakeLists.txt, resolved in
cmake/toolchain.cmake:
| Option | Target | Toolchain |
|---|---|---|
ARDUBOT_BUILD_SIM |
Host simulator (SDL2 + PortAudio) | native compiler |
ARDUBOT_BUILD_ESP32 |
ESP32 | ESP-IDF or bare-metal Xtensa GCC |
ARDUBOT_BUILD_ESP8266 |
ESP8266 | Xtensa lx106 GCC |
ARDUBOT_BUILD_AVR |
Mega2560 | avr-gcc |
The simulator is the primary development target: sim/ implements the HAL against SDL2
(video/GPIO keys), PortAudio (audio), and per-device register models (sim_i2c.c,
sim_spi.c, sim/models/) so board-level drivers can be developed and tested on a host
machine before hardware is involved.
Testing
tests/unit/— fast, host-only, no simulator init (Unity framework).tests/integration/— exercises the simulator end-to-end.tests/hardware/— QEMU + physical hardware (later phases).
CI (.github/workflows/sim.yml) builds and runs tests/unit + tests/integration via the
simulator's --headless --test=all mode on Ubuntu, Windows, and macOS on every push/PR.