BERGSONNE

WASM Blink

Run a precompiled WebAssembly module on a Core. The blink program lives in WASM bytecode and drives the onboard LED by calling host firmware imports through the WAMR interpreter — and a new module can be hot-swapped live over USB, no reflash required.

Overview

This is the only example where the application logic doesn’t ship as native ARM code. The firmware (main.c, a generated harness) boots the Core, starts the WAMR (WebAssembly Micro Runtime) interpreter, registers the SDK’s core_* functions as native imports, loads a WASM module, and calls its exports in a loop. It targets a Core.ST.H5.1 at the medium clock.

Cortex-M33 only
WAMR is gated to Cortex-M33 Cores (Core.ST.H5, Core.ST.W5). The build hard-errors if WAMR_ENABLED=1 on anything else — Core.ST.L4’s Cortex-M4 footprint was over budget and routes through a separate path (future work).

Architecture

  • Guest (WASM module) — exports two functions the host calls: studio_start (once at boot) and studio_loop (repeatedly). It imports one host function, core_led_heartbeat, from the "env" namespace. The module has no timing or GPIO code — each loop just re-asserts the blink period through the import.
  • Host (firmware) — a generic harness that brings up WAMR and the import boundary. The actual LED toggling happens in native firmware (core_led_heartbeat); the WASM side only decides when and at what period.
  • Import boundary — each core_* function is wrapped in a *_native() adapter and listed in a NativeSymbol table. Two tables register under "env": a static SDK table and a per-project table coregen emits for functions that touch generated pad/PWM/ADC state.

The program

The example starts as a two-line Studio DSL program — import the LED heartbeat host call, then loop calling it with a 500 ms period:

import Core.LED.heartbeat(period_ms: int)

loop {
  Core.LED.heartbeat(500)
}

The DSL compiler turns that into a WASM module with one import and two exports. The real blink timing happens on the MCU; the module just re-issues the period each iteration:

(module
  (import "env" "core_led_heartbeat" (func $heartbeat (param i32)))
  (func (export "studio_start"))
  (func (export "studio_loop")
    (call $heartbeat (i32.const 500))))

A build tool compiles the module to bytecode and emits it as a C byte array (module_wasm.h), which the harness embeds and copies into RAM at boot. Regenerate it from the DSL with Studio’s dsl-to-wasm tool when you change the program.

The module must load from RAM
WAMR’s loader rewrites import/export name strings in place (it writes over the length prefix), so the module buffer must live in RAM. Loading directly from the flash-resident .rodata blob silently no-ops those writes on Cortex-M and every export lookup returns NULL — which is why the harness memcpys the boot blob into a RAM buffer first.

Host harness

main() initializes the Core, USB, and LED, then brings up WAMR with a fixed memory pool (not newlib malloc — under nosys _sbrk fails silently and init would hang) and registers the two native tables that satisfy the module’s import:

core_init();
core_usb_init();
core_led_init();

/* Seed the RAM buffer with the flash-resident boot module. */
memcpy(g_wasm_blob, g_wasm_blob_flash, g_wasm_blob_flash_len);
g_wasm_blob_len = g_wasm_blob_flash_len;

RuntimeInitArgs init_args = { 0 };
init_args.mem_alloc_type = Alloc_With_Pool;
init_args.mem_alloc_option.pool.heap_buf = g_wamr_heap;
init_args.mem_alloc_option.pool.heap_size = sizeof(g_wamr_heap);
wasm_runtime_full_init(&init_args);

wasm_runtime_register_natives("env", g_studio_natives, g_studio_natives_count);
wasm_runtime_register_natives("env", g_studio_natives_project,
                              g_studio_natives_project_count);

The host function the module’s import resolves to is a thin wrapper:

/* core_led_heartbeat  (i)  (category: led) */
static void core_led_heartbeat_native(wasm_exec_env_t env, int period_ms)
{
    (void)env;
    core_led_heartbeat(period_ms);
}

/* ...registered in the NativeSymbol table: */
{ "core_led_heartbeat", (void *)core_led_heartbeat_native, "(i)", NULL },
Keep the NativeSymbol table writable
The table is deliberately not const. wasm_runtime_register_natives runs an in-place qsort on it; in .rodata that sort no-ops on Cortex-M and lookups then fail with “failed to call unlinked import function” even though every symbol is present. Leaving it writable keeps it in RAM-backed .data.

Drive & hot-swap

With the module loaded and studio_start run once, the host calls studio_loop over and over. It also drains CDC RX so a pushed module can hot-swap the running one, and tears down a faulting instance so a new push recovers without a reboot:

uint32_t tick = 0;
while (1) {
    if (!wasm_runtime_call_wasm(g_exec, g_fn_loop, 0, NULL)) {
        const char *ex = wasm_runtime_get_exception(g_inst);
        core_usb_printf("studio: studio_loop exception: %s\r\n", ex ? ex : "(none)");
        unload_current();
    }

    if (frame_poll()) {            /* a .wasm module arrived over CDC */
        unload_current();
        if (load_and_start(err_buf, sizeof err_buf))
            core_usb_printf("studio: running new module\r\n");
        frame_reset();
    }

    if (!g_inst) core_delay_ms(100);
}

Over USB CDC the harness accepts a framed .wasm payload: a 12-byte header (TSWM magic, version, flags, reserved, little-endian length) followed by the bytecode. This is how Studio pushes a freshly compiled DSL program to a connected Core without reflashing. The WAMR build bumps the CDC RX buffer to 4 KB so a whole module can land in one burst.

config.json

The descriptor targets a Core.ST.H5.1 at the medium clock, brings USB up on pads 6/7, and selects the ROM bootloader — which is why you flash with make flash-dfu.

{
  "core": "Core.ST.H5.1",
  "clock": "medium",
  "programming": {
    "methods": ["usb-dfu"],
    "usb_dedicated": true,
    "boot0_pad": "11"
  },
  "bootloader": "rom",
  "usb": { "enabled": true },
  "pads": {
    "6": "USB.DP",
    "7": "USB.DM"
  }
}

Build & flash

The example Makefile presets the WASM flags (WAMR_ENABLED=1, ROM_DFU=1) for the Core.ST.H5.1 target and delegates to the SDK root:

cd examples/h1-wasm-blink
make
make flash-dfu    # USB DFU via the ROM bootloader

Only if you change the program (module.dsl), regenerate the bytecode header with Studio’s tool first:

cd ../../studio    # the studio repo
npm run dsl-to-wasm -- \
  ../tiles/examples/h1-wasm-blink/module.dsl \
  --header=../tiles/examples/h1-wasm-blink/module_wasm.h \
  --core=Core.ST.H5