🍻

四則演算をする簡単なWASMランタイムを作ってみた

2025/01/18に公開

はじめに

最近「WASM(WebAssembly)」という言葉を耳にする機会が増えました。
「Webでアセンブリとは一体?」とよくわからないので、WASMの仕様を読みながらに冬休みに簡単な
WASMランタイムを作りました。

この記事では、テキストフォーマットをバイナリフォーマットに変換し、バイナリを解釈して足し算や引き算など簡単な計算を実行するランタイムを説明します。
今回説明するソースコードのレポジトリは以下になります。

https://github.com/sat0ken/go-wasm

WASMテキストフォーマット

WASMにはテキストフォーマットとバイナリフォーマットがあります。
アセンブリと同じように人が読める形式がテキストフォーマットです。

https://webassembly.github.io/spec/core/text/index.html

以下は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 0i32.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.geti32.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エンコーディング形式でエンコードされた整数をデコードするための関数です。
sectionIDcodeSectionNumだった場合、以後が計算命令のバイナリデータになるので命令を実行する関数を呼びます。

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