Layer diagram

ThistleOS three-tier architecture: Recovery, Kernel, and Userspace
Show text-based diagram
┌──────────────────────────────────────────────────┐ │ Apps (.app.elf) — PSRAM, loaded at runtime │ │ launcher / messenger / navigator / your-app │ ├──────────────────────────────────────────────────┤ │ Syscall table — symbol resolution for ELF loader │ ├──────────────────────────────────────────────────┤ │ Kernel — 100% Rust, 57 modules, 1,231 tests │ │ app_manager driver_manager ipc event_bus ota │ │ wifi_manager ble_manager permissions elf_loader │ │ signing manifest crypto version display_server │ ├──────────────────────────────────────────────────┤ │ WM (.wm.elf) — thistle-tk (Rust) | LVGL (C) │ │ window_manager statusbar theme toast app_switcher│ ├──────────────────────────────────────────────────┤ │ HAL registry — hal_get_registry() returns all drivers│ │ display input radio gps audio power imu │ │ storage net crypto rtc │ ├──────────────────────────────────────────────────┤ │ 15 Rust Drivers (drv_*) │ │ epaper lcd oled kbd touch gps imu rtc │ │ audio power sdcard (light sensor, imu stubs) │ ├──────────────────────────────────────────────────┤ │ Board config (board.json on SPIFFS) │ │ pin assignments, bus config, driver list │ │ selected/downloaded by Recovery OS board catalog │ └──────────────────────────────────────────────────┘

HAL vtable design

Every piece of hardware is represented by a C struct of function pointers — a vtable. The kernel calls these function pointers without knowing which chip is behind them. A display driver for an SSD1306 OLED and one for a GDEQ031T10 e-paper display both implement the same hal_display_driver_t interface.

// components/thistle_hal/include/hal/display.h
typedef struct {
    esp_err_t (*init)(const void *config);
    void      (*deinit)(void);
    esp_err_t (*flush)(const hal_area_t *area, const uint8_t *color_data);
    esp_err_t (*set_brightness)(uint8_t percent);
    esp_err_t (*sleep)(bool enter);
    esp_err_t (*set_refresh_mode)(hal_display_refresh_mode_t mode);
    uint16_t   width;
    uint16_t   height;
    hal_display_type_t type;
    const char *name;
} hal_display_driver_t;

A driver exports a single function returning a const pointer to its vtable. It never allocates — the vtable lives in flash, and the driver state lives in the driver's own static variables or in a config struct passed at registration.

// How a driver exposes itself
const hal_display_driver_t *drv_epaper_gdeq031t10_get(void);

// How a board registers it
hal_display_register(drv_epaper_gdeq031t10_get(), &epaper_config);

HAL vtable interfaces

InterfaceHeaderKey functions
hal_display_driver_thal/display.hinit, flush, set_brightness, sleep, set_refresh_mode
hal_input_driver_thal/input.hinit, register_callback, poll
hal_radio_driver_thal/radio.hinit, set_frequency, set_tx_power, send, start_receive
hal_gps_driver_thal/gps.hinit, enable, get_position
hal_audio_driver_thal/audio.hinit, play_pcm, set_volume
hal_power_driver_thal/power.hinit, get_battery_mv, get_battery_pct
hal_imu_driver_thal/imu.hinit, get_accel, get_gyro, register_gesture_cb
hal_storage_driver_thal/storage.hinit, mount, unmount (POSIX FS after mount)
hal_net_driver_thal/net.hinit, is_connected, get_ip
hal_crypto_driver_thal/crypto.haes_encrypt, aes_decrypt, sha256 — hardware or software fallback
hal_rtc_driver_thal/rtc.hinit, get_time, set_time, set_alarm

Kernel modules

app_manager

Tracks all running apps. Each app has a numeric ID, an app_manifest_t (parsed from its ELF .thistle_app section), a FreeRTOS task handle, and a permission bitmask. Provides start, pause, resume, and destroy lifecycle hooks.

driver_manager

Calls board_init() — which registers drivers with the HAL — then calls init() on every registered driver in registration order. On failure, it logs the error and skips that driver; the rest of the system continues.

ELF loader

Loads position-independent ELF files from the SD card into PSRAM. Symbol resolution walks the syscall table by name — each entry is a {"name", func_ptr} pair. If an app references an unknown symbol the load fails cleanly. The loader uses the Espressif espressif/elf_loader managed component under the hood.

// Load and start an app at runtime
elf_app_handle_t handle;
esp_err_t ret = elf_app_load("/sdcard/apps/chat.app.elf", &handle);
if (ret == ESP_OK) {
    elf_app_start(handle);
}

IPC (inter-process communication)

Apps communicate through typed messages. Each message carries a source app ID, destination app ID (0 = broadcast), a 32-bit type, and up to 256 bytes of payload. Messages are delivered via FreeRTOS queues — queue depth is 16 per recipient.

// Send a message to app 0x02
ipc_message_t msg = {
    .src_app  = my_app_id,
    .dst_app  = 0x02,
    .msg_type = MSG_TYPE_LOCATION,
    .data_len = sizeof(location_data),
};
memcpy(msg.data, &location_data, sizeof(location_data));
ipc_send(&msg);

Event bus

A publish/subscribe system layered on top of IPC. Apps subscribe to event types (e.g., EVT_BATTERY_LOW, EVT_GPS_FIX, EVT_RADIO_RX) and receive callbacks when those events fire. The kernel itself publishes hardware events from driver interrupt handlers.

Permissions

Every app has a permission_set_t bitmask. When an app calls a syscall that requires a permission (e.g., SYSCALL_RADIO_SEND requires PERM_RADIO), the kernel checks the bitmask before dispatching. Unsigned apps loaded from the SD card get no permissions by default — they must be declared in the app manifest and approved.

FlagValueGrants access to
PERM_RADIO1 << 0LoRa radio send/receive
PERM_GPS1 << 1GPS location data
PERM_STORAGE1 << 2SD card file access
PERM_NETWORK1 << 3WiFi / BLE / 4G cellular
PERM_AUDIO1 << 4Audio playback and recording
PERM_SYSTEM1 << 5System settings, reboot, OTA
PERM_IPC1 << 6Inter-process messaging
PERM_ALL0x7FAll of the above (built-in apps only)

wifi_manager / ble_manager

wifi_manager wraps ESP-IDF's WiFi station mode: scan, connect, disconnect, RSSI, IP, and NTP time sync. ble_manager is planned for a future release. Both are exposed to apps through the syscall table under the PERM_NETWORK permission.

OTA

Wraps ESP-IDF's esp_https_ota. The kernel can update itself from an SD card file (/sdcard/update/thistle_os.bin) or from an HTTP URL. The OTA module writes to the inactive partition (ota_1) and reboots. If the new firmware fails to call ota_mark_valid() within 30 seconds, the bootloader rolls back.

Memory layout

RegionSizeUsed for
Internal DRAM~320 KBFreeRTOS stacks, kernel BSS, ISR handlers, time-critical code
PSRAM (SPIRAM)8 MBLVGL frame buffers, loaded app ELFs, app heap allocations
Flash — ota_02.5 MBRecovery OS (Rust)
Flash — ota_14.5 MBMain ThistleOS firmware
Flash — nvs24 KBWiFi credentials, app settings (ESP-IDF NVS)
SD cardunlimitedApps (.app.elf), drivers (.drv.elf), themes, user data

Apps are loaded into PSRAM via heap_caps_malloc(size, MALLOC_CAP_SPIRAM). Each app's ELF segments (text + data + BSS) are mapped there. The kernel's text and read-only data live in flash and are executed via the instruction cache.

Boot sequence

Power on / Reset │ ▼ [Bootloader (flash)] ─────── checks OTA state │ ├─ ota_1 valid? ──────────────── YES ─► [ThistleOS main firmware] │ │ └─ ota_1 missing / invalid ─► [Recovery OS] ▼ kernel_init() │ ├─ syscall_table_init() ├─ permissions_init() ├─ driver_manager_init() │ └─ board_init() │ ├─ spi_bus_initialize() │ ├─ i2c_new_master_bus() │ └─ hal_*_register() x8 ├─ driver_manager_start_all() ├─ elf_loader_init() ├─ ui_init() (LVGL) ├─ load SD card .drv.elf files ├─ launch launcher_app └─ kernel_run() (never returns)

Driver loading: compiled-in vs SD card

ThistleOS supports two driver deployment modes:

Compiled-in drivers (the default): The driver component is listed in the board component's CMakeLists.txt as a REQUIRES dependency. The linker includes it in the firmware binary. board_init() calls drv_*_get() and passes the vtable directly to hal_*_register(). Zero overhead, zero startup latency, no SD card needed.

Runtime drivers (SD card .drv.elf): During kernel_init(), after built-in drivers are registered, the ELF loader scans /sdcard/drivers/ and loads each .drv.elf it finds. Each file must export a driver_init() function (its ELF entry point). That function calls hal_*_register() — the same registration functions used by built-in drivers, exposed through the syscall table.

Tip
Runtime drivers let you ship a display driver update without reflashing the OS. Drop a new drv_oled.drv.elf on the SD card and reboot.

LVGL UI framework

ThistleOS uses LVGL 9 via the Espressif esp_lvgl_port managed component. The UI layer registers the display driver and input drivers with LVGL during ui_init() by reading them from the HAL registry.

Apps create LVGL objects (widgets) directly — LVGL symbols are exported through the syscall table, so lv_label_create(), lv_obj_set_style_*, and all other LVGL functions are available to dynamically loaded apps without being duplicated in every ELF.

The window manager assigns each app a top-level lv_obj_t container. When an app is paused, its container is hidden; when resumed, it is shown. This gives instant task switching without rebuilding the widget tree.

The e-paper display (HAL_DISPLAY_TYPE_EPAPER) gets special treatment: the epaper_refresh module coalesces LVGL flush calls and decides whether to issue a fast, partial, or full panel refresh based on how many pixels changed and when the last full refresh happened.