Wasm is cool.
Recently I took another look at WebAssembly (Wasm), as it seems to be popping up in the nerdy news more often these days and I'm really impressed with its nice design and its present day support and ecosystem.
Back when it was relatively new I took a look and wasn't hugely enamored. I used Emscripten and some C++ + SDL2 code to write a pretty neat little graphical demo which ran in a web page, but the tooling was fairly poor and the whole experience was rather frustrating. And I'm not really a web guy.
But looking at it now I can appreciate just how cool it actually is. Wasm isn't necessarily a web technology any more and its goal to be a simple, fast and secure platform providing an interface to, well anything, as opposed to a browser DOM necessarily, seems to be realised today.
I wondered how hard it'd be to target as a back-end to a compiler. So I built one as an educational experiment. Starting with a simple expression:
(print "Hello world!")
Could I get that running on Wasm? Well, yep, it wasn't too hard. And actually, I probably took the long way around getting there.
The compiler, written in Rust, will parse the above 'Lisp' into the following AST:
Ast([
App {
func: Ident("print", 1..5),
arg: StrLit("Hello world!", 7..20),
span: 0..21
}
])
Pretty straightforward -- a single application of a func 'print' (which will be a compiler intrinsic) to an argument string "Hello world!".
I then added a pass to type check it, because why not? And then also a pass to convert the high-level application into a fully applied Call
, which is easier to lower later on. Partial application would convert into something else.
And then this will compile into the following SSA:
globals:
g0 = str "Hello world!"
g1 = { ptr g0, i64 12 }
fn main() -> i64:
entry:
v0 = call 'print' (ptr g1)
ret i64 v0
fn print(ptr) -> i64:
entry (a0 ptr):
v1 = call intrinsic 'print' a0
ret i64 v1
This is pretty basic I think. The globals will be lowered into memory. The raw "Hello..." string sits there but also a tuple with a pointer to the raw string and its length. This is the 'real' string type, rather than null terminating it or whatever.
And so the print
call in main
is passing a reference to g1
, both the pointer and length. And print
itself is just wrapping around an intrinsic call to print
.
So then we want to lower the intrinsic call itself to something that Wasm can use. A native backend might generate code to call the WRITE(2)
libc function, but in Wasm we need to call fd_write
which is a part of WASI. WASI is the WebAssembly System Interface which provides basic system support for things like I/O and the environment.
fd_write
is simple but also trickier than WRITE(2)
. It takes a target file descriptor as an argument, which like WRITE(2)
can just be 1 for stdout
. It also takes a list of buffers to write, as opposed to a single buffer. So this list needs to be constructed in memory. I chose to employ the multi-buffers to pass the print
string argument along with a newline buffer, so print
resembles the Lisp-y print
and not prin
. Also, fd_write
takes a 32-bit length for the strings and our string length above is 64-bit, so we need to truncate that.
Here's the SSA with the lowered intrinsic call:
globals:
g0 = str "Hello world!"
g1 = { ptr g0, i64 12 }
g2 = str "\n\n\n\n"
g3 = i32 0
g4 = i32 0
g5 = ptr g2
g6 = i32 1
g7 = i32 0
fn main() -> i64:
entry:
v0 = call 'print' (ptr g1)
ret i64 v0
fn print(ptr) -> i64:
entry (a0 ptr):
v1 = get_elem_ptr { ptr, i64 } a0 0
v2 = load i32 v1
v3 = get_elem_ptr { ptr, i64 } a0 1
v4 = load i64 v3
v5 = trunc v4 to i32
store i32 v2 to (ptr g3)
store i32 v5 to (ptr g4)
v6 = call 'fd_write' 1 (ptr g3) 2 (ptr g7)
ret i64 0
imports:
wasi_snapshot_preview1/fd_write(i32, i32, i32, i32) -> i32
So v2
is our raw string pointer and v5
is the 32-bit length. We store them in some memory slots and call fd_write
.
The arguments are 1 for stdout
and then g3
which points to our list of buffers. So we will have g3
actually lowered into 4 32-bit values (g3
, g4
, g5
and g6
) which are the "Hello..." string, its length, a pointer to a "\n\n\n\n"
string and its length of 1. We pass 2 as the third argument to fd_write
indicating there are two buffers in the list. The final argument is an offset to a memory location g7
to receive the result of fd_write
, which is the number of bytes written on success, or an errno
on failure.
Phew. Now, how do we convert this to Wasm? The Wasm execution model is a stack machine, though functions may have local variables and there's free access to the memory buffer.
Here's what the compiler outputs. I've annotated it in comments:
(module
(type (;0;) (func (param i32 i32 i32 i32) (result i32)))
(type (;1;) (func (result i64)))
(type (;2;) (func (param i32) (result i64)))
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write (type 0)))
(func $main (type 1)
(result i64)
i32.const 12
call $print
)
(func $print (type 2)
(param i32)
(result i64)
(local i32 i32 i32 i32)
local.get 0 ;; Get the arg value, our string tuple pointer.
i32.const 0
i32.add ;; Add 0. This could be optimised away.
i32.load ;; Read from the pointer for the actual raw string pointer.
local.set 1 ;; Save it in local var 1. This is actually the first local variable.
local.get 0 ;; Get the tuple pointer again.
i32.const 4
i32.add ;; Add 4.
i64.load ;; Load the length of the string.
i32.wrap_i64 ;; Truncate that length to 32-bits.
local.set 2 ;; Save it in local var 2.
i32.const 28 ;; This is the offset to our buffer descriptor lists.
local.set 3 ;; Save it in local var 3. This is probably unnecessary, a future optimisation.
local.get 3 ;; Get it again. This could be replaced with `local.tee`. One day.
local.get 1 ;; Get the raw string pointer.
i32.store ;; Save it to the buffer descriptor.
i32.const 32 ;; This is the offset to the buffer descriptor length field.
local.get 2 ;; Get the string length.
i32.store ;; And save it.
i32.const 1 ;; Stdout.
local.get 3 ;; Buffer descriptor offset.
i32.const 2 ;; Number of buffers. The second one is already referring to "\n".
i32.const 44 ;; Offset to the place to store the result.
call $fd_write ;; Call it.
local.set 4 ;; Save the result, though we could just `drop` it.
i64.const 0 ;; Return 0.
)
(memory 1)
(data (i32.const 0) "Hello world!\00\00\00\00\0c\00\00\00\00\00\00\00\0a\0a\0a\0a\00\00\00\00\00\00\00\00\18\00\00\00\01\00\00\00\00\00\00\00")
(export "memory" (memory 0))
(export "_start" (func $main))
)
It's an interesting choice for local.get
and local.set
to treat the function arguments and the local variables as belonging to the same list. So fetching the first local var requires indexing num-args + 0
.
The memory is all flattened with our globals from the SSA. And we have to export the memory and our entry point for them to be callable from the Wasm runtime. But that's it.
If we put this in a WAT
file (WebAssembly Text) and pass it to a runtime it will hopefully print Hello world!
with a newline! I've been using wasmer
which I've found works really quite well. I've also played with wasmtime
but found it hard to get working (especially on FreeBSD) and also the wabt
tools which are great for manipulating Wasm binary files and WAT files.
I also installed the wasm-wasi
target for Rust and used the output it produces for educational purposes, but it's pretty full on as you'd expect, including all sorts of boilerplate which can confuse things most of the time.
$ rustup target add wasm32-wasi
$ rustc hello.rs --target wasm32-wasi
But overall I've been quite impressed with the whole thing, from the simple Wasm architecture to WASI and its support, along with wasmer
and wabt
for actually running things. I think I'll continue with this compiler and work on harder stuff like local control flow, local function declarations and calls, etc. Should be fun. :)