Programming WebAssembly with Rust Part 4: Writing a WASM Host
Introduction
I have been reading through this book which goes through the intricacies and benefits of building to and using Wasm for creating modules that can be run in polyglot environments. I will be augmenting and summarizing the book chapters throughout these articles.
WebAssembly (Wasm) has revolutionized the web by enabling high-performance code execution across various platforms and languages. In the past few articles, we have been focusing on writing WASM to run on a JS based host in a web browser. However, what if you could take control of how and where those Wasm modules run? This opens up a world of possibilities, allowing Wasm to execute beyond the browser — on servers, embedded devices, or even custom runtimes.
In this article, I’ll walk you through how to move beyond the browser by creating your own Wasm host using wasmi, a Rust library that allows us to run WebAssembly modules in our own custom environment.
The code for this article can be found in this github repo
Basic Host
Let’s first build a simple WebAssembly host to execute a basic add.wasm
module. This will give us a better understanding of how to run Wasm in a non-JS environment using the Rust wasmi library.
extern crate wasmi;
use std::{fs::File, io::Read};
use wasmi::{Engine, Linker, Module, Store};
fn main() -> Result<(), String> {
let mut buffer = Vec::new();
{
let mut f = File::open("../fundamentals/add.wasm").unwrap();
f.read_to_end(&mut buffer);
}
let engine = Engine::default();
let module = Module::new(&engine, &buffer).unwrap();
let mut store = Store::new(&engine, ());
let mut linker = Linker::<()>::new(&engine);
let instance = linker.instantiate(&mut store, &module).unwrap().ensure_no_start(&mut store).unwrap();
let add_func = instance.get_func(&mut store, "add").ok_or("Function not found").unwrap();
let typed_add_func = add_func.typed::<(i32, i32), i32>(&store).unwrap();
let lhs = 10;
let rhs = 20;
let result = typed_add_func.call(&mut store, (lhs, rhs)).unwrap();
println!("Wasm function executed with result: {}", result);
Ok(())
}
We’ll start by loading the Wasm binary into our Rust program as a byte array. In the preceding code snippet, we use the standard Rust I/O library to read the add.wasm
file into memory.
Once we’ve loaded the module, we need to set up a few core components: the engine, store, and linker. Each of these plays a vital role in executing WebAssembly.
- Engine: The engine compiles and optimizes the Wasm bytecode. It acts as the core of the WebAssembly execution environment.
- Store: The store is a container for all the instances and memories used by a running WebAssembly program. It keeps track of the program’s state during execution.
- Linker: The linker allows us to provide functions from the host environment to the Wasm module and link the Wasm module’s functions with external imports.
Now that we have the module loaded and our environment set up, the next step is to instantiate the module and extract the WebAssembly function we want to call.
Hosting Checkers
Now that we’ve successfully hosted and run a simple add.wasm
module, it’s time to step things up a bit. In this section, we’ll run a more complex WebAssembly module: checkers.wasm
, which we created earlier. This module contains the logic for a game of checkers, allowing us to see how WebAssembly can handle more complex applications beyond simple arithmetic functions.
Loading WASM
Just as before, we load the Wasm module as a byte array and instantiate it.
fn main() -> Result<(), String> {
let mut buffer = Vec::new();
{
let mut f = File::open("./checkers.wasm").unwrap();
f.read_to_end(&mut buffer);
}
let engine = Engine::default();
let module = Module::new(&engine, &buffer).unwrap();
let mut store = Store::new(&engine, ());
let mut linker = Linker::<()>::new(&engine);
let events_piecemoved = Func::wrap(&mut store, |param1: i32, param2: i32, param3: i32, param4: i32| {
println!("Piece Moved from ({param1}, {param2}) to ({param3}, {param4})");
});
linker.define("events", "piecemoved", events_piecemoved).unwrap();
let events_piececrowned = Func::wrap(&mut store, |param1: i32, param2: i32| {
println!("Piece crowned at ({param1}, {param2})");
});
linker.define("events", "piececrowned", events_piececrowned).unwrap();
let instance = linker.instantiate(&mut store, &module).unwrap().ensure_no_start(&mut store).unwrap();
let init = instance.get_typed_func::<(), ()>(&store, "initBoard").unwrap();
let mv = instance.get_typed_func::<(i32, i32, i32, i32), (i32)>(&store, "move").unwrap();
let get_turn_owner = instance.get_typed_func::<(), (i32)>(&store, "getTurnOwner").unwrap();
init.call(&mut store, ());
{
let memory = instance.get_memory(&mut store, "memory").ok_or("Failed to find 'memory' export").unwrap();
let board = get_board(&memory, &store);
println!("{}", board);
}
println!("At the start of game, current turn is : {:?}", get_turn_owner.call(&mut store, ()).unwrap());
mv.call(&mut store, (0, 5, 1, 4));
println!("After first move, current turn is : {:?}", get_turn_owner.call(&mut store, ()).unwrap());
{
let memory = instance.get_memory(&mut store, "memory").ok_or("Failed to find 'memory' export").unwrap();
let board = get_board(&memory, &store);
println!("{}", board);
}
Ok(())
}
One of the main features of this example is defining host functions (piecemoved and piececrowned) that WebAssembly can call. These functions are defined on the host side and then linked into the Wasm instance using our linker.
After initializing the board and making a move, we access the module’s memory and print the board to visualize the changes.
Display
In this section, instead of calling WebAssembly functions directly, we leverage the WebAssembly memory to display the current state of the game board. The board’s state is stored within the WebAssembly instance’s memory, and we can read from that memory to visualize the board.
fn get_board(memory: &Memory, store: &Store<()>) -> String {
let memory_size = memory.data_size(store);
let mut memory_vec = vec![0u8; memory_size];
memory.read(store, 0, &mut memory_vec).unwrap();
let mut values = Vec::<String>::new();
for y in 0..8 {
for x in 0..8 {
let mut byte_vec = vec![0u8; 4];
let offset = calc_offset(x, y) as usize;
memory.read(store, offset, &mut byte_vec);
let value = to_u32(&byte_vec[..]);
values.push(format!("|{}", value_label(value)));
}
values.push("|\n".into());
}
values.join("")
}
fn value_label(v: u32) -> String {
match v {
0 => " ",
1 => " B ",
2 => " W ",
5 => " B*",
6 => " W*",
_ => "???",
}.into()
}
fn to_u32(bytes: &[u8]) -> u32 {
bytes.iter().rev().fold(0, |acc, &b| acc * 2 + b as u32)
}
fn calc_offset(x: usize, y: usize) -> u32 {
((x + y * 8) * 4) as u32
}
We assign characters for the different numerical values in the Wasm module’s memory (i.e. the board). Below is the output of running cargo run
| | W | | W | | W | | W |
| W | | W | | W | | W | |
| | W | | W | | W | | W |
| | | | | | | | |
| | | | | | | | |
| B | | B | | B | | B | |
| | B | | B | | B | | B |
| B | | B | | B | | B | |
At the start of game, current turn is : 1
Piece Moved from (0, 5) to (1, 4)
After first move, current turn is : 2
| | W | | W | | W | | W |
| W | | W | | W | | W | |
| | W | | W | | W | | W |
| | | | | | | | |
| | B | | | | | | |
| | | B | | B | | B | |
| | B | | B | | B | | B |
| B | | B | | B | | B | |
Conclusion
WebAssembly (Wasm) isn’t just limited to running in browsers or being tied to JavaScript hosts. It can be executed in any environment that respects the rules of the WebAssembly standard and can load the module. By using tools like wasmi
, we can easily create custom hosts to execute WebAssembly modules, giving us the flexibility to run Wasm in diverse scenarios, such as on servers, in embedded systems, or even within custom-built environments like our checkers game.