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

Smersh1307n6
Posts: 2
Joined: Tue Nov 18, 2025 3:33 pm

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

Postby Smersh1307n6 » Tue Nov 18, 2025 4:01 pm

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:

Code: Select all

#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);
    }
}
Example symbol table in the interpreter

Code: Select all

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
};
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.

MicroController
Posts: 2669
Joined: Mon Oct 17, 2022 7:38 pm
Location: Europe, Germany

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

Postby MicroController » Fri Nov 21, 2025 8:19 am

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."
Interesting approach. Haven't looked into WASM too much yet, but I'd expect the needed glue code to be pretty straight-forward to generate. It's not?

I'll have a look at ESPB.

Smersh1307n6
Posts: 2
Joined: Tue Nov 18, 2025 3:33 pm

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

Postby Smersh1307n6 » Fri Nov 21, 2025 4:14 pm

I wanted to try and reproduce the xTaskCreate example, but with wasm. I haven't worked with it for a long time. Now I can't even load a simple example. I'm getting an error: E (1928) wamr: Error in wasm_runtime_load: WASM module load failed: unexpected end. I must be doing something wrong.
Correct me if I'm wrong, but for wasm it should be like this:

main.c

Code: Select all

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

// Core WAMR header files
#include "wasm_export.h"
#include "bh_platform.h"

// Include our WASM module, which has been converted to a C array
#include "wasm_task_app.h"

#define WASM_STACK_SIZE (8192)  // Stack size for the WASM interpreter
#define WASM_HEAP_SIZE  (8192)  // Heap size for the WASM interpreter


/************************************************************************
 * IMPLEMENTATION OF THE "BRIDGE" BETWEEN NATIVE AND WASM               *
 * These wrapper functions will be called from WASM, and they, in turn, *
 * will call the actual ESP-IDF/FreeRTOS functions.                     *
 ************************************************************************/

/**
 * @brief Wrapper for vTaskDelay.
 * Receives ticks from WASM (as an i32) and calls the native function.
 */
static void wasm_vTaskDelay_wrapper(wasm_exec_env_t exec_env, uint32_t ticks) {
    vTaskDelay(ticks);
}

/**
 * @brief Wrapper for printf.
 * This is a simplified version for printing a single string.
 * WASM passes a pointer (an offset in its own memory), we convert it
 * to a native pointer and pass it to printf.
 */
static int32_t wasm_printf_wrapper(wasm_exec_env_t exec_env, const char* format) {
    // Get the module instance to work with its memory
    wasm_module_inst_t module_inst = wasm_runtime_get_module_inst(exec_env);

    // Check if the pointer from WASM is valid
    if (!wasm_runtime_validate_app_addr(module_inst, (int32_t)(uintptr_t)format, 1)) {
        printf("Error: printf pointer is invalid.\n");
        return 0;
    }

    // Convert the WASM memory offset to a native C pointer
    const char* native_format_str = wasm_runtime_addr_app_to_native(module_inst, (int32_t)(uintptr_t)format);

    return printf(native_format_str);
}

/**
 * @brief Wrapper for xTaskCreate.
 * This is the most critical part. It accepts a function pointer from WASM
 * and uses WAMR to create a native "thunk" for it, which can then be
 * passed to the real xTaskCreate.
 */
static int32_t wasm_xTaskCreate_wrapper(wasm_exec_env_t exec_env,
                                        int32_t task_func_wa_offset, // WASM memory offset for the task function
                                        const char* task_name,       // WASM memory offset for the task name
                                        uint32_t stack_depth,
                                        int32_t pvParameters,
                                        int32_t uxPriority,
                                        int32_t pxCreatedTask) { // Not used for simplicity

    wasm_module_inst_t module_inst = wasm_runtime_get_module_inst(exec_env);

    // Convert the task name offset to a native pointer
    if (!wasm_runtime_validate_app_addr(module_inst, (int32_t)(uintptr_t)task_name, 1)) {
        printf("Error: task name pointer is invalid.\n");
        return pdFAIL;
    }
    const char* native_task_name = wasm_runtime_addr_app_to_native(module_inst, (int32_t)(uintptr_t)task_name);

    // --- THE KEY PART ---
    // Convert the WASM memory function offset into a callable native pointer.
    // WAMR creates a "thunk" on the fly, which, when called by FreeRTOS,
    // will switch the context back to WASM and execute the original function.
    TaskFunction_t native_task_func = (TaskFunction_t)wasm_runtime_addr_app_to_native(module_inst, task_func_wa_offset);
    if (!native_task_func) {
        printf("Error: failed to create native thunk for WASM function.\n");
        return pdFAIL;
    }

    // Call the actual xTaskCreate from FreeRTOS
    BaseType_t result = xTaskCreate(native_task_func,
                                    native_task_name,
                                    stack_depth,
                                    (void*)pvParameters,
                                    uxPriority,
                                    (TaskHandle_t*)pxCreatedTask);

    return result == pdPASS ? 0 : -1;
}

/**
 * @brief List of all native functions we make available to WASM.
 * Format: { "name_in_WASM", wrapper_function, "signature", flags }
 * Signature: i - i32, * - pointer (i32)
 * In parentheses - argument types, after parentheses - return value type.
 */
static NativeSymbol native_symbols[] = {
    { "xTaskCreate", (void*)wasm_xTaskCreate_wrapper, "(i*iiii)i", NULL },
    { "vTaskDelay",  (void*)wasm_vTaskDelay_wrapper,  "(i)",       NULL },
    { "printf",      (void*)wasm_printf_wrapper,      "(*)",       "i"    }
};


/************************************************************************
 * MAIN LOGIC OF THE ESP-IDF APPLICATION                                *
 ************************************************************************/

void app_main(void) {
    printf("--- WASM FreeRTOS Task Example ---\n");

    // Initialize the WAMR runtime environment
    printf("Initializing WAMR...\n");
    wasm_runtime_init();

    // Register our "bridge" of native functions
    // The name "env" must match what the WASM module expects on import
    if (!wasm_runtime_register_natives("env", native_symbols, sizeof(native_symbols) / sizeof(NativeSymbol))) {
        printf("Error: Failed to register native symbols.\n");
        return;
    }
    printf("Native functions registered.\n");


    wasm_module_t module = NULL;
    wasm_module_inst_t module_inst = NULL;
    wasm_exec_env_t exec_env = NULL;
    wasm_function_inst_t func_inst = NULL;
    char error_buf[128];

    // Load the WASM module from the embedded C array
    // wasm_task_app_wasm and wasm_task_app_wasm_len are created by the xxd utility
    module = wasm_runtime_load(wasm_task_app_wasm, wasm_task_app_wasm_len, error_buf, sizeof(error_buf));
    if (!module) {
        printf("Error loading WASM module: %s\n", error_buf);
        goto fail;
    }
    printf("WASM module loaded successfully.\n");

    // Instantiate the module, allocating memory for it (stack and heap)
    module_inst = wasm_runtime_instantiate(module, WASM_STACK_SIZE, WASM_HEAP_SIZE, error_buf, sizeof(error_buf));
    if (!module_inst) {
        printf("Error instantiating WASM module: %s\n", error_buf);
        goto fail;
    }
    printf("WASM module instantiated.\n");

    // Create an execution environment
    exec_env = wasm_runtime_create_exec_env(module_inst, WASM_STACK_SIZE);
    if (!exec_env) {
        printf("Error creating WASM execution environment.\n");
        goto fail;
    }

    // Look for the exported "start_wasm_task" function in the WASM module
    func_inst = wasm_runtime_lookup_function(module_inst, "start_wasm_task", NULL);
    if (!func_inst) {
        printf("Error: Could not find 'start_wasm_task' function in WASM module.\n");
        goto fail;
    }
    printf("Found 'start_wasm_task' function. Calling it...\n");

    // Call the start_wasm_task() function inside WASM
    if (!wasm_runtime_call_wasm(exec_env, func_inst, 0, NULL)) {
        printf("Error calling WASM function: %s\n", wasm_runtime_get_exception(module_inst));
        goto fail;
    }
    printf("WASM function 'start_wasm_task' executed. The FreeRTOS task should now be running.\n");
    printf("----------------------------------------\n");

    // The main task (app_main) can now do other things.
    // The infinite loop is needed here to prevent the task from finishing, which would cause a system reboot.
    while (true) {
        vTaskDelay(pdMS_TO_TICKS(10000));
    }

fail:
    // Resource cleanup in case of an error
    if (exec_env) wasm_runtime_destroy_exec_env(exec_env);
    if (module_inst) wasm_runtime_deinstantiate(module_inst);
    if (module) wasm_runtime_unload(module);
    wasm_runtime_destroy();
    printf("WASM Runtime destroyed. Restarting...\n");
    vTaskDelay(pdMS_TO_TICKS(1000));
    esp_restart();
}

wasm_task.c

Code: Select all

#include <stdint.h> // for types like int32_t, uint32_t

/***********************************************************************
 * STEP 1: DECLARING IMPORTED FUNCTIONS                                *
 *                                                                     *
 * We declare functions with the `extern` keyword. This tells the      *
 * compiler: "These functions exist, but their implementation will be   *
 * provided externally (by the runtime environment) at execution time."*
 *                                                                     *
 * The signatures (argument types and return value) must exactly match *
 * those defined in the `native_symbols` array in the runtime code.    *
 ***********************************************************************/

/**
 * @brief Imports the xTaskCreate function from the host environment (ESP-IDF).
 * 
 * @param task_func A pointer to our function that will become the task.
 * @param name      The name of the task (a string).
 * @param stack_depth The stack size for the task.
 * @param param     A parameter to be passed to the task (not used).
 * @param priority  The priority of the task.
 * @param task_handle A pointer to receive the task handle (not used).
 * @return 0 on success, -1 on failure.
 */
extern int32_t xTaskCreate(void* task_func, const char* name, uint32_t stack_depth, void* param, int32_t priority, void* task_handle);

/**
 * @brief Imports vTaskDelay from the host environment.
 * 
 * @param ticks The number of ticks to delay.
 */
extern void vTaskDelay(uint32_t ticks);

/**
 * @brief Imports printf from the host environment.
 * 
 * @param format The string to print.
 * @return The number of characters printed.
 */
extern int32_t printf(const char* format);


/***********************************************************************
 * STEP 2: IMPLEMENTING THE MODULE'S LOGIC                             *
 ***********************************************************************/

/**
 * @brief This is our FreeRTOS task function.
 * 
 * It will be called by the FreeRTOS scheduler from the native world
 * via the "thunk" created by WAMR. The code inside this function
 * executes within the sandboxed WebAssembly environment.
 */
void my_task(void* pvParam) {
    // An infinite loop, just like in a standard FreeRTOS task.
    while (1) {
        // We call the imported printf function.
        // WAMR will intercept this call and direct it to our
        // native wrapper function, wasm_printf_wrapper.
        printf("Hello from a WASM task!\n");

        // We call the imported vTaskDelay.
        // Delay for 1000 ticks. If the Tick Rate is 1000 Hz (common in ESP-IDF),
        // this will correspond to 1 second.
        // We cannot use the `portTICK_PERIOD_MS` macro because the FreeRTOS
        // header files are not available during the WASM compilation phase.
        vTaskDelay(1000);
    }
}

/**
 * @brief The entry point into our WASM module.
 * 
 * This function is exported so that our runtime code on the ESP32 can
 * find and call it. Its only job is to kick off the FreeRTOS task
 * creation process.
 *
 * The `__attribute__((export_name("...")))` is the standard way in
 * Clang/GCC to make a function visible outside the WASM module.
 */
__attribute__((export_name("start_wasm_task")))
void start_wasm_task() {
    // We call the imported xTaskCreate function.
    // WAMR will intercept this call and pass all arguments, including
    // the pointer to our WASM function `my_task`, to the native
    // wrapper function `wasm_xTaskCreate_wrapper`.
    xTaskCreate(
        my_task,          // Pointer to the task function
        "wasm_task",      // Name for the task
        4096,             // Stack size (it's good to be generous)
        NULL,             // Parameters for the task (not used)
        5,                // Task priority
        NULL              // Task handle (not used)
    );
}

Who is online

Users browsing this forum: Bing [Bot], Bytespider, Google [Bot], PetalBot and 2 guests