Rust + WebAssembly の x86 エミュレータに gdb 風デバッガを作る — 倒れた場所は犯行現場ではない

16bit

自作の x86 エミュレータ (rustx86) で FreeDOS を動かしたら、Tab キーを押すとカーソルが画面の先頭へ飛んだ。Ctrl+C も効かない。前回はこの手のバグを、割り込みの統計ダンプと勘で追い詰めた。今回はその反省から、デバッガを作った話である。

作ってみたら、単なる道具では終わらなかった。デバッガは望遠鏡でもあった — 最後に、FreeDOS が止まっているように見えて実は永久に走り続けている姿を観測する。

潰したバグを並べたら、道具の傾向が見えた

デバッガを作る前に、実OSを動かして潰したバグを表にしてみた。

症状 本当の原因 効いたはずの道具
遠くの番地で 0F 4C という嘘の panic 0xA9 命令の幅を取り違え、IP が2バイトずれた 巻き戻し
FreeDOS が CPU を 486 と誤認 POPFD が AC ビットを素通しした フラグの監視
Tab でカーソルが先頭へ BDA 0x450 (カーソル位置) を更新していなかった 誰がこの番地を書いたか
画面がスクロールしない CRTC の開始アドレスレジスタを無視していた 誰がこのポートを書いたか

眺めて気づいたのは、一つとして「1命令ずつ進めたい」ではなかったことだ。

IP がずれるバグが典型で、panic した場所は犯行現場ではない。ずれた IP のまま何万命令も走った先で、偶然おかしなバイト列を踏んで倒れる。倒れた場所からステップ実行で進んでも、犯人には永遠に辿り着かない。要るのは時間をさかのぼることと、誰がその番地を触ったかである。

決定性が巻き戻しを無料にする

普通のデバッガで「時間をさかのぼる」は大仕事だが、エミュレータでは違う。うちのエミュレータは決定的に作ってある — 時計 (CMOS) すらホストの時刻ではなく命令数から導いている。つまり同じ入力なら、何度流しても同じ命令数で同じ状態になる

実測もした。FreeDOS で同じコマンドを2回流したら、どちらもぴったり 779,000,000 命令だった。

だから「n 命令目に戻る」の実装はこれだけになる。

// goto <n> — その命令数まで巻き戻す。
// 実装は「機械を作り直して、最初から n 命令流し直す」だけ
m = Machine::new();
m.boot_from_disk(disk.clone()).unwrap();
m.dbg.run_to(n);
run(&mut m, n + 1);

数億命令の流し直しも数秒で終わる。決定性は最初からスナップショット再現のために入れた性質だが、ここで二度目の配当が出た。

止まった理由は「どこで」ではなく「なぜ」

設計の核はこの enum で、ウォッチポイントが値の前後と、書いた命令の位置まで抱えて止まる。

/// 止まった理由。**「どこで」ではなく「なぜ」を持つ**のが要点
pub enum Stop {
    /// 実行ブレークポイント (線形アドレス)
    Break(u32),
    /// メモリ書き込み。`at` は**書いた命令の先頭**
    WriteMem { addr: u32, old: u8, new: u8, at: (u16, u16) },
    /// I/O書き込み
    WriteIo { port: u16, val: u8, at: (u16, u16) },
    /// I/O読み出し。装置が**何を答えたか**まで残す
    ReadIo { port: u16, val: u8, at: (u16, u16) },
    /// 指定した命令数に達した
    Count(u64),
}

フックはメモリ書き込みの本道に置く。ここは最も回数の多い経路なので、切っている間は真偽値1つの判定で抜けるようにした。

pub fn write8(&mut self, addr: u32, val: u8) {
    let a = (addr as usize) & (MEM_SIZE - 1);
    // デバッガを切っていれば真偽値1つで抜ける
    if self.dbg.on && self.dbg.mem_write.contains(&(a as u32)) {
        self.dbg.stop = Some(debug::Stop::WriteMem {
            addr: a as u32,
            old: self.mem[a],
            new: val,
            at: self.dbg.at,
        });
    }
    self.mem[a] = val;
}

ベンチで測ると 375.9 → 375.2 MIPS。測定ばらつき (±10%) に埋もれて差は測れない

実演: 先週のバグを一撃で

Tab バグの調査を、デバッガで再現してみる。犯人は「BDA 0x450 を誰も更新していない」だったが、当時は実機の資料を読み漁って辿り着いた。今なら:

$ cargo run --release --example dbg -- images/fd14games.img

until FreeDOS kernel
found "FreeDOS kernel" after 1033830 instructions

w 0x450
break on write to 0x00450

c
-> 0x00450 changed 0x0e -> 0x0f by f000:0010 (instr 1033870)

f000:0010 は BIOS の INT 10h 入口。「カーソル位置を書いているのは BIOS だけ」と機械が即答している。 ゲスト自身が書いていないと分かれば、あとは BIOS 側の実装漏れを疑うだけだ。当時この1行が出るまでに数時間かかった。

全体はこう繋がっている。

デバッガの構え

作った道具が、直したばかりの病気にかかる

continue コマンドに上限を付けずにパイプ越しに流したら、10分回り続けた。パイプの向こうに Ctrl+C は無い。

実はこの直前、起動スクリプトから「9億命令」という当てずっぽうの上限値を消したばかりだった。理由まで同じ — 上限は使う人が当てる数字ではなく、暴走を止める番人であるべきで、そして打ち切ったら必ず「どこまで走ったか」を言う

c
-> ran 1000000000 instructions, nothing hit (`c <count>` to run longer)

道具を作る側も、道具に課した規律から逃れられない。

「HLT してるのに数字が増える」という嘘

ブラウザ版 (エミュレータの画面の横に子ウインドウで出す) を作っていて、ユーザー視点の指摘をもらった。「ベンチのワークロードが HLT で止まっているのに、命令数が増え続けている」。

原因は数え方で、表示していたのは実行した命令数ではなく、機械を進めた回数だった。HLT 中も装置 (タイマ) を進めるために機械は回り続ける。何も実行していないのに数字が増えれば、人は「動いている」と読む。

数を2本に分けた。

executed  369.10 M   369,095,176   ← 本当に実行した命令数
steps     702.00 M   702,000,000   ← 機械を進めた回数 (HLT中も進む)

executed は検算できる。ベンチは 512 × 65535 回のループで本体11命令だから 512 × 65535 × 11 = 369,093,120。差は 2,056 命令 (初期化と外側ループのぶん) — ワークロードが完走したことが数字で裏取りできた

2本の差はそのまま「暇にしていた時間」になる。そしてこの差が、最後の観測につながる。

望遠鏡としてのデバッガ — FreeDOS は寝ない

ELKS (16bit UNIX) をデバッガで覗くと、プロンプトで [HLT] と出て executed が止まる。寝ている。

FreeDOS を覗くと、プロンプトでレジスタが暴れ続け、executed が増え続ける。最初はデバッガのバグを疑った (デバッガを一切開かずに測り直して無実を確認した)。犯人は FreeDOS 自身だった。割り込みの内訳を採ると:

INT 16h (BIOSキーボード)   × 192,425回   「キーは来たか?」
INT 28h (DOSアイドルフック) ×  43,719回   「暇です。誰か仕事ある?」
直近の並び: 16h → 28h → 16h → 28h → 16h → 28h → …

DOS のプロンプトの正体は、この2つを永久に交互に呼び続けるループである。

理由は怠慢ではなく構造で、HLT (割り込みが来るまで眠る命令) は「実行可能なタスクの列が空だ」と言える者 — スケジューラ — がいて初めて意味を持つ。単一タスクの DOS には列そのものが無いので、待つ = 回す以外の選択肢が無い。INT 28h は常駐プログラムに CPU を譲ろうとするフックで、DOS なりの誠意ではあるのだが、誰も引っかけていなければそのまま戻ってきて回り続ける。

1981年の机上機には省電力という発想が無かった。この設計が電池で動く機械で問題になるのは、ずっと後の話である。

エミュレータの実利もある。回り続けるゲストはホストの CPU を本当に食う。executed と steps の2行は、ゲストが電気を無駄にしているかどうかの計器でもある。

数字

  • 実装: 土台 (core/src/debug.rs) + CLI + ブラウザの子ウインドウ
  • テスト13件 (ブレークが実行前に止まる、書いた命令の位置、ワード幅アクセスがフックを通る、巻き戻しの決定性、など)
  • フックのコスト: 実測差なし (375.9 → 375.2 MIPS、ばらつき±10%)
  • 逆アセンブラは入れていない。16bit の間は自分で書いた nasm を追うので生バイトで足りる。他人のコードを記号なしで読む日 (Linux のエントリを追う日) が来たら、実行系と知識が二重にならないよう既存クレートを core の外に置く

次回

このデバッガはプロテクトモード (32bit化) のために作った。gdb は GDT もセグメントの隠しレジスタも見せてくれないが、保護モードで死ぬ原因はほぼそこにある。次回は「リアルモードは保護モードの特殊ケースだった」という話になる予定。

コードはすべて公開している: github.com/yoshiharu-ishii/rustx86

コメント

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