How to use WAMR with Zephyr
September 10, 2026 · View on GitHub
Zephyr is an open source real-time operating system (RTOS) with a focus on security and broad hardware support. WAMR is compatible with Zephyr via the Zephyr WAMR port, and is packaged as a Zephyr module so that an application only has to enable a few Kconfig options to get the runtime linked into its image.
Samples
| Sample | What it demonstrates |
|---|---|
| simple | Minimal application: run a WASM module with the built-in libc |
| simple-file | WASI file system API on top of Zephyr fs_* |
| simple-http | WASI socket API on top of Zephyr zsock_* |
| user-mode | Running the runtime inside a Zephyr user-mode thread |
Setup
Using WAMR with Zephyr can be accomplished by either using the provided Docker image, or by installing Zephyr locally. Both approaches are described below.
Docker
The provided Dockerfile sets up the Zephyr SDK, west, a
Zephyr workspace matching the CI layout (with WAMR marked inactive, see
Keeping west update off your checkout),
and the wasi-sdk in /opt/wasi-sdk
($WASI_SDK_PATH) for recompiling the samples' WASM applications. Only the ARC and x86 toolchains are
installed to keep the image reasonably small (~5 GB); add more -t <toolchain> options to setup.sh in the Dockerfile if you need other
architectures.
The helper script build_and_run.py builds
the image and runs the samples inside a container against your local checkout.
It only needs Python 3 and docker on the host, so it works on Linux, macOS
and Windows alike:
# build the image (only needed once)
python3 build_and_run.py --build
# every sample and test, on every simulator it declares
python3 build_and_run.py
# narrow it down while working on one of them
python3 build_and_run.py simple
python3 build_and_run.py --sim qemu_arc user-mode
The console only carries progress; the full docker build, CMake and emulator
output goes to build/logs/, and the tail of the relevant log is printed if a
step fails. See --help for details.
WAMR itself is not baked into the image. The script bind mounts the repository
at /root/zephyrproject/modules/wasm-micro-runtime and mirrors this directory
into the workspace's application/ on every run, so both the runtime and the
applications being built are always the ones in your working tree and the image
does not have to be rebuilt when you change them.
To work inside the container interactively instead — the mount is required, the module directory is empty otherwise:
docker build -t wamr-zephyr .
docker run -it --rm \
-v "$(git rev-parse --show-toplevel)":/root/zephyrproject/modules/wasm-micro-runtime \
wamr-zephyr
If you are planning to flash a device from the container, pass the device with
--device,
e.g. --device=/dev/ttyUSB0.
Local Environment
Zephyr can also be set up locally. This gives you more control over which modules and tools are installed, which can drastically reduce the required storage compared to the Docker image. Follow the Zephyr Getting Started guide, then install the Zephyr SDK toolchains for the architectures you target.
Workspace
WAMR is consumed as a Zephyr module, so the repository has to be visible to
west. This directory is a standalone Zephyr application repository: it holds
the applications and, in west.yml, everything they need, WAMR
included. So it is the manifest repository of a
T2 star topology
workspace, and the runtime is just another module west clones:
zephyrproject/ <- topdir
├── .west/config
├── zephyr/ <- fetched by west update
├── zephyr-sdk/
├── modules/
│ ├── fs/littlefs <- fetched by west update
│ └── wasm-micro-runtime <- fetched by west update, from west.yml
│ ├── zephyr/module.yml <- declares the Zephyr module
│ ├── zephyr/Kconfig <- CONFIG_WAMR_* options
│ └── zephyr/CMakeLists.txt <- builds the runtime as a Zephyr library
└── application/ <- a copy of THIS directory
├── west.yml <- the manifest above was read from here
└── <sample>/
├── CMakeLists.txt <- the application only adds its own sources
├── prj.conf <- CONFIG_WAMR_* selections for the sample
└── src/main.c
Copy this directory out anywhere and build from it:
export ZWS=~/zephyrproject
mkdir -p $ZWS
cp -r /path/to/wasm-micro-runtime/product-mini/platforms/zephyr $ZWS/application
cd $ZWS
west init -l --mf west.yml application
west update --stats
west zephyr-export
pip install -r zephyr/scripts/requirements.txt
west build application/<sample> -b <board> -p always
The applications find the runtime through
${ZEPHYR_WASM_MICRO_RUNTIME_MODULE_DIR}, never through a relative path, so
nothing here depends on sitting inside the WAMR repository.
Note that application/ has to be a copy, not a symlink to this directory:
west init -l resolves the manifest directory to its real path and would put
the topdir inside the WAMR checkout.
Keeping west update off your checkout
Working on the runtime itself is the same workspace with one change. west.yml
lists WAMR, so west update would clone it from GitHub straight over the
checkout under test — the bind mount in the container, the pull request in CI.
Mark the project inactive and west update skips it:
west config --global manifest.project-filter -- -wasm-micro-runtime
The runtime then comes from the working tree instead, attached to the build
with EXTRA_ZEPHYR_MODULES:
west build application/<sample> -b <board> -p always -- \
-DEXTRA_ZEPHYR_MODULES=/path/to/wasm-micro-runtime
That is all the Dockerfile, CI and build_and_run.py do
differently; the layout, the manifest and the build commands are the ones
above. Set the config before west init, and prefer --global: the CI action
that creates the workspace runs west init and west update as one step,
leaving no moment in between to configure the workspace itself.
A file:/// URL in the manifest is not a substitute for any of this: west update clones and checks out a fixed revision, so uncommitted changes — the
whole point of a local build — would not be there.
The one thing this arrangement never exercises is the WAMR entry in west.yml
itself, since every automated build skips it. After changing that entry, check
it by hand in a throwaway workspace, without the config:
workspace=$(mktemp -d)
cp -r . $workspace/application
cd $workspace && west init -l --mf west.yml application && west update --stats
west zephyr-export && west twister -T application -p native_sim \
--disable-warnings-as-errors --jobs 1
Building
With the environment set up, build any of the samples with
west from
the sample directory:
west build . -b <board-identifier> -p always
The <board-identifier> can be found in the Zephyr supported boards
documentation. Board
specific Kconfig fragments go into the sample's boards/<board>.conf.
WAMR_BUILD_TARGET is derived from the board architecture by
zephyr/CMakeLists.txt, so it normally does
not have to be passed. Override it to select a sub-variant (e.g. THUMBV7
instead of the generic THUMB):
west build . -b <board-identifier> -p always -- -DWAMR_BUILD_TARGET=THUMBV7
The list of supported targets is in the main project README.md.
Running under QEMU
Emulated boards are built the same way and run with west:
west build . -b qemu_x86 -p always
west build -t run
Press
CTRL+a, xto exit QEMU.
Boards that are regularly exercised:
| Board | Arch | Notes |
|---|---|---|
qemu_x86 | X86_32 | Default smoke test target |
qemu_arc/qemu_arc_hs | ARC | Needs arc-zephyr-elf and arc64-zephyr-elf |
qemu_cortex_a53 | AARCH64 | 64-bit ARM |
qemu_riscv32 / qemu_riscv64 | RISCV | AOT is not supported, add -DWAMR_BUILD_AOT=0 |
qemu_xtensa | XTENSA |
AOT is not available on every architecture. Where it is not, disable it with
-DWAMR_BUILD_AOT=0 or CONFIG_WAMR_AOT=n.
Running with native_sim on Linux
native_sim
compiles Zephyr and the application into a native Linux executable. There is
no emulation involved, so builds and runs are fast, which makes it the
quickest way to smoke test a change to the runtime.
# 32-bit host build, WAMR_BUILD_TARGET is derived as X86_32
west build . -b native_sim -p always
./build/zephyr/zephyr.exe
# 64-bit host build, WAMR_BUILD_TARGET is derived as X86_64
west build . -b native_sim/native/64 -p always
./build/zephyr/zephyr.exe
west build -t run works as well. The 32-bit variant needs the multilib host
compiler (gcc-multilib g++-multilib on Debian/Ubuntu); the Docker image
already has it.
Note that native_sim runs with the host libc and host memory sizes, so it
will not catch problems that only show up under the tight memory constraints
of a real target.
Flashing a device
west flash
west automatically identifies the board if it is connected to the host
machine.
Reporting results
Every layer reports what happened, so a failure is visible both in the output and in the exit status:
-
The WASM application returns
0on success and a distinct non-zero code per failure (see theEXIT_*defines in its source), after printingERROR: <what went wrong>. The runtime hands that code to the Zephyr application, as the WASI exit code for the WASI samples and as the return value of the entry point for the others. -
The Zephyr application checks the call result, the exception and the module exit code, prints
ERROR: ...for anything unexpected andPASS: <what was verified>once everything completed, then returns:Code Meaning 0 the module ran to completion and reported success 1 the host failed: runtime init, load, instantiate, missing entry point 2 the module faulted or returned a non-zero code -
Each sample declares in its
sample.yamlwhichPASS:line a successful run must print, so twister turns that into a test verdict.
Testing with twister
The samples and the Ztest suites under tests/ are twister test cases:
sample.yaml and testcase.yaml list the scenarios, the platforms they may
run on and the expected console output.
build_and_run.py is a thin wrapper around twister, either
in the Docker image or, with --no-docker, in the current environment — which
is exactly what CI does. Given no test root and no --sim, it hands twister
this whole directory and every simulator and lets the sample.yaml and
testcase.yaml files decide what runs where, so nothing keeps a second copy of
that mapping:
python3 build_and_run.py # what CI runs
python3 build_and_run.py --sim qemu_arc user-mode
To run twister directly, from the workspace:
west twister -T application/simple -p native_sim \
-x EXTRA_ZEPHYR_MODULES=$PWD/modules/wasm-micro-runtime \
--disable-warnings-as-errors
--disable-warnings-as-errors is needed because twister compiles with
-Werror, which the runtime is not built with in any other configuration.
Dedicated Ztest suites
simple, simple-file, simple-http, and user-mode remain sample programs:
they demonstrate an integration and retain their console harnesses. The
dedicated tests/platform-api and tests/runtime applications are the
blocking Ztest suites that make contract assertions and let Twister decide the
verdict.
Run these commands from product-mini/platforms/zephyr to use the repository
Docker environment (the default):
python3 build_and_run.py --sim native_sim tests/platform-api
python3 build_and_run.py --sim qemu_arc tests/runtime
In an already configured local Zephyr workspace, use the same interface with
--no-docker; this is the interface CI uses inside its Zephyr container:
python3 build_and_run.py --no-docker --sim native_sim tests/platform-api
python3 build_and_run.py --no-docker --sim qemu_arc tests/runtime
Each invocation writes its streamed log to
build/logs/<test-root>-<sims>.log and the Twister report, including
individual Ztest case records, to
build/twister-<test-root>-<sims>/twister.json. For
example, tests/platform-api on native_sim uses
build/twister-tests-platform-api-native_sim/. The wrapper forwards Twister's
exit status; do not infer a result from console text.
The pilot supports native_sim and qemu_arc/qemu_arc_hs. native_sim runs
the kernel scenarios only and is a fast host smoke target, not a userspace
isolation claim. On QEMU ARC, both suites run their kernel scenario and their
applicable userspace scenario. The test configurations deliberately cover the
interpreter with the global heap pool; they do not enable AOT or exercise
alternate allocation modes.
Some named contracts are expected to skip while port work is outstanding:
- On
native_sim, the platform userspace scenario is filtered out; concurrent and repeated WAMR thread creation can block, and the CPU-time counter does not advance during the busy-work contract. - On QEMU ARC, the corresponding repeated/concurrent thread cases can block.
In userspace, Zephyr 3.7's
sys_mutexinitialization/locking limits the positive synchronization cases, andk_thread_runtime_stats_get()reaches privilegedarch_irq_lock(), so the CPU-time contracts are skipped.
These are explicit, named skips that retain their test bodies; they are not passing demonstrations. A QEMU ARC user protection-fault case remains active and verifies that a user worker cannot write supervisor-only memory.
Phase Two should first add comprehensive MPU/verifier/illegal-pointer fault matrices and exhaustive platform API coverage. Filesystem, sockets, AOT, alternate allocators, stress, coverage, and physical-board testing remain lower-priority future work.
Adding a new sample
-
Create a directory next to the existing samples with the usual Zephyr application layout:
CMakeLists.txt,prj.conf,src/, and optionallyboards/<board-identifier>.conf. KeepCMakeLists.txttofind_package(Zephyr ...),project(...)andtarget_sources(app ...); the runtime comes from the module, so nothing WAMR specific belongs there. If the application does need a path into the WAMR tree — a CMake module underbuild-scripts/, a source file undercore/— take it from${ZEPHYR_WASM_MICRO_RUNTIME_MODULE_DIR}, never from a relative path that leaves this directory, which would only work inside the WAMR repository. -
Select the runtime features with
CONFIG_WAMR_*inprj.conf, as described in Configuring the runtime. -
If the sample needs a Zephyr module that the workspace does not have yet — littlefs, mbedTLS, an HAL for a new SoC — add it to west.yml. It is deliberately minimal: Zephyr and only the modules the samples actually use, which keeps both the CI setup and the Docker image small. Copy the
name,revisionandpathof the project from Zephyr's ownwest.ymlso that the versions match:- name: littlefs url: https://github.com/zephyrproject-rtos/littlefs revision: 408c16a909dd6cf128874a76f21c793798c9e423 path: modules/fs/littlefsExisting workspaces need a
west updateafterwards, and the Docker image has to be rebuilt (python3 build_and_run.py --build). -
Add a
sample.yamldeclaring the twister scenarios: the platforms the sample may run on and thePASS:line its console output must carry. Follow the exit code convention in Reporting results so that a failure is visible in the exit status too. -
Add a row to the Samples table and a
README.mdin the sample directory covering only what is specific to it.
CI needs no change: it runs twister over this whole directory, so a sample is
picked up as soon as it has a sample.yaml, on the platforms that file allows.
The same holds for a Ztest suite under tests/ and its testcase.yaml.
Configuring the runtime
The runtime is configured through the CONFIG_WAMR_* Kconfig options defined
in zephyr/Kconfig. Set them in the sample's
prj.conf:
CONFIG_WAMR=y
CONFIG_WAMR_INTERP=y
CONFIG_WAMR_AOT=y
CONFIG_WAMR_LIBC_BUILTIN=y
CONFIG_WAMR_GLOBAL_HEAP_POOL=y
CONFIG_WAMR_GLOBAL_HEAP_SIZE=131072
CONFIG_WAMR=n (the default) leaves the runtime out of the image entirely.
Each option maps onto the corresponding WAMR_BUILD_* CMake variable that the
regular WAMR build scripts use. Options can still be overridden on the CMake
command line (-DWAMR_BUILD_AOT=0), which is handy for one-off builds, but
prj.conf is the place to record a configuration.
Exposing a new WAMR_BUILD_XYZ as CONFIG_WAMR_XYZ
Two edits are needed.
-
Declare the option in zephyr/Kconfig, inside the
if WAMRblock. Mirror the runtime default and add the dependencies that the runtime itself requires:config WAMR_LIB_WASI_THREADS bool "wasi-threads library" depends on WAMR_LIBC_WASI help Provide the wasi-threads library to WASM modules. -
Map it in zephyr/CMakeLists.txt with the
wamr_option_from_kconfigmacro, which translates the undefined-when-off Kconfig boolean into the plain0/1the runtime expects:wamr_option_from_kconfig (LIB_WASI_THREADS)
Non-boolean options are copied over directly, guarded by the boolean that
enables them — see how CONFIG_WAMR_GLOBAL_HEAP_SIZE becomes
WAMR_BUILD_GLOBAL_HEAP_SIZE. Keep the Kconfig name equal to the
WAMR_BUILD_* suffix so the mapping stays mechanical.