DEV Community

Cover image for ESPB: WASM-like bytecode interpreter for ESP32 with seamless FreeRTOS integration.
Smersh
Smersh

Posted on

ESPB: WASM-like bytecode interpreter for ESP32 with seamless FreeRTOS integration.

Hi.

I want to present a project born from a long search for a way to dynamically load code onto a running ESP32 device. I think many have researched this direction.
It all started with an applied task, again, as a "can I do it?" challenge. In simple terms, a device was developed and assembled to switch pumps based on operating hours and manage a make-up system for an individual heating unit. It connects to a phone for monitoring and configuration via Bluetooth. At some point, I wanted to be able to extend its logic with new control schemes directly from the phone, without recompiling or re-flashing the main core. And so it began...

The Agony of Choice: Why Not WASM, Lua, or Something Else?

I considered standard solutions but rejected them for various reasons. In the end, the concepts of an ELF Loader and WASM caught my attention.
ELF Loader: This allows loading native code and executing it at maximum speed, with only a table of function pointers (let's call it a symbol table) on the firmware side. The resulting ELF file is tightly coupled to the architecture. Code compiled for an ESP32-S3 (Xtensa) will not run on an ESP32-C3 (RISC-V). I wanted universality—"one binary for the entire lineup."
WebAssembly (WASM): A sufficiently fast and interesting technology whose bytecode is not tied to a specific architecture. However, anyone who has tried to call a native function like xTaskCreate from WASM and pass a callback to it knows what a pain it is. It requires writing a huge amount of "glue code" and manually registering imports/exports. I wanted to write standard C code using the standard ESP-IDF APIs and have it "just work." This is how the idea for ESPB (ESP Bytecode) was born.

What is ESPB?

It's an ecosystem consisting of a Translator (which turns your C/(possibly)C++ code into bytecode) and an Interpreter (a virtual machine running on the microcontroller).
The main feature of the project is its seamless integration with the native API. By using a symbol table and a custom implementation of libffi, ESPB allows calling FreeRTOS functions (timers, tasks) directly from a loaded module without writing any wrappers.

How It Works Under the Hood:

Translator (based on LLVM)
I didn't invent my compiler from scratch; instead, I used LLVM. The process looks like this:
You write code in a standard ESP-IDF project.
You compile it with clang into LLVM Intermediate Representation (.bc file).
The Translator analyzes this .bc file and generates .espb bytecode as output.
The magic happens in the third step. The Translator performs complex work: it conducts a deep static analysis of the IR to understand the semantics of native function calls.
For example, it sees a call to xTaskCreate and understands that:
the first argument is a pointer to a function that will become the task body;
the last argument is a pointer to a TaskHandle_t, meaning it's an output (OUT) parameter.
Based on this analysis, the translator automatically generates special metadata:
cbmeta section: Information for the interpreter on how to correctly create a "trampoline" for the callback (my_task).
immeta section: Instructions for the interpreter on how to marshal OUT parameters—that is, how to safely copy the task handle from native memory back into the virtual machine's memory after the call.
It is this automatic analysis that eliminates the need to write tons of "glue code" manually.
What If the Automation Fails? The .hints Files
I aimed to make the translator as "smart" as possible. As mentioned, it performs a deep static analysis of LLVM IR, trying to automatically determine the semantics of calls: which pointer is an output (OUT), where the callback function is, and where its user data is.
Automatic analysis is not omnipotent. There will always be non-standard APIs or complex cases where heuristics can fail.
This is precisely why I introduced .hints files. These are simple text files that can be "fed" to the translator along with the .bc file. They allow you to manually "hint" to the translator how to correctly handle a particular function.

How does it work?

Suppose you have a native function my_complex_api(char* out_buffer, int size, my_callback_t cb). If the translator couldn't automatically determine that out_buffer is an output parameter, you can simply add one line to a .hints file:
Code: Select all

File my_project.hints

my_complex_api: out 0, cb 2

This entry tells the translator:
"For the function my_complex_api...
...the parameter at index 0 is an output (out).
...and the parameter at index 2 is a callback function (cb)."

This way, you get full control over the generation of FFI metadata, correcting any inaccuracies of the automatic analysis without changing a single line in the translator's source code.
Interpreter (on the device)
This is a virtual machine that executes the .espb file. It was designed from the ground up specifically for the ESP32 series of microcontrollers.

Key implementation features:

Custom libffi with "trampolines" placed in IRAM: I took the libffi library as a basis and adapted it to support the Xtensa and RISC-V architectures for this VM. A key feature of my adaptation is a special allocator that places the executable code of closures ("trampolines" for callbacks) in fast IRAM (Instruction RAM). This is critically important as it allows callbacks to be invoked (for example, from FreeRTOS timers).
Register-based machine with a shadow stack: Unlike stack-based VMs, ESPB uses a register-based model. This is closer to the architecture of real processors and allows for the generation of more efficient bytecode. For maximum compactness, register indices in instructions are encoded in just one byte. All operations with the call stack and local variables of functions occur in a special "shadow stack"—a dedicated buffer in RAM, which ensures isolation and predictability.

Memory Isolation:

Isolated Linear Memory and a Private Heap: For each ESPB module, the interpreter allocates a contiguous block of RAM—linear memory. This block becomes the full address space for the executed bytecode. This is where:
Static data is copied: All global variables, string literals, and constant arrays from your C code are placed in this memory when the module is loaded.
A private heap operates: When your ESPB code calls malloc, calloc, or free, it is actually accessing a memory manager (espb_heap_manager) that manages memory allocation within this same isolated block. This prevents fragmentation of the global ESP-IDF heap and increases system stability.
Stack variables are placed: The alloca instruction also allocates memory in this area, emulating the behavior of a native stack.
Vibecoding and the Role of Neural Networks
This project is the result of so-called "vibecoding." It has been a long and rather difficult journey since May. Neural networks helped to implement this project. It was exclusively this symbiosis that allowed me to take on system programming at this level.

How to Try It?

I tried to make the process as similar as possible to standard ESP32 development. There is a template project, ESP32_PRJ_TO_LLVM. This is effectively a standard ESP-IDF project. You write your code in it, include libraries, and debug. The get-ir-cmake.ps1 script extracts the .bc file from the build system. This file is fed into the online translator (link below), which outputs a ready-to-use .espb file. The .espb file is placed in the firmware (or uploaded via Wi-Fi/UART) and executed by the interpreter. So far, I have only tested hard-coding the .espb file along with the .bin. To obtain the .bc, I used the clang version included with ESP-IDF 5.4. It's worth noting that the translator supports clang versions no higher than 20.1.2.
Example of What Works "Out of the Box"
The most interesting part is that you can write the following code, compile it into bytecode, and it will work:

#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);
    }
}

Enter fullscreen mode Exit fullscreen mode

Example symbol table in the interpreter

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 },
    // ... and other necessary functions
    ESP_ELFSYM_END
};
Enter fullscreen mode Exit fullscreen mode

Tested on Hardware

I didn't limit myself to simulators. The entire system was tested and debugged on real devices to ensure cross-architecture compatibility:
ESP32 (dual-core Xtensa LX6)
ESP32-C3 (single-core RISC-V)
ESP32-C6 (single-core RISC-V)
The same .espb file successfully launched and ran on all these platforms, confirming the main idea—the universality of the executable code.

Project Status and Links

The current implementation of the interpreter does not yet support JIT or AOT—it is a pure interpreter. The project is in an active Proof of Concept (PoC) stage but can already execute quite complex logic. Future plans include polishing, bug fixing, and optimization.

Online Translator: http://espb.runasp.net/
Interpreter Repository: https://github.com/smersh1307n2/ESPB
Project for preparing LLVM IR: https://github.com/smersh1307n2/ESP32_PRJ_TO_LLVM

For the Online Translator, I need to add a translation statistics output to make it clear how the cbmeta and immeta sections are formed. The site is also in its infancy. Essentially, it's just for translating the .espb file for now and contains a generated description.
I would be glad to receive any criticism and advice.

Top comments (0)