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 at the medium clock.
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) andstudio_loop(repeatedly). It imports one host function,core_led_heartbeat, from the"env"namespace. The module has no timing or GPIO code:studio_startsets the heartbeat once, andstudio_loopis empty. - Host (firmware) — a generic harness that brings up WAMR and the import boundary. The actual LED toggling happens in native firmware (
core_led_heartbeat, serviced from the 1 ms SysTick); the WASM side only picks the period and the on time. - Import boundary — each
core_*function is wrapped in a*_native()adapter and listed in aNativeSymboltable. 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 short Studio DSL program: import the LED heartbeat host call, then start it once, a 2 ms blip every second. Calling it on every loop pass would restart the cycle each time and hold the LED on:
import Core.LED.heartbeat(period_ms: int, on_ms: int)
# Set once: each call restarts the cycle.
on start {
Core.LED.heartbeat(1000, 2)
}The DSL compiler turns that into a WASM module with one import and two exports. The real blink timing happens on the MCU:
(module
(import "env" "core_led_heartbeat" (func $heartbeat (param i32 i32)))
(func (export "studio_start")
(call $heartbeat (i32.const 1000) (i32.const 2)))
(func (export "studio_loop")))The bytecode ships as a C byte array (module_wasm.h, and the same bytes in main.c), which the harness embeds and copies into RAM at boot. Studio compiles DSL to WASM in the browser and pushes it over USB, so most changes never touch these files.
.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 (ii) (category: led) */
static void core_led_heartbeat_native(wasm_exec_env_t env, int period_ms, int on_ms)
{
(void)env;
core_led_heartbeat(period_ms, on_ms);
}
/* ...registered in the NativeSymbol table: */
{ "core_led_heartbeat", (void *)core_led_heartbeat_native, "(ii)", NULL },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 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. The programming block is left over from an older schema; coregen ignores it.
{
"core": "Core.ST.H5",
"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 target and delegates to the SDK root:
cd examples/h1-wasm-blink
make
make flash-dfu # USB DFU via the ROM bootloaderTo change the boot module itself, there is no generator script: edit module.wat, assemble it, and update both byte arrays (module_wasm.h and g_wasm_blob_flash in main.c) before make.

