HAL Vtable Architecture

Driver SDK overview

The Driver SDK is a single header: app_sdk/include/thistle_driver.h. It declares the HAL registration functions and the kernel utilities available to drivers.

A runtime driver (loaded from SD card) must export one symbol: driver_init(). This is called by the kernel synchronously during boot, after all compiled-in drivers have been registered. Return 0 on success.

// Minimal driver skeleton
#include "thistle_driver.h"
#include "hal/display.h"

static esp_err_t my_init(const void *config)   { return 0; }
static void      my_deinit(void)               {}
static esp_err_t my_flush(const hal_area_t *area,
                          const uint8_t *data) { return 0; }

static const hal_display_driver_t my_display = {
    .init            = my_init,
    .deinit          = my_deinit,
    .flush           = my_flush,
    .set_brightness  = NULL,
    .sleep           = NULL,
    .set_refresh_mode = NULL,
    .width  = 320,
    .height = 240,
    .type   = HAL_DISPLAY_TYPE_LCD,
    .name   = "my_display",
};

int driver_init(void)
{
    thistle_log("mydrv", "Registering my display");
    return hal_display_register(&my_display, NULL);
}

HAL vtable interfaces

Display — hal_display_driver_t

Function pointerSignatureNotes
init(const void *config) → esp_err_tInitialize hardware. Config struct is driver-defined.
deinit(void) → voidRelease hardware resources.
flush(const hal_area_t *, const uint8_t *) → esp_err_tWrite pixels to the display. Called by LVGL.
set_brightness(uint8_t percent) → esp_err_t0–100. Null if unsupported.
sleep(bool enter) → esp_err_tDisplay sleep / wake.
set_refresh_mode(hal_display_refresh_mode_t) → esp_err_tFULL / PARTIAL / FAST. E-paper only.
width, heightuint16_tPhysical pixel dimensions.
typehal_display_type_tLCD or EPAPER.

Input — hal_input_driver_t

Function pointerSignatureNotes
init(const void *config) → esp_err_tInitialize device.
deinit(void) → void
register_callback(hal_input_cb_t, void *) → esp_err_tDriver calls this callback on every event.
poll(void) → esp_err_tCalled periodically if the driver is polled (not interrupt-driven).
is_touchboolTrue for touch panels; false for keyboards/buttons.

Radio — hal_radio_driver_t

Function pointerSignature
set_frequency(uint32_t freq_hz) → esp_err_t
set_tx_power(int8_t dbm) → esp_err_t
set_bandwidth(uint32_t bw_hz) → esp_err_t
set_spreading_factor(uint8_t sf) → esp_err_t
send(const uint8_t *, size_t) → esp_err_t
start_receive(hal_radio_rx_cb_t, void *) → esp_err_t
get_rssi(void) → int
sleep(bool) → esp_err_t

Step-by-step: implementing a display driver

This walkthrough implements a driver for a hypothetical SPI LCD. The same pattern applies to any SPI or I2C display.

1. Create the driver component

mkdir -p components/drv_lcd_mydevice/include
mkdir -p components/drv_lcd_mydevice/src

2. CMakeLists.txt

idf_component_register(
    SRCS "src/drv_lcd_mydevice.c"
    INCLUDE_DIRS "include"
    REQUIRES "thistle_hal" "driver"  # ESP-IDF "driver" for GPIO/SPI
)

3. Config struct in the header

// include/drv_lcd_mydevice.h
#pragma once
#include "hal/display.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"

typedef struct {
    spi_host_device_t spi_host;
    gpio_num_t        pin_cs;
    gpio_num_t        pin_dc;
    gpio_num_t        pin_rst;
    uint32_t          spi_clock_hz;
} lcd_mydevice_config_t;

const hal_display_driver_t *drv_lcd_mydevice_get(void);

4. Driver implementation

// src/drv_lcd_mydevice.c
#include "drv_lcd_mydevice.h"
#include "esp_log.h"

static const char *TAG = "drv_lcd_mydevice";
static const lcd_mydevice_config_t *s_cfg;
static spi_device_handle_t s_spi;

static esp_err_t lcd_init(const void *config)
{
    s_cfg = (const lcd_mydevice_config_t *)config;

    // Toggle reset pin
    gpio_set_direction(s_cfg->pin_rst, GPIO_MODE_OUTPUT);
    gpio_set_level(s_cfg->pin_rst, 0);
    vTaskDelay(pdMS_TO_TICKS(10));
    gpio_set_level(s_cfg->pin_rst, 1);
    vTaskDelay(pdMS_TO_TICKS(50));

    // Add device to SPI bus
    spi_device_interface_config_t dev_cfg = {
        .clock_speed_hz = s_cfg->spi_clock_hz,
        .spics_io_num   = s_cfg->pin_cs,
        .queue_size     = 7,
    };
    esp_err_t ret = spi_bus_add_device(s_cfg->spi_host, &dev_cfg, &s_spi);
    if (ret != ESP_OK) return ret;

    /* Send init command sequence ... */
    ESP_LOGI(TAG, "LCD initialized");
    return ESP_OK;
}

static esp_err_t lcd_flush(const hal_area_t *area,
                          const uint8_t *color_data)
{
    /* Set column/page address, then DMA the pixel data */
    return ESP_OK;
}

static void lcd_deinit(void)
{
    spi_bus_remove_device(s_spi);
}

static const hal_display_driver_t s_driver = {
    .init            = lcd_init,
    .deinit          = lcd_deinit,
    .flush           = lcd_flush,
    .set_brightness  = NULL,
    .sleep           = NULL,
    .set_refresh_mode = NULL,
    .width  = 320,
    .height = 240,
    .type   = HAL_DISPLAY_TYPE_LCD,
    .name   = "lcd_mydevice",
};

const hal_display_driver_t *drv_lcd_mydevice_get(void)
{
    return &s_driver;
}

5. Register it in a board definition

// In your board_*/src/board_*.c
#include "drv_lcd_mydevice.h"

static const lcd_mydevice_config_t lcd_cfg = {
    .spi_host    = SPI2_HOST,
    .pin_cs      = GPIO_NUM_10,
    .pin_dc      = GPIO_NUM_11,
    .pin_rst     = GPIO_NUM_12,
    .spi_clock_hz = 40000000,
};

// In board_init():
hal_display_register(drv_lcd_mydevice_get(), &lcd_cfg);

Implementing an input driver

Input drivers are interrupt-driven. When the hardware fires an interrupt, the ISR reads the event and calls the registered callback. Here is the pattern used by the TCA8418 keyboard driver:

static hal_input_cb_t   s_callback;
static void             *s_user_data;

static void IRAM_ATTR kbd_isr(void *arg)
{
    /* Read key event from TCA8418 over I2C (in a task, not ISR)
     * — use a binary semaphore to signal a task */
    BaseType_t woken = pdFALSE;
    xSemaphoreGiveFromISR(s_isr_sem, &woken);
    if (woken) portYIELD_FROM_ISR();
}

static void kbd_task(void *arg)
{
    while (1) {
        xSemaphoreTake(s_isr_sem, portMAX_DELAY);

        /* Read key from TCA8418 */
        uint8_t key_event = tca8418_read_key();
        hal_input_event_t evt = {
            .type      = (key_event & 0x80) ?
                         HAL_INPUT_EVENT_KEY_DOWN :
                         HAL_INPUT_EVENT_KEY_UP,
            .timestamp = esp_timer_get_time() / 1000,
            .key       = { .keycode = key_event & 0x7F },
        };

        if (s_callback)
            s_callback(&evt, s_user_data);
    }
}

static esp_err_t kbd_register_callback(hal_input_cb_t cb, void *ud)
{
    s_callback  = cb;
    s_user_data = ud;
    return ESP_OK;
}

Using ESP-IDF APIs from a runtime driver

Runtime drivers (.drv.elf) cannot call ESP-IDF functions directly — those symbols live in the kernel's flash and are not exported. Instead, the kernel re-exports the most commonly needed GPIO, I2C, and SPI functions through the syscall table.

Symbol availability
Only symbols listed in the syscall table are available to runtime drivers. If you need a function that is not exported, either use a compiled-in driver (no restriction) or open a GitHub issue to request an export.

Currently exported hardware symbols include:

Building as a runtime driver (.drv.elf)

Runtime drivers use the same CMake flags as apps: position-independent, no stdlib, relocatable output.

add_executable(drv_lcd_mydevice src/drv_lcd_mydevice.c)

target_include_directories(drv_lcd_mydevice PRIVATE
    include
    ${THISTLE_SDK_PATH}/include
)

target_compile_options(drv_lcd_mydevice PRIVATE
    -fPIC -nostdlib -fno-exceptions
)

target_link_options(drv_lcd_mydevice PRIVATE
    -nostdlib -Wl,-relocatable
)

set_target_properties(drv_lcd_mydevice PROPERTIES SUFFIX ".drv.elf")

Deploy by copying to the SD card:

cp build/drv_lcd_mydevice.drv.elf /sdcard/drivers/
# Reboot — the kernel will load it during driver_manager_init()

Signing and publishing

Driver signing follows the same process as app signing. See Security & Signing. In the app store catalog, use "type": "driver" to distinguish drivers from apps.