ゲームボーイエミュレータを完成させた勢いで、次はx86に手を出した。最終目標は「ブラウザで32bit Linuxが起動し、busyboxシェルが叩ける」こと。今回はその第一歩、リアルモード8086が動くところまでの開発記である。
主役は命令の実装ではなく検証手法だ。x86にはゲームボーイのBlarggテストROMに相当する「これを流せば全部わかる」テストが存在しない。代わりに、正解を知っているCPUに答え合わせをさせる仕組みを作った。
コードはGitHubで公開している: https://github.com/yoshiharu-ishii/rustx86
この記事では図とコードで役割を分けている。図は「どこで何が起きるか」という全体の地図で、コードはその地図上の一点を実際に動かしている実物である。各図の下には対応するソースファイルへのリンクを置いたので、地図で位置を掴んでから実物を見に行ってほしい。
なぜx86は大変か
ゲームボーイのSM83と比べると、x86の面倒さは桁が違う。
- 命令が可変長: 1〜15バイト。プレフィクス(セグメント上書き、REP、LOCK)が任意個先頭に付く
- 歴史の地層: リアルモード→プロテクトモード→ページング→ロングモード。Linuxを起動するにはこの層を貫通する必要がある
- EFLAGSの意味論: 全ALU命令がCF/OF/SF/ZF/AF/PFの6フラグを規則に従って更新する。特にAF(下位4bitからの桁上がり)とOF(符号付きオーバーフロー)は境界値でしか姿を現さない
3つ目が最大の敵だ。フラグが1ビットずれるバグは、Linux起動中に条件分岐がひとつ曲がって初めて発覚し、そこから原因の命令まで遡るのは地獄である。
実行の仕組み
まず全体像から。命令1個の実行はこう流れる。

図の役割: 命令1個が通る道筋。この図の「巨大な match」が cpu/mod.rs で、下の段の共通部品が cpu/operand.rs cpu/alu.rs などにあたる。
コードの役割: 上の図の「ALUグリッド」の箱を実際に開けると、こうなっている。
x86のオペコードは一見でたらめだが、規則的な「格子」を持つ部分が大きい。たとえば 0x00〜0x3D はADD/OR/ADC/SBB/AND/SUB/XOR/CMPの8演算 × 6形式の格子になっていて、48命令が1つのハンドラで処理できる。
0x00..=0x3F if op & 7 <= 5 => {
let kind = (op >> 3) & 7; // 演算種別
match op & 7 { /* 形式ごとにオペランドを解決 */ }
}
ゲームボーイで確立した「ビットパターンで畳む」方式がそのまま効く。畳めない歴史的な不規則部分(十進補正やストリング命令)だけ個別に書けばいい。実物は cpu/mod.rs の match にある。
正解を知っているCPUに答え合わせさせる
本題の検証手法だ。使ったのはUnicorn Engine、QEMUのCPUエミュレーション部分をライブラリとして切り出したものである。Rustから数行で呼べる形で「正確なx86」が手に入る。
これをオラクル(神託)として、同じ問題を自作CPUと解かせて答えを突き合わせる。co-simulation(比較実行)と呼ばれる、ハードウェア検証では定番の手法だ。

図の役割: 検証の1サイクル。実物は cosim/src/lib.rs (状態の定義と差分表示) と cosim/tests/alu.rs (オラクル呼び出しと命令テンプレート) に分かれている。
肝は同じ初期状態を両方に与えること。レジスタもフラグもメモリも揃えて1命令だけ走らせ、実行後の全状態を比較する。食い違えば自作CPUのバグで、しかも「どの命令の、どの入力で、どのフラグが」という粒度で即座に分かる。
生成はランダムだが、完全なランダムバイト列は撒かない。ほとんどが無効命令になって無駄だからだ。代わりに「このオペコードはこういう形」というテンプレートを定義し、オペランドと初期値だけを振る。初期値には0x00/0x01/0x0F/0x10/0x7F/0x80/0xFFといった境界値を高頻度で混ぜる。AFやOFは境界を踏まないと姿を現さないからである。
緑のテストは、それ自体では何も証明しない
ALU全8演算・メモリオペランド・シフト/回転・乗除算・十進補正・ストリング命令、と実装してテンプレートを足していったところ、全部一発で通った。
ここで手を止めた。テストが緑なのは、実装が正しいからか、テストが何も見ていないからか。区別がつかない。
そこで、わざとCPUを壊して検出されるかを確かめた(変異テスト)。ADCのAFフラグ計算からキャリーの加算を1箇所落とす。
[ADC r/m8,r8] code=[10, d4] regs=[8000, 19ff, ...] flags_in=CF|PF
FLAGS: ours=PF|SF oracle=PF|AF|SF (差分 AF)
即座に捕まった。命令、入力レジスタ、違うフラグまで出る。これでハーネスは信用できる。
ランダムでは踏めない床があった
ところが、2つ目の変異で問題が露呈した。十進補正命令DAAの境界値を 0x99 から 0x9A にずらしてみたところ、3000ケース流しても検出できなかった。
理由は単純で、ALがちょうど 0x9A になるケースを踏み損ねていたのだ。ランダム生成は広く浅く探索するので、1点だけの境界には当たりにくい。
DAAのような十進補正命令は、ALの値とCF/AFだけで分岐が決まる。状態空間は 256 × 3 × 4 しかない。ならば総当たりすればいい。
co-sim mismatch [DAA] code=[27] AX=009a flags_in=-
AX: ours=00a0 oracle=0000
切り替えた途端、まさに 0x9A で一発で捕まった。状態空間が小さいならランダムより総当たりという、当たり前だが実際に踏むまで気づかない使い分けである。
オラクルも嘘をつくことがある
もう一つ面白い引っかかりがあった。ストリング命令のテストで、こんな差分が出た。
[MOVSB] SI: ours=0000 oracle=ffff
DI: ours=1060 oracle=105f
IP: ours=1001 oracle=1000
自作CPU側はSI/DIを更新しているのに、オラクル側は何も変わっていない。IPすら進んでいない。つまりUnicornは命令を実行しなかった。
原因はランダムなDIがコード領域を指していたことだった。QEMUは自己書き換えコードを検出すると、生成済みの変換ブロックを捨てて命令をやり直す。1命令だけ実行する設定だとその「やり直し」で止まってしまう。自作CPUのバグではなく、オラクル側の都合である。SI/DIをデータ領域に収めて解決した。
オラクルは正しいが、オラクルの使い方は間違えられる。差分が出たとき「自分が悪い」と決めつけないのも大事だと学んだ。
932行になったので割った
命令を増やすうちに cpu.rs が932行に育った。プロテクトモードを実装すれば確実に倍以上になる。
分割にあたって、命令ごとに1ファイルにはしなかった。x86の命令は数百あるし、そもそもALUグリッドのように48命令が1ハンドラで処理される構造を壊してしまう。代わりに、振り分け表と共通部品を分けた。
| ファイル | 役割 | 行数 |
|---|---|---|
cpu/mod.rs |
オペコードの振り分け表(巨大なmatch) | 502 |
cpu/operand.rs |
ModRM解決、オペランドアクセス、スタック | 116 |
cpu/alu.rs |
8演算とフラグ計算 | 96 |
cpu/shift.rs |
シフトと回転 | 94 |
cpu/string.rs |
ストリング命令とREP | 119 |
cpu/decimal.rs |
十進補正 | 73 |
matchが1箇所に集まっていること自体が価値で、「この命令はどこで処理されるか」を探す手間がゼロになる。core/src/cpu/ がその形である。
そして分割のような大きな移動こそ、co-simが真価を発揮する場面だった。9万ケースが番人になってくれるので、大胆に動かして最後に一発流せばいい。実際、分割の前後で全通過を確認して終わった。
ちなみに分割中、Rustコンパイラが unreachable pattern 警告で実バグを1つ見つけてくれた。古いLODSB/STOSBのmatchアームが、新しく書いたREP対応版を覆い隠していたのだ。網羅性を検査してくれる言語の恩恵である。
まとめ
- x86には網羅テストROMがない。ならば正解を知っているCPUを持ってきて答え合わせさせる
- 緑のテストは検証能力を証明しない。わざと壊して検出できるかを確かめる
- ランダム探索には踏めない床がある。状態空間が小さいなら総当たり
- オラクルが正しくても、オラクルの使い方は間違えられる
コードは公開しているので、co-simハーネスの実装を見たい方はどうぞ: https://github.com/yoshiharu-ishii/rustx86
現在地はリアルモードの完成。次はプロテクトモード、ページング、そしてBIOSを実装せずにLinuxカーネルを直接ロードする方式でbusyboxシェルを目指す。道のりは長いが、答え合わせしてくれる相棒がいるので迷子にはならないはずだ。


コメント