Go WebAssembly Internals - Part 2
Denis Sedchenko
Posted on November 4, 2022
In previous article we covered how to build a simple Go program, interact with host environment by wrapping Go functions as JavaScript functions and how JS-to-Go call magic works under the hood.
This article will cover how Go runtime access global JavaScript objects and helper functions from wasm_exec.js
glue-code library and how this mechanism can be exploited to link external JavaScript functions directly to our programs.
WebAssembly Import Object
Although WebAssembly programs are isolated and have no direct access to a browser (or other host environment), each module can import symbols (usually functions) during instantiation that can be used to communicate with outside world.
All module dependencies to import have to be provided in a form of an object with a symbol name as key and function as a value.
On WASM program side, module should do an import with import
statement.
(module
(func $myFunction (;0;) (import "myFunction") (param i32))
)
Browser will link import object to a program during module instantiation process.
const importObject = {
"myFunction": () => console.log("hello world")
}
WebAssembly.instantiate(/* wasm module binary */, importObject);
Go Runtime Dependencies
Go WebAssembly module imports
Each Go program import a couple of runtime dependencies from import object prepared by Go
helper class provided from wasm_exec.js
file.
Most of those functions are used for syscall/js
package but also there are a few core Go runtime dependencies.
Import object in wasm_exec.js
Each imported function accepts current program stack pointer and may manipulate with go program memory inside this.mem
field of Go
helper class and return result.
We also see to what exact Go function is will be linked and what params it will return by function key name and comments left above function declarations.
Linking Imported Function
Lets have a look at declaration of syscall/js.stringVal
function which present in import object:
//go:build js && wasm
package js
type ref uint64
func stringVal(x string) ref
The stringVal
function doesn't have //go:linkname
or other compiler directives, so how Go linker know that function implementation is defined in import object?
Function implementation is defined in a separate file js_js.s
in the same folder. File is written in a Go assembler language and contains body for each syscall/js
dependency.
#include "textflag.h"
TEXT 路stringVal(SB), NOSPLIT, $0
CallImport
RET
The CallImport
instruction is used to declare and call function imports.
Under the hood, Go compiler will generate an import
statement with a path that corresponds to a function (including package name).
There is a proposal to replace CallImport
instruction with a more convenient //go:wasmimport
compiler directive.
Importing Custom Functions
All information above allows to define and link custom functions directly to a Go program. Execution of such calls are much cheaper from performance standpoint and Go programs and don't require global namespace pollution (function shouldn't be present in window
object).
The main downside of this approach is that we have to manually deal with Go program stack, manually read and write data into program memory.
Lets write and import a simple multiplication function. Function will accept 2 integers and will return a result.
Full example source code is available in this repo.
Go Program
Our program will consist of 2 files: a main Go file and accompanying Go assembly file with WASM import.
Program will import and call multiply
function which is written in JavaScript.
main.go
package main
import "fmt"
func multiply(a, b int) int
package main() {
fmt.Println("Multiply result:", multiply(3, 4))
}
main_js.s
#include "textflag.h"
TEXT 路multiply(SB), NOSPLIT, $0
CallImport
RET
The textflag.h
file is available in $GOROOT/src/runtime
directory.
Building Program
Set GOOS
and GOARCH
environment variables to build a program as WebAssembly module:
GOOS=js GOARCH=wasm go build -o main.wasm main.go
Writing Wrapper
Go SDK provides a wasm_exec.js
file with Go
helper class that implements Go WebAssembly ABI for browsers and contains an import object that needs to be modified.
Copy of the file is available in $GOROOT/misc/wasm/wasm_exec.js
.
Lets extend Go
class with a small wrapper which will allow exporting custom functions without touching original Go
class implementation.
custom-go.mjs
// Copied from '$GOROOT/misc/wasm/wasm_exec.js'
import './wasm_exec.js';
export default class CustomGo extends global.Go {
MAX_I32 = Math.pow(2, 32);
// Copied from 'setInt64'
setInt64(offset, value) {
this.mem.setUint32(offset + 0, value, true);
this.mem.setUint32(offset + 4, this.MAX_I32, true);
}
/**
* Adds function to import object
* @param name symbol name (package.functionName)
* @param func function.
*/
importFunction(name, func) {
this.importObject.go[name] = func;
}
}
Function Implementation
Create a main.mjs
file that will import and run our WebAssembly program.
import Go from './custom-go.mjs';
import { promises as fs } from 'fs';
// Instantiate Go wrapper instance
const go = new Go();
// Add our function to import object
go.importFunction('main.multiply', sp => {
sp >>>= 0;
const a1 = go.mem.getInt32(sp + 8, true); // SP + sizeof(int64)
const a2 = go.mem.getInt32(sp + 16, true); // SP + sizeof(int64) * 2
const result = a1 * a2;
console.log('Got call from Go:', {a1, a2, result});
go.setInt64(sp + 24, result);
});
// Run the program
const buff = await fs.readFile('./main.wasm');
const {instance} = await WebAssembly.instantiate(buff, go.importObject);
await go.run(instance);
Reading arguments
The sp
argument is a stack pointer address which is passed to each imported function.
Values of first and second integers should be manually obtained from stack.
Go
class has mem
property which is an instance of DataView
interface. As MDN documentation says:
DataView provides a low-level interface for reading and writing different number values into program memory without having to care about platform endianness.
The this.mem.getInt32
method accepts offset and boolean parameter to indicate that our value is stored in little endian format.
const a1 = go.mem.getInt32(sp + 8, true); // Stack Pointer + sizeof(int64)
const a2 = go.mem.getInt32(sp + 16, true); // Stack Pointer + sizeof(int64) * 2
Returning result
Result of the function should be put in memory after the last argument on stack.
go.setInt64(sp + 24, result);
Final Result
Lets run our WebAssembly module with multiply
function implementation inside Node.js.
Attention: for Node.js 18 and below, WebCrypto API polyfil is required.
$ node ./main.mjs
Got call from Go: { a1: 3, a2: 4, result: 12 }
Multiply result: 12
Full example source code with polyfill is available in this repo.
Posted on November 4, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.