✖️

RustやZigのコードをWebAssembly経由でレトロPC(X68000)で実行する

2024/01/28に公開

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のソースコードなどは下記のリポジトリで公開しています。
以下の説明はこのリポジトリをクローンした環境で作業することを前提としています。

https://github.com/icontrader/wasmx68.wb

ZigでのHello World

まずZigでのHello Worldから見ていきます。Rustと比較して軽量なバイナリを出力することができるので、中間で生成されるCのコードや実行ファイルがどのようになっているのか確認するのにはこちらから始めるのがよいと思います。
軽量なZigのWasmバイナリを生成するのに、こちらの記事[2]を参考にさせていただきました。

まず、ビルドを行うディレクトリのセットアップが終わったら、下記内容のhello.zigを用意しmakefileの最終生成ファイルの名称とソースファイルの指定を調整します。

hello.zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Hello, world!\n", .{});
}
makefile
# 最終生成物
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とやや大きめのサイズの実行ファイルが生成されているのがわかります。

.map
==========================================================
_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)としているようです。

hello-zig.c
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しているのがわかります。ここで文字列を出力しているようです。

hello-zig.c
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の文字列を格納しているのもわかります。

hello-zig.c
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.cLOG_TRACEを設定してWASIのインタフェースの呼び出しをトレースしてみます。
まず、定数定義を変更してmakeしなおします。

lib/wasi.c
#define LOG_TRACE 1

この状態で実行するとfd_writeproc_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をビルドするようになっているので調整は不要です。

hello.rs
fn main() {
    println!("Hello world!");
}

makeしてみると今度はhello.wasm67kByte、RSHELLO.X132kByteとなかなかのサイズのバイナリが生成されています。

-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から変換された部分のサイズはtext85kByte(0x14dc0)、data8kByte(0x200c)で合計93kByteとなっています。

==========================================================
_build/m68000/hello.o
==========================================================
align			 : 00000002
text			 : 00001a68 - 00016827 (00014dc0)
data			 : 0001abe6 - 0001cbf1 (0000200c)
bss			 : 
stack			 : 

変換されたCのコードを見ていきましょう。
なぜかエントリーポイントが2つあります。今回はwasm__startだけが使われることになります。
また、テーブル領域に大量の関数へのポインタを設定しているのが確認できます。
このテーブルはWASMが関数を引数や戻り値として受け渡すときに使用する仕組みになっているようです。

hello-rust.c
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の領域の先頭近くに見つけられます。

hello-rust.c
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

脚注
  1. Goodbye to the C++ Implementation of Zig ↩︎

  2. WasmtimeでZigのプログラムを動かしてみる ↩︎

Discussion