RustやZigのコードをWebAssembly経由でレトロPC(X68000)で実行する
RustやZigのコードからWebAssembly(Wasm)バイナリを経由してX68000向けのバイナリをビルドする実験を行ったので、その内容と結果を報告します。現時点ではHello Worldをビルドして一部のエミュレータ上で実行できることが確認できているレベルとなっています。
ビルドの流れ
ビルドの流れは下記のようになります。
最初にRustやZigからWasmのバイナリをビルドします、その結果をwasm2c
でC言語のコードに変換して、このC言語のコードをxdev68k
を使ってビルドして最終的にX68000の実行形式であるXファイルを得ます。
wasm2c
はZigのstage1コンパイラが使っているwasm2c
を改造して利用しました。
ZigはZig言語で書かれているZigコンパイラをZigコンパイラのない環境上でビルドするために、最小セットのZigコンパイラのWasmバイナリとこれをCのコードに変換するためのCで書かれた軽量なwasm2c
を用意しています[1]。このWasmバイナリからC経由でビルドされるコンパイラがZigのstage1コンパイラです。今回はこの軽量wasm2c
を利用させていただくかたちとなっています。
Hello Worldのビルドと実行
利用するwasm2c
のソースコードなどは下記のリポジトリで公開しています。
以下の説明はこのリポジトリをクローンした環境で作業することを前提としています。
ZigでのHello World
まずZigでのHello Worldから見ていきます。Rustと比較して軽量なバイナリを出力することができるので、中間で生成されるCのコードや実行ファイルがどのようになっているのか確認するのにはこちらから始めるのがよいと思います。
軽量なZigのWasmバイナリを生成するのに、こちらの記事[2]を参考にさせていただきました。
まず、ビルドを行うディレクトリのセットアップが終わったら、下記内容のhello.zig
を用意しmakefile
の最終生成ファイルの名称とソースファイルの指定を調整します。
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, world!\n", .{});
}
# 最終生成物
EXECUTABLE = ZGHELLO.X
TARGET_FILES = $(EXECUTABLE)
...
# *.rsソースファイル
RS_SRCS =
# *.zig ソースファイル
ZIG_SRCS = hello.zig
この状態でmake
を行うと以下のようにWASMとXファイルが生成されます。
-rw-r--r-- 1 icon icon 26746 Jan 26 10:46 ZGHELLO.X
-rwxr--r-- 1 icon icon 335 Jan 26 10:46 hello.wasm
hello.wasm
が335byteなのに対してZGHELLO.X
が26kByteとやや大きめのサイズの実行ファイルが生成されているのがわかります。
==========================================================
_build/m68000/hello.o
==========================================================
align : 00000002
text : 00001a68 - 00001b81 (0000011a)
data : 000052e6 - 000052fd (00000018)
bss :
stack :
.map
ファイルを見るとhello.wasm
由来の部分はtext
が282byte(0x11a)なのでそこそこコンパクトなコードになっていることが見て取れます。
今度は実際にどのようなCのコードが生成されているか見ていきます。現状makeを行うと中間生成物のhello.c
が削除されてしまうのでもう一度wasm2c
だけを実行します。
../BIN/wasm2c hello.wasm hello-zig.c
出力されたhello-zig.c
の中を見ていきます。
wasm__start
がコードのエントリーポイントとなっています。
この関数からコードを追っていくと初期化の後、f1を呼び出してproc_exit(0)
としているようです。
void wasm__start(void) {
init();
f0();
}
static void f0(void) {
f1();
uint32_t l1 = UINT32_C(0x0);
wasi_snapshot_preview1_proc_exit(l1);
abort();
goto l0;
l0:;
}
f1
はやや長いので途中を省略しますが、fd_write
しているのがわかります。ここで文字列を出力しているようです。
static void f1(void) {
uint32_t l0 = 0;
uint32_t l1 = 0;
uint32_t l3 = g0;
uint32_t l4 = UINT32_C(0x10);
l3 -= l4;
l0 = l3;
g0 = l3;
...
l14 = wasi_snapshot_preview1_fd_write(l10, l12, l11, l13);
...
}
続くinit_data
はメモリの確保とdata
領域の初期化を行うコードとなっています。17*64kByteで1MByteを超える領域が確保されています。Hello worldを出力だけの処理にしてはやや過大な領域を要求しているようにも感じます。
インデックス0x100000
からの領域にHello world
の文字列を格納しているのもわかります。
static void init_data(void) {
p0 = UINT32_C(17);
c0 = p0;
m0 = calloc(c0, UINT32_C(1) << 16);
static const uint8_t s0[UINT32_C(15)] = {
0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x2C, 0x20, 0x77, 0x6F, 0x72, 0x6C, 0x64, 0x21, 0x0A, 0x00,
};
memcpy(&m0[UINT32_C(0x100000)], s0, UINT32_C(15));
}
では、実際に動作を見ていきます。ここではrun68
で動作を確認します。
$run68 ZGHELLO.X
Hello, world!
これだけでは味気ないので-f
オプションでファンクションコールトレースを見てみます。
file_no=2
に文字列を書き込んでいるのがわかります。標準エラー出力(stderr
)に出力しているようですね。
$run68 -f ZGHELLO.X
ファンクションコールトレースフラグ=ON
$035a40 FUNC(4A):SETBLOCK size=1415286
$035aba FUNC(44):IOCTRL mode=0 stack=0004B472
$035aba FUNC(44):IOCTRL mode=0 stack=0004B472
$035aba FUNC(44):IOCTRL mode=0 stack=0004B472
$035aba FUNC(44):IOCTRL mode=0 stack=0004B472
$035aba FUNC(44):IOCTRL mode=0 stack=0004B472
$035aee FUNC(44):IOCTRL mode=0 stack=0004B472
$035aee FUNC(44):IOCTRL mode=0 stack=0004B472
$035aee FUNC(44):IOCTRL mode=0 stack=0004B472
$035aee FUNC(44):IOCTRL mode=0 stack=0004B472
$035aee FUNC(44):IOCTRL mode=0 stack=0004B472
IOCS(7F): PC=036AC8
$035b7a FUNC(25):INTVCS intno=-15 vec=35BBE
$035b88 FUNC(25):INTVCS intno=-14 vec=35BB6
$0370b4 FUNC(30):VERNUM
$036132 FUNC(2A):GETDATE date=7981B
$036136 FUNC(27):GETTIM2
$03613a FUNC(2A):GETDATE date=7981B
Hello, world!$0380ac FUNC(40):WRITE file_no=2 size=13 ret=13 str=Hello, world!
\0120ca FUNC(40):WRITE file_no=2 size=2 ret=2 str=\015
$036132 FUNC(2A):GETDATE date=7981B
$036136 FUNC(27):GETTIM2
$03613a FUNC(2A):GETDATE date=7981B
$037212 FUNC(0D):FFLUSH
$0371dc FUNC(4C):EXIT2
d0-7=00000000,00000000,00020802,0000000e,0004b450,000ffffc,00000000,00000000
a0-7=0004dd62,00039048,00039190,00100000,00035748,000ffff0,000ffff4,0004b42a
pc=000371de sr=0004
最後にlib/wasi.c
のLOG_TRACE
を設定してWASIのインタフェースの呼び出しをトレースしてみます。
まず、定数定義を変更してmake
しなおします。
#define LOG_TRACE 1
この状態で実行するとfd_write
とproc_exit
が呼び出されているのがわかります。
$run68 ZGHELLO.X
wasi_snapshot_preview1_fd_write(2, 0xFFFF0, 1)
Hello, world!
wasi_snapshot_preview1_proc_exit(0)
では、ZigはこのくらいにしてRustの確認に移りましょう。
RustでのHello World
まず、Zigの場合と同様にセットアップしたディレクトリにRustのソースコードhello.rs
を下記内容で用意します。makefileはデフォルトでhello.rs
をビルドするようになっているので調整は不要です。
fn main() {
println!("Hello world!");
}
make
してみると今度はhello.wasm
67kByte、RSHELLO.X
132kByteとなかなかのサイズのバイナリが生成されています。
-rw-r--r-- 1 icon icon 132300 Jan 26 10:42 RSHELLO.X
-rwxr-xr-x 1 icon icon 67897 Jan 26 10:42 hello.wasm
.map
ファイルを見るとWASMから変換された部分のサイズはtext
85kByte(0x14dc0)、data
8kByte(0x200c)で合計93kByteとなっています。
==========================================================
_build/m68000/hello.o
==========================================================
align : 00000002
text : 00001a68 - 00016827 (00014dc0)
data : 0001abe6 - 0001cbf1 (0000200c)
bss :
stack :
変換されたCのコードを見ていきましょう。
なぜかエントリーポイントが2つあります。今回はwasm__start
だけが使われることになります。
また、テーブル領域に大量の関数へのポインタを設定しているのが確認できます。
このテーブルはWASMが関数を引数や戻り値として受け渡すときに使用する仕組みになっているようです。
void wasm__start(void) {
init();
f1();
}
uint32_t wasm___main_void(void) {
init();
return f6();
}
static void init_elem(void) {
t0[UINT32_C(1)] = (void (*)(void))&f4;
t0[UINT32_C(2)] = (void (*)(void))&f3;
t0[UINT32_C(3)] = (void (*)(void))&f2;
t0[UINT32_C(4)] = (void (*)(void))&f5;
本体部分にはf0
からf248
までの大量の関数が定義されていますが、正直どの部分が動いてHello worldになっているのかはよくわかりませんでした。
init_data
を見るとZigと同様に17*64kByteの領域を確保して、インデックス0x100000
からの領域を初期化しています。
Zigと違って、8kByteの領域と8byteの領域を個別に初期化しているのがわかります。
Hello worldの文字列は8kByteの領域の先頭近くに見つけられます。
static void init_data(void) {
p0 = UINT32_C(17);
c0 = p0;
m0 = calloc(c0, UINT32_C(1) << 16);
static const uint8_t s0[UINT32_C(8195)] = {
0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F,
0x72, 0x6C, 0x64, 0x21, 0x0A, 0x00, 0x00, 0x00, 0x18, 0x00, 0x10, 0x00, 0x0D, 0x00, 0x00, 0x00, 0x28, 0x29, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00,
...
0x72, 0x6F, 0x72,
};
memcpy(&m0[UINT32_C(0x100000)], s0, UINT32_C(8195));
static const uint8_t s1[UINT32_C(8)] = {
0x01, 0x00, 0x00, 0x00, 0x8C, 0x13, 0x10, 0x00,
};
memcpy(&m0[UINT32_C(0x102004)], s1, UINT32_C(8));
}
各種トレースを見ていきます。
ファンクションコールを見るとfile_no=1
に文字列を出力しているのがわかります。こちらは標準出力(stdout
)に出力しています。
$run68 -f RSHELLO.X
ファンクションコールトレースフラグ=ON
$04a6e6 FUNC(4A):SETBLOCK size=1521236
$04a760 FUNC(44):IOCTRL mode=0 stack=00065250
$04a760 FUNC(44):IOCTRL mode=0 stack=00065250
$04a760 FUNC(44):IOCTRL mode=0 stack=00065250
$04a760 FUNC(44):IOCTRL mode=0 stack=00065250
$04a760 FUNC(44):IOCTRL mode=0 stack=00065250
$04a794 FUNC(44):IOCTRL mode=0 stack=00065250
$04a794 FUNC(44):IOCTRL mode=0 stack=00065250
$04a794 FUNC(44):IOCTRL mode=0 stack=00065250
$04a794 FUNC(44):IOCTRL mode=0 stack=00065250
$04a794 FUNC(44):IOCTRL mode=0 stack=00065250
IOCS(7F): PC=04B76E
$04a820 FUNC(25):INTVCS intno=-15 vec=4A864
$04a82e FUNC(25):INTVCS intno=-14 vec=4A85C
$04c9b2 FUNC(30):VERNUM
$04add8 FUNC(2A):GETDATE date=7981B
$04addc FUNC(27):GETTIM2
$04ade0 FUNC(2A):GETDATE date=7981B
$04d9aa FUNC(40):Hello world!WRITE file_no=1 size=12 ret=12 str=Hello world!
$04d9c8 FUNC(40):
\012E file_no=1 size=2 ret=2 str=\015
$04add8 FUNC(2A):GETDATE date=7981B
$04addc FUNC(27):GETTIM2
$04ade0 FUNC(2A):GETDATE date=7981B
$04cb10 FUNC(0D):FFLUSH
$04cada FUNC(4C):EXIT2
d0-7=00000000,00000000,00020802,00000000,00000000,00000000,00000000,00000000
a0-7=0006515a,00050a48,00050d1c,00021c00,0004a5e2,00033c10,00000000,00065250
pc=0004cadc sr=0004
LOG_TRACE
を有効にしてみると下記のようになります。
環境変数を見に行こうとしていることと、WASIのproc_exit
を明示的には呼んでいないことがZigとの違いになります。
$run68 RSHELLO.X
wasi_snapshot_preview1_environ_sizes_get()
wasi_snapshot_preview1_fd_write(1, 0xFFE48, 1)
Hello world!
run68
を使った確認は以上です。続いて他のエミュレータでの動作です。
エミュレータ上での実行
XM6 TypeG
上で実行が確認できました。メモリ2MBで動いています。
課題
今回試したWASMを経由したコード生成は開発で利用するには下記の課題があると考えられます。
- コードの効率
- 大容量のHeap使用
- デバッグの難しさ
- 未確認のopコード
コードの効率
直接ターゲットのアセンブラを出力するコンパイラに比べると当然のことながらコードの効率が悪くなることが懸念されます。特にm68k
のようなビッグエンディアンのMCUにとってはWasmのメモリ上にリトルエンディアンでデータを格納する仕様がネックになります。
大容量のHeap使用
今回実験してみて、事前に考えていたよりHeapを大きく確保しているのが気になりました。メモリ空間の限られている環境では大きくネックになります。今回の実験では出てきませんでしたがWasmの処理がメモリ空間の拡張を要求する`memory grow‘が発生した場合には連続した領域を確保する必要があることも併せて大きな問題になるかと思います。
デバッグの難しさ
一度WasmのバイナリにコンパイルしたものをCのソースコードに変換してさらにコンパイルをかけているので、最終的なバイナリが元の処理にどのように結びついているのか調査することがとても難しい状態になっています。元のソースコードのラベル情報と最終的なバイナリを対応付ける仕組みを作らないと少し大きなコードをデバッグしようとしたときに問題となると思います。
未確認のopコード
Wasmのopコードを網羅的に動作確認・検証することはなかなか大変な作業になるかと思います。このあたりの品質がある程度確保できないと、この仕組みで生成されたバイナリの信頼性もなかなか上がらないかもしれません。
今後の展開
今後の展開として下記のようなことに挑戦しようと考えています。
- 大きなサイズのコードのビルド
- Rustのno-stdコードの確認
- importを使った既存資産との結合
次回予告
次回は大きなサイズのコードとして、Zigのstage1コンパイラをX68000上で動かすことに挑戦してみます。
環境とツールのバージョン
今回の実験はWSL2上のUbuntuで実施しています。
利用している各コンパイラーのバージョンは以下の通りです。
clang: 10.0.0-4ubuntu1
rustc: 1.67.0
zig: 0.11.0
Discussion