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

Это моя первая публикация, и я хочу представить проект, идея которого родилась после долгих поисков способа динамически подгружать код в работающее устройство на 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 файл. Ну и содержит сгенерированное описание.
Буду рад любой критике и советам.

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


  1. riky
    24.11.2025 00:03

    На самом деле интересная вещь. Сам тоже время от времени о подобных вещах думаю. Хочется для esp сделать какой то hot reload как в вебе. Будет время посмотрю как у вас устроено.
    А компиляция быстро происходит или как обычно? На выходе файл небольшого размера? Там ведь нет библиотечного кода, только "наш"?
    И ещё если я захочу его через вифи загрузить куда его положить, в отдельный раздел на флеше? Или просто в файл в разделе littlefs?


    1. Smersh1307n2 Автор
      24.11.2025 00:03

      Доброе время суток Riky. Ответил Вам в качестве отдельного комментария.


  1. Smersh1307n2 Автор
    24.11.2025 00:03

    Проект ESP32_PRJ_TO_LLVM — это, по сути, стандартный ESP-IDF проект-обертка. Он нужен, чтобы компилятор (clang) видел все системные пути и хедеры (например, #include "freertos/FreeRTOS.h"). Настраивать эти пути вручную — та еще головная боль, поэтому я пришел к такому решению.
    Это дает огромный плюс: вы можете сначала отладить свой код как обычное приложение, а потом скриптом собрать из него .bc файл.

    Нюанс отладки: Если ваш модуль вызывает функции основной прошивки (через extern), то для локальной отладки внутри шаблона им нужны «заглушки» (тела функций). А когда собираем финальный .bc — комментируем тела и оставляем только объявления, чтобы транслятор понял, что это внешний вызов.

    Компиляция в промежуточный код (.bc) и трансляция в .espb проходят очень быстро.Размер итогового файла получается компактным, так как он содержит только вашу логику. Никакого библиотечного кода (FreeRTOS, драйверы, lwIP) в него не включается — всё это уже есть в основной прошивке. Мы просто ссылаемся на системные функции через таблицу символов.

    Загрузка (Wi-Fi / Файловая система)
    Тут есть два пути, в зависимости от задач:

    • LittleFS / SPIFFS: Если файлы .espb небольшие, их можно хранить в файловой системе. Но для работы интерпретатору нужен указатель на непрерывный участок памяти. Поэтому файл из LittleFS придется целиком перегнать в ОЗУ. Исполнять код напрямую из файловой системы (побайтовым чтением) в теории можно, но это убьет производительность из-за накладных расходов SPI. И данный функционал не реализован.

    • Отдельный раздел (Partition): Если модули большие или нужно экономить ОЗУ, лучше писать их в Raw-раздел (через OTA API) и использовать MMAP. Это позволит исполнять код (читать опкоды) напрямую из Flash-памяти, не занимая оперативку.


    1. riky
      24.11.2025 00:03

      вещь конечно очень нишевая за счет новой VM. имхо зря зациклились на кроссплатформенности. сложно представить где это надо, не так и сложно собрать код под несколько версий esp, да и как праивло не надо это.
      из того что я думал все таки компилировать в обычный байткод. - интересующие функции (которые надо сделать горячими hot-swap) делать в основном коде ссылками на функции. тогда при заливке нового кода в IRAM можно поменять ссылки на новые функции. в коде патча ссылки должны быть сразу правильные, т.к. они делаются для конкретной версии билда.

      ваш способ я пока поверхностно понял но конечно выглядит универсальнее и в теории позволит сделать настоящую ОС для ESP с онлайн маркетплейсом приложений и их запуском.

      но это ваша LLVM все таки темная лошадка не понятно насколько производительность сьедает. ведь у разных ESP архитектура довольно сильно отличается и оптимизации им подходят разные.

      плюс порог входа довольно высокий. как видите комментариев даже немного. кажется даже wasm неплохой вариант, видел есть проект по его портированию на esp.

      но могу в чем то ошибаться пока поверхностно понял детали проекта


      1. Smersh1307n2 Автор
        24.11.2025 00:03

        Тяжелая оптимизация происходит на хосте (на ПК) внутри транслятора.
        В микроконтроллер прилетает уже "вылизанный" регистровый байт-код. Да, интерпретация медленнее натива но для бизнес-логики (опросить датчик, принять JSON, дернуть реле) это вообще не заметно. Мы же не биткоины майним и не видео кодируем в VM. Пока все это так.
        Так же согласен, в DIY-проекте собрать бинарники несложно. Но если мы говорим о продукте, который живет годами, парк устройств становится разношерстным (тут S3, там C3...). Возможность отправить "по воздуху" (OTA) один файл обновления бизнес-логики, который гарантированно заведется на всём парке — это огромное упрощение инфраструктуры обновлений.

        И да, WASM крут, но он изолирован. Чтобы из WASM дернуть xTaskCreate или кастомную функцию вашего драйвера, вам нужно написать C-обертку (binding), скомпилировать её в прошивку и зарегистрировать.
        В ESPB благодаря libffi вызов происходит прозрачно. Не нужно писать "клей" для каждой функции. Это радикально снижает порог входа именно для написания кода под VM.

        То, что я делаю это по сути тот же WASM, но с подвязками под libffi и без дескрипторов типов - (i*iiii)i. Когда все начинал. во главе угла стояло удобство использования и максимальная скорость выполнения кода близкая к нативной. С последним пока еще вопрос не решен, это следующий шаг- если вообще выйдет. Сначала хочу внедрить дополнительную таблицу символов без строковых имен, только доступом по индексам. Так можно открыть для .espb все, что необходимо не сильно жертвуя занимаемым местом во flash.

        Вы абсолютно правы про "ОС с маркетплейсом" — это именно тот вектор, куда хочется двигаться. Чтобы приложения не зависели от версии ядра и железа.


        1. NutsUnderline
          24.11.2025 00:03

          "ОС с маркетплейсом" — это именно тот вектор, куда хочется двигаться

          можеееееет нееее наааадооооо Просто что то прям хорошего результата от подобных решений маловато. В умных часах стали получаться относительно гибкие платформы, но все равно портируемость от модели к модели - очень так себе


          1. Smersh1307n2 Автор
            24.11.2025 00:03

            Ну допустим у Вас все получилось. Есть интерпретатор+JIT. Все работает очень шустро, глаз радует. Сделали взаимодействие между "модулями". В каком бы направлении двигались дальше?


            1. NutsUnderline
              24.11.2025 00:03

              Предположим максимальное расширение "библиотеки модулей". А для этого ведь нонче нужен магазин сразу конечно платежи прикрутить.

              Удивительно но часоделы не все идут по этому пути пока что. При этом них сильно меняется платформа внутри, выпускаются новые модели с фичами. Возможно им не хочется нести всю эту тягомотину плюс легаси. Выхлоп не очень


              1. Smersh1307n2 Автор
                24.11.2025 00:03

                Спасибо! Честно говоря, пока не копал в этом направлении - сейчас голова занята другим. Все это «мысли завтрашнего дня». Какую-то инфраструктуру для удобного распространения и загрузки модулей сделать определенно стоит и то, в том случае если проект .espb вообще заинтересует и будет востребован.

                Скажите. А если лично Вы напишите модуль, потратите уйму времени, сил. И захотите выложить этот модуль ну и поиметь чуток с этого дела. То как быть?


                1. NutsUnderline
                  24.11.2025 00:03

                   ну и поиметь чуток с этого дела

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

                  часы дают интересные примеры в этом разрезе. например что запилили альтернативный сайт- магазин к официальному но без регистрации и смс. с часовыми табло довольно большой движ самодеятельности превращая их натурально в миниприложения


                  1. riky
                    24.11.2025 00:03

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

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


  1. osmanpasha
    24.11.2025 00:03

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

    Я рассматривал стандартные решения, но по тем или иным причинам отказался от них.

    И все же, почему не Lua или тот же микропитон?


    1. DieSlogan
      24.11.2025 00:03

      Toit, шустрый, с быстрой перезагрузкой, простой.


      1. Smersh1307n2 Автор
        24.11.2025 00:03

        Toit интересная штука. Но допустим Вам потребовалось использовать CAN. Его кстати не реализовали еще в Toit?


    1. Smersh1307n2 Автор
      24.11.2025 00:03

      В поисках тогда перепробовал многое.
      Сначала делал ставку на ELF loader (загрузку нативного кода). Хотелось получить поддержку полноценного C++ с классами. Даже модифицировал загрузчик и в целом получилось, но уперся в стену при попытке подружить его со стандартной библиотекой шаблонов (STL) и механизмом исключений(это вообще как я понял нерешаемая задача).
      Смотрел и в сторону скриптовых языков. NodeMCU (Lua) оставила приятное впечатление своей модульностью: удобно собирать прошивку только из нужных компонентов. Но у Lua и MicroPython есть фундаментальный недостаток — производительность. Это интерпретаторы. Искал производительность уровня JIT-компиляции, но для ESP32 готовых решений такого класса просто не было. LuaJIT не завезли...
      Самое основное:  чем ближе ты к ESP-IDF, тем больше у тебя реальных возможностей. Любой скриптовый язык — это надстройка, которая неизбежно ограничивает доступ к железу и API операционной системы.


      1. osmanpasha
        24.11.2025 00:03

        Но у Lua и MicroPython есть фундаментальный недостаток — производительность. Это интерпретаторы.

        Ну просто ваше решение тоже является интерпретатором, с куда меньшим количеством вложенных человекочасов, так что можно предположить, что его производительность не будет выше. По крайней мере, без сравнения производительности отмахиваться от распространенного интерпретатора в пользу нового выглядит такой себе затеей.


        1. Smersh1307n2 Автор
          24.11.2025 00:03

          В целом вы правы — код проекта молодой и требует «полировки», это факт.
          Ну на счет человекочасов сложно стало судить, если учесть, что основную работу выполняют AI. Да и нет пока ни JIT ни AOT. Если у вас будет возможность и желание прогнать бенчмарки — буду только рад увидеть результаты, самому очень интересно!


    1. Smersh1307n2 Автор
      24.11.2025 00:03

      Доброе время суток Osmanpasha. Подскажите Вы бы каким скриптовым языком воспользовались для решения задач?


  1. riky
    24.11.2025 00:03

    лично мне этот/подобные проекты инетерсны в разрезе разных задач. например:

    1. обновление прошивки серийно выпускаемых изделий. пока обходимся OTA - и думаю на данном этапе это идеальное решение. патчами наверное и нет смысла обновлять, лучше полностью, это не долго и надежнее.

    2. быстрое прототипирование. чтобы как в браузере, поменял код нажал сохранить и через пару сек выполняется новый код, особенно если работать с UI выводить чтото на дисплей будет очень полезно. ну или в сериал чтото выводить. или тестовые скетчи чтобы новую железку протестировать. вот эта ниша у меня не закрыта и данный проект в теории мог бы помочь, но пока что для сборки много телодвижений, кажется это будет еще дольше чем если просто собрать и загрузить полную прошивку. загружать в теории быстрее но собирать столько же или дольше.

    3. у устрйоства которое делаем есть моб приложение откуда можно создать программу работы. то есть устройство обрабатывает несколько шагов с разными режимами работы, настройками и тд. моб приложение генерирует некий декларативный псевдокод и передает в устройство по нему устройство генерирует необходимые структуры объектов и дальше работает по ним. язык включает блоки для объявления переменных, простейшая арифметика, таймеры, ПИД регуляторы и тд. в общем все работет, но код специфичный и очень ограниченные конструкции.

    Изначально думал использовать чтото типа Lua/wasm или скорее даже lisp и подобных ЯП или свой движок для языка похожего на них.
    для такой задачи важно чтобы он был простой легкий для МК и можно было легко и быстро компилировать код на мобильном приложении. а также простая пошаговая отладка.
    для такого применения данный проект бы тоже не стал использовать, т.к. компилировать сложно.

    идея про маркетплейс зайдет если будет какое то популярное устройство типа флиппер зиро или как от них же проект с матричным дисплеем busybox.

    проекты же типа запуска высокоуровневых языков на есп типа micropython или espruino (javascript) к сожалению прожорливые по памяти и подозреваю медленные. для есп нужно чтото более скомпилированное и низкоуровневое типа wasm или lisp. у wasm плюс в том что в него можно скомпилировать код из разных языков это очень полезно и расширяет зону применения. другое дело что при компиляции добавляется много функций конкретного языка и в итоге результат будет немало весить.

    ну и я бы конечно делал ставку на опенсорс проекты.