四則演算をする簡単なWASMランタイムを作ってみた
はじめに
最近「WASM(WebAssembly)」という言葉を耳にする機会が増えました。
「Webでアセンブリとは一体?」とよくわからないので、WASMの仕様を読みながらに冬休みに簡単な
WASMランタイムを作りました。
この記事では、テキストフォーマットをバイナリフォーマットに変換し、バイナリを解釈して足し算や引き算など簡単な計算を実行するランタイムを説明します。
今回説明するソースコードのレポジトリは以下になります。
WASMテキストフォーマット
WASMにはテキストフォーマットとバイナリフォーマットがあります。
アセンブリと同じように人が読める形式がテキストフォーマットです。
以下は2つの数を足すaddTwo
という関数が書かれているテキストです。
(module
(func (export "addTwo") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add))
上記のテキストフォーマットをWASMバイナリに変換すると以下のようになります。
$ xxd add.wasm
00000000: 0061 736d 0100 0000 0107 0160 027f 7f01 .asm.......`....
00000010: 7f03 0201 0007 0a01 0661 6464 5477 6f00 .........addTwo.
00000020: 000a 0901 0700 2000 2001 6a0b ...... . .j
WASMテキストフォーマットをバイナリフォーマットに変換
addTwo
関数が書かれたテキスト形式のwatファイルを読み込み、構文解析をしてバイナリフォーマットに変換する処理を作成します。
watファイルを読み込んで、解析した結果をWasmModule
構造体にセットして返すreadWatFile
関数です。
テキストファイルを先頭から1行ずつ読み込んで処理をしていきます。
まず最初の行にmodule宣言がないと、テキストフォーマットに違反になるのでエラーで終了します。
次の行には関数名、引数、戻り値が書かれているので、それぞれを解析します。
WASMモジュールには関数以外にもタイプはあるのですが、関数だけ対応しています。
func readWatFile(path string) WasmModule {
f, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
wasmmodule := WasmModule{}
scanner := bufio.NewScanner(f)
cnt := 0
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// 0行目にmodule宣言がなければエラーで終了
if cnt == 0 && !strings.Contains(line, "module") {
log.Fatalf("WASM text format error")
}
// 1行目を解析
if cnt == 1 {
tokens := strings.Split(line, "\n")
for _, token := range tokens {
// 関数名、引数、戻り値を解析する
// 関数型以外のモジュールもあるが今はこれだけ対応
switch {
case strings.HasPrefix(token, "(func"):
wasmmodule.typeSection.moduleSection = typeSectionNum
wasmmodule.typeSection.moduleType = function
setTypeSection(&wasmmodule, token)
setFunctionSection(&wasmmodule)
setExportSection(wasmmodule.typeSection.moduleType, &wasmmodule.exportSection, wasmmodule.funcName)
}
}
}
// 2行目以後はInstructionを解析する
if 2 <= cnt {
//fmt.Println(line)
readInstructions(&wasmmodule, line)
}
cnt++
}
setCodeSection(wasmmodule.instructionSet, &wasmmodule.codeSection)
return wasmmodule
}
WASMバイナリフォーマットにはセクションという形式でデータを保持するので、形式に応じて値をセットします。
以下のような主要なセクションに値をセットします。
- Typeセクション: 関数のシグネチャを定義。
- Functionセクション: 関数の型を指定。
- Exportセクション: エクスポート情報を定義。
- Codeセクション: 関数の命令を定義。
// 関数名、引数、戻り値を解析する
// 関数型以外のモジュールもあるが今はこれだけ対応
switch {
case strings.HasPrefix(token, "(func"):
wasmmodule.typeSection.moduleSection = typeSectionNum
wasmmodule.typeSection.moduleType = function
setTypeSection(&wasmmodule, token)
setFunctionSection(&wasmmodule)
setExportSection(wasmmodule.typeSection.moduleType, &wasmmodule.exportSection, wasmmodule.funcName)
}
例えば、moduleのタイプが関数の場合、関数はバイナリでは0x60
で表す仕様なので、定数で宣言しておいた値をセットします。
const (
// Function Types
function = 0x60
)
setTypeSection
ではWATファイルの(func (export "addTwo") (param i32 i32) (result i32)
の文字列を分割して、関数名、引数、戻り値を読み取ります。
func setTypeSection(wasmFunction *WasmModule, tokens string) {
splitTokens := strings.Split(tokens, ")")
for _, token := range splitTokens {
splitSpace := strings.Split(token, " ")
switch {
case strings.Contains(token, "export"):
// 関数名をセットする
wasmFunction.funcName = []byte(strings.Replace(splitSpace[(len(splitSpace)-1)], "\"", "", -1))
case strings.Contains(token, "param"):
// 引数をセットする
wasmFunction.typeSection.args, wasmFunction.typeSection.argsNum = setParams(splitSpace)
case strings.Contains(token, "result"):
// 戻り値をセットする
wasmFunction.typeSection.result, _ = setParams(splitSpace)
}
}
}
setParam
では引数と戻り値をそれぞれ、バイナリ値に変換します。
func strToType(str string) int {
switch str {
case "i32":
return numberTypeI32
case "i64":
return numberTypeI64
case "f32":
return numberTypeF32
case "f64":
return numberTypeF64
default:
return -1
}
}
func setParams(lines []string) ([]byte, uint8) {
var params []byte
var cnt uint8
for _, line := range lines {
if strings.Contains(line, "32") || strings.Contains(line, "64") {
params = append(params, byte(strToType(line)))
cnt++
}
}
return params, cnt
}
定数で定義しているnumberType
は、バイナリフォーマットの仕様で明記されているNumber Typesの値です。
const(
// Number Types
numberTypeI32 = 0x7f
numberTypeI64 = 0x7e
numberTypeF32 = 0x7d
numberTypeF64 = 0x7b
)
Functionセクションの情報をセットするsetFunctionSection
は固定のテキストフォーマットのみ対応しているので、決め打ちの値をセットしています。
今回は1つの関数だけが書かれているという想定なので、値も決め打ちで固定値をセットしています。
func setFunctionSection(wasmFunction *WasmModule) {
switch wasmFunction.typeSection.moduleType {
case function:
wasmFunction.functionSection.sectionID = functionSectionNum
}
// サイズは決め打ちで2
wasmFunction.functionSection.sectionSize = 2
// 関数の数は決め打ちで1
wasmFunction.functionSection.num = 1
// 関数の数は1つなので参照するIndexは0
wasmFunction.functionSection.index = 0
}
エクスポート情報をセットするsetExportSection
は関数名やエクスポートしている関数の数などをセットします。
関数の数は1つのフォーマットのみ対応しているので、1を決め打ちでセットします。
func setExportSection(modtype uint8, export *exportSection, funcName []byte) {
export.sectionID = exportSectionNum
export.sectionSize = uint8(4 + len(funcName))
export.exportNum = 1
export.nameLength = uint8(len(funcName))
export.name = funcName
// functionだけ対応,本当は後tableとmemoryとglobalもある
// https://webassembly.github.io/spec/core/binary/modules.html#export-section
switch modtype {
case function:
export.exportType = 0
}
// 0番目の関数をエクスポートで決め打ち
export.index = 0
}
エクスポート情報まで読んだら関数の中身を読み込みます。
WATファイルの以下の3行を読み込みます。
local.get 0
local.get 1
i32.add
local.get 0
やi32.add
をスペースで分割して、命令と引数に分けて構造体にセットします。
func readInstructions(wasmFunction *WasmModule, tokens string) {
splitTokens := strings.Split(tokens, " ")
if len(splitTokens) == 1 {
// 引数がない命令なら命令のみをセットする
wasmFunction.instructionSet = append(wasmFunction.instructionSet, instruction{order: strings.Replace(splitTokens[0], ")", "", -1)})
} else {
// 引数があれば命令と引数をセットする
wasmFunction.instructionSet = append(wasmFunction.instructionSet, instruction{
order: splitTokens[0],
args: splitTokens[1]})
}
}
文字として命令を読み込んで構造体にセットしてから、最後にsetCodeSection
でバイナリの仕様に変換してCodeセクションの情報を生成します。
func setCodeSection(instructions []instruction, codeSection *codeSection) {
// codeを生成する
codeSection.code = append(codeSection.code, 0) // ローカル変数の数は0
for _, inst := range instructions {
switch {
case strings.EqualFold(inst.order, "local.get"):
codeSection.code = append(codeSection.code, localGet)
args, _ := strconv.ParseInt(inst.args, 10, 64)
codeSection.code = append(codeSection.code, byte(args))
case strings.EqualFold(inst.order, "i32.add"):
codeSection.code = append(codeSection.code, i32Add)
case strings.EqualFold(inst.order, "i32.sub"):
codeSection.code = append(codeSection.code, i32Sub)
case strings.EqualFold(inst.order, "i32.mul"):
codeSection.code = append(codeSection.code, i32Mul)
case strings.EqualFold(inst.order, "i32.div_s"):
codeSection.code = append(codeSection.code, i32Divs)
}
}
// 関数の終了を入れる
codeSection.code = append(codeSection.code, 0x0b)
// 関数コードのサイズをセット
codeSection.codeSize = uint8(len(codeSection.code))
codeSection.sectionID = 0x0a
codeSection.sectionSize = 2 + codeSection.codeSize
codeSection.funcNum = 1
}
スタックから値をGetするlocal.get
やi32.add
など計算命令のInstructionの仕様に沿ってバイナリ値に変換します。
ここまででWATファイルの解析が終わり、各セクションごとの構造体のメンバーにバイナリ値がセットされているので、createWasmBinary
でバイナリフォーマットを生成します。
func createWasmBinary(wasmmodule WasmModule) []byte {
var bin []byte
// Magic Numberをセット
bin = append(bin, magicNumber...)
// Versionをセット
bin = append(bin, version...)
// Typeセクションをセット
bin = append(bin, wasmmodule.typeSection.toByte()...)
// Functionセクションをセット
bin = append(bin, wasmmodule.functionSection.toByte()...)
// Exportセクションをセット
bin = append(bin, wasmmodule.exportSection.toByte()...)
// Codeセクションをセット
bin = append(bin, wasmmodule.codeSection.toByte()...)
return bin
}
ここまででテキストフォーマットをバイナリフォーマットに変換する処理は終わりです。
WATファイルを読み込んで、バイナリをprint出力してみます。
$ go run .
0061736d0100000001070160027f7f017f03020100070a010661646454776f00000a09010700200020016a0b
$ xxd add.wasm
00000000: 0061 736d 0100 0000 0107 0160 027f 7f01 .asm.......`....
00000010: 7f03 0201 0007 0a01 0661 6464 5477 6f00 .........addTwo.
00000020: 000a 0901 0700 2000 2001 6a0b ...... . .j.
比較がとても見づらいですが、たぶん合っていると思いますw
バイナリフォーマットの実行
生成されたバイナリフォーマットを読み込むparseWASMCodeSection
です。
先頭の8byteはMagic NumberとVersion情報なので、読み飛ばします。
readULEB128
はWASMバイナリフォーマットで使用されるULEB128エンコーディング形式でエンコードされた整数をデコードするための関数です。
sectionID
がcodeSectionNum
だった場合、以後が計算命令のバイナリデータになるので命令を実行する関数を呼びます。
func parseWASMCodeSection(wasmData []byte) error {
reader := bytes.NewReader(wasmData[8:]) // Skip magic number and version
for reader.Len() > 0 {
sectionID, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("failed to read section ID: %w", err)
}
sectionSize, err := readULEB128(reader)
if err != nil {
return fmt.Errorf("failed to read section size: %w", err)
}
sectionData := make([]byte, sectionSize)
if _, err := reader.Read(sectionData); err != nil {
return fmt.Errorf("failed to read section data: %w", err)
}
if sectionID == codeSectionNum {
return executeCodeSection(sectionData)
}
}
return errors.New("code section not found")
}
簡易WASMランタイムは3つの関数で構成されています。
-
executeCodeSection
: バイナリデータの読み込み -
applyBinaryOperation
: stackの操作 -
executeFunctionBody
: stackに値をセットして、命令コードに応じて計算を実行
func executeCodeSection(sectionData []byte) error {
reader := bytes.NewReader(sectionData)
funcCount, err := readULEB128(reader)
if err != nil {
return fmt.Errorf("failed to read function count: %w", err)
}
for i := uint32(0); i < funcCount; i++ {
bodySize, err := readULEB128(reader)
if err != nil {
return fmt.Errorf("failed to read function body size: %w", err)
}
body := make([]byte, bodySize)
if _, err := reader.Read(body); err != nil {
return fmt.Errorf("failed to read function body: %w", err)
}
err = executeFunctionBody(body)
if err != nil {
return fmt.Errorf("failed to execute function body: %w", err)
}
}
return nil
}
func applyBinaryOperation(stack *Stack, operation func(a, b int32) int32) error {
b, err := stack.Pop()
if err != nil {
return err
}
a, err := stack.Pop()
if err != nil {
return err
}
result := operation(a, b)
stack.Push(result)
return nil
}
func executeFunctionBody(body []byte) error {
stack := &Stack{}
stack.Push(20)
stack.Push(10)
reader := bytes.NewReader(body)
for reader.Len() > 0 {
opcode, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("failed to read opcode: %w", err)
}
switch opcode {
case i32Add:
err := applyBinaryOperation(stack, func(a, b int32) int32 {
fmt.Printf("Executed i32.add: %d + %d = %d\n", a, b, a+b)
return a + b
})
if err != nil {
return err
}
case i32Sub:
err := applyBinaryOperation(stack, func(a, b int32) int32 {
fmt.Printf("Executed i32.sub: %d - %d = %d\n", a, b, a-b)
return a - b
})
if err != nil {
return err
}
case i32Mul:
err := applyBinaryOperation(stack, func(a, b int32) int32 {
fmt.Printf("Executed i32.mul: %d * %d = %d\n", a, b, a*b)
return a * b
})
if err != nil {
return err
}
case i32Divs:
err := applyBinaryOperation(stack, func(a, b int32) int32 {
fmt.Printf("Executed i32.mul: %d * %d = %d\n", a, b, a/b)
return a / b
})
if err != nil {
return err
}
//default:
// fmt.Printf("Unsupported opcode: 0x%x\n", opcode)
}
}
fmt.Println("After execution, stack:", stack.data)
return nil
}
WATファイルの命令がi32.add
の場合は足し算が実行され、i32.sub
の場合は引き算が実行されます。
$ go run .
Executed i32.add: 20 + 10 = 30
After execution, stack: [30]
$ go run .
Executed i32.sub: 20 - 10 = 10
After execution, stack: [10]
さいごに
ここまで読んで頂いた方は「テキストをバイナリ値に決め打ちで変換して、それを読み込んで決め打ちで実行してるだけじゃん」というご感想をお持ちかと思います。
全くその通りでございまして、実際のWASMランタイムでは、複数の型や関数、セクションの動的な解析をより複雑に行っていますが、今回の簡易ランタイムでは特定のフォーマットの関数のみを対象にしているので、めちゃくちゃ手を抜いています。
しかしこの簡易ランタイムを作ることでWASMが何なのか、なぜ便利なのかといった仕組みや理解度が少し上がったのでヨシ!としたいと思います。
Discussion