Introduction to AssemblyScript (Part 2)

CoinEx Smart Chain
13 min readJul 20, 2020

This article is written by the CoinEx Chain lab. CoinEx Chain is the world’s first public chain exclusively designed for DEX, and will also include a Smart Chain supporting smart contracts and a Privacy Chain protecting users’ privacy.

In the last article we discussed how AssemblyScript (hereinafter referred to as AS) programs are compiled into WebAssembly (hereinafter referred to as Wasm) modules as a whole, and introduced in detail how various elements of the AS language are mapped to each section of the Wasm binary module. This article will go into functions, and we will discuss how the AS compiler uses the Wasm instruction set to implement grammatical elements. Before we start, let’s briefly review the Wasm instruction set (for a detailed introduction to the Wasm module and instruction set, please refer to the previous articles):

Wasm uses stack-based virtual machine and bytecode, and its instructions can be divided into five categories:

  • Control Instructions, including structured control instructions, jump instructions, and function call instructions.
  • Parametric Instructions, only two in total, respectively drop and select.
  • Variable Instructions, including local variable instructions and global variable instructions.
  • Memory Instructions, including storage instructions and load instructions.
  • Numeric Instructions, including constant instructions, test instructions, comparison instructions, unary operation instructions, binary operation instructions, and type conversion instructions.

Next, we will introduce details about how the AS compiler uses these five types of instructions through examples. For the convenience of testing, part of the sample codes given below call external functions for assistance of which implementation is not important. The declarations of these external functions are shown as below:

declare function printI32(n: i32): void;
declare function printI64(n: i64): void;
declare function printF32(n: f32): void;
declare function printF64(n: f64): void;
declare function randomI32(): i32;

Control Instructions

As mentioned earlier, Wasm control instructions include structured control instructions (block, loop, and if-else), jump instructions (br, br_if, br_table, and return), function call instructions (call and call_indirerct), plus nop and unreachable. Among them, structured control instructions and jump instructions together can be used to implement various control structures of the AS language, such as the if-else statement, for loop statement, and switch-case statement. The call instruction can be used to implement AS function calls, and the call_indirerct instruction can be used to support first-class functions.

The if-elsestatement of the AS language can be directly implemented using Wasm's if-elseinstruction. Here is an example:

export function printEven(n: i32): void {
if (n % 2 == 0) {
printI32(1);
} else {
printI32(0);
}
}

The following is the compilation result (the compiled function bytecode has been decompiled into WAT. The same below):

(func $printEven (type 0) (param i32)
(if
(i32.rem_s (local.get 0) (i32.const 2))
(then (call $printI32 (i32.const 0)))
(else (call $printI32 (i32.const 1)))
)
)

The above example also shows the usage of the callinstruction, which will not be described separately later. By the way, some simple if-elsestatements will be optimized by the AS compiler into selectinstructions. Here is an example:

exportfunctionmax(a: i32, b: i32): i32{
if(a>b) {
returna;
} else{
returnb;
}
}

The following is the compilation result:

(func $max (type 2) (param i32 i32) (result i32)
(select
(local.get 0)
(local.get 1)
(i32.gt_s (local.get 0) (local.get 1))
)
)

Loop statements such as for, while, and do-whilein the AS language can be implemented with Wasm's loopinstruction. Note that the loopinstruction cannot automatically form a loop, so it must be used with jump instructions like br, br_ifor br_table. Let's look at a slightly more complicated example:

exportfunctionprintNums(n: i32): void{
for(leti: i32=0; i<n; i++) {
printI32(i);
if(i==100) {
break;
}
}
}

This example shows the usage of loop, block, br, and br_ifinstructions. Here is the compilation result:

(func $printNums (type 0) (param i32)
(local i32)
(loop ;; label = @1
(if ;; label = @2
(i32.lt_s (local.get 1) (local.get 0))
(then
(block ;; label = @3
(call $printI32 (local.get 1))
(br_if 0 (;@3;)
(i32.eq (local.get 1) (i32.const 100)))
(local.set 1
(i32.add (local.get 1) (i32.const 1)))
(br 2 (;@1;))
) ;; end of block
) ;; end of then
) ;; end of if
) ;; end of loop
)

The switch-casestatement of the AS language can be implemented with Wasm's br_tableinstruction. The following is an example:

export function mul100(n: i32): i32 {
switch (n) {
case 1: return 100;
case 2: return 200;
case 3: return 300;
default: return n * 100;
}
}

In addition to the br_tableinstruction, this example also shows the usage of the returninstruction. Here is the compilation result:

(func $mul100 (type 1) (param i32) (result i32)
(block ;; label = @1
(block ;; label = @2
(block ;; label = @3
(block ;; label = @4
(br_table 0 (;@4;) 1 (;@3;) 2 (;@2;) 3 (;@1;)
(i32.sub (local.get 0) (i32.const 1))))
(return (i32.const 100)))
(return (i32.const 200)))
(return (i32.const 300)))
(i32.mul (local.get 0) (i32.const 100))
)

The first-class functions in the AS language are similar to the function pointers in languages such as C/C++, and can be implemented with the call_indirectinstruction. Let's look at this example:

type OP = (a: i32, b: i32) => i32;function add(a: i32, b: i32): i32 { return a + b; }
function sub(a: i32, b: i32): i32 { return a - b; }
function mul(a: i32, b: i32): i32 { return a * b; }
function div(a: i32, b: i32): i32 { return a / b; }
export function calc(a: i32, b: i32, op: i32): i32 {
return getOp(op)(a, b);
}
function getOp(op: i32): OP {
switch (op) {
case 1: return add;
case 2: return sub;
case 3: return mul;
case 4: return div;
default: return add;
}
}

The compilation result is shown as below. Please pay attention to the table, elemfields, as well as the instructions of the calc()function:

(module
(type (;0;) (func (param i32 i32) (result i32)))
(type (;1;) (func (param i32) (result i32)))
(type (;2;) (func (param i32 i32 i32) (result i32)))
(func $add (type 0) (i32.add (local.get 0) (local.get 1)))
(func $sub (type 0) (i32.sub (local.get 0) (local.get 1)))
(func $mul (type 0) (i32.mul (local.get 0) (local.get 1)))
(func $div (type 0) (i32.div_s (local.get 0) (local.get 1)))
(func $getOp (type 1) (param i32) (result i32) (;; ommitted ;;))
(func $calc (type 2) (param i32 i32 i32) (result i32)
(call_indirect (type 0)
(local.get 0)
(local.get 1)
(call $getOp (local.get 2))
)
)
(table (;0;) 5 funcref)
(memory (;0;) 0)
(export "memory" (memory 0))
(export "calc" (func $calc))
(export "getOp" (func $getOp))
(elem (;0;) (i32.const 1) func $add $sub $mul $div)
)

In the end of this section, let’s talk about the unreachableinstruction. AS is designed to support exceptionsafter the Wasm exception handling proposalis passed. For the time being, an exception thrown will cause the abort()function to be called. We can disable abort by adding the compiler option --use abort=so that the compiler will replace the abort()function call with an unreachableinstruction. In addition, we can also explicitly insert an unreachableinstruction by directly calling the low-level built-in unreachable()function. Here is an example:

export function crash2(): void {
unreachable();
}

The compilation result is also simple:

(func $crash2 (type 1)
(unreachable)
)

Parametric Instructions

The parametric instructions are relatively simple, with only dropand select. We’ve mentioned the selectinstruction in the introduction of the if-elsestatement, so will be no more details here. The dropinstruction can be used to pop the extra operands at the top of the operand stack and throw them away. Let's look at a simple example:

export function dropRandom(): void {
randomI32();
}

The compilation result is also very simple:

(func $dropRandom (type 0)
(drop (call $randomI32))
)

Variable Instructions

There are three local variable instructions: local.get, local.set, and local.tee. If optimization is not considered, every AS function can be compiled into a Wasm function by the compiler. Both reading and writing of function parameters and local variables can be completed by local variable instructions. Let's look at an example:

export function addLocals(a: i32, b: i32): i32 {
let c: i32 = a + b;
return c;
}

The following is the compilation result (for better observation of the result, the compiler optimization is turned off during the compiling of part of the sample code. The same below):

(func $addLocals (type 1) (param i32 i32) (result i32)
(local i32)
(local.set 2 (i32.add (local.get 0) (local.get 1)))
(local.get 2)
)

There are only two global variable instructions: global.getand global.set. The global variables of the AS language can be directly implemented using Wasm global variables, and they can be read and write through global variable instructions. Let’s look at an example:

let a: i32;
let b: i32;
let c: i32;
export function addGlobals(): void {
c = a + b;
}

The complete compilation result is as shown below:

(module
(type (;0;) (func))
(func $addGlobals (type 0)
(global.set 2 (i32.add (global.get 0) (global.get 1)))
)
(global (;0;) (mut i32) (i32.const 0))
(global (;1;) (mut i32) (i32.const 0))
(global (;2;) (mut i32) (i32.const 0))
(export "addGlobals" (func $addGlobals))
)

Memory Instructions

The Wasm virtual machine can carry a piece of virtual memory, with many instructions to operate this memory. Among these instructions, the load instructions can load data from the memory and put it into the operand stack. The store instructions can take data out of the operand stack and store it in memory. In addition, the current number of memory can be obtained through the memory.sizeinstruction, and the memory can be expanded by page through the memory.growinstruction. We will use a simple structure to better observe the usage of memory instructions. Below is the definition of this structure:

class S {
a: i8; b: u8; c: i16; d: u16; e: i32; f: u32; g: i64; h: u64;
i: f32; j: f64;
}

The following function shows the usage of load instructions for i32type:

export function loadI32(s: S): void {
printI32(s.a as i32); // i32.load8_s
printI32(s.b as i32); // i32.load8_u
printI32(s.c as i32); // i32.load16_s
printI32(s.d as i32); // i32.load16_u
printI32(s.e as i32); // i32.load
printI32(s.f as i32); // i32.load
}

The following is the compilation result. It can be seen from the offset immediate operand of the load instruction that the AS compiler has not rearranged the structure fields, but has performed proper alignment.

(func $loadI32 (type 0) (param i32)
(call $printI32 (i32.load8_s (local.get 0)))
(call $printI32 (i32.load8_u offset=1 (local.get 0)))
(call $printI32 (i32.load16_s offset=2 (local.get 0)))
(call $printI32 (i32.load16_u offset=4 (local.get 0)))
(call $printI32 (i32.load offset=8 (local.get 0)))
(call $printI32 (i32.load offset=12 (local.get 0)))
)

The following function shows the usage of load instructions for i64type:

export function loadI64(s: S): void {
printI64(s.a as i64); // i64.load8_s?
printI64(s.b as i64); // i64.load8_u?
printI64(s.c as i64); // i64.load16_s?
printI64(s.d as i64); // i64.load16_u?
printI64(s.e as i64); // i64.load32_s?
printI64(s.f as i64); // i64.load32_u?
printI64(s.g as i64); // i64.load
printI64(s.h as i64); // i64.load
}

The compilation result is as shown below. It can be seen that, in some cases, where the i64load instructions are expected, the AS compiler uses i32load instructions with the help of extend instructions.

(func $loadI64 (type 0) (param i32)
(call $printI64 (i64.extend_i32_s (i32.load8_s (local.get 0))))
(call $printI64 (i64.extend_i32_u (i32.load8_u offset=1 (local.get 0))))
(call $printI64 (i64.extend_i32_s (i32.load16_s offset=2 (local.get 0))))
(call $printI64 (i64.extend_i32_u (i32.load16_u offset=4 (local.get 0))))
(call $printI64 (i64.extend_i32_s (i32.load offset=8 (local.get 0))))
(call $printI64 (i64.extend_i32_u (i32.load offset=12 (local.get 0))))
(call $printI64 (i64.load offset=16 (local.get 0)))
(call $printI64 (i64.load offset=24 (local.get 0)))
)

The following function shows the usage of load instructions for float type:

export function loadF(s: S): void {
printF32(s.i); // f32.load
printF64(s.j); // f64.load
}

The compilation result is as shown below:

(func $loadF (type 0) (param i32)
(call $printF32 (f32.load offset=32 (local.get 0)))
(call $printF64 (f64.load offset=40 (local.get 0)))
)

The store instructions are simpler than load instructions. The following example shows the usage of store instructions:

export function store(s: S, v: i64): void {
s.a = v as i8; // i32.store8
s.b = v as u8; // i32.store8
s.c = v as i16; // i32.store16
s.d = v as u16; // i32.store16
s.e = v as i32; // i32.store
s.f = v as u32; // i32.store
s.g = v as i64; // i64.store
s.h = v as u64; // i64.store
s.i = v as f32; // f32.store
s.j = v as f64; // f64.store
}

The compilation result is as shown below:

(func $store (type 1) (param i32 i64)
(i32.store8 (local.get 0) (i32.wrap_i64 (local.get 1)))
(i32.store8 offset=1 (local.get 0) (i32.wrap_i64 (local.get 1)))
(i32.store16 offset=2 (local.get 0) (i32.wrap_i64 (local.get 1)))
(i32.store16 offset=4 (local.get 0) (i32.wrap_i64 (local.get 1)))
(i32.store offset=8 (local.get 0) (i32.wrap_i64 (local.get 1)))
(i32.store offset=12 (local.get 0) (i32.wrap_i64 (local.get 1)))
(i64.store offset=16 (local.get 0) (local.get 1))
(i64.store offset=24 (local.get 0) (local.get 1))
(f32.store offset=32 (local.get 0) (f32.convert_i64_s (local.get 1)))
(f64.store offset=40 (local.get 0) (f64.convert_i64_s (local.get 1)))
)

Like the unreachableinstruction introduced earlier, the memory.sizeand memory.growinstructions can also be generated by built-in functions. The following is a simple example:

export function sizeAndGrow(n: i32): void {
printI32(memory.size());
printI32(memory.grow(n));
}

The compilation result is as shown below:

(func $sizeAndGrow (type 0) (param i32)
(call $printI32 (memory.size))
(call $printI32 (memory.grow (local.get 0)))
)

Numeric Instructions

As mentioned earlier, numerical instructions can be divided into constant instructions, test instructions, comparison instructions, unary and binary arithmetic instructions, and type conversion instructions.

Specifically, there are four constant instructions in total. Numerical literals in the AS language can be implemented with constant instructions. Here is an example:

export function consts(): void {
printI32(1234); // i32.const
printI64(5678); // i64.const
printF32(3.14); // f32.const
printF64(2.71); // f64.const
}

Below is the compilation result:

(func consts (type 1)
(call $printI32 (i32.const 1234))
(call $printI64 (i64.const 5678))
(call $printF32 (f32.const 0x1.91eb86p+1 (;=3.14;)))
(call $printF64 (f64.const 0x1.5ae147ae147aep+1 (;=2.71;)))
)

There are only two test instructions: i32.eqzand i64.eqz. The following example shows the usage of i32.eqzinstruction:

export function testOps(a: i32): void {
if (a == 0) { // i32.eqz
printI32(123);
}
}

The following is the compilation result:

(func $testOps (type 0) (param i32)
(if (i32.eqz (local.get 0))
(then (call $printI32 (i32.const 123)))
)
)

The relational operators supported by the AS language can be implemented with comparison instructions. The following example shows the usage of comparison instructions for i32type:

export function relOps(a: i32, b: i32, c: u32, d:  u32): void {
if (a == b) { printI32(0); } // i32.eq
if (a != b) { printI32(1); } // i32.ne
if (a < b) { printI32(2); } // i32.lt_s
if (c < d) { printI32(3); } // i32.lt_u
if (a > b) { printI32(4); } // i32.gt_s
if (c > d) { printI32(5); } // i32.gt_u
if (a <= b) { printI32(6); } // i32.le_s
if (c <= d) { printI32(7); } // i32.le_u
if (a >= b) { printI32(8); } // i32.ge_s
if (c >= d) { printI32(9); } // i32.ge_u
}

Here is the compilation result:

(func relOps (type 2) (param i32 i32 i32 i32)
(if (i32.eq (local.get 0) (local.get 1))
(then (call $printI32 (i32.const 0))))
(if (i32.ne (local.get 0) (local.get 1))
(then (call $printI32 (i32.const 1))))
(if (i32.lt_s (local.get 0) (local.get 1))
(then (call $printI32 (i32.const 2))))
(if (i32.lt_u (local.get 2) (local.get 3))
(then (call $printI32 (i32.const 3))))
(if (i32.gt_s (local.get 0) (local.get 1))
(then (call $printI32 (i32.const 4))))
(if (i32.gt_u (local.get 2) (local.get 3))
(then (call $printI32 (i32.const 5))))
(if (i32.le_s (local.get 0) (local.get 1))
(then (call $printI32 (i32.const 6))))
(if (i32.le_u (local.get 2) (local.get 3))
(then (call $printI32 (i32.const 7))))
(if (i32.ge_s (local.get 0) (local.get 1))
(then (call $printI32 (i32.const 8))))
(if (i32.ge_u (local.get 2) (local.get 3))
(then (call $printI32 (i32.const 9))))
)

Except for the negate operation for floating-point numbers, other unary arithmetic instructions are not directly used by the AS compiler, but can be generated by built-in functions. The following example shows the usage of i32and f32unary arithmetic instructions:

export function unOps(a: i32, b: f32): void {
printI32(clz<i32>(a)); // i32.clz
printI32(ctz<i32>(a)); // i32.ctz
printI32(popcnt<i32>(a)); // i32.popcnt
printF32(abs<f32>(b)); // f32.abs
printF32(-b); // f32.neg
printF32(sqrt<f32>(b)); // f32.sqrt
printF32(floor<f32>(b)); // f32.floor
printF32(trunc<f32>(b)); // f32.trunc
printF32(nearest<f32>(b)); // f32.nearest
}

The compilation result is as shown below:

(func unOps (type 3) (param i32 f32 f32)
(call $printI32 (i32.clz (local.get 0)))
(call $printI32 (i32.ctz (local.get 0)))
(call $printI32 (i32.popcnt (local.get 0)))
(call $printF32 (f32.abs (local.get 1)))
(call $printF32 (f32.neg (local.get 1)))
(call $printF32 (f32.sqrt (local.get 1)))
(call $printF32 (f32.floor (local.get 1)))
(call $printF32 (f32.trunc (local.get 1)))
(call $printF32 (f32.nearest (local.get 1)))
)

The binary operators supported by AS language can be implemented with binary operation instructions. The following example shows the usage of binary operation instructions for i32type:

export function binOps(a: i32, b: i32, c: u32, d: u32, e: f32, f: f32): void {
printI32(a + b); // i32.add
printI32(a - b); // i32.sub
printI32(a * b); // i32.mul
printI32(a / b); // i32.div_s
printI32(c / d); // i32.div_u
printI32(a % b); // i32.rem_s
printI32(c % d); // i32.rem_u
printI32(a & b); // i32.and
printI32(a | b); // i32.or
printI32(a ^ b); // i32.xor
printI32(a << b); // i32.shl
printI32(a >> b); // i32.shr_s
printI32(a >>> b); // i32.shr_u
printI32(rotl<i32>(a, b)); // i32.rotl
printI32(rotr<i32>(a, b)); // i32.rotr
}

Since the AS language does not have a “rotate” operator, we can only generate rotate instructions through built-in functions. The following is the compilation result:

(func binOps (type 3) (param i32 i32 i32 i32 f32 f32)
(call $printI32 (i32.add (local.get 0) (local.get 1)))
(call $printI32 (i32.sub (local.get 0) (local.get 1)))
(call $printI32 (i32.mul (local.get 0) (local.get 1)))
(call $printI32 (i32.div_s (local.get 0) (local.get 1)))
(call $printI32 (i32.div_s (local.get 2) (local.get 3)))
(call $printI32 (i32.rem_s (local.get 0) (local.get 1)))
(call $printI32 (i32.rem_s (local.get 2) (local.get 3)))
(call $printI32 (i32.and (local.get 0) (local.get 1)))
(call $printI32 (i32.or (local.get 0) (local.get 1)))
(call $printI32 (i32.xor (local.get 0) (local.get 1)))
(call $printI32 (i32.shl (local.get 0) (local.get 1)))
(call $printI32 (i32.shr_s (local.get 0) (local.get 1)))
(call $printI32 (i32.shr_u (local.get 0) (local.get 1)))
(call $printI32 (i32.rotl (local.get 0) (local.get 1)))
(call $printI32 (i32.rotr (local.get 0) (local.get 1)))
)

The type conversion operation in the AS language can be immplemented by type conversion instructions. Here is an example:

export function cvtOps(a: i32, b: i64, c: u32, d: u64, e: f32, f: f64): void {
printI32(b as i32); // i32.wrap_i64
printI32(e as i32); // i32.trunc_f32_s
printI32(e as u32); // i32.trunc_f32_u
printI32(f as i32); // i32.trunc_f64_s
printI32(f as u32); // i32.trunc_f64_u
printI64(a); // i64.extend_i32_s
printI64(a as u32); // i64.extend_i32_u
printI64(e as i64); // i64.trunc_f32_s
printI64(e as u64); // i64.trunc_f32_u
printI64(f as i64); // i64.trunc_f64_s
printI64(f as u64); // i64.trunc_f64_u
printF32(a as f32); // f32.convert_i32_s
printF32(c as f32); // f32.convert_i32_u
printF32(b as f32); // f32.convert_i64_s
printF32(d as f32); // f32.convert_i64_u
printF32(f as f32); // f32.demote_f64
printF64(a as f64); // f64.convert_i32_s
printF64(c as f64); // f64.convert_i32_u
printF64(b as f64); // f64.convert_i64_s
printF64(d as f64); // f64.convert_i64_u
printF64(e); // f64.promote_f32
printI32(reinterpret<i32>(e)); // i32.reinterpret_f32
printI64(reinterpret<i64>(f)); // i64.reinterpret_f64
printF32(reinterpret<f32>(a)); // f32.reinterpret_i32
printF64(reinterpret<f64>(b)); // f64.reinterpret_i64
}

The compilation result is shown as below:

(func cvtOps (type 4) (param i32 i64 i32 i64 f32 f64)
(call $printI32 (i32.wrap_i64 (local.get 1)))
(call $printI32 (i32.trunc_f32_s (local.get 4)))
(call $printI32 (i32.trunc_f32_u (local.get 4)))
(call $printI32 (i32.trunc_f64_s (local.get 5)))
(call $printI32 (i32.trunc_f64_u (local.get 5)))
(call $printI64 (i64.extend_i32_s (local.get 0)))
(call $printI64 (i64.extend_i32_u (local.get 0)))
(call $printI64 (i64.trunc_f32_s (local.get 4)))
(call $printI64 (i64.trunc_f32_u (local.get 4)))
(call $printI64 (i64.trunc_f64_s (local.get 5)))
(call $printI64 (i64.trunc_f64_u (local.get 5)))
(call $printF32 (f32.convert_i32_s (local.get 0)))
(call $printF32 (f32.convert_i32_u (local.get 2)))
(call $printF32 (f32.convert_i64_s (local.get 1)))
(call $printF32 (f32.convert_i64_u (local.get 3)))
(call $printF32 (f32.demote_f64 (local.get 5)))
(call $printF64 (f64.convert_i32_s (local.get 0)))
(call $printF64 (f64.convert_i32_u (local.get 2)))
(call $printF64 (f64.convert_i64_s (local.get 1)))
(call $printF64 (f64.convert_i64_u (local.get 3)))
(call $printF64 (f64.promote_f32 (local.get 4)))
(call $printI32 (i32.reinterpret_f32 (local.get 4)))
(call $printI64 (i64.reinterpret_f64 (local.get 5)))
(call $printF32 (f32.reinterpret_i32 (local.get 0)))
(call $printF64 (f64.reinterpret_i64 (local.get 1)))
)

Summary

In this article we discussed how the AS compiler implements AS syntax elements through various Wasm instructions. In simple terms: various control structures are implemented through control instructions, local variables and global variables are read and written through variable instructions, memory operations are performed through memory instructions, and operators and type conversions are implemented through numerical instructions. In the following articles, we will discuss in depth how AS implements object-oriented programming and automatic memory management.

exportfunctionunOps(a: i32, b: f32): void{
printI32(clz<i32>(a)); // i32.clz
printI32(ctz<i32>(a)); // i32.ctz
printI32(popcnt<i32>(a)); // i32.popcnt
printF32(abs<f32>(b)); // f32.abs
printF32(-b); // f32.neg
printF32(sqrt<f32>(b)); // f32.sqrt
printF32(floor<f32>(b)); // f32.floor
printF32(trunc<f32>(b)); // f32.trunc
printF32(nearest<f32>(b)); // f32.nearest
}

--

--