【お知らせ】プログラミング記事の投稿はQiitaに移行しました。

i8086のインタプリタでMINIXのコマンドを動かす

i8086の逆アセンブラに引き続きインタプリタを実装して、MINIXのカーネルがビルドできるようになりました。

OSごと動かすエミュレータではなく、ユーザープロセスのみをホスティングするインタプリタとして実装しています。Windowsにはforkがないため、1プロセスの中で擬似的に複数のプロセスを再現しています。

実装した命令

16bitのユーザープロセスを動かすのに必要な命令だけを実装しています。セグメントを触ることはないため、セグメント関係の命令は実装していません。メモリはCSとDSに別々に64KBを割り振るだけで事足りるため、セグメントから物理アドレスへの変換は実装していません。BCD(AFを含む)や割り込みやI/Oを直接触ることもないため、それらの命令も実装していません。

黄色の命令だけを実装しました。灰色の命令は未実装で、青色の命令は一部実装です。

Intelのマニュアルを見ながらひたすら実装しました。

システムコール

以前、PDP-11用のUNIX V6を対象とした同じようなインタプリタを作りました。

今回のシステムコール関連のコードは、そのPDP-11インタプリタから持って来ました。MINIXUNIX V7のシステムコール互換から出発したため、基本的なシステムコールは番号を含めてUNIX V6とほぼ同じです。ただし引数の受け渡し方法は異なります。

例としてwriteシステムコールの呼び出しを見てみます。

呼び出し側

UNIX V6(PDP-11)は第一引数をr0レジスタ、それ以降の引数は割り込み命令の直後に置きます。

mov $1, r0
sys write
label
6

.data
label: <hello\n>

MINIX 2.0.4(8086)はメッセージ構造体を渡します。メッセージの送信先をAXレジスタ、構造体のアドレスをBXレジスタ、メッセージの種類をCXレジスタで渡します。例をNASM文法で示します。

sub sp, 0x20

mov ax, 1
mov bx, sp
mov cx, 3
mov word [bx+ 2], 4
mov word [bx+ 4], 1
mov word [bx+10], label
mov word [bx+ 6], 6
int 0x20

section .data
label: db "hello", 10

通常はC言語からlibc経由で呼び出しますが、それについては回を改めます。

受け側

カーネルのコードは構造が違い過ぎて比較しにくいため、私がC++で実装したインタプリタのコードを比較します。(比較のため単純化しています)

UNIX V6(PDP-11)

void VM::_write() {
    int fd  = R0;
    int buf = read16(PC); PC += 2;
    int len = read16(PC); PC += 2;
    int result = write(fd, &mem[buf], len);
    R0 = (C = (result == -1)) ? errno : result;
}

MINIX 2.0.4(8086)

void VM::_write() {
    int fd  = read16(BX +  4);
    int buf = read16(BX + 10);
    int len = read16(BX +  6);
    int result = write(fd, &mem[buf], len);
    write16(BX + 2, result == -1 ? -errno : result);
    AX = 0;
}

試したコマンド

MINIXカーネルのビルドが目的なので、簡単なコマンド(yes/cat/date/crc)で動作確認後、すぐにコンパイラ関係に取り掛かっています。

  1. /usr/bin/yes
  2. /usr/bin/cat(命令実装ミスでハマる)
  3. /usr/bin/date
  4. /usr/bin/crc
  5. /usr/bin/ar(命令実装ミスでハマる)
  6. /usr/bin/nm
  7. /usr/lib/as
  8. /usr/bin/cc
  9. /usr/lib/ncpp(システムコール実装ミスでハマる)
  10. /usr/lib/ncem(命令実装ミスでハマる)
  11. /usr/lib/ld(命令実装ミスでハマる)
  12. /usr/lib/ncg
  13. /usr/lib/nopt(スタックの初期状態違いでハマる)

ハマりポイント

命令やシステムコールの実装を間違えたため何度もハマりました。何かの参考になるかもしれないので、時系列で追っていきます。

repプレフィックス

Intelのマニュアルにはrep movs m8, m8がF3 A4だと書いてありましたが、MINIXのバイナリではF2 A4が使われていました。F2とF3は繰り返し条件が違いますが、movsでは条件を無視するためどちらも同じ動きをするようです。

オペランドの指定ミス

デスティネーションのオペランド指定を間違えました。

    case 0xa2: // mov [addr], al
-       set8(op.opr2, AL);
+       set8(op.opr1, AL);
        return;
    case 0xa3: // mov [addr], ax
-       set16(op.opr2, AX);
+       set16(op.opr1, AX);
        return;

xchgやtestはオペランドの順序を入れ替えても結果に影響しないため、アセンブリ言語ではどのような順序でも書けますが、バイナリ上は一種類に固定化されて表現されます。固定化したときに想定される順序を間違えました。

    case 0x84:
-   case 0x85: return regrm(mem, "test", true, b & 1);
+   case 0x85: return regrm(mem, "test", false, b & 1);
    case 0x86:
-   case 0x87: return regrm(mem, "xchg", true, b & 1);
+   case 0x87: return regrm(mem, "xchg", false, b & 1);
フラグの設定ミス

add命令でCFの設定を間違えていました。周りを見るとadcやsbbも間違っていました。元の値と結果とを比較してCFを設定していますが、キャストを間違えていました。

    case 0: // add
        val = int16_t(dst = get16(op.opr1)) + int8_t(opr2);
-       set16(op.opr1, setf16(val, dst > uint8_t(val)));
+       set16(op.opr1, setf16(val, dst > uint16_t(val)));
        return;
    case 2: // adc
        val = int16_t(dst = get16(op.opr1)) + int8_t(opr2) + int(CF);
-       set16(op.opr1, setf16(val, dst > uint8_t(val)));
+       set16(op.opr1, setf16(val, dst > uint16_t(val)));
        return;
    case 3: // sbb
        val = int8_t(dst = get8(op.opr1)) - int8_t(src = opr2 + int(CF));
-       set8(op.opr1, setf8(val, dst < uint8_t(src)));
+       set8(op.opr1, setf8(val, dst < uint16_t(src)));
        return;

16bitレジスタ2個で32bit値を計算するときaddとadcを組み合わせますが、CFがおかしかったためadcが誤動作していました。

初期化漏れ

メンバ変数を初期化しなかったため、想定外の値が残って誤動作していました。

+   dsize = vm.dsize;

初期化を忘れると、毎回実行結果が変わったり、OSを変えると動きが変わったりと、不審な動きをします。

pid

Windowsのpidをそのまま返したところ、50000のような値が16bit符号付きとして処理され、マイナスになって誤動作しました。少し細工してみました。

static int convpid() {
    return (getpid() << 4) % 30000;
}

シフトしているのはforkしたときに子プロセスのpidを割り当てるための空きを確保するためです。Windowsではforkがないため、インタプリタのプロセス内で擬似的に表現しています。

ファイルディスクリプタの仮想化ミス

インタプリタ内で擬似的に複数のプロセスを表現している関係上、ファイルディスクリプタはホスト上のものを使わずに仮想化しています。そうしないと子プロセスでファイルディスクリプタを操作したら親プロセスまで影響を与えてしまうためです。

forkを実装する前はホストのファイルディスクリプタをそのまま使っていましたが、仮想化する際にlseek()だけ漏れて誤動作していました。Fileクラスでファイル操作をラッピングしているのでlseek()関数を追加しました。

off_t File::lseek(off_t o, int w) {
   return ::lseek(fd, o, w);
}

当初は戻り値をuint16_tにしていたため、64KB以上のファイルで値が壊れました。ldではオフセットを取得するのに戻り値を使っていたため誤動作しましたが、これが原因だとなかなか気付けませんでした。lseekのlはlongのlだということを痛感しました。

16進数ダンプの消し忘れ

トレースのため全命令を16進数でダンプしていましたが、不要になってからもコードを消し忘れていたため、実行速度に大きく影響していました。

-    std::string hex = hexdump(text + ip, op.len);

このhexはその後で使っていません。これを消すことで20倍近く高速化しました。文字列処理は重いということを再認識しました。

処理bit数のミス

sbb命令で16bitの値を処理しないといけないのに8bitで処理して誤動作していました。

     case 3: // sbb
-        val = int8_t(dst = get8(op.opr1)) - int8_t(src = opr2 + int(CF));
-        set8(op.opr1, setf8(val, dst < uint16_t(src)));
+        val = int16_t(dst = get16(op.opr1)) - int8_t(src = opr2 + int(CF));
+        set16(op.opr1, setf16(val, dst < uint16_t(src)));
         return;

この部分は8bitの処理をコピペして修正しましたが、そのときに修正が漏れていたようです。周りの似た命令は正しく実装していたのに、なぜかsbbだけ間違えていました。

アドレスのオーバーフロー

スタック内のアドレスをチェックするときに、想定されたSPよりもアドレスが高かったため、オーバーフローして誤動作していました。

具体的な状況を示します。

; si=0xffa4, di=0xff98 のとき
lea bx, [di+0x70]
cmp bx, si
jbe ...

16bit符号なしで di + 0x70 <= si をチェックするコードです。di + 0x70 がオーバーフローして 0x0008 となるため真と判定されます。しかしオーバーフローを想定していない部分のため、想定と異なった結果です。

diはスタックを指しているため、スタック自体を押し下げれば回避できます。実行開始時のスタックにはargc, argv, envpが入っていますが、envpは空でした。ダミーでPATHを設定することで回避しました。

std::vector<std::string> envs;
envs.push_back("PATH=/bin:/usr/bin");
vm.run(args, envs);

このバグは分かりにくかったため、原因を突き止めるのにかなり苦労しました。

リンク

開発過程のツイートをまとめました。

これらを使ってUNIX V6を8086に移植するハッカソンを開催します。