Привет, Хабр! Меня зовут Андрей. По основной профессии я инженер-проектировщик, а программирование для меня — это хобби и инструмент, помогающий в работе.

Это моя первая публикация, и я хочу представить проект, идея которого родилась после долгих поисков способа динамически подгружать код в работающее устройство на ESP32. Думаю многие проводили изыскания в данном направлении.

Всё началось с прикладной задачи, опять же в качестве - а смогу ли? Если упрощенно, то было разработано и собрано устройство обеспечивающее переключение насосов по наработке и управлению системой подпитки для индивидуального теплового пункта. Оно подключается к телефону для мониторинга и настройки через блютуз. В какой-то момент захотелось иметь возможность дополнять его логику новыми схемами управления прямо с телефона, не перекомпилируя и не перепрошивая основное ядро. И понеслась...

Муки выбора: почему не WASM, Lua или что то еще?

Я рассматривал стандартные решения, но по тем или иным причинам отказался от них. В итоге зацепили концепции ELF Loader и WASM

ELF Loader: Позволяет грузить нативный код и выполнять его с максимальной скоростью, при этом на стороне прошивки всего лишь таблица указателей на функции(пусть будет таблица символов). Получаемый elf файл жестко привязан к архитектуре. Код, скомпилированный для ESP32-S3 (Xtensa), не запустится на ESP32-C3 (RISC-V). Мне же хотелось универсальности — «один бинарник для всей линейки».

WebAssembly (WASM): Достаточно быстрая и интересная штука, байт-код которой не привязан к архитектуре, но любой, кто пробовал вызвать из WASM нативную функцию вроде xTaskCreate и передать туда callback, знает, какая это боль. Требуется писать огромное количество "клея" (glue code), регистрировать импорты/экспорты вручную. Хотелось писать обычный C-код, используя стандартные API ESP-IDF, и чтобы оно «просто работало». Так появилась идея ESPB (ESP Bytecode).

Что такое ESPB?

Это экосистема, состоящая из Транслятора (превращает ваш C/(возможно)C++ код в байт-код) и Интерпретатора (виртуальная машина, работающая на микроконтроллере).
Главная фишка проекта — бесшовная интеграция с нативным API. Благодаря использованию таблицы символов и кастомной реализации libffi, ESPB позволяет вызывать функции FreeRTOS (таймеры, задачи) прямо из подгружаемого модуля без написания оберток.

Как это устроено под капотом

 Транслятор (на основе LLVM)

  • Я не стал изобретать свой компилятор с нуля, а вместо этого использовал LLVM. Процесс выглядит так:

    1. Вы пишете код в обычном проекте ESP-IDF.

    2. Компилируете его с помощью clang в промежуточное представление LLVM IR (.bc файл).

    3. Транслятор анализирует этот .bc файл и генерирует на выходе .espb байт-код.

    На третьем этапе и происходит вся магия. Транслятор делает сложную работу: он проводит глубокий статический анализ IR, чтобы понять семантику вызовов нативных функций.

    Например, он видит вызов xTaskCreate и понимает, что:

    • первый аргумент — это указатель на функцию, которая станет телом задачи;

    • последний аргумент — это указатель на TaskHandle_t, то есть выходной (OUT) параметр.

    На основе этого анализа транслятор автоматически генерирует специальные метаданные:

    • Секция cbmeta: Информация для интерпретатора, как правильно создать "трамплин" для колбэка (my_task).

    • Секция immeta: Инструкции для интерпретатора по маршалингу OUT-параметров — то есть, как безопасно скопировать дескриптор задачи из нативной памяти обратно в память виртуальной машины после вызова.

    Именно этот автоматический анализ избавляет от необходимости писать тонны "клея" вручную.

    А что, если автоматика ошибается? Файлы .hints

    Я стремился сделать транслятор максимально «умным». Как уже упомянул, он проводит глубокий статический анализ LLVM IR, пытаясь автоматически определить семантику вызовов: какой указатель является выходным (OUT), где находится функция обратного вызова (callback), а где — данные для неё (user_data).

    Автоматический анализ не всесилен. Всегда найдутся нестандартные API или сложные случаи, где эвристика может дать сбой.

    Именно для этого ввел .hints файлы. Это простые текстовые файлы, которые можно «скормить» транслятору вместе с .bc файлом. Они позволяют вручную "подсказать" транслятору, как правильно обрабатывать ту или иную функцию.

    Как это работает?

    Предположим, у вас есть нативная функция my_complex_api(char* out_buffer, int size, my_callback_t cb). Если транслятор не смог автоматически определить, что out_buffer — это выходной параметр, вы можете просто добавить в .hints файл одну строку:

    # Файл my_project.hints
    
    my_complex_api: out 0, cb 2

    эта запись говорит транслятору:

    • "Для функции my_complex_api...

    • ...параметр с индексом 0 является выходным (out).

    • ...а параметр с индексом 2 — это функция обратного вызова (cb)."

    Таким образом, вы получаете полный контроль над генерацией метаданных FFI, исправляя любые неточности автоматического анализа, не меняя ни строчки в исходном коде транслятора.

    Интерпретатор (на устройстве)

    Это виртуальная машина, которая исполняет .espb файл. Она спроектирована с нуля специально для микроконтроллеров серии ESP32

    Ключевые особенности реализации:

    • Кастомная libffi с размещением "трамплинов" в IRAM: Я взял за основу библиотеку libffi и переработал её для поддержки архитектур Xtensa и RISC-V для этой VM. Ключевой особенностью моей адаптации является специальный аллокатор, который размещает исполняемый код замыканий ("трамплинов" для колбэков) в быстрой IRAM (Instruction RAM). Это критически важно, так как позволяет вызывать колбэки (например, из таймеров FreeRTOS).

    • Регистровая машина с теневым стеком: В отличие от стековых VM, ESPB использует регистровую модель. Это ближе к архитектуре реальных процессоров и позволяет генерировать более эффективный байт-код. Для максимальной компактности индексы регистров в инструкциях кодируются всего одним байтом. Все операции со стеком вызовов и локальными переменными функций происходят в специальном "теневом стеке" (shadow stack) — выделенном буфере в ОЗУ, что обеспечивает изоляцию и предсказуемость.

      Изоляция памяти: 

      Изолированная Линейная Память и Собственная Куча (Heap): Для каждого ESPB-модуля интерпретатор выделяет непрерывный блок ОЗУ — линейную память. Этот блок становится полноценным адресным пространством для исполняемого байт-кода. Именно сюда:

      • Копируются статические данные: Все глобальные переменные, строковые литералы и константные массивы из вашего C-кода размещаются в этой памяти при загрузке модуля.

      • Работает собственная куча: Когда ваш ESPB-код вызывает malloc, calloc или free, он на самом деле обращается к менеджеру памяти (espb_heap_manager), который управляет выделением памяти внутри этого же изолированного блока. Это предотвращает фрагментацию глобальной кучи ESP-IDF и повышает стабильность системы.

      • Размещаются стековые переменные: Инструкция alloca также выделяет память в этой области, эмулируя работу нативного стека.

    Вайбкодинг и роль нейросетей

    Этот проект — результат так называемого «вайбкодинга». С мая был пройден долгий и достаточно сложный путь. Нейросети помогли реализовать данный проект. Исключительно этот симбиоз позволил замахнуться на системное программирование такого уровня.

    Как это попробовать?

    Постарался сделать процесс максимально похожим на обычную разработку под ESP32. Есть проект-шаблон ESP32_PRJ_TO_LLVM. Это фактически обычный ESP-IDF проект. Вы пишете в нем код, подключаете библиотеки, отлаживаете. Скрипт get-ir-cmake.ps1 выдергивает из системы сборки .bc файл. Этот файл скармливается онлайн-транслятору (ссылка ниже), который выдает готовый .espb. Файл .espb кладется в прошивку (или загружается по Wi-Fi/UART) и исполняется интерпретатором. Тестировал пока только с жесткой прошивкой .espb файла совместно с .bin. для получения .bc использовал clang, который в комплекте с ESP-IDF 5.4. Стоит учитывать, что транслятор поддерживает clang не выше версии 20.1.2.

    Пример того, чтоработает «из коробки».

    Самое интересное, что вы можете написать такой код, скомпилировать его в байт-код, и он будет работать:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h>

while (true)
{
    vTaskDelay(1000 / portTICK_PERIOD_MS);
}


void my_task(void* pvParam) {
    while(1) {
        printf("Hello from dynamic code!\n");
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}


void app_main(int argc, char* argv[], char* envp[])
{
    xTaskCreate(my_task, "dyn_task", 4048, NULL, 5, NULL); 

    while (true)
    {
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
    
}
// Пример таблицы символов в интерпретаторе
static const EspbSymbol cpp_symbols[] = {
    { "printf", (const void*)&printf },         
    { "puts", (const void*)&puts },
    { "vTaskDelay", (const void*)&vTaskDelay },
    { "xTaskCreatePinnedToCore", (const void*)&xTaskCreatePinnedToCore },
    { "xTimerCreate", (const void*)&xTimerCreate },
    { "pvTimerGetTimerID", (const void*)&pvTimerGetTimerID },
    { "xTimerGenericCommand", (const void*)&xTimerGenericCommand },
    { "xTaskGetTickCount", (const void*)&xTaskGetTickCount },
    {"pvTimerGetTimerID", (const void*)pvTimerGetTimerID},
    { "vTaskDelete", (const void*)&vTaskDelete },
    // ... и другие необходимые функции
    ESP_ELFSYM_END
};

Проверено на железе.


Я не ограничивался симуляторами. Вся система была протестирована и отлажена на реальных устройствах, чтобы убедиться в кросс-архитектурной совместимости:
ESP32 (двухъядерный Xtensa LX6)
ESP32-C3 (одноядерный RISC-V)
ESP32-C6 (одноядерный RISC-V)
Один и тот же .espb файл успешно запускался и работал на всех этих платформах, что подтверждает главную идею — универсальность исполняемого кода.

Статус проекта и ссылки

Текущая реализация интерпретатора пока не поддерживает JIT и AOT — это чистый интерпретатор. Проект находится в стадии активного PoC (Proof of Concept), но уже умеет выполнять достаточно сложную логику. В дальнейшем планируется шлифовка, вылавливание багов и оптимизация.

В идеале смотрю в сторону создания компилятора работающего прямо на устройстве, что бы получать AOT на месте. Не знаю только возможно ли подобное.

Онлайн-транслятор
Репозиторий интерпретатора
Проект для подготовки LLVM IR

Для Онлайн-транслятора нужно сделать вывод статистики трансляции, что бы было понятно чем формируются секции cbmeta и immeta. Сайт так же находится в зачаточном состоянии. По сути только что бы транслировать .espb файл. Ну и содержит сгенерированное описание.
Буду рад любой критике и советам.

Комментарии (0)