Доброе время суток! Так уж вышло, что написание статьи подоспело аккурат к празднику. И пока одни раздают поцелуи, а другие принимают в подарок долгожданные носки, я решил подготовить продолжение этой хардкорной разработки.
Со времени первой публикации прошло ровно 3 месяца. За это время переработал многое: добавил полноценный JIT для Xtensa и RISC-V, внедрил кучу оптимизаций в транслятор. Все это старательно тестировал на чипах ESP32, ESP32-C3 и ESP32-C6 (последний — по остаточному принципу, запустил только основной тест, главная отладка шла на первых двух).
Встреча��те главные нововведения.
Fast Symbols: добавил Fast Symbols в дополнение к обычным таблицам символов. Это две таблицы: одна для системных функций ESP-IDF, другая — кастомная для вашего удовольствия. Суть в том, что мы убираем строковое название для функций из бинарника, оставляя только указатель. Данный подход требует жесткого согласования таблицы с транслятором, чтобы он точно знал, какой индекс использовать для обращения к функции через libffi. Это уменьшает занимаемое во flash пространство и убирает медленный strcmp при загрузке модуля. В рантайме линковка превращается в мгновенное взятие адреса из плоского массива.
А как же Kconfig?
В ESP-IDF модули можно отключать (например, выпилить GPIO). Если просто удалить функцию из массива, все последующие индексы сместятся, и вызовется не та функция. Этот момент решается через макрос:// idf_fast.sym ESPB_SYM("printf", (const void*)&printf) ESPB_SYM("vTaskDelay", (const void*)&vTaskDelay) // Если GPIO отключен в menuconfig, макрос подставит NULL, но сохранит индекс! ESPB_SYM_OPT(CONFIG_ESPB_IDF_GPIO, "gpio_set_level", (const void*)&gpio_set_level)Размер массива и порядок индексов остаются абсолютно стабильными при любой конфигурации прошивки.
JIT-компилятор:
Вторая фича — JIT. Решил, что лучше всего дать разработчику возможность самому помечать в коде нужные функции, которые будем транслировать в машинный код.
ESPB изначально спроектирован как регистровая машина (до 256 виртуальных регистров). Всю сложную математику (Graph Coloring, Register Allocation) делает C# транслятор на ПК. Рантайму ESPB на микроконтроллере остается простейшая задача: сгенерировать инструкции Xtensa или RISC-V.Как это работает:
В C/C++ коде скрипта разработчик помечает тяжелую функцию макросом JIT_HOT.
Транслятор видит это и ставит флаг ESPB_FUNC_FLAG_HOT в заголовок функции внутри .espb файла.
При инстанцировании модуля рантайм выделяет память через heap_caps_malloc(size, MALLOC_CAP_EXEC) (память, откуда разрешено исполнение).
JIT-движок генерирует бинарный код и кладет указатель в таблицу.
P.S. Как ни странно, сложнее всего пришлось с реализацией JIT для архитектуры Xtensa из-за её оконного ABI и литерных пулов.Момент истины: ESPB vs WAMR.
Я подготовил проект для wasm-micro-runtime (WAMR) от Espressif с реализацией теста Фибоначчи(85), идентичного тому, что использую для ESPB.
Тесты проводились на чипе ESP32-C3 (160 MHz):
Режим выполнения
Время 1 в��зова, µs
Тактов CPU за цикл
Отставание от Native
Native C (Эталон)
5
942
1x
ESPB JIT
20
3 236
~3.4x
WAMR Fast Interp
45
7 245
~7.7x
WAMR Classic Interp
75
12 138
~12.8x
ESPB Interpreter
125
20 024
~21.2x
Чистый интерпретатор ESPB пока работает медленнее интерпретатора WAMR. Моих усилий здесь оказалось недостаточно, и есть куда расти (в ESPB пока нет супер-опкодов, это когда в одну инструкцию зашито несколько действий. Да и сам транслятор можно еще долго и нудно оптимизировать как и интерпретатор).
Но хорошая новость в том, что горячий код работает. И здесь ESPB в 2+ раза быстрее лучшего режима WAMR, если судить по одному тесту конечно. К слову, в WAMR для ESP32 вообще нет JIT-компилятора, только режимы Classic и Fast.
Видно что команда программистов тщательно оптимизировала интерпретаторы WAMR и это вызывает уважение, сие однако не в пользу сравнения с поделкой страдальца-вайбкодера. Похоже пришло время для промышленного шпионажа...Режим AOT в WAMR я не рассматриваю, т.к. основная мысль заставить работать единый байт-код на всех системах.
Тут кстати вырисовывается следующее направление развития помимо оптимизации: я вижу это как "AOT on Device", т.е. компиляция вся кода в JIT с размещением сего в разделе на flash с последующим выполнением через XIP. Все это щедро нужно разбавить таблицей GOT, что бы изменения основных прошивок через OTA позволило работать такой вот AOT версии, ну и провести эксперименты сначала, но думаю направление должно быть рабочим. Это режим кстати рассматриваю как основной если все выгорит.Битва за память.
Собрал пять вариантов прошивки для ESP32-C3: от пустого «Hello World» до полного фарша.
Цифры из отчета сборки (idf.py size):Конфигурация прошивки
DRAM, Кб
Flash Code (.text), Кб
Flash Code (.text), Кб
Пустой проект
54.7
74.2
169.7
ESPB (No JIT)
67.3
342.7
481.1
WAMR Classic
65.9
354.6
483.7
WAMR Fast
66.9
359.4
488.7
ESPB (с JIT)
67.3
398.6
539.9
Что мы видим:
Самый компактный движок: Чистый интерпретатор ESPB (No JIT) занимает меньше места во Flash-памяти, чем даже самый базовый WAMR Classic (~ на 2.5 КБ меньше).
Тут оговорюсь WASM генерировался с опциями Lib pthread, Libc builtin, Libc WASI, Loader mode- normal.
Цена JIT: Включение JIT-компилятора в ESPB увеличивает размер прошивки (Flash) примерно на 56 КБ. При этом статическое потребление RAM (DRAM) не меняется.
DRAM: Все рантаймы добавляют примерно 11–12 КБ к потреблению оперативной памяти относительно пустого проекта.
Размер скриптов для кода Фибоначчи:
Размер .wasm файла — 1277 байт (использует LEB128 сжатие).
Размер .espb файла — 1511 байт.
Сгенерированный JIT-код двух тестовых функций занял в IRAM 2494 байта для ESP32c3.FFI: Смерть «клей-коду»
Простые функции вызывать легко везде. Но настоящая боль начинается, когда вам нужно использовать колбэки. Представьте задачу: создать программный таймер FreeRTOS (xTimerCreate), который при срабатывании вызывает функцию внутри вашего скрипта.
Посмотрим, как это решается в WAMR и в ESPB.
WAMR: Архитектурная боль
WASM изолирован от памяти микроконтроллера. Вы не можете просто передать указатель на функцию в FreeRTOS, потому что нативный код не знает, где искать эту функцию в виртуальной машине.
Шаг 1. Пишем скрипт (Guest side).
Мы не може�� передать функцию напрямую. Приходится передавать её индекс в таблице.// WASM (Guest) typedef void (*timer_cb_t)(uint32_t, uint32_t); // Получаем индекс функции (в wasm32 это не адрес, а индекс!) timer_cb_t cb_ptr = test_timer_cb; uint32_t cb_func_idx = (uint32_t)(uintptr_t)cb_ptr; // Вызываем кастомную обертку, передавая индекс вместо указателя xTimerCreate_native("tmr", 2000, 1, 0, cb_func_idx);Шаг 2. Пишем «Мост» в прошивке (Host side).
// Host (Firmware) // 1. Контекст для проброса аргументов typedef struct { wasm_exec_env_t cb_exec_env; uint32_t cb_func_idx; // ... еще поля для instance и handle } wasm_timer_ctx_t; // 2. Нативный колбэк-переходник static void native_timer_callback(TimerHandle_t xTimer) { wasm_timer_ctx_t *ctx = (wasm_timer_ctx_t *)pvTimerGetTimerID(xTimer); uint32_t argv[2] = { ctx->wasm_handle, ctx->timer_id }; // Ручной вызов интерпретатора wasm_runtime_call_indirect(ctx->cb_exec_env, ctx->cb_func_idx, 2, argv); } // 3. Обертка над xTimerCreate static uint32_t native_xTimerCreate(wasm_exec_env_t exec_env, const char *name, uint32_t period, uint32_t reload, uint32_t id, uint32_t cb_idx) { // ... тут нужно аллоцировать контекст, сохранить env, создать таймер ... // ... передать native_timer_callback вместо реального колбэка ... return (uint32_t)handle; } // 4. Регистрация со страшными сигнатурами static NativeSymbol native_symbols[] = { { "xTimerCreate_native", native_xTimerCreate, "($iiii)i", NULL } };Итог: ~100 строк кода только для того, чтобы запустить один таймер.
ESPB: Zero Glue Code
В ESPB решил эту проблему системно. Транслятор знает, что xTimerCreate принимает колбэк. Рантайм на лету генерирует трамплин через libffi в IRAM, который FreeRTOS считает обычной C-функцией.
Шаг 1. Пишем скрипт.
Это обычный C-код. Мы передаем функцию test_timer_cb как есть.// ESPB (Script) extern uint64_t get_cpu_cycles(void); static void test_timer_cb(TimerHandle_t xTimer) { printf("Timer tick!\n"); } void app_main(void) { // Вызываем стандартный API FreeRTOS TimerHandle_t t = xTimerCreate("tcb", pdMS_TO_TICKS(2000), pdTRUE, NULL, test_timer_cb); if (t) xTimerStart(t, 0); }Шаг 2. Добавляем в прошивку. Нам не нужны обертки. Мы просто экспортируем 3 функции: создание таймера, получение ID (для контекста) и команду управления (так как xTimerStart — это макрос над xTimerGenericCommand).
// Host (Firmware) - Таблица символов ESPB_SYM("xTimerCreate", (const void*)&xTimerCreate) ESPB_SYM("pvTimerGetTimerID", (const void*)&pvTimerGetTimerID) ESPB_SYM("xTimerGenericCommand", (const void*)&xTimerGenericCommand)Итог: 0 строк связующего кода (только регистрация символов). Вы пишете скрипт так, будто это часть прошивки. Рантайм сам понимает, что передан указатель на функцию, и создает для неё нативное замыкание.
Сам проект wamr я выложил в github:
https://github.com/smersh1307n2/wamrDeveloper Experience: Никакого «Header Hell»
Обычно разработка под кастомные VM — это боль. IDE не видит системные хедеры FreeRTOS, автодополнение не работает, а чтобы скомпилировать скрипт, нужно вручную прописывать сотни путей к include директориям.
Тут решил эту проблему радикально: используем штатную систему сборки ESP-IDF, но настроенную для использования clang в его составе.
Вы пишете код скрипта в обычном C/C++ проекте внутри VS Code. У вас работает IntelliSense, навигация по коду и подсветка ошибок, потому что проект настроен(или вы его можете настроить) как легальное приложение ESP32.
А для сборки байт-кода я написал PowerShell-скрипт get-ir-cmake.ps1, который делает магию(с некоторыми нюансами):
Вытягивает актуальные флаги сборки прямо из CMake вашего проекта.
Компилирует файлы скрипта с помощью Clang в LLVM Bitcode (.bc).
Линкует результат (llvm-link) в единый .bc файл, готовый к отправке в транслятор.
Вам остается только писать код и помечать критичные участки JIT_HOT. Можно проводить обычную отладку и запуск, т.к. это простой ESP-IDF проект.Трансляция (Desktop Client)
Сделал ESPB Desktop Client. Это легковесная утилита, которая работает в связке с облачным транслятором. Пока только так, позже планируется и локальная утилита для трансляции но с нюансами.
Ну а сейчас Вы просто скармливаете клиенту требуемые файлы.
Клиент отправляет их в облако, где сервер проводит оптимизацию регистров, рассчитывает метаданные для FFI, заменяет строковые имена функций на индексы (Fast Symbols) и возвращает готовый .espb файл на указанное место .В завершении поздравляю всех причастных с праздником, крепкого здоровья.
На этом позвольте откланяться.В дополнении записал ролик показывающий описанные процессы:
https://youtu.be/UbcuU-mabLsСсылка на начало начал:
https://habr.com/ru/articles/969400/
