WebAssembly Brief Intro

WebAssembly is a target format for programming languages to generate a portable binary for use in several different platforms. Can be used in the browser, in the CLI by targeting the WASI interface or by importing it as a library where supported.

This binary runs in a stack-based virtual machine, meaning that operations and function calls use the stack to retrieve arguments and store results. This is not uncommon, for instance, the stdcall convention in C/C++ uses the stack to pass parameters, and the Factor programming language embraces the stack paradigm to its full extent.

Usually, webassembly gets generated by other programming languages (rust, swift, go, and several others), but you can also build it manually by using its text representation: WAT.

WebAssembly Text Format (WAT)

This format has a syntax based on s-expressions, quite similar to lisp. More information is found on mozilla's website for it. And allows writing low-level webassembly code for its virtual machine, which is in many ways, pretty similar to regular x86 assembly.

The top-level construct for a webassembly binary is a module.

    (module

        ...

    )

Imports are then specified. Those include external functions that can be called from inside the binary, but while being implemented elsewhere. The following code is importing a WASI function to exit the program

    (import "wasi_snapshot_preview1" "proc_exit" (func $wasi|exit (param i32)))

How much memory is going to be needed is specified by the function below. Memory in webassembly is paged in 64KB blocks, and you need to specify how much of that is going to be needed during execution.

    (memory 1)

If you need to export some of that memory, you use the export syntax. This allows the code to share information with the caller.

    (export "memory" (memory 0))

Globals can be used inside the module using the global keyword. The type can be any of the ones available but can only be initialized by constant values.

    (global $answer i32 (i32.const 42))

Memory is initialized using the data keyword.

    (data (global.get $numbers) "1234")

As an example of how a function is written, the following is a very verbose way to add two numbers.

    (func $add (param $a i32) (param $b i32) (result i32)
        (local $result i32)
        (local.set $result (i32.add (local.get $a) (local.get $b)))
        (local.get $result)
    )
  • func declares a function and its name.

  • param identifies a parameter with a name and a type.

  • result indicates that something is going to be left in the stack as a return for this function, and what type is that going to be.

  • local declares a local variable, can be set by local.setand retrieved by local.get.

  • the result of the function is the last value left in the stack.

Other functions

  • drop: discards one value from the stack

  • block: encapsulates a bunch of instructions

  • loop: allows for iterations

  • br_if: branch if, used to exit from a loop on a condition

  • br: jump to the beginning of a branch unconditionally

  • call: execute a function

  • if: conditionally execute code

Data types

  • i32

  • i64

  • f32

  • f64

A complete example

Here is a very small example of a module using the WASI interface, it adds two numbers and prints the result to the console.

(module
    (import "wasi_snapshot_preview1" "proc_exit" (func $wasi|exit (param i32)))
    (import "wasi_snapshot_preview1" "fd_write" (func $wasi|print (param i32 i32 i32 i32) (result i32)))
    (memory 1)
    (export "memory" (memory 0))
    (global $chars i32 (i32.const 0))
    (data (global.get $chars) "0123456789\n")
    (global $ciovec|buf i32 (i32.const 24))
    (global $ciovec|size i32 (i32.const 28))
    (global $ciovec|res i32 (i32.const 32))
    (global $str_buffer i32 (i32.const 36)) (;100 chars;)
    (global $i32_to_str_buffer i32 (i32.const 135))
    (global $i32_to_str_buffer|end i32 (i32.const 155))

    (func $i32_to_str (param $buf i32) (param $num i32) (result i32)
        (local $count i32)
        (local $rem i32)
        (local $ptr i32)
        (local $base i32)
        (local.set $base (i32.const 10))
        (local.set $count (i32.const 0))
        (local.set $ptr (global.get $i32_to_str_buffer|end))
        (block
            (loop
                (i32.store8
                    (local.get $ptr) ;; destination
                    (i32.load8_u
                        (i32.add
                            (i32.rem_u
                                (local.get $num)
                                (i32.const 10))
                            (global.get $chars)))) ;; source = chars + (num % 10)


                (local.set $num
                    (i32.div_u
                        (local.get $num)
                        (i32.const 10))) ;;num = Math.floor(num / 10)


                (local.set $count
                    (i32.add
                        (local.get $count)
                        (i32.const 1))) ;;count = count + 1

                (br_if 1
                    (i32.eq
                        (local.get $num)
                        (i32.const 0))) ;; if num is zero, exit loop
                (local.set $ptr
                    (i32.sub
                        (local.get $ptr)
                        (i32.const 1))) ;;move ptr one byte to the left
                (br 0)
            )
        )

        (block
            (loop
                (i32.store8
                    (i32.add
                        (local.get $buf) ;; destination
                        (local.get $num))
                    (i32.load8_u
                        (i32.add
                            (local.get $ptr)
                            (local.get $num))))

                (local.set $num
                    (i32.add
                        (local.get $num)
                        (i32.const 1))) ;;increment num

                (br_if 1
                    (i32.eq
                        (local.get $num)
                        (local.get $count))) ;; if num equals count, exit loop
                (br 0)
            )
        )

        (local.get $count)
    )
    (func $print_cr
        (call $print
            (i32.add
                (global.get $chars)
                (i32.const 10))
            (i32.const 1)) ;; print CR
    )
    (func $print (param $str_ptr i32) (param $str_len i32)
        (i32.store (global.get $ciovec|buf) (local.get $str_ptr))
        (i32.store (global.get $ciovec|size) (local.get $str_len))
        (call $wasi|print
            (i32.const 1)
            (global.get $ciovec|buf)
            (i32.const 1)
            (global.get $ciovec|res))
        (drop)
    )
    (func $add (param $a i32) (param $b i32) (result i32)
        (local $result i32)
        (local.set $result (i32.add (local.get $a) (local.get $b)))
        (local.get $result)
    )
    (func $main (export "_start")
        (local $res i32)

        (local.set $res
            (call $add
                (i32.const 12)
                (i32.const 30)
            )
        )

        (call $print
            (global.get $str_buffer)
            (call $i32_to_str
                (global.get $str_buffer)
                (local.get $res)
                ))

        (call $print_cr)

        ;; exit the program

        (call $wasi|exit
            (i32.const 0))
    )
)

Building and executing

You need webassembly binary toolkit (or wabt for short), to generate a wasm binary from a wat file.

The following command is usually all that is needed for this:

wat2wasm add.wat -o add.wasm

You can enable some advanced features like bulk memory operations or SIMD extensions, but those are not supported by all runtimes. Defaults offer the best compatibility, as expected.

Finally, to execute the binary module, you use wasmer or any other runtime (like wasmtime or wasm3).

wasmer add.wasm

Also, in the case of wasmer, you can execute the WAT file directly.

More resources


Enrique CR
All posts