Up to README.md
Modules are declared using the "Module" function. The DSL parameter contains the declarations of all the sections. Example:
val module = Module {
}
Functions are declared using the "Func" function inside a module, taking the return value type as a direct argument and the function body as a DSL parameter. Example:
val module = Module {
val answer = Func(I32) {
Return (42)
}
}
Function parameters are declared inside the "body" using the "Param" function. Example:
val sqr = Func(I32) {
val x = Param(I32)
Return (x * x)
}
Functions (and other constructs) can be exported using Export()
. A full example for
a WebAssembly module exporting a function "sqr" for calculating the square of 32 bit integers is:
val module = Module {
Export("sqr", Func(I32) {
val x = Param(I32)
Return (x * x)
})
}
The exported sqr()
-function can be invoked from Kotlin by instantiating the module and
then invoking the exported function:
val instance = module.instantiate()
val sqr4 = instance.invoke("sqr", 4)
println("The square of 4 is: $sqr4")
Local variables are declared similar to parameters using Var()
or Const()
.
Instead of the type argument, they take an expression defining the initial value.
Immutable local variables are not supported by Wasm directly -- the mutability property is checked by the DSL only.
Mutable local variables can be assigned new values by calling .set()
.
Functions can be imported using the ImportFunc()
function.
val LogStr = ImportFunc("console", "logStr") { Param(I32, I32) }
To avoid confusion with "regular" Kotlin, all top level constructs use camel case starting with an uppercase
letter, e.g. Wasm if
becomes If
in the DSL.
For mapping Wasm instructions to our DSL, we divide them into two groups:
- We'll call instructions that push a value on the stack "expressions".
- Instructions that don't push a value on the stack we'll call "statements".
Expressions can be nested and statements can take expressions as parameters, but a line of code always has to be a statement.
Expressions can be turned into statements by either dropping their return value
using the Drop()
statement -- or by putting their return value on the
stack using Push()
. Push isn't really a Wasm instructions, it just makes pushing the result of
an expression on the stack explicit for our Kotlin DSL. The unary plus operator can be used as a
shorthand for Push()
.
Literal values -- or kotlin numbers in general -- are mapped as follows to Wasm types:
Kotlin type | Wasm type |
---|---|
Boolean, Int | I32 |
Long | I64 |
Float | F32 |
Double | F64 |
Typically, Kotlin numbers can be used directly and will be converted to expressions implicitly.
The main exception is the first parameter of an operator: in this case, they need to be wrapped
in a Const()
call.
Mathematical operations that match an overloadable Kotlin operator are mapped accordingly. If there are signed and unsigned variants of the operation, the signed variant is mapped to the operator and the unsigned operation is mapped to a Kotlin infix function.
We have already seen I32.mul
mapped to the multiplication operator in the square example above.
Other binary operations such as shl
, xor
and or
are mapped to kotlin infix operations,
starting with an uppercase letter.
Unfortunately, Kotlin operator overloading for relational operations doesn't allow us to change the return type, so we map them to infix functions named after the corresponding Wasm instructions.
To simplify combining comparisons, we provide a special type named Bool
that maps to I32 but only can hold the values 0 (false) and 1 (true) .
Blocks are mapped to Kotlin functions with "builder" parameters. Branch labels
are created using the Label()
constructor immediately preceding the target block.
For instance, a simple loop counting a variable i
down looks as follows:
val i = Var(5)
val cont = Label()
Loop {
i.set(i - 1)
BrIf (cont, i Gt 1)
}
For conditional statements, an optional else block can be provided via the .Else()
method:
If (condition) {
// Do something
}.Else {
// Do something else
}
Blocks returning a value take the return type as a parameter and are treated as expressions:
Return (Block(I32) {
Push(42)
})
For "If", there is an "expression" variant that doesn't have an explicit return type but works much like ternaries in other languages:
Return (If(condition, 42, 43))
In addition to the Wasm Loop
block, our DSL provides convenience While()
and For()
functions.
While()
will iterate the following block of instructions until the condition expression is false.
For()
takes two to three arguments:
- The initial loop variable value
- The maximum loop variable value. The iteration will continue until this value is reached, exluding the maximum value.
- An optional step value
Both constructs will map to a Wasm loop
inside a block
with some additional instructions for
the condition and correspdonding branch.
The mappings described so far allow us to port a slightly more complex example than sqr()
from the
Wasm test suite:
val factorialIterative = Func(I64) {
val n = Param(I64)
val res = Var(n)
For (2L, n) {
res.set(res * it)
}
Return(res)
}
Unfortuantely, assigning function declarations to kotlin variables means that the variable is not available inside the function declaration. We need to use forward declarations to work around this limitation:
val factorialRecursive = ForwardDecl(I64) { Param(I64) }
Implementation(factorialRecursive) {
val n = Param(I64)
Return(If(n Eq 0L, 1L, n * factorialRecursive(n - 1L)))
}
Memory is declared using the Memory
function, taking the
minimum and optional maximum size as parameters.
Although there is currently only one "memory", all memory access is based on the reference returned from the memory declaration, so it makes sense to keep hold of it in a variable.
val module = Module {
val mem = Memory(1)
}
Memory can be statically initialized with data. If no offset is provided, the end of the previous data will be used as the start offset. The reference returned from the data declaration will refer to its offset. This reference also has a "len" property which will provide the byte size of the corrsponding data item.
val module = Module {
val LogStr = ImportFunc("console", "logStr") { Param(I32, I32) }
val mem = Memory(1)
val message = mem.data("FizzBuzz")
val f = Func() {
LogStr(message, message.len)
}
}
Wasm memory load
and store
instructions are mapped to array access
on a memory property indicating the access width, offset and align.
The following example implements two functions providing bytewise
memory read and write acces via peek
and poke
functions.
val module = Module {
val mem = Memory(1)
val peek = Func(I32) {
val address = Param(I32)
Return (mem.i32U8[address])
}
val poke = Func() {
val address = Param(I32)
val value = Param(I32)
mem.i32U8[address] = value
}
}
It's possible to hold on to references with a given width, align and offset:
val mem8 = mem.i32U8(offset = 1000)
It's also possible to provide an alignment and offset on access. The access alignment overrides the general offset of the access type property while the two offsets are added.
Tables declarations are similar to memory declarations, but they take an additional type parameter --
which is required to be FuncRef
for Wasm 1.
val table = Table(FuncRef, 10)
Similar to data declarations, the elem
method on table references can be used to pre-fill table
elements:
val constI32A = Func(I32) { Return(65) }
table.elem(7, constI32A)
In order to call functions stored in tables, their type must be supplied explicitly. For this purpose, we declare a function type returning an I32 value and not taking any parameters:
val outI32 = Type(I32) {}
We can now call this function by invoking the table with the function index, the expected type and the function parameters (which we don't have in this simple case):
val call7 = Func(I32) {
Return(table(7, outI32))
}
Continue reading with Instruction Name Mapping