👣

X68000上でZigコンパイラを動かすことに挑戦(動かない)

2024/02/03に公開

前回記事[1]でX68000上でRustとZigのHello worldが動くところまでできたので今回は気を良くしてZigのStage1コンパイラを動かすことに挑戦していきます。結果としては残念ながら、、ですがX68000にとってはやや大きめのアプリケーションをwasm経由で持ってきたときに何が起こるのかという情報として記事にまとめたいと思います。

Zigのstage1コンパイラとは

まず、今回動かそうとしているZigのstage1コンパイラについて説明します。
前回[2]も簡単に触れましたがZigのstage1コンパイラは、Zig言語で書かれたZigコンパイラのソースコードをC言語のソースコードに変換するために利用されるものです。
今回はこのZigのstage1コンパイラをX68000向けにビルドして、Zigで書かれたHello worldをC言語のソースに変換することに挑戦します。

今回のモチベーションとしてはどうしてもこのコンパイラを動かしたいというよりは、大きめのWasmファイルをX68000に持ってくる過程でどのような課題があるのか洗い出すことが目標になります。

ビルド編

早速、前回用意したwasm2cを利用して、zig1.wasmをX68000向けにビルドしていきます。
結果、下記のようにHAS060のエラーが出力されます。

_build/m68000/zig1.s 453523: Error: オーバーフローしました
_build/m68000/zig1.s 453524: Error: オーバーフローしました
_build/m68000/zig1.s 453525: Error: オーバーフローしました
_build/m68000/zig1.s 453526: Error: オーバーフローしました
...
エラーが 282 個ありました.アセンブルを中止します

いきなりハードなスタートです。大量のオーバーフローエラーを出力して止まってしまいました。

switch文の相対ジャンプテーブル

このオーバーフローエラーは、下記のように16ビット相対アドレスのテーブルを使ってジャンプを行う個所で発生しています。
ラベルの相対アドレスが16ビットで扱える範囲を超えていることがオーバーフローエラーの原因となっています。

	add.l d0,d0					*	add.l %d0,%d0	| tmp14625
	move.w _?L22342(pc,d0.l),d0			*	move.w .L22342(%pc,%d0.l),%d0	|, tmp14626
	jmp 2(pc,d0.w)					*	jmp %pc@(2,%d0:w)	| tmp14626
	.align 2,0x284c					*	.balignw 2,0x284c
							*	.swbeg	&159
_?L22342:						*.L22342:
	.dc.w _?L22441-_?L22342				*	.word .L22441-.L22342
	.dc.w _?L22440-_?L22342				*	.word .L22440-.L22342
	.dc.w _?L22439-_?L22342				*	.word .L22439-.L22342
	.dc.w _?L22438-_?L22342				*	.word .L22438-.L22342
	.dc.w _?L22437-_?L22342				*	.word .L22437-.L22342
...

このアセンブリコードは下記のようなswitch文のコンパイル結果として生成されます。
wasm2cが機械的に生成したコードには巨大なswitch文が含まれており、これをコンパイルした時に同じ関数内にあるラベルとの相対アドレスが16ビットの範囲を超えてしまうという現象です。

    switch (l205) {
    case 0: goto l146;
    case 1: goto l139;
    case 2: goto l167;
    case 3: goto l168;
    case 4: goto l164;
...

これを回避するために絶対アドレスでジャンプするjmp命令を1段追加して2段ジャンプを行うように手を加えました。
このコードを生成するために今回はHAS060に小細工を加えました。HAS060に対するハックの詳細は今回の記事では説明を省略させていただきます。

イメージ
_?L22342:						*.L22342:
	.dc.w _?L22441_jmp-_?L22342				*	.word .L22441-.L22342
	.dc.w _?L22440_jmp-_?L22342				*	.word .L22440-.L22342
	.dc.w _?L22439_jmp-_?L22342				*	.word .L22439-.L22342
	.dc.w _?L22438_jmp-_?L22342				*	.word .L22438-.L22342
	.dc.w _?L22437_jmp-_?L22342				*	.word .L22437-.L22342
...
_?L22441_jmp
    jmp _?L22441
_?L22440_jmp
    jmp _?L22440
...

HLKのメモリ不足

ジャンプテーブルのオーバーフローに対処するとビルドはリンカの処理まで進みます。しかし、今度はHLKが下記のエラーを出力して止まります。

$run68 /xdev68k/x68k_bin/hlk301.x -p -i _build/m68000/_lk_list.tmp -o ZIG1.X
Out of memory !! (゚_゚;

今回はrun68に変更を加え12MByte以上のメモリを使えるようにして対処しました。run68に対する変更の詳細に関してはこの記事では説明を省略させていただきます。

ビルド結果

テーブルジャンプの対応と、run68のメモリ拡張を行うとビルドが通るようになります。
生成されたファイルのサイズは 5MByteとなっています。やや大きいサイズではありますが、X68000の12MByteのメモリで何とか実行できそうな期待を持たせてくれます。

-rw-r--r--  1 icon icon   5074282 Feb  2 16:55 ZIG1.X
-rw-r--r--  1 icon icon   2491454 Jan  9 22:22 zig1.wasm

ヒープサイズ

ビルドも通るようになったので必要となるヒープサイズの目安を確認します。wasm2cによって生成されたinit_data関数の冒頭の処理で確保しているメモリサイズを確認します。

static void init_data(void) {
    p0 = UINT32_C(521);
    c0 = p0;
    m0 = calloc(c0, UINT32_C(1) << 16);

    static const uint8_t s0[UINT32_C(18)] = {
        0xC7, 0x59, 0x01, 0x02, 0x07, 0x00, 0x00, 0x00, 0xC7, 0x59, 0x01, 0x02, 0x07, 0x00, 0x00, 0x00, 0x20, 0x02,
    };
    memcpy(&m0[UINT32_C(0x2000000)], s0, UINT32_C(18));

    static const uint8_t s1[UINT32_C(175945)] = {

calloc関数で確保しているメモリは521かける0x10000なので必要なメモリサイズとしては0x2090000となります。
このサイズは24bitで表せる範囲を超えています。この時点で24bitアドレスのm68000上での実行はできないことが確定しました。
ただここで諦めるのはつまらないので、これ以降はメモリ空間を拡張したrun68上で動かすことを新たな目標とします。
ヒープ領域の設定は、リンク時のオプション-d _HEAP_SIZE=5000000の数値部分を変えることで変更できます。この数値は16進数で値を設定します。

xcのmallocの24bitアドレス制限を外す

32bitアドレスで動かすにはxcのライブラリにも一部手を加える必要があります。
xcライブラリで提供されているmallocなどのメモリ確保を行う関数では、24bitアドレスを超えたメモリ空間上にメモリを確保しないように制限がかけられています。
具体的には管理テーブルの初期化を行う__MAIN.Oの中とrealloc関数の中で24bitアドレスに強制する処理が入っています。
今回の実験ではこの制限が邪魔になるので外してしまいます。
__MAIN.Oでは強制のために0x00FFFFFFでアドレスをマスクする処理が入っていますのでこれをバイナリエディタで全ビットマスクになるよう改修します。
reallocは24bit強制なし版を作成してリンクするようにします。

これらのファイルはFSHARP許諾条件[3]の範囲で扱うことになります。

デバッグ

ここまでで実行するファイルが出来上がったので、run68上で実行していきます。
実行時、コマンドの第一引数にzigコンパイラリポジトリのlibディレクトリを指定し、それ以降にzigコンパイラのオプションを指定していきます。下記の実行例のオプションはzigコンパイラリポジトリのCMake.txtを参考に設定しました。
では実行してみましょう。

$run68 ZIG1.X ../../../zig/lib build-exe hello.zig -ofmt=c -lc -OReleaseSmall -target x86_64-linux
未対応のリロケート情報があります

またも、簡単には通してもらえません。

リロケーションテーブル

未対応のリロケート情報があります

このエラーは実行ファイルのリロケーションテーブルにrun68では解釈できないものが含まれていることを表しています。
リロケーションテーブルは再配置に伴ってベースアドレスによる補正が必要な個所のリストとなっています。通常このリスト内の情報は16bitの相対アドレスで格納されています。
例えば下記のように6byteごとに絶対アドレスが登場するコードが先頭にあれば、0x0002, 0x0006, 0x0006のように前の補正個所からの相対アドレスが格納されることになります。

23C0 0049 EA90            move.l  d0,$49ea90
23C0 0049 EA94            move.l  d0,$49ea94
23C0 0049 EA98            move.l  d0,$49ea98

続いて、補正が必要な個所の間隔が16bitで表せる範囲を超えた場合に何が起こるか見ていきます。
今回の場合、wasm2cの生成した下記の初期値ありのポインタがdataセクション上に作成されます。この初期値はm0の絶対アドレスとなるため再配置での補正の対象になります。

zig1.c
uint8_t **const wasm_memory = &m0;

このwasm_memoryのアドレスは、この直前の補正個所からの相対アドレスが16bitでは収まらない状態となっています。
このような場合、HLKはリロケーションテーブルに0x0001を置いた後、32bitの相対アドレスを格納しているようです。
一方でrun68ではこの0x0001を見つけた時点で冒頭のエラーを返すようになっています。

したがって今回はrun68のリロケート処理を下記のように書き換えて対応しました。

/*
  機能:Xファイルをリロケートする
 戻り値: TRUE = 正常終了
     FALSE = 異常終了
*/
static	int	xrelocate( Long reloc_adr, Long reloc_size, Long read_top )
{
	Long	prog_adr;
	Long	data;
	UShort	disp;
	prog_adr = read_top;
	for(; reloc_size > 0; reloc_size -= 2, reloc_adr += 2 ) {
		disp = (UShort)mem_get( read_top + reloc_adr, S_WORD );
		if ( disp == 1 ) {
			reloc_size -= 2;
			reloc_adr += 2;
			prog_adr += mem_get( read_top + reloc_adr, S_LONG );
			reloc_size -= 2; // for文内の操作と合わせて4byte分進める
			reloc_adr += 2;
		} else {
		    prog_adr += disp;
		}
		data = mem_get( prog_adr, S_LONG ) + read_top;
		mem_set( prog_adr, data, S_LONG );
	}

	return( TRUE );
}

memory.grow失敗のハンドリング

ここまでの変更を加えると、run68上に実行ファイルが配置されて最初の部分が動き出します。
実行時ログ出力の冒頭はこのような感じになります。

wasi_snapshot_preview1_args_sizes_get()
wasi_snapshot_preview1_args_get()
wasi_snapshot_preview1_fd_prestat_get(3)
wasi_snapshot_preview1_fd_prestat_dir_name(3, "")
wasi_snapshot_preview1_fd_prestat_get(4)
wasi_snapshot_preview1_fd_prestat_dir_name(4, "")
wasi_snapshot_preview1_fd_prestat_get(5)
wasi_snapshot_preview1_fd_prestat_dir_name(5, "")
wasi_snapshot_preview1_fd_prestat_get(6)
wasi_snapshot_preview1_path_filestat_get(3, 0x1, "./build.zig")
wasi_snapshot_preview1_path_create_directory(4, "h")
wasi_snapshot_preview1_path_open(4, 0x1, "h", 0x2, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_path_create_directory(4, "o/cb5efcac25e8361855b0a6001f8ada9b")
wasi_snapshot_preview1_path_open(4, 0x1, "o/cb5efcac25e8361855b0a6001f8ada9b", 0x2, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_path_open(5, 0x1, "std", 0x2, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_path_create_directory(4, "z")
wasi_snapshot_preview1_path_open(4, 0x1, "z", 0x2, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_path_create_directory(4, "z")
wasi_snapshot_preview1_path_open(4, 0x1, "z", 0x2, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_path_open(3, 0x0, "hello.c", 0x1, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_clock_time_get(1, llu)
wasi_snapshot_preview1_path_filestat_get(7, 0x1, "builtin.zig")
wasi_snapshot_preview1_random_get(32)
wasi_snapshot_preview1_path_open(7, 0x0, "ECPq6GK_MkfezbBT", 0xD, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_fd_write(12, 0x1FF2340, 1)
wasi_snapshot_preview1_fd_close(12)
wasi_snapshot_preview1_path_rename(7, "ECPq6GK_MkfezbBT", 7, "builtin.zig")
wasi_snapshot_preview1_clock_time_get(1, llu)
wasi_snapshot_preview1_path_open(8, 0x0, "std.zig", 0x0, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_fd_filestat_get(13)
wasi_snapshot_preview1_path_open(10, 0x0, "bc4efcbe47a767d0a76afe4767871fe6", 0x1, 0xllX, 0xllX, 0x0)
wasi_snapshot_preview1_fd_read(14, 0x1FF1EB0, 1)
wasi_snapshot_preview1_fd_filestat_set_size(14, llu)
wasi_snapshot_preview1_fd_read(13, 0x1FF1EC0, 1)
wasi_snapshot_preview1_fd_write(14, 0x1FF2148, 5)
wasi_snapshot_preview1_fd_close(14)
wasi_snapshot_preview1_fd_close(13)
wasi_snapshot_preview1_clock_time_get(1, llu)
wasi_snapshot_preview1_path_open(8, 0x0, "array_list.zig", 0x0, 0xllX, 0xllX, 0x0)

libディレクトリのstdライブラリのソースを端から順にコンパイルしているようです。
しばらく動かしていると下記のようなエラーを出して停止します。

run68 exec error: アドレスエラーが発生しました PC=33382C20
PC of previous op code: PC=452502
** EXECUTED INSTRUCTION HISTORY **
ADDRESS OPCODE                    MNEMONIC
-------------------------------------------------------
$452468 0800 000A                 btst    #10,d0
$45246C 667A                      bne.b   $4524E8
$4524E8 FF3F                      FCALL $3F
$4524EA 2200                      move.l  d0,d1
$4524EC 6B1C                      bmi.b   $45250A
$4524EE 660C                      bne.b   $4524FC
$4524FC 2001                      move.l  d1,d0
$4524FE 4FEF 000A                 lea.l   10(a7),a7
$452502 4E75                      rts
$000000 0000 0000                 or.b    #$00,d0
アドレス:33382C20
Segmentation fault

スタックを破壊したようですね。
このようなエラーが出るたびにrun68の-debugオプションを使って解析することになるのですが、ここから先はすべてHeap領域の不足によるwasmのmemory.grow動作の失敗に起因するエラーでした。したがって、この後はエラーが出るたびに解析してはHeapの設定を変更するという作業の繰り返しになりました。

個々のエラー解析の詳細は省略しますが、解析の中でmemory.grow失敗時に特徴的な値がメモリアクセスのインデックスに使われることが分かったのでそのことについて記載しておきます。
メモリ破壊のエラーが発生するとき、多くの場合wasmのメモリ内のインデックスとして0xFFFF0000が使われていました。このインデックスはマイナスの値のインデックスを使っているのと同じになるので結果としてメモリ領域外の値が破壊されることにつながっていました。

この0xFFFF0000という値が使われてしまうメカニズムについて以下で説明します。
memory.growが実行されたときに十分なメモリがない時には結果として-1を返すことが仕様で定められています。

The memory.size instruction returns the current size of a memory. The memory.grow instruction grows memory by a given delta and returns the previous size, or -1 if enough memory cannot be allocated. Both instructions operate in units of page size.[4]

wasm2cが出力するmemory.growの処理もrealloc失敗の時などには-1を返すようになっています。(Fの数が合わない気もしますが、、)

static uint32_t memory_grow(uint8_t **m, uint32_t *p, uint32_t *c, uint32_t n) {
    uint8_t *new_m = *m;
    uint32_t r = *p;
    uint32_t new_p = r + n;
    if (new_p > UINT32_C(0x10000)) return UINT32_C(0xFFFFFFF);
    uint32_t new_c = *c;
    if (new_c < new_p) {
        do new_c += new_c / 2 + 8; while (new_c < new_p);
        if (new_c > UINT32_C(0x10000)) new_c = UINT32_C(0x10000);
        new_m = realloc(new_m, new_c << 16);
        if (new_m == NULL) return UINT32_C(0xFFFFFFF);
        *m = new_m;
        *c = new_c;
    }
    *p = new_p;
    memset(&new_m[r << 16], 0, n << 16);
    return r;
}

一方でこの関数の呼び出し元では下記のようなハンドリングになっています。

memory.grow結果のハンドリング
        l6 = memory_grow(&m0, &p0, &c0, l8);
        l0 = l6;
        l8 = UINT32_C(0x10);
        l6 <<= l8 & 0x1F;
        l7 = UINT32_C(0x0);
        l8 = l0;
        uint32_t l14 = UINT32_C(0x0);
        l8 = (int32_t)l8 > (int32_t)l14;
        l6 = l8 ? l6 : l7;
        l3 = l6;
        goto l4;
    l4:;
    uint32_t l15 = l3;
    return l15;

処理を見るとmomory_growから戻った結果を-1との比較を経ずに16bitシフトして使ってしまっているようです。(なぜか0との比較は行っていますが)
この結果、0xFFFF0000が新しく確保された領域の先頭を指すインデックスとして使われてしまうことになります。
Zigのコードをwasm2cで動かしていてこの値がインデックスとして出てきたらHeap領域不足と考えてよさそうです。

旅の終わり

Heap領域のサイズを192MByteまで増やすと、momory.grow由来のエラーは出ずに、下記のようなエラーを1,000行程度出して止まるようになります。今回はrun68の出力するエラーではなく、Zig stage1コンパイラが出力しているエラーとなっています。エラー内容から考えると字句解析はある程度進んでシンボルの解決でエラーになっているようです。

エラーメッセージ
/lib/std/Build.zig:1165:27: error: use of undeclared identifier 'UserInputOption'
/lib/std/Build.zig:1458:5: error: use of undeclared identifier 'assert'
/lib/std/Build.zig:1499:17: error: use of undeclared identifier 'allocPrintCmd'
/lib/std/Build.zig:1519:5: error: use of undeclared identifier 'assert'
/lib/std/Build.zig:1621:39: error: use of undeclared identifier 'name'
/lib/std/Build.zig:1732:17: error: use of undeclared identifier 'dumpBadGetPathHelp'
/lib/std/Build.zig:1812:29: error: use of undeclared identifier 'VcpkgRootStatus'
/lib/std/Build.zig:1833:23: error: use of undeclared identifier 'InstallDir'
/lib/std/Build.zig:1843:10: error: use of undeclared identifier 'InstallDir'
/lib/std/Build.zig:1855:32: error: use of undeclared identifier 'Allocator'
/lib/std/Build.zig:1889:39: error: use of undeclared identifier 'fs'
/lib/std/mem.zig:1815:72: error: use of undeclared identifier 'Endian'
/lib/std/mem.zig:1928:17: error: use of undeclared identifier 'std'
/lib/std/mem.zig:1958:5: error: use of undeclared identifier 'byteSwapAllFields'
...

ここから先はもう少しリッチなデバッグ環境がないと解析が難しそうなので今回の挑戦はここまでとさせていただきたいと思います。

まとめ

X68000上でZigのstage1コンパイラを動かすことに挑戦することで、wasm2cでやや大きめのアプリケーションをビルドする際の課題を見てきました。
最終的に正常動作させることはできませんでしたが、いくつかの課題とその対処方針が得られました。
2020年代の環境で動かすことを前提としたバイナリであることを差し引いて考える必要はありますが、大量のHeap領域が必要になることと、デバッグする際の情報(関数のメモリ配置など)の少なさはこの方式でX68000の実行ファイルを開発する際の大きな課題だと感じました。

次回予告

次回はRustのno_stdで作成したwasmファイルをX68000で動かしてみたいと思います

脚注
  1. RustやZigのコードをWebAssembly経由でレトロPC(X68000)で実行する ↩︎

  2. Goodbye to the C++ Implementation of Zig ↩︎

  3. 許諾条件 ↩︎

  4. https://webassembly.github.io/spec/core/syntax/instructions.html#syntax-instr-memory ↩︎

Discussion