Architecture
A layered design: HAL interfaces at the bottom, 15 Rust drivers implementing them, a pure Rust kernel (57 modules) coordinating everything, and apps at the top — with each layer knowing nothing about the one above it.
✎ Edit on GitHubLayer diagram
Show text-based diagram
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
| Interface | Header | Key functions |
|---|---|---|
hal_display_driver_t | hal/display.h | init, flush, set_brightness, sleep, set_refresh_mode |
hal_input_driver_t | hal/input.h | init, register_callback, poll |
hal_radio_driver_t | hal/radio.h | init, set_frequency, set_tx_power, send, start_receive |
hal_gps_driver_t | hal/gps.h | init, enable, get_position |
hal_audio_driver_t | hal/audio.h | init, play_pcm, set_volume |
hal_power_driver_t | hal/power.h | init, get_battery_mv, get_battery_pct |
hal_imu_driver_t | hal/imu.h | init, get_accel, get_gyro, register_gesture_cb |
hal_storage_driver_t | hal/storage.h | init, mount, unmount (POSIX FS after mount) |
hal_net_driver_t | hal/net.h | init, is_connected, get_ip |
hal_crypto_driver_t | hal/crypto.h | aes_encrypt, aes_decrypt, sha256 — hardware or software fallback |
hal_rtc_driver_t | hal/rtc.h | init, 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.
| Flag | Value | Grants access to |
|---|---|---|
PERM_RADIO | 1 << 0 | LoRa radio send/receive |
PERM_GPS | 1 << 1 | GPS location data |
PERM_STORAGE | 1 << 2 | SD card file access |
PERM_NETWORK | 1 << 3 | WiFi / BLE / 4G cellular |
PERM_AUDIO | 1 << 4 | Audio playback and recording |
PERM_SYSTEM | 1 << 5 | System settings, reboot, OTA |
PERM_IPC | 1 << 6 | Inter-process messaging |
PERM_ALL | 0x7F | All 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
| Region | Size | Used for |
|---|---|---|
| Internal DRAM | ~320 KB | FreeRTOS stacks, kernel BSS, ISR handlers, time-critical code |
| PSRAM (SPIRAM) | 8 MB | LVGL frame buffers, loaded app ELFs, app heap allocations |
Flash — ota_0 | 2.5 MB | Recovery OS (Rust) |
Flash — ota_1 | 4.5 MB | Main ThistleOS firmware |
Flash — nvs | 24 KB | WiFi credentials, app settings (ESP-IDF NVS) |
| SD card | unlimited | Apps (.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
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.
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.