Based on embedded-wasm collection — a set of repos that explores the WebAssembly Component Model runtime (Wasmtime + Pulley interpreter) from desktop tutorials to bare-metal RP2350 embedded targets with hardware capabilities exposed through WIT.
A Rust project that runs WebAssembly Component Model #![no_std] guest components through the Pulley interpreter using Wasmtime. Two guest components are compiled to wasm32-unknown-unknown, encoded via ComponentEncoder, AOT-precompiled to Pulley bytecode at build time, and deserialized at runtime by the host — the same architecture used on embedded microcontrollers like the RP2350.
- Overview
- Architecture
- Project Structure
- Source Files
- Prerequisites
- Building
- Usage
- Testing
- How It Works
- WIT Interface Contract
- Extending the Project
- Troubleshooting
- Tutorial
- Reverse Engineering
- License
This project demonstrates that the WebAssembly Component Model is not limited to browsers — the same host/guest architecture runs identically on a laptop and on a bare-metal microcontroller. The host uses Wasmtime with the Pulley interpreter (a portable WebAssembly execution backend) to deserialize and run AOT-precompiled WASM components that communicate through typed WIT interfaces.
Key properties:
- Pure Rust — host and guests are 100% Rust
#![no_std]guests — guests usewasm32-unknown-unknownwithdlmallocandwit-bindgen, no WASI dependency- Component Model — typed WIT interfaces, not raw
extern "C"imports - AOT precompilation —
build.rscompiles guests, encodes viaComponentEncoder, and precompiles to Pulley bytecode at build time - Pulley execution — compiled to Pulley bytecode via
config.target("pulley64"), portable to any CPU Component::deserialize— host loads precompiled artifacts viainclude_bytes!, zero runtime compilation- Parameterized exports — guest2 accepts
option<string>and returnsstringvia the Component Model canonical ABI - Multiple guests — two components with intentionally different WIT contracts loaded by the same host
- Industry-standard runtime — Wasmtime is the reference WebAssembly implementation
- Embedded-ready — identical architecture to embedded-wasm-uart-rp2350, swap
pulley64forpulley32
┌──────────────────────────────────────────────────────────┐
│ Build Time (build.rs) │
│ │
│ guest1/src/lib.rs -> wasm32-unknown-unknown │
│ -> ComponentEncoder -> engine.precompile_component │
│ -> guest1.cwasm (Pulley bytecode in OUT_DIR) │
│ │
│ guest2/src/lib.rs -> wasm32-unknown-unknown │
│ -> ComponentEncoder -> engine.precompile_component │
│ -> guest2.cwasm (Pulley bytecode in OUT_DIR) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Host (host.rs) │
│ │
│ ┌───────────┐ ┌─────────┐ ┌──────────────────────┐ │
│ │ Engine │ │ Linker │ │ Store<()> │ │
│ │ Pulley64 │ │ <()> │ │ (no WASI state) │ │
│ │ CompModel│ │ │ │ │ │
│ └─────┬─────┘ └────┬────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ v v v │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Component::deserialize(include_bytes!(...)) │ │
│ │ linker.instantiate(&store, &component) │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ │ │ │
│ v v │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ guest1.cwasm │ │ guest2.cwasm │ │
│ │ (#![no_std]) │ │ (#![no_std]) │ │
│ │ │ │ │ │
│ │ exports: │ │ exports: │ │
│ │ run() -> str │ │ run(name: opt) -> str │ │
│ │ │ │ describe() -> str │ │
│ │ no WASI imports │ │ no WASI imports │ │
│ └──────────────────┘ └──────────────────────────┘ │
│ │
│ Host prints returned strings to stdout │
└──────────────────────────────────────────────────────────┘
Wasm/
├── host.rs # Host binary: deserialize, instantiate, call exports
├── build.rs # AOT pipeline: compile guests, encode, precompile to Pulley
├── Cargo.toml # Host deps (wasmtime 43.0.0) + build-deps (wit-component)
├── tests/
│ └── integration.rs # 15 integration tests: loading, exports, return values
├── guest1/
│ ├── Cargo.toml # Guest1 package (cdylib, wit-bindgen 0.44.0, dlmalloc)
│ ├── wit/
│ │ └── world.wit # WIT contract: export run: func() -> string
│ └── src/
│ └── lib.rs # Guest1 impl: #![no_std], returns "guest1 run() called"
├── guest2/
│ ├── Cargo.toml # Guest2 package (cdylib, wit-bindgen 0.44.0, dlmalloc)
│ ├── wit/
│ │ └── world.wit # WIT contract: export run(name), export describe
│ └── src/
│ └── lib.rs # Guest2 impl: #![no_std], greeting with optional name
├── TUTORIAL.md # Comprehensive line-by-line tutorial
├── README.md # This file
└── target/ # Build artifacts
Defines the component:guest1 package with the guest1-world world. Exports a single run function returning a string — the simplest possible Component Model contract.
Defines the component:guest2 package with the guest2-world world. Exports run with an option<string> parameter returning a string, and describe returning a string — demonstrating rich Component Model types across the host-guest boundary.
The simplest #![no_std] guest component compiled to wasm32-unknown-unknown. Uses wit_bindgen::generate! to produce bindings from the WIT world and implements the Guest trait with a run() function that returns "guest1 run() called". Uses dlmalloc as the global allocator for the canonical ABI's cabi_realloc.
A #![no_std] guest component with a richer API. Implements Guest with run(name: Option<String>) that returns a greeting using the provided name (defaulting to "world" via DEFAULT_NAME) and describe() that returns a short string identifying the component. Demonstrates option<string> parameter and string return types through the canonical ABI.
Orchestrates the build-time compilation of both guest components: compiles each guest crate to wasm32-unknown-unknown via cargo build, reads the core wasm bytes, encodes them as WebAssembly components via ComponentEncoder, and precompiles to Pulley bytecode via engine.precompile_component(). Writes both .cwasm (AOT-precompiled) and .component.wasm (encoded, for tests) artifacts to OUT_DIR.
Orchestrates everything at runtime: creates an Engine configured for Component Model + Pulley (pulley64), deserializes each precompiled guest artifact via Component::deserialize with include_bytes!, builds a Linker<()> (no WASI required), creates a Store<()>, instantiates each component, and calls exports. run_guest1 calls run() and returns the result string; run_guest2 calls run(Option<String>) and describe(). Reads an optional CLI argument for the guest name (defaults to "Pulley").
15 tests validating both guest components end-to-end: component loading, export verification (run, describe, absence of describe on guest1), return value checks, describe return value, absence of WASI imports, and parameter passing (default name, custom name, exact message matching).
# Rust (stable) with wasm32-unknown-unknown target
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknowncargo buildThe build.rs script handles everything automatically:
- Compiles
guest1andguest2towasm32-unknown-unknown(release mode) - Encodes each core wasm module as a Component Model component via
ComponentEncoder - AOT-precompiles each component to Pulley bytecode via
engine.precompile_component() - Writes
.cwasmartifacts toOUT_DIRforinclude_bytes!in the host - Compiles
host.rswith the embedded precompiled components
No separate guest build step required.
cargo run --bin helloOutput:
Building Pulley component engine...
Deserializing guest1 component...
guest1 run() called
Deserializing guest2 component...
guest2 run() called: hello, Pulley!
describe: guest2 has an extra `describe` export
Done.
cargo run --bin hello -- "Kevin"Output:
Building Pulley component engine...
Deserializing guest1 component...
guest1 run() called
Deserializing guest2 component...
guest2 run() called: hello, Kevin!
describe: guest2 has an extra `describe` export
Done.
The -- separates cargo arguments from your program's arguments. "Kevin" becomes args[1] in host.rs.
cargo testRuns 15 integration tests validating:
- Component loading (guest1, guest2)
- Export contract (
runfunction signatures) - Export contract (
describepresent on guest2, absent on guest1) - Return value verification (exact strings)
- Describe return value check
- Absence of WASI imports (guests are
#![no_std]) - Parameter passing (default
None->"world", customSome("Pulley"))
Define the contract between host and guest:
guest1:
package component:guest1;
world guest1-world {
export run: func() -> string;
}guest2:
package component:guest2;
world guest2-world {
export run: func(name: option<string>) -> string;
export describe: func() -> string;
}The host looks up exports by name and verifies signatures at runtime via get_typed_func. If a component's exports do not match, instantiation fails.
Each guest uses #![no_std] with wit_bindgen::generate! to produce bindings from the WIT world and implements the Guest trait:
guest1:
#![no_std]
extern crate alloc;
use alloc::string::String;
#[global_allocator]
static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
wit_bindgen::generate!({ world: "guest1-world", path: "wit" });
impl Guest for Component {
fn run() -> String {
String::from("guest1 run() called")
}
}guest2:
#![no_std]
extern crate alloc;
const DEFAULT_NAME: &str = "world";
impl Guest for Component {
fn run(name: Option<String>) -> String {
let name = name.as_deref().unwrap_or(DEFAULT_NAME);
format!("guest2 run() called: hello, {name}!")
}
fn describe() -> String {
String::from("guest2 has an extra `describe` export")
}
}No WASI, no println! — guests return strings through the canonical ABI. dlmalloc provides the heap allocator required by cabi_realloc.
At cargo build time, the build script:
- Compiles each guest crate to
wasm32-unknown-unknownviacargo build --release --target wasm32-unknown-unknown - Reads the core wasm binary produced by each guest build
- Encodes each core module as a WebAssembly component via
ComponentEncoder(adds component type metadata) - Precompiles each component to Pulley bytecode via
engine.precompile_component()(AOT compilation) - Writes
.cwasmfiles toOUT_DIRfor the host to embed viainclude_bytes!
The host executes in this sequence:
main()— Callsrun(), returnswasmtime::Result.parse_name()— Reads optional CLI argument (defaults to"Pulley").build_engine()— Creates Engine:Config::new() .wasm_component_model(true) -> enable Component Model .target("pulley64") -> target Pulley bytecode Engine::new(&config)load_component(engine, bytes)— Deserializes precompiled component:unsafe { Component::deserialize(engine, bytes) }run_guest1(engine, component)— Instantiates and calls:Linker::<()>::new(engine) -> empty linker, no WASI needed Store::new(engine, ()) -> unit state linker.instantiate() -> create Instance get_typed_func("run") -> look up export run.call() -> execute via Pulley, get Stringrun_guest2(engine, component, name)— Same pattern, callsrunanddescribe.
main()
-> run()
-> parse_name() [CLI arg or "Pulley"]
-> build_engine() [Config: pulley64 + component-model]
-> load_component(engine, GUEST1_PRECOMPILED) [Component::deserialize]
-> run_guest1(engine, component)
-> Linker::<()>::new(engine)
-> Store::new(engine, ())
-> linker.instantiate(&store, &component)
-> get_typed_func::<(), (String,)>("run")
-> run.call(&store, ()) [Pulley interprets guest bytecode]
-> guest returns String [via canonical ABI]
-> load_component(engine, GUEST2_PRECOMPILED) [Component::deserialize]
-> run_guest2(engine, component, name)
-> get_typed_func::<(Option<String>,), (String,)>("run")
-> run.call(&store, (Some(name),)) [Pulley interprets guest bytecode]
-> get_typed_func::<(), (String,)>("describe")
-> describe.call(&store, ())
-
Create a new guest crate:
cargo init --lib guest3
-
Configure
guest3/Cargo.toml:[lib] crate-type = ["cdylib"] [dependencies] dlmalloc = { version = "0.2", features = ["global"] } wit-bindgen = "0.44.0" [workspace]
-
Create
guest3/wit/world.wit:package component:guest3; world guest3-world { export run: func() -> string; }
-
Implement
guest3/src/lib.rswith#![no_std],wit_bindgen::generate!, and theGuesttrait. -
Add to
build.rs— add constants for paths and names, add acompile_guest_to_pulleycall inmain(). -
Add to
host.rs— addGUEST3_PRECOMPILEDconstant, add arun_guest3function, call it fromrun(). -
Build and run:
cargo build && cargo run --bin hello
guest1:
package component:guest1;
world guest1-world {
export run: func() -> string;
}guest2:
package component:guest2;
world guest2-world {
export run: func(name: option<string>) -> string;
export describe: func() -> string;
}| Component | Function | Signature | Description |
|---|---|---|---|
| guest1 | run |
func() -> string |
Returns "guest1 run() called" |
| guest2 | run |
func(name: option<string>) -> string |
Returns greeting with name (defaults to "world") |
| guest2 | describe |
func() -> string |
Returns a description string identifying the component |
-
Add the export in a guest's
world.wit:world guest1-world { export run: func() -> string; export version: func() -> string; }
-
Implement the new method in
lib.rson theGuesttrait. -
Look it up in
host.rs:let version = instance.get_typed_func::<(), (String,)>(&mut store, "version")?; let (v,) = version.call(&mut store, ())?; println!("version: {v}");
-
Rebuild (
cargo buildhandles everything).
Edit the run() function in any guest's lib.rs. Run cargo build — the build script recompiles the guest, re-encodes, and re-precompiles automatically.
| Symptom | Cause | Fix |
|---|---|---|
Component::deserialize fails |
Engine config mismatch | Ensure runtime engine config matches build.rs config exactly |
| Build fails with guest compilation error | Missing wasm target | Run rustup target add wasm32-unknown-unknown |
get_typed_func fails |
Signature mismatch | Verify WIT export matches the type parameters |
config.target("pulley64") fails |
Pulley feature not enabled | Ensure wasmtime dependency has features = ["pulley"] |
| Guest fails to compile | Missing dlmalloc or wit-bindgen |
Check guest Cargo.toml dependencies |
cabi_realloc link error |
No global allocator | Add #[global_allocator] with dlmalloc::GlobalDlmalloc |
| Tests fail | Guests not rebuilt | Run cargo build before cargo test |
For a comprehensive, line-by-line walkthrough of every source file, struct, and function in this project — including detailed explanations of Engine, Store, Linker, Component, AOT precompilation, Pulley, and the connection to embedded systems — see TUTORIAL.md.
A comprehensive reverse engineering analysis of the release binary is available in RE.md. It covers the Mach-O structure, arm64 host code, Cranelift compiler integration, the Pulley interpreter dispatch loop, embedded cwasm blobs, full Pulley ISA reference, bytecode disassembly, and a Ghidra analysis walkthrough.
For Pulley bytecode analysis inside Ghidra, use the G-Pulley extension — a custom Ghidra processor module that disassembles Wasmtime's Pulley ISA and extracts cwasm blobs from host binaries.