beriberikix/zephyr-wasm-soc

Zephyr with WebAssembly as a real architecture: Asyncify context switching, cooperative interrupts, the host as a SoC. Try it in a browser.

C

1

42 commits

updated Sep 20, 2026

See the code
wasm
webassembly
zephyr-rtos

See what people are saying (1)

SourceMessageScoreDate

Zephyr RTOS Ported to WebAssembly

1

Sep 20, 2026

README

zephyr-wasm

Zephyr with WebAssembly as a real architecture.

Try it in your browser — boot the kernel, run its test suite, or type into the Zephyr shell. Nothing to install.

This is not native_sim built with a Wasm toolchain. The kernel runs freestanding in a single wasm32 linear memory, using Zephyr's own libc and scheduler. The host plays the part of a SoC: instead of memory-mapped registers it provides a handful of imported functions, and it owns the clock.

Context switching runs on Binaryen's Asyncify. Interrupts are cooperative: the host sets a pending word in linear memory and the kernel notices it at a safepoint. By default the clock is virtual, so a run does not depend on how fast the machine underneath it is, and two runs produce identical output.

DESIGN.md records the decisions and the host ABI. NOTES.md is the running log, including what did not work. BRIEF.md is the original task.

What works

  • samples/hello_world boots and exits cleanly.
  • samples/synchronization alternates two threads with k_msleep honoured.
  • tests/kernel/semaphore/semaphore passes all 32 tests under ztest.
  • Two runs in virtual time produce byte-identical output.
  • Two equal-priority threads that never yield are time-sliced against each other, through safepoints inserted after linking.
  • samples/subsys/shell/shell_module runs interactively over a polled UART.

Not done: twister builds for this board but cannot find the module's SoC. It takes a --board-root and no --soc-root, relying on module discovery, and discovery finds nothing because this module is the manifest repository rather than a project inside it. NOTES.md has the detail.

How preemption works

Nothing preempts a running wasm function, so CONFIG_WASM_SAFEPOINTS inserts a call at the top of every loop body after linking, and a pending interrupt is taken there. It runs before Asyncify, so those calls can suspend: taking an interrupt may switch threads.

It needs a second piece. Under virtual time the clock only moves when the kernel idles, so a thread that spins without calling the kernel would freeze it, and a frozen clock means the timer never fires. Every CONFIG_WASM_SAFEPOINTS_PER_TICK safepoints the guest gives the host a chance to advance time.

The cost, on 800 million iterations of a tight arithmetic loop, which is the worst case by construction:

WithoutWithRatio
Code size3296863371751.023x
Wall time1.74 s3.83 s2.28x

The acceptance suite shows no perceptible change.

Requirements

ToolVersion usedNotes
clang23.1.1needs the wasm32 target; CI uses 21
wasm-ld23.1.1must match clang; Homebrew ships it in the separate lld formula
wasm-opt132Binaryen
wasm-objdump1.0.42wabt
Node.js2620 or newer should do
west, cmake, ninjawith Zephyr's Python dependencies

Version matters more than it should. On clang 18 the linker leaves the iterable-section bounds undefined and ztest will not link, so the section scheme quietly stops working while simpler applications still build. 21 and 23 are known good; the true minimum is not established.

On macOS:

brew install llvm lld binaryen wabt node
brew link --overwrite wabt          # binaryen owns the wasm2c name

Both LLVM formulas are keg-only, so tools.env carries their paths. Adjust it if your toolchain lives elsewhere.

Setting up

git clone <this repo> zephyr-wasm
west init -l zephyr-wasm
west update
zephyr-wasm/scripts/apply_patches.sh

The Zephyr tree is otherwise read-only. Five patches are needed and each is explained in patches/README.md; four of the five are the same underlying gap, which is that several places in Zephyr assume an architecture is in-tree or assume a linker script exists.

Building and running

scripts/build.sh wraps west build with the flags this module needs on every build, because Zephyr looks for a toolchain under TOOLCHAIN_ROOT rather than through the module system.

zephyr-wasm/scripts/build.sh build-hello zephyr/samples/hello_world
node zephyr-wasm/host/run.mjs build-hello/zephyr/zephyr.wasm
*** Booting Zephyr OS build e201b84b04e4 ***
Hello World! wasm_node/node

Two threads alternating, with sleeps honoured:

zephyr-wasm/scripts/build.sh build-sync zephyr/samples/synchronization
node zephyr-wasm/host/run.mjs --max-time 2000 build-sync/zephyr/zephyr.wasm
*** Booting Zephyr OS build e201b84b04e4 ***
thread_a: Hello World from cpu 0 on wasm_node!
thread_b: Hello World from cpu 0 on wasm_node!
thread_a: Hello World from cpu 0 on wasm_node!

The kernel test suite:

zephyr-wasm/scripts/build.sh build-sem zephyr/tests/kernel/semaphore/semaphore
node zephyr-wasm/host/run.mjs --max-time 60000 build-sem/zephyr/zephyr.wasm
Running TESTSUITE semaphore
 PASS - test_k_sem_define in 0.000 seconds
 ...
PROJECT EXECUTION SUCCESSFUL

The shell, interactively. --interactive forwards this terminal's input to the guest UART and keeps the run alive while the guest is idle:

zephyr-wasm/scripts/build.sh build-shell zephyr/samples/subsys/shell/shell_module
node zephyr-wasm/host/run.mjs --interactive --max-time 600000 build-shell/zephyr/zephyr.wasm
uart:~$ kernel version
Zephyr version 4.4.99
uart:~$ demo ping
pong

Ctrl-C exits. Commands can also be piped in, which is how the run above was checked.

Preemption, which is what safepoints are for. Two threads at equal priority, both spinning with no kernel calls and no way out:

zephyr-wasm/scripts/build.sh build-slice zephyr-wasm/tests/timeslice
node zephyr-wasm/host/run.mjs --max-time 30000 build-slice/zephyr/zephyr.wasm
main: a=50499611 b=49519850
PASS: both threads ran, so preemption works

The exact counts move with the binary; what matters is that both are large and roughly equal. Within one binary they are reproducible, like everything else under virtual time.

Build the same test with -DCONFIG_WASM_SAFEPOINTS=n and it hangs after its first line, which is the control.

Determinism, which is the point of virtual time:

zephyr-wasm/scripts/check_determinism.sh build-sem/zephyr/zephyr.wasm --max-time 60000
deterministic: two runs produced identical output (143 lines)

west build -t run also works and does the same thing:

zephyr-wasm/scripts/build.sh build-hello zephyr/samples/hello_world
ninja -C build-hello run

In a browser

The same module runs in Chrome. The guest lives in a Worker, because the driver loop blocks its thread between suspensions and would otherwise freeze the tab; output and keystrokes cross by message.

The published copy is at https://beriberikix.github.io/zephyr-wasm-soc/, built by CI from a bare runner. To do the same locally:

zephyr-wasm/scripts/stage_site.sh      # builds five applications into _site/
zephyr-wasm/scripts/serve_web.sh 8777  # then open http://127.0.0.1:8777/

Pick a build and press Run. For the shell, click the output area and type; Ctrl+C stops it. A server is needed because file:// blocks both Workers and fetch; this one is bound to the loopback address. stage_site.sh is what CI runs too, so what you see locally is what is published.

Verified in Chrome: hello_world, synchronization, the 32-test ztest suite, the time slicing test, and the shell answering kernel version and demo ping. The ztest output is byte-identical to the Node run once carriage returns are accounted for, which the page's terminal consumes as a terminal should.

This says nothing new about engine neutrality, because Chrome is V8, the same engine as Node. That claim rests on the wasmtime result below. What the browser shows is that the harness is portable to somewhere with no filesystem, no stdio and no blocking main thread.

A second engine

host/run.mjs runs on Node, which is V8. host/run_wasmtime.py implements the same ABI and the same driver loop against wasmtime, to show that neither the port nor its determinism depends on one engine:

python3 -m venv /tmp/wtenv && /tmp/wtenv/bin/pip install wasmtime
/tmp/wtenv/bin/python zephyr-wasm/host/run_wasmtime.py --max-time 60000 \
    build-sem/zephyr/zephyr.wasm

It produces byte-identical output to the Node harness, including all 143 lines of the ztest run. It is not interactive: no UART input, no tracing.

Browsers are untested and out of scope. The module would run in one; the harness would not, because the driver loop is synchronous and blocks until the guest suspends, which on a page's main thread would freeze the tab. That needs a Worker and an inside-out driver loop, not a kernel change.

Host options

host/run.mjs takes the module and:

FlagEffect
--realtimefollow the wall clock instead of virtual time
--trace-switcheslog every context switch and idle to stderr
--max-time <ms>give up after this much guest time, default 10000
--interactiveforward this terminal's input to the guest UART, and keep running while the guest is idle

Continuous integration

.github/workflows/pages.yml starts from a bare Ubuntu runner, installs the toolchain, clones Zephyr, applies the seven patches, builds five applications, runs three of them under Node, checks two runs are byte-identical, and only then publishes. It is the reproducibility check for everything above: if it is green, these instructions work on a machine that is not the author's.

Layout

arch/wasm/            the architecture: switching, interrupts, idle, fatal
include/zephyr/arch/wasm/   its headers, including the zephyr_host ABI
soc/wasm/             the host as a SoC
boards/wasm/wasm_node/      the board
drivers/              console and system timer over host imports
cmake/                toolchain variant, and the build steps Zephyr lacks
scripts/              offsets and section generators, build and check scripts
host/core.mjs         the engine-neutral driver loop and host ABI
host/run.mjs          the Node front-end
host/run_wasmtime.py  a separate implementation, for wasmtime
host/web/             the browser front-end: a page and a Worker
spikes/               the Milestone 0 experiments, each with a run.sh
tests/two_threads/    a minimal two-thread reproducer
tests/timeslice/      two spinners that only run if preemption works
tests/safepoint_cost/ fixed compute, for measuring what safepoints cost
patches/              the seven Zephyr changes, each explained
.github/workflows/    builds from scratch and publishes the demo

Licence

Apache-2.0, matching Zephyr. See LICENSE. The files under patches/ are diffs against Zephyr and carry Zephyr's licence, which is the same.

Where this is going

Issue #1 sets out the vision: how much of Zephyr can run in a browser tab, as a way to learn it. Progress is measured in upstream Zephyr samples that run unmodified, which is three today.

Feedback

The interesting parts to argue with are patches/README.md, which explains each change to Zephyr and why, and the final report at the end of NOTES.md, which covers what the approach costs and what it would take to upstream any of it. Neither needs a build to read.

Contributors

beriberikix

42 commits

beriberikix/zephyr-wasm-soc

Zephyr with WebAssembly as a real architecture: Asyncify context switching, cooperative interrupts, the host as a SoC. Try it in a browser.

C

1

42 commits

updated Sep 20, 2026

See the code
wasm
webassembly
zephyr-rtos

See what people are saying (1)

SourceMessageScoreDate

Zephyr RTOS Ported to WebAssembly

1

Sep 20, 2026

README

zephyr-wasm

Zephyr with WebAssembly as a real architecture.

Try it in your browser — boot the kernel, run its test suite, or type into the Zephyr shell. Nothing to install.

This is not native_sim built with a Wasm toolchain. The kernel runs freestanding in a single wasm32 linear memory, using Zephyr's own libc and scheduler. The host plays the part of a SoC: instead of memory-mapped registers it provides a handful of imported functions, and it owns the clock.

Context switching runs on Binaryen's Asyncify. Interrupts are cooperative: the host sets a pending word in linear memory and the kernel notices it at a safepoint. By default the clock is virtual, so a run does not depend on how fast the machine underneath it is, and two runs produce identical output.

DESIGN.md records the decisions and the host ABI. NOTES.md is the running log, including what did not work. BRIEF.md is the original task.

What works

  • samples/hello_world boots and exits cleanly.
  • samples/synchronization alternates two threads with k_msleep honoured.
  • tests/kernel/semaphore/semaphore passes all 32 tests under ztest.
  • Two runs in virtual time produce byte-identical output.
  • Two equal-priority threads that never yield are time-sliced against each other, through safepoints inserted after linking.
  • samples/subsys/shell/shell_module runs interactively over a polled UART.

Not done: twister builds for this board but cannot find the module's SoC. It takes a --board-root and no --soc-root, relying on module discovery, and discovery finds nothing because this module is the manifest repository rather than a project inside it. NOTES.md has the detail.

How preemption works

Nothing preempts a running wasm function, so CONFIG_WASM_SAFEPOINTS inserts a call at the top of every loop body after linking, and a pending interrupt is taken there. It runs before Asyncify, so those calls can suspend: taking an interrupt may switch threads.

It needs a second piece. Under virtual time the clock only moves when the kernel idles, so a thread that spins without calling the kernel would freeze it, and a frozen clock means the timer never fires. Every CONFIG_WASM_SAFEPOINTS_PER_TICK safepoints the guest gives the host a chance to advance time.

The cost, on 800 million iterations of a tight arithmetic loop, which is the worst case by construction:

WithoutWithRatio
Code size3296863371751.023x
Wall time1.74 s3.83 s2.28x

The acceptance suite shows no perceptible change.

Requirements

ToolVersion usedNotes
clang23.1.1needs the wasm32 target; CI uses 21
wasm-ld23.1.1must match clang; Homebrew ships it in the separate lld formula
wasm-opt132Binaryen
wasm-objdump1.0.42wabt
Node.js2620 or newer should do
west, cmake, ninjawith Zephyr's Python dependencies

Version matters more than it should. On clang 18 the linker leaves the iterable-section bounds undefined and ztest will not link, so the section scheme quietly stops working while simpler applications still build. 21 and 23 are known good; the true minimum is not established.

On macOS:

brew install llvm lld binaryen wabt node
brew link --overwrite wabt          # binaryen owns the wasm2c name

Both LLVM formulas are keg-only, so tools.env carries their paths. Adjust it if your toolchain lives elsewhere.

Setting up

git clone <this repo> zephyr-wasm
west init -l zephyr-wasm
west update
zephyr-wasm/scripts/apply_patches.sh

The Zephyr tree is otherwise read-only. Five patches are needed and each is explained in patches/README.md; four of the five are the same underlying gap, which is that several places in Zephyr assume an architecture is in-tree or assume a linker script exists.

Building and running

scripts/build.sh wraps west build with the flags this module needs on every build, because Zephyr looks for a toolchain under TOOLCHAIN_ROOT rather than through the module system.

zephyr-wasm/scripts/build.sh build-hello zephyr/samples/hello_world
node zephyr-wasm/host/run.mjs build-hello/zephyr/zephyr.wasm
*** Booting Zephyr OS build e201b84b04e4 ***
Hello World! wasm_node/node

Two threads alternating, with sleeps honoured:

zephyr-wasm/scripts/build.sh build-sync zephyr/samples/synchronization
node zephyr-wasm/host/run.mjs --max-time 2000 build-sync/zephyr/zephyr.wasm
*** Booting Zephyr OS build e201b84b04e4 ***
thread_a: Hello World from cpu 0 on wasm_node!
thread_b: Hello World from cpu 0 on wasm_node!
thread_a: Hello World from cpu 0 on wasm_node!

The kernel test suite:

zephyr-wasm/scripts/build.sh build-sem zephyr/tests/kernel/semaphore/semaphore
node zephyr-wasm/host/run.mjs --max-time 60000 build-sem/zephyr/zephyr.wasm
Running TESTSUITE semaphore
 PASS - test_k_sem_define in 0.000 seconds
 ...
PROJECT EXECUTION SUCCESSFUL

The shell, interactively. --interactive forwards this terminal's input to the guest UART and keeps the run alive while the guest is idle:

zephyr-wasm/scripts/build.sh build-shell zephyr/samples/subsys/shell/shell_module
node zephyr-wasm/host/run.mjs --interactive --max-time 600000 build-shell/zephyr/zephyr.wasm
uart:~$ kernel version
Zephyr version 4.4.99
uart:~$ demo ping
pong

Ctrl-C exits. Commands can also be piped in, which is how the run above was checked.

Preemption, which is what safepoints are for. Two threads at equal priority, both spinning with no kernel calls and no way out:

zephyr-wasm/scripts/build.sh build-slice zephyr-wasm/tests/timeslice
node zephyr-wasm/host/run.mjs --max-time 30000 build-slice/zephyr/zephyr.wasm
main: a=50499611 b=49519850
PASS: both threads ran, so preemption works

The exact counts move with the binary; what matters is that both are large and roughly equal. Within one binary they are reproducible, like everything else under virtual time.

Build the same test with -DCONFIG_WASM_SAFEPOINTS=n and it hangs after its first line, which is the control.

Determinism, which is the point of virtual time:

zephyr-wasm/scripts/check_determinism.sh build-sem/zephyr/zephyr.wasm --max-time 60000
deterministic: two runs produced identical output (143 lines)

west build -t run also works and does the same thing:

zephyr-wasm/scripts/build.sh build-hello zephyr/samples/hello_world
ninja -C build-hello run

In a browser

The same module runs in Chrome. The guest lives in a Worker, because the driver loop blocks its thread between suspensions and would otherwise freeze the tab; output and keystrokes cross by message.

The published copy is at https://beriberikix.github.io/zephyr-wasm-soc/, built by CI from a bare runner. To do the same locally:

zephyr-wasm/scripts/stage_site.sh      # builds five applications into _site/
zephyr-wasm/scripts/serve_web.sh 8777  # then open http://127.0.0.1:8777/

Pick a build and press Run. For the shell, click the output area and type; Ctrl+C stops it. A server is needed because file:// blocks both Workers and fetch; this one is bound to the loopback address. stage_site.sh is what CI runs too, so what you see locally is what is published.

Verified in Chrome: hello_world, synchronization, the 32-test ztest suite, the time slicing test, and the shell answering kernel version and demo ping. The ztest output is byte-identical to the Node run once carriage returns are accounted for, which the page's terminal consumes as a terminal should.

This says nothing new about engine neutrality, because Chrome is V8, the same engine as Node. That claim rests on the wasmtime result below. What the browser shows is that the harness is portable to somewhere with no filesystem, no stdio and no blocking main thread.

A second engine

host/run.mjs runs on Node, which is V8. host/run_wasmtime.py implements the same ABI and the same driver loop against wasmtime, to show that neither the port nor its determinism depends on one engine:

python3 -m venv /tmp/wtenv && /tmp/wtenv/bin/pip install wasmtime
/tmp/wtenv/bin/python zephyr-wasm/host/run_wasmtime.py --max-time 60000 \
    build-sem/zephyr/zephyr.wasm

It produces byte-identical output to the Node harness, including all 143 lines of the ztest run. It is not interactive: no UART input, no tracing.

Browsers are untested and out of scope. The module would run in one; the harness would not, because the driver loop is synchronous and blocks until the guest suspends, which on a page's main thread would freeze the tab. That needs a Worker and an inside-out driver loop, not a kernel change.

Host options

host/run.mjs takes the module and:

FlagEffect
--realtimefollow the wall clock instead of virtual time
--trace-switcheslog every context switch and idle to stderr
--max-time <ms>give up after this much guest time, default 10000
--interactiveforward this terminal's input to the guest UART, and keep running while the guest is idle

Continuous integration

.github/workflows/pages.yml starts from a bare Ubuntu runner, installs the toolchain, clones Zephyr, applies the seven patches, builds five applications, runs three of them under Node, checks two runs are byte-identical, and only then publishes. It is the reproducibility check for everything above: if it is green, these instructions work on a machine that is not the author's.

Layout

arch/wasm/            the architecture: switching, interrupts, idle, fatal
include/zephyr/arch/wasm/   its headers, including the zephyr_host ABI
soc/wasm/             the host as a SoC
boards/wasm/wasm_node/      the board
drivers/              console and system timer over host imports
cmake/                toolchain variant, and the build steps Zephyr lacks
scripts/              offsets and section generators, build and check scripts
host/core.mjs         the engine-neutral driver loop and host ABI
host/run.mjs          the Node front-end
host/run_wasmtime.py  a separate implementation, for wasmtime
host/web/             the browser front-end: a page and a Worker
spikes/               the Milestone 0 experiments, each with a run.sh
tests/two_threads/    a minimal two-thread reproducer
tests/timeslice/      two spinners that only run if preemption works
tests/safepoint_cost/ fixed compute, for measuring what safepoints cost
patches/              the seven Zephyr changes, each explained
.github/workflows/    builds from scratch and publishes the demo

Licence

Apache-2.0, matching Zephyr. See LICENSE. The files under patches/ are diffs against Zephyr and carry Zephyr's licence, which is the same.

Where this is going

Issue #1 sets out the vision: how much of Zephyr can run in a browser tab, as a way to learn it. Progress is measured in upstream Zephyr samples that run unmodified, which is three today.

Feedback

The interesting parts to argue with are patches/README.md, which explains each change to Zephyr and why, and the final report at the end of NOTES.md, which covers what the approach costs and what it would take to upstream any of it. Neither needs a build to read.

Contributors

beriberikix

42 commits

Languages

C

34.8%

JavaScript

20.2%

Python

17.8%

CMake

14.3%

Shell

8.4%

HTML

4.4%