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.
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 — 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 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 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.
.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 },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 bootloaderOnly 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
