Rust製x86エミュレータで本物のLinux 6.18を起動し、BusyBoxシェルでゲームを動かすまで

AI

自作のx86エミュレータ rustx86 に、Alpine Linux の本物のカーネル (Linux 6.18) を食わせた。丸一日の格闘の末、start_kernel を完走し、ユーザー空間に降り、BusyBox のシェルに ~ # のプロンプトが出て、そこで自作のスネークゲームが動いた。

この記事は、その一日の開発記である。BIOS を通さず bzImage を直接ロードするところから、実カーネルが炙り出した約20個の CPU バグ、リング3への遷移で踏んだ罠、そして「どうせ作らないといけない」SSE の実装まで。エミュレータのバグは、たいてい倒れた場所に犯人がいない。 その謎解きが読みどころである。

rustx86 は Rust 製で、core は外部依存ゼロ。同じコアが WASM でブラウザでも、ネイティブでも動く。リポジトリはこちら:

GitHub - yoshiharu-ishii/rustx86
Contribute to yoshiharu-ishii/rustx86 development by creating an account on GitHub.

出発点: BIOS を通さず bzImage を直接ロードする

実機の Linux 起動は長い。BIOS → ブートローダ (GRUB) → カーネルの16bit setup → 32bit カーネル、と段を踏む。だがブートローダの仕事の本質は「カーネルをメモリに置いて、約束された状態で飛ぶこと」であり、エミュレータならそれを直接やれる。QEMU の -kernel と同じ「32bit ブートプロトコル」を使い、16bit setup を飛ばして protected-mode のカーネルへ直接ジャンプする。

やることは3つだけだ。

  1. カーネル本体を物理 1MB へ置く
  2. zero page (boot_params) を組む — コマンドライン、メモリマップ (e820)、initrd の場所
  3. protected mode に入り、%esi に zero page を指させて、code32_start へ飛ぶ
pub fn boot_bzimage_with_initrd(
    &mut self, image: &[u8], cmdline: &str, initrd: Option<&[u8]>,
) -> Result<(), String> {
    let hdr = bzimage::SetupHeader::parse(image)?;   // "HdrS" マジックを読む
    // カーネル本体を物理 1MB へ
    let kbody = &image[hdr.kernel_offset()..];
    for (i, b) in kbody.iter().enumerate() {
        self.write_phys8(0x0010_0000 + i as u32, *b);
    }
    // ... zero page と initrd を配置 ...
    self.cpu.regs[cpu::SI] = ZERO_PAGE_ADDR;     // %esi = boot_params
    self.cpu.set_ip(hdr.code32_start);
    Ok(())
}

最初の罠: GDT を省いたら即死した

私は最初、protected mode のフラットなセグメントを隠しレジスタに直接書いた。GDT (セグメント記述子表) を組むのが面倒だったからだ。動いた ── ように見えた。

だが、カーネルは起動直後に mov ds, ax のようにセグメントレジスタを再ロードする。そのとき CPU は GDTR の指す表を読み直す。表が無いと、物理0番地のゴミを記述子として読んで base が壊れ、墜落する。

Linux boot protocol は「ブートローダが flat GDT を用意して GDTR をロードしておくこと」を要求している。私のショートカットは、カーネルがセグメントを触るまでしか保たなかったわけだ。素直に GDT を物理メモリに組んだ。

実カーネルは最高のバグ発見器である

ここからが本番だ。実カーネルを走らせ、止まった場所を見て、原因を実装する、という繰り返しに入る。

未実装命令に当たっても、私のエミュレータは panic しない。trap という「巻き戻せる停止」にして、機械を生かしたままレジスタもスタックも覗けるようにしてある。これが Linux 起動のデバッグの生命線になる。

そして最初に踏んだのが、この一日で何度も顔を出す「16bit固定の巣」だった。

謎解き1: rep movsl が3648回で尽きる

カーネルのデコンプレッサ (自己解凍部) は、自分自身をメモリの別の場所へコピーする。rep movsl ── 「ECX 回、4バイトずつコピー」だ。ところがコピーが途中で尽き、飛んだ先がゼロ領域で墜落した。

犯行現場を見ると、rep movsl の後で ESI が 0x9371fc → 0x9338fc、つまり たった 0x3900 (3648 dword) しか進んでいない。本来は ECX=0x20DC80 (約215万回) 回るはずだ。

string.rs を覗いて理由が分かった。ストリング命令の実装がまるごと16bit専用だったのだ。

// 修正前: カウンタもインデックスも 16bit 固定
if d.rep.is_some() && m.cpu.reg16(CX) == 0 { break; }  // CX (16bit)
let si = m.cpu.reg16(SI);                              // SI (16bit)
// MOVS も size 1 か 2 バイトしか動かさない (dword=4 が無い!)

rep movsl は本来、ECX をカウンタに、ESI/EDI をインデックスにして、4バイトずつ動かす。それが CX/SI/DI の16bit・2バイトで回っていた。ELKS や FreeDOS は16bitなので、この実装で何十年も (いや、数週間だが) 平気だった。Linux が初めてこの地雷を踏んだ。

オペランドサイズとアドレスサイズの両方を見る形に書き直した。

let a32 = d.addrsize32;
// 転送幅: 偶数オペコード=1バイト、奇数=オペランドサイズ (2 or 4)
let width: u32 = if op & 1 == 0 { 1 } else if d.opsize32 { 4 } else { 2 };
loop {
    if d.rep.is_some() && m.cpu.reg_w(CX, a32) == 0 { break; }
    let si = m.cpu.reg_w(SI, a32);   // a32 なら ESI
    // ... width バイト転送、ESI/EDI/ECX を a32 幅で更新 ...
}

この「幅なし命令族が32bitで牙を剥く」パターンは、この日だけで十数回踏むことになる。PUSH imm、moffs、string、SP幅、POP r/m、GRP3 (MUL/DIV/NOT)、シフト、CWD/CDQ、XCHG、LOOP、IN/OUT ── どれも「16bit時代に書いて、そのまま忘れていた」実装だった。

検証の切り札: カーネルと Unicorn の1命令ロックステップ

こうしたバグは、命令の実行結果としては「動く」。だが値が微妙に違う。デコンプレッサのような「アルゴリズムが自分の出力を検証するコード」は、CPU のわずかな噛み違いを何十万命令も先で「uncompression error」としてしか教えてくれない。犯行現場が遠すぎるのだ。

そこで、実カーネルの実行列そのものを、オラクル (Unicorn Engine = QEMU の CPU 部分) と1命令ずつ突き合わせるハーネスを作った。

// 同じ初期状態から、自作CPUと Unicorn を1命令ずつ進めて突き合わせる
loop {
    uc.emu_start(eip, 0, 0, 1)?;   // Unicorn を1命令
    m.step();                       // 自作CPUを1命令
    if m.cpu.ip != uc.reg_read(EIP)? { /* ここで食い違った! */ }
    for (ur, gi) in gpr {
        if m.cpu.regs[gi] != uc.reg_read(ur)? { /* レジスタが違う */ }
    }
}

これが GRP3 (NOT/MUL/DIV) やシフトの16bit固定を、数十命令で特定してくれた。「NOT 0xFFFFFF が 0x00FF0000 になっている」といった具合に、その場で。エミュレータ開発における最強の道具のひとつだと思う。

ページングと保護 ── 「壊れたWP」と言われて起動を拒否される

デコンプレッサを抜け、カーネル本体が動き出すと、次はページングと保護の世界だ。ここで印象的なメッセージに出会った。

Checking if this processor honours the WP bit even in supervisor mode...No.
Kernel panic - not syncing: Linux doesn't support CPUs with broken WP.

CR0.WP (Write Protect) ビットは、「リング0 (カーネル) であっても、読み取り専用ページへの書き込みを禁じる」という機能だ。カーネルはこれを起動時に自分で試験する。わざと読み取り専用ページに書いてみて、#PF (ページフォールト) が飛んでくるか確かめる。飛んでこないと「壊れた WP の CPU だ」として起動を拒否する。

つまり #PF の配送を、ページ保護のチェック込みで実装しないと、Linux は起動すらしてくれない。R/W ビット・U/S ビットを裁き、CR0.WP を見る変換を書いた。

pub fn translate_for(&self, la: u32, write: bool) -> Result<u32, PageFault> {
    // ... 2段のページテーブルを歩く ...
    let user = self.cpu.cpl() == 3 && !self.sys_access.get();
    let wp = self.cpu.cr0 & 0x0001_0000 != 0;  // CR0.WP
    // 書き込みで R/W=0 のページは、WP が立っていれば (リング0でも) フォールト
    if write && (pde & 2 == 0 || pte & 2 == 0) && (user || wp) {
        return Err(fault(true));
    }
    // ...
}

実装した瞬間、...honours the WP bit... Ok. に変わった。カーネルの自己診断が、そのまま第二のオラクルになっている。 WP 試験も、FPU 検出列も、rdmsr_safe の #GP フォールバックも、すべて「実機と同じ答え」を返せば通過する。

フォールトした命令は「何も起きなかったことにする」

PF を実装すると、今度は「フォールトからの再開」という難物が出てくる。実機の約束は明快だ ── フォールトした命令は、何も起きなかったことになる。 ハンドラがページを直して iret すれば、同じ命令が白紙からやり直される。

最初、私は IP だけ巻き戻していた。すると add mem, reg の読みがデマンドページングに当たったとき、フォールトの器 (未マップの 0xFFFFFFFF) を足した EDX (= -1) が残り、再実行が汚れの上に積んでいった。musl の ELF ヘッダ解析が1バイトずれ、全ユーザープロセスが同じ NULL デリファレンスで死ぬ

Kernel panic - not syncing: Attempted to kill init! exitcode=0x0000000b

犯人は「巻き戻しが不完全」だった。CPU 状態を丸ごと控えて、フォールト時に戻す。

let saved = self.cpu.clone();   // 命令の前に控える
cpu::step(self);
if let Some(f) = self.pending_fault.take() {
    self.cpu = saved;           // 命令前の姿へ完全に戻す
    self.cpu.cr2 = f.la;
    cpu::page_fault(self, err); // #PF を配送
}

リング3 ── ユーザー空間への降下で踏んだ2つの罠

start_kernel を完走し、Run /init as init process が出た。ついにユーザー空間 (リング3) のコードが動き出す。だが最初のユーザー空間復帰で、また墜落した。

罠1: iret の pop 順序

iret で外側のリング (リング0 → リング3) へ戻るとき、スタックから EIP・CS・EFLAGS・ESP・SS を pop する。私は CS を先に積んで (= その瞬間 CPL=3 になる)、残りの pop をしていた。

だが、そのとき pop するのはカーネルのスタック (U/S=0 のスーパーバイザページ) だ。CPL=3 になった直後の pop は、私のページ保護チェックに弾かれて 0xFFFF を拾った。実機のリング遷移は命令の最後に一括で完成する ── すべての pop は旧特権 (CPL=0) で行われる。

// pop は全部、旧特権のうちに済ませてから CS を積む
let outer = if to_outer {
    let esp = pop32(m);   // まだ CPL=0
    let ss = pop32(m) as u16;
    Some((esp, ss))
} else { None };
load_seg_raw(m, CS, sel);   // ここで初めて CPL=3 になる

罠2: 暗黙のスーパーバイザアクセス

もう一つ。リング3から例外 (デマンドページングの #PF は日常茶飯事だ) を配送するとき、CPU は IDT・GDT・TSS を読み、カーネルスタックに push する。これらは CPL=3 のさなかでもスーパーバイザ権限で行われる ── 実機の「暗黙のシステムアクセス」だ。

私の変換は「CPL=3 なら全部ユーザーアクセス」と一律に裁いていたので、IDT 読みが弾かれ、ゲート記述子がゴミ (存在しない型 0x1F) になっていた。sys_access フラグを立てて、記述子読みと例外配送の間だけ U/S 検査を免除した。

これらを直すと、ついにシェルのプロンプトにコマンドを打てるようになった。ただし ── 一つだけ、大きな壁が残っていた。

SSE ── 「どうせ作らないといけない」

Alpine のユーザーランドは i686 ビルドだ。そして gcc は memcpy も strlen も、XMM レジスタで16バイトずつ束ねて回すコードを吐く。しかも CPUID を確認せず、無条件に xorps movdqa pcmpeqb を使う。

つまり ── 現代の Linux ユーザーランドを走らせることは、SSE を持つことと同義なのだ。snake を実行した瞬間、invalid opcode で SIGILL。逃げ場はない。

相棒 (私は AI と一緒に開発している) がこう言った。「SSE命令を作ろうよ。どうせ作らないといけないんだぜ」。正論だ。正面から作った。

SSE のデコードには面白い仕掛けがある。オペコード空間を増やさず、既存のプレフィクスを味変に使うのだ。

バイト列 命令 プレフィクスの意味
0F 10 movups なし = packed single
66 0F 10 movupd 66 = packed double
F3 0F 10 movss F3 = scalar single
F2 0F 10 movsd F2 = scalar double

66 は「オペランドサイズ」、F2/F3 は「REP」だったものが、0F の直後では命令選択子に化ける。デコーダはすでに両方を記録しているので、読み替えるだけでよい。

XMM レジスタ8本 (128bit) を CPU に足し、データ移動・整数演算・浮動小数点、そして strlen/memchr の心臓部である pcmpeqb/pmovmskb を実装した。

// pcmpeqb: 一致したバイトを全1に。文字列処理の核
0x74 => int_binop(m, d, lanes8(|a, b| if a == b { 0xFF } else { 0 })),
// pmovmskb: 各バイトの符号ビットを16bitに束ねる ── 「どのレーンが一致したか」
0xD7 if p == Pfx::P66 => {
    let mask = to8(v).iter().enumerate()
        .fold(0u32, |acc, (i, &b)| acc | ((b >> 7) as u32) << i);
    m.cpu.regs[reg] = mask;
}

浮動小数点はホストの f32/f64 でそのまま計算する割り切り。カーネル起動と busybox に必要なのは、FP の厳密さではなく「値の器としての XMM」だからだ。

そして忘れてはいけないのが FXSAVE/FXRSTOR だ。これはカーネルがコンテキストスイッチで XMM を退避するための命令で、CPUID で「SSE 持ってます」と名乗った以上、これが動かないとプロセス間で XMM が混線する。512バイトの決められた配置に書く。

CPUID の返り値に FXSR|SSE|SSE2 のビットを立てた。「名乗った分は全部実装する」が、このプロジェクトの流儀だ。

そして、ゲームが動いた

SSE を実装して再起動。snake が動いた。

  rustx86 mini initramfs — busybox shell
  ゲーム: snake   エディタ: vi

~ # snake
+----------------------------------------+  score 0
|                    oo@                 |     ← 蛇が這っている
+----------------------------------------+  hjkl/wasd で移動、q で終了
 GAME OVER — score 0
~ # echo ALIVE_AFTER_GAME
ALIVE_AFTER_GAME                               ← ゲーム終了後もシェルは健在

Linux 6.18 のユーザー空間で、termios の生モード・ANSI エスケープ描画・nanosleep・キー入力・正常終了までフルに通った。自作の x86 エミュレータの上で、本物の Linux が本物のプログラムを走らせている。

最後の仕上げとして、UART が「FIFO なしの素の8250」を名乗っていたせいで1バイトごとに割り込み1回という遅さだったのを、16550A を名乗って16倍速にし、snake を差分描画 (動いたマスだけ書く) にして、シリアルの細い管でも滑らかに動くようにした。

一日の全景と、この先

朝は「デコンプレッサで謎の墜落」だった。夜には「Linux 6.18 のシェルでゲームが動く」になった。この一日で踏んだ CPU バグは約20件、新設したデバッグ道具は3つ (カーネルとの1命令ロックステップ、RAM からの dmesg 発掘、cpio を直接書く mkcpio)。

貫いた設計思想が2つある。ひとつは panic ではなく trap ── 機械を殺さず、止まった場所を生かしたまま覗ける。もうひとつは カーネル自身をオラクルにする ── WP 試験も FPU 検出も、実機と同じ答えを返せば通過する。実カーネルは、これ以上ないほど厳密で網羅的なテストスイートだった。

rustx86 の当初のゴールは「32bit Linux がブラウザで起動する」だった。その中核である CPU とカーネルの境界は、これで開通した。この先のロードマップは、GUI (X の動く UTM のようなもの)、ネイティブ化 (M1 Mac / Windows)、そして KVM を使わない Firecracker ── ハードウェア仮想化に頼らないからこそできる、決定的な (ビット単位で再現する) マイクロ VM へと続く。

自作エミュレータで本物の OS を起動する遊びは、教科書の知識が「なぜそうなっているか」の実感に変わる瞬間の連続だった。GDT がなぜ要るのか、WP ビットが何を守るのか、SSE がなぜメモリ操作に化けたのか ── 全部、動かないという形で教わった。

コードは全部公開してある。同じ遊びをしたい人の役に立てば嬉しい。

GitHub - yoshiharu-ishii/rustx86
Contribute to yoshiharu-ishii/rustx86 development by creating an account on GitHub.

コメント

タイトルとURLをコピーしました