Skip to main content

A WebAssembly interpreter (Part 2)

· 6 min read

In our last blog post, we implemented a WebAssembly interpreter that can do arithmetic and comparison operations on i32 values.

In this post, we’re going to extend the interpreter: rather than only operating on literal values, we’ll add support for local and global variables. And just like before, we’re writing everything from scratch, in JavaScript — no libraries or frameworks.

If that sounds like your cup of tea, keep reading.

🛠️ Setup

If you’d like to follow along by running the code yourself, then you should start by creating a file named wasm-vm-02.js. All the snippets we show will be added to that file. Let’s start with a few imports:

wasm-vm-02.js

import assert from 'node:assert';
import test from 'node:test';

We’ll also import a few definitions from the previous post:

wasm-vm-02.js

import {i32, instrImm, VM} from './wasm-vm-01.js';

If you don’t have that code in a file already, you can go to the end of that post and click “Copy up to here” under the last snippet (or copy it from StackBlitz) then paste it into a file called wasm-vm-01.js.

Okay, let’s get started.

An important thing to know about WebAssembly is that inside a module, things aren’t referenced by name, but by index. Things of different types are grouped together in different index spaces, a fancy name for what is basically just an array.

Local variables are no exception; they’re stored in the locals index space, which maps from an index to a value of one of the core Wasm types (i32, i64, f32, f64).

To keep the code short and simple we’re going to extend the VM class and add a locals index space. It’s not really the “right way” to do it, but in the next post we’ll rewrite this class anyways, so let’s go with a simple solution:

wasm-vm-02.js

class FlatFrame extends VM {
constructor(instructions, locals = []) {
super(instructions);
this.locals = locals;
}
}

Now that we have a locals space, we need some instructions to work with it. Let’s define the first instruction: local.get, which takes the index as an immediate and pushes the variable’s current value onto the stack:

wasm-vm-02.js

const local = {};
local.get = (index) =>
instrImm(0x20, 'local.get', index, (vm, index) => {
assertLocalIndexInRange(vm.locals, index);
const value = vm.locals[index];
vm.push(value);
});

Since we don’t have an instruction to set local variables yet, let’s test it by creating a FlatFrame with a local variable initialized to 100:

wasm-vm-02.js

test('local.get', () => {
const vm = new FlatFrame([local.get(0)], [i32(100)]);
vm.step();
assert.deepStrictEqual(vm.stack.items, [i32(100)]);
});

In the instruction implementation above we check if the index is in bounds (i.e., it refers to an existing variable). Let’s extract that check into a separate function that can be reused for the other instructions:

wasm-vm-02.js

function assertLocalIndexInRange(locals, index) {
if (index < 0 || index >= locals.length) {
throw new Error(`Invalid local index: ${index}`);
}
}

Now we can implement the local.set instruction, which also takes the variable’s index as an immediate. But unlike local.get, it also requires a dynamic argument: the new value of the variable. It gets this value from the stack.

wasm-vm-02.js

local.set = (index) =>
instrImm(0x21, 'local.set', index, (vm, index) => {
assertLocalIndexInRange(vm.locals, index);
const value = vm.pop();
vm.locals[index] = value;
});

Let’s test it:

wasm-vm-02.js

test('local.set', () => {
const vm = new FlatFrame([i32.const(42), local.set(0)], [i32(0)]);
vm.step();
vm.step();
assert.deepStrictEqual(vm.stack.items, []);
assert.strictEqual(vm.locals[0].value, 42);
});

The local.set instruction pops the value from the stack, but sometimes you want to set a local variable and leave the original value in the stack too. For example, many languages (including JavaScript) support assignment expressions, which assign a value to a variable, but also produce a value:


let a = 0;
function plusOne(val) {
return val + 1;
}
plusOne(a = 6); // Returns 7
console.log(a); // Prints '6'

That’s one example of where the local.tee instruction comes in handy. We can easily implement it, by using peek() to read the top-of-stack value without popping it:

wasm-vm-02.js

local.tee = (index) =>
instrImm(0x22, 'local.tee', index, (vm, index) => {
assertLocalIndexInRange(vm.locals, index);
const value = vm.peek();
vm.locals[index] = value;
});

Okay, let’s test it:

wasm-vm-02.js

test('local.tee', () => {
const vm = new FlatFrame([i32.const(42), local.tee(0)], [i32(0)]);
vm.step();
vm.step();
assert.strictEqual(vm.locals[0].value, 42);
assert.deepStrictEqual(vm.stack.items, [i32(42)]);
});

Now we have local variables, but local to what? Right now they are the only kinds of variables we have.

The answer, of course, is that they’re local to a function — or, to be more specific, they’re local to a specific call stack frame. Hence the name of our FlatFrame class: we’re modeling a virtual machine with a single frame, rather than a stack of frames.

In WebAssembly, like JavaScript, functions are grouped into modules. To make our locals truly local, we can put the FlatFrame inside a module instance. For now, we’ll still assume a single function and a single stack frame, stored in a currentFrame property.

And then we can also add support for global variables, in a property named…you guessed it, globals:

wasm-vm-02.js

class MonoInstance {
constructor(instructions, locals = [], globals = []) {
this.currentFrame = new FlatFrame(instructions, locals);
this.globals = globals;
}
// ...
}

The MonoInstance constructor takes the same arguments as FlatFrame, plus an extra one for globals.

To make all our previous instruction definitions still work and keep the new ones as simple as possible, let’s “wrap and forward” some of the FlatFrame methods in our new class:

wasm-vm-02.js

class MonoInstance {
// ...
get locals() {
return this.currentFrame.locals;
}
get stack() {
return this.currentFrame.stack;
}
push(value) {
this.currentFrame.push(value);
}
pop() {
return this.currentFrame.pop();
}
peek() {
return this.currentFrame.peek();
}
popI32() {
return this.currentFrame.popI32();
}
popType(T) {
return this.currentFrame.popType(T);
}
step() {
const {currentFrame} = this;
const instruction = currentFrame.instructions[currentFrame.pc];
instruction.eval(this);
currentFrame.pc += 1;
}
// ...
}

To make sure everything works, we can run the local.tee test from above on our new MonoInstance:

wasm-vm-02.js

test('local.tee for MonoInstance', () => {
const vm = new MonoInstance([i32.const(42), local.tee(0)], [i32(0)]);
vm.step();
vm.step();
assert.strictEqual(vm.locals[0].value, 42);
assert.deepStrictEqual(vm.stack.items, [i32(42)]);
});

If you compare the tests, you’ll see that the only thing that changed is what class we instantiate for the vm.

With local instructions completed we can now move to the global instructions. Let’s start with global.get:

wasm-vm-02.js

const global = {};
global.get = (index) =>
instrImm(0x23, 'global.get', index, (vm, index) => {
assertGlobalIndexInRange(vm.globals, index);
const value = vm.globals[index];
vm.push(value);
});

You may notice that it’s almost identical to local.get. The only difference is what index space it uses.

As before, we define a function to check that the index is valid:

wasm-vm-02.js

function assertGlobalIndexInRange(globals, index) {
if (index < 0 || index >= globals.length) {
throw new Error(`Invalid global index: ${index}`);
}
}

Okay — let’s test our new instruction:

wasm-vm-02.js

test('global.get', () => {
const instance = new MonoInstance([global.get(0)], [], [i32(100)]);
instance.step();
assert.strictEqual(instance.peek().value, 100);
});

Finally, we’ll define global.set:

wasm-vm-02.js

global.set = (index) =>
instrImm(0x24, 'global.set', index, (vm, index) => {
assertGlobalIndexInRange(vm.globals, index);
const value = vm.pop();
vm.globals[index] = value;
});

…and test it too:

wasm-vm-02.js

test('global.set', () => {
const instance = new MonoInstance(
[i32.const(42), global.set(0)],
[],
[i32(0)],
);
instance.step();
instance.step();
assert.strictEqual(instance.globals[0].value, 42);
});

And we are done! 🎉

Not bad — with a fairly small amount of code, we were able to extend our WebAssembly VM to support local and global variables. In the next post, we’ll extend it further, and add support for multiple functions.

Let’s finish up by exporting everything we’ll need in the next post:

wasm-vm-02.js

export * from './wasm-vm-01.js';
export {local, global};

🏁 wasm-vm-02.js
Open in StackBlitz

If you enjoyed this post, you should check out WebAssembly from the Ground Up — an online book to learn Wasm by building a simple compiler in JavaScript.

If you’d like to be notified when we publish the next post, you can sign up for our mailing list, where we share periodic updates on the book and interesting WebAssembly tidbits.