Rustでエミュレータを書く 連載 第4回
1. RustとWASMでゲームボーイエミュレータを作る
2. x86エミュレータを自作する — 正解を知っているCPUに答え合わせさせる
3. 8259 PIC と BIOS を書いて、16bit UNIX (ELKS) をログインまで動かした
4. FreeDOS を起動して、ブラウザで DOS ゲームを動かした ← 今ここ
前回は自作の x86 エミュレータで
16bit UNIX (ELKS) をブラウザで動かすところまで書いた。今回はその続きで、
FreeDOS が A:\> まで起動し、DOSのゲームがブラウザで遊べるようになった。
読みどころは犯人探しである。「ゲームの画面が下半分出ない」という症状を追いかけて、
BIOSを疑い、ゲームを疑い、最後に自分が書いて自分で使っていなかった仕組みに
行き着くまでの話。
コードは GitHub にある: https://github.com/yoshiharu-ishii/rustx86
動いているもの

左のメニューで FreeDOS を選ぶと、勝手に A:\> まで進む。ELKSと違って
画面もキーもディスクも全部BIOS経由なので、こちらはBIOS層の検証になる。

1966年の対話プログラム ELIZA。画面・キーボード・ファイル読み出しが全部
通らないとこの受け答えは出ないので、総合試験としてよくできている。
まず32bit命令が要った
FreeDOS の起動途中で、こんな命令に当たって止まった。
53 PUSH BX
66 9C PUSHFD ← 32bitオペランドサイズ・プレフィクス
66 58 POP EAX
66 0D 00 00 04 00 OR EAX, 0x00040000 ← bit18 = AC フラグ
386と486を見分ける定番のCPU判定である。カーネルは8086ビルドなのに、
起動時に走るユーティリティが386命令を使っていた。
ここで良い発見があった。0x66 はプロテクトモードと独立している。
「既定の幅をひっくり返す」だけのプレフィクスなので、GDTも特権リングも
実装せずにこれだけ先に動かせる。「32bit化」は命令セットとモードという
2つの別の話だった、というのが今回一番の収穫かもしれない。
そして器を作り直す必要が無かった。連載の最初からレジスタを u32 で
持っていて、16bit命令が上位を保存する形にしてあったからである。
fn set_reg16(&mut self, r: usize, v: u16) {
self.regs[r] = (self.regs[r] & 0xFFFF_0000) | v as u32;
}
/// 32bit書き込みは**上位も含めて全部置き換える** (16bitのような保存はしない)
fn set_reg32(&mut self, r: usize, v: u32) {
self.regs[r] = v;
}
実機の EAX と AX の関係がそのまま出ている。幅は型ではなく実行時のフラグで選ぶ。
/// 幅を実行時に選ぶALU。呼ぶ側に同じ形のコードを2本書かせないための入口
pub fn alu_w(c: &mut Cpu, op: u8, a: u32, b: u32, wide: bool) -> u32 {
if wide { alu32(c, op, a, b) } else { alu16(c, op, a as u16, b as u16) as u32 }
}
幅対応を忘れた命令は、静かに壊れる
実装したのに動かない。しかもまったく関係ない番地で「0F 4C (CMOVL) が未実装」
という顔をして倒れる。Pentium Pro の命令が8086向けビルドに出てくるはずがない。
原因は TEST EAX, imm32 の幅対応漏れだった。即値を16bitで読むのでIPが2バイトずれ、
以後はデータを命令として食い始めていたのである。
見つけたのは、こういう仕掛けを入れたからだった。
if d.opsize32 {
m.prefixed_ops.insert(op);
}
0x66 が付いて実行されたオペコードを控えておく。止まったときに出す。
--- 0x66 を付けて実行されたオペコード ---
0x02 0x0d 0x21 0x33 0x39 0x50 0x53 0x58 0x5b 0x8b 0x9c 0x9d 0xa9 0xb9
^^^^ 未対応
一発だった。panicも出さずに静かに壊れるものは、記録を取るしかない。
BIOSの穴が4つ出てきた
前回、アーキテクチャの文書にこう書いた。
BIOSの死んだ役割はサービス提供者、生きている役割は立ち上げとマシンの説明書
今回踏んだ4つは、全部後者だった。書いた本人が同じ穴に4回落ちたことになる。
1. BIOSがPITを設定していなかった
実BIOSはPOST時にタイマを 18.2 Hz に設定する。ELKSは自分で設定するので
気づかなかったが、DOSはBIOSが設定済みであることを前提にしている。
fn install_pit_defaults(&mut self) {
let pit = &mut self.devices.pit;
pit.write_control(0x36); // カウンタ0、LoHi、モード3 (方形波)
pit.write_counter(0, 0x00);
pit.write_counter(0, 0x00); // 分周値0 = 65536 → 18.2 Hz
pit.write_control(0x54); // カウンタ1、LoOnly、モード2 (レート生成)
pit.write_counter(1, 18); // DRAMリフレッシュ
}
時計が進まないだけでなく、「HLTして待つ」形の待ち合わせが永久に目を覚まさない。
2. IRQ0/1 がマスクされたままだった
割り込みを全部閉じて渡していた。キーが押されたことを知る手段が割り込みしか
無いのだから、閉じたまま渡す意味が無い。
3. INT 09h がスキャンコードを捨てていた
INT 16h が8042を直接読む作りだったので、INT 16h しか使わない
プログラムだけが動いていた。BIOSデータエリアを直接覗くプログラム
(DOSには多い) からは、キーが永久に来ないように見える。
実BIOSと同じ 8042 → IRQ1 → INT 09h → BIOSの待ち行列 → INT 16h の経路に直した。
4. INT 16h が待つときにIFを開けていなかった
これが決定打だった。
0x00 | 0x10 => match self.take_key() {
Some(v) => self.cpu.regs[cpu::AX] = v as u32,
None => {
// **待つなら割り込みを開ける。**
//
// `INT` 命令はIFを落とす (x86の仕様)。落としたまま待つと
// キーボード割り込みが永久に来ず、待っているものが二度と届かない。
// 実BIOSの待ちループに `STI` があるのはこのためで、ここでも同じことをする
self.cpu.set_flag(cpu::IF, true);
return false;
}
},
INT 命令はIFを落とす。x86の仕様どおりの正しい挙動である。だから
待つ側が自分で開けなければ、待っているものは二度と来ない。
実BIOSの待ちループに STI があるのは飾りではなかった。
診断: 割り込みの持ち主を出す
途中で「BIOSを直したのに効かない」に何度もぶつかったので、こういうものを出すようにした。
--- 割り込みベクタの持ち主: 0x08=0070:000f(ゲスト) 0x09=0070:0016(ゲスト)
0x10=f000:0010(BIOS) 0x16=f000:0016(BIOS) ---
FreeDOSは INT 08h と 09h を自分のものにしている。 つまりFreeDOS下では
こちらのキーボード割り込み処理は一度も走らない。直しても効かないわけである。
そしてゲーム
FreeDOS公式のリポジトリにフリーソフトウェアのゲームが並んでいるので、
起動フロッピーに載せた。ELIZA、四目並べ、スネーク、ハングマン。
動かしてすぐ、スネーク (zmiy) が変だった。画面の下半分が出てこない。

ここから犯人探しが始まる。
疑い1: ゲームがVGAを要求している?
説明書にこうあった。
Zmiy simulates graphics using a 80×50 text mode on VGA.
If running on a non-VGA card, it will use the standard 80×25 mode.
80×50 で描こうとしている。こちらは MC6845 と 0xB8000 を持つCGA相当なので、
VGAではないと答えているつもりだった。答え方が悪いのだろうか。
INT 10h AH=1A (表示装置の種別) で AL=0 を返し、さらに BL=0x02 (カラーCGA) も
明示した。変わらない。
疑い2: 行数の答えが悪い?
BIOSデータエリアの 0x484 (行数-1) を24にしてある。試しに23にしてみた。
何も変わらない。 そもそも読んでいなかった。
疑い3: CRTCを直接叩いている?
トレースを入れた。
CRTC R0d <- 0x00
CRTC R0c <- 0x00
R0C/R0D — 表示開始アドレス。幾何レジスタは触っていない。
疑い4: 機種を名乗っていない?
実機のBIOS ROMには末尾に日付と機種コードが焼かれていて、ソフトはそこを読んで
機械の世代を決める。こちらは 0 のままで、「どの機種でもない機械」に見えていた。
F000:FFFE = 0xFE (PC/XT) を置いた。やはり変わらない。
ソースを読んだ
zmiy はBSDライセンスで、上流の配布物にソースが入っていた。推測をやめて読んだ。
VGA判定はこうだった。
/* returns 0 if no VGA has been detected, non-zero otherwise */
int detectvga(void) {
union REGS regs;
regs.x.ax = 0x1A00;
int86(0x10, ®s, ®s);
if (regs.h.al == 0x1A) return(1); /* VGA supported */
return(0); /* else it's not vga */
}
こちらの返事で正しく非VGAになる。疑い1は無実だった。
そして描画がこうなっていた。
static void refreshscreen(struct gamestruct *game) {
/* if screen's eight less than 50, then use scrolling when needed */
if (game->screenheight < 50) {
int newscrolloff = game->snakeposy[0] - 12; /* 蛇に追従 */
if (newscrolloff < 0) newscrolloff = 0;
else if (newscrolloff > 25) newscrolloff = 25;
if (newscrolloff != game->scrolloffset) {
game->scrolloffset = newscrolloff;
scrollscreen((newscrolloff << 6) + (newscrolloff << 4)); /* = 行 × 80 */
}
}
...
50行の盤面を常にVRAMへ描き、見える25行の窓をCRTCの開始位置で蛇に追従させる。
ゲームは何も間違っていなかった。CGA機での正しい遊び方だった。
犯人
画面を読み出すこの関数である。
/// テキスト画面の生バイト列
pub fn text_vram(&self) -> &[u8] {
let b = bus::VRAM_TEXT_BASE as usize;
&self.mem[b..b + bus::TEXT_LEN] // ← 常に窓の先頭から4000バイト
}
テキストVRAMの窓は 32KB ある。80×25の1画面はそのうち4000バイトでしかない。
どこから表示するかを決めるのはCRTCのレジスタで、ここを動かすとメモリを
1バイトも書き換えずに画面がスクロールする。
そして自分の crtc.rs には、こう書いてあった。
/// 表示を開始するVRAM上の位置 (文字単位)。
/// ここを動かすとメモリを触らずにスクロールできる (ハードウェアスクロール)
pub fn start_offset(&self) -> u16 {
実装して、説明も書いて、描く側が読んでいなかった。
直した後。
pub fn text_vram(&self) -> &[u8] {
let win = (bus::VRAM_TEXT_END - bus::VRAM_TEXT_BASE + 1) as usize;
// 開始位置は文字単位。1文字2バイトなので倍にする
let start = (self.devices.crtc.start_offset() as usize * bus::TEXT_CELL) % win;
let b = bus::VRAM_TEXT_BASE as usize + start;
if start + bus::TEXT_LEN <= win {
&self.mem[b..b + bus::TEXT_LEN]
} else {
let base = bus::VRAM_TEXT_BASE as usize;
&self.mem[base..base + bus::TEXT_LEN]
}
}
蛇を下へ走らせると、窓が段階的に降りていく。
0行目 → 5 → 9 → 13 → 17 → 22 → 25 → 25 (上限)
25で止まるのは、盤面50行から画面25行を引いた値。ソースの
if (newscrolloff > 25) newscrolloff = 25; と一致する。テストに固定した。
/// **ハードウェアスクロール。**
///
/// テキストVRAMの窓は32KBあり、80x25の1画面はそのうち4000バイトでしかない。
/// **ここを動かすとメモリを1バイトも書き換えずに画面がスクロールする**。
#[test]
fn crtc_start_address_scrolls_the_screen_without_touching_memory() {
let mut m = machine();
put(&mut m, 0, 0, b'A', 0x07);
let below = VRAM_TEXT_BASE + (TEXT_COLS * 25 * 2) as u32;
m.write8(below, b'B');
m.write8(below + 1, 0x07);
assert_eq!(m.text_screen_string().chars().next(), Some('A'));
// CRTCの開始位置を1画面ぶん進める。**メモリは触らない**
m.io_write8(0x3D4, 0x0C);
m.io_write8(0x3D5, (2000u16 >> 8) as u8);
m.io_write8(0x3D4, 0x0D);
m.io_write8(0x3D5, 2000u16 as u8);
assert_eq!(m.text_screen_string().chars().next(), Some('B'));
}
80年代の機械が、あの遅いCPUで滑らかにスクロールできた理由がこれである。
CPUは何もしない。レジスタを2本書き換えるだけで画面が動く。
測り方も間違っていた
途中「駒が1つも無い」「ゲームが止まっている」と判断した場面があったが、
どちらも測り方のミスだった。背景色でしか駒を探しておらず、実際は
毎ステップ画面が変化していた。
相手を疑う前に、自分の物差しを疑うべきだった。 今回いちばんの教訓かもしれない。
動かないものも1本入れてある
ハングマンは動かない。CGAのグラフィックスモード (0x04) を要求するためで、
この機械にはテキスト画面しか無い。
それでも配布スクリプトにあえて入れてある。動かないものが1つ要るからで、
「描いていない」のか「描く先が無い」のかを区別できるようにしてある。
// AH=00: ビデオモード設定。**テキスト以外は実現できない**。
//
// 落とさずに受けるのは、モードを試して戻すプログラムがあるためだが、
// 黙って無視すると「描いた先が存在しない」ことに誰も気づけない。
// 要求されたモードを控えて、後から言えるようにしておく
0x00 => {
self.video_modes.insert(self.cpu.regs[cpu::AX] as u8 & 0x7F);
}
止まったときにこう出る。
--- テキスト以外のビデオモードを要求された: 0x04 (グラフィックスは未実装 → Tier 6) ---
白い画面の理由が分かるというだけのことだが、これがあるかないかで
調べる時間がまるで違う。
ディスクイメージは配らず、作り方を配る
ELKSもFreeDOSもゲームも自由ソフトウェアなので、再配布自体は許されている。
それでもリポジトリには置かず、取得と組み立てのスクリプトを置いた。
tools/fetch-images.sh
理由は2つある。GPLのバイナリ配布にはソースの提供義務が付くこと。
そしてもう1つが実際に踏んだ失敗で、手で組み立てたイメージは再現できない。
手元でゲームを載せたイメージと、他の人が持っているイメージの中身が食い違って
「アプリが入っていない」が起きた。リポジトリに置いても、置き忘れれば同じことが起きる。
スクリプトなら中身が一意に決まり、どこから来たものかもコードに書いてある。
今の状態
$ cargo test --workspace --exclude rustx86-cosim
81 passed; 0 failed
$ cargo test -p rustx86-cosim
15 passed; 0 failed (Unicorn との突き合わせ)
到達点はこうなった。
| ELKS 0.9.1 | ログイン → シェル。tetris invaders ttypong sl matrix |
| FreeDOS 1.4 | A:\> まで自動。eliza zmiy row4t |
| ハングマン | CGAグラフィックス要求 → Tier 6 |
次は32bitプロテクトモード、そしてLinux。ネットワークはそのあとに回した。
タブの中で完結するものを先にやると決めたためで、踏み台の用意が要るのは
ネットワークだけだからである。
今回いちばん効いたのは、推測をやめてソースを読んだところだった。
4つ疑って4つとも無実で、答えは自分のリポジトリの中にあった。
しかも「ここを動かすとスクロールできる」と自分で書いてあった。
エミュレータを書いていると、こういうことが定期的に起きる。
動かないものを前にしたとき、相手より先に自分を疑うのが、たぶん一番の近道である。


コメント