Rustでエミュレータを書く 連載 第3回
1. RustとWASMでゲームボーイエミュレータを作る — テスト全通過から「実機の音」になるまで
2. x86エミュレータを自作する — 正解を知っているCPUに答え合わせさせる
3. Rust + WebAssembly で 8259 PIC と BIOS を書いて、ブラウザで 16bit UNIX (ELKS) をログインまで動かした ← 今ここ
前回は x86 の命令を実装して、Unicorn Engine (QEMU の CPU 部分) をオラクルにした答え合わせの仕組みを作るところまで書いた。CPU としては動くが、まだ何も起動しない状態である。
今回はその続きで、ブラウザのタブの中で 8086 が起動し、フロッピーから 16bit UNIX (ELKS) が立ち上がり、login: が出て、ユーザ名を打つとシェルが返ってくるところまで行った。ls も vi も動く。
CPU から先に必要だったのは 3 つ。装置、BIOS、そしてブラウザに載せる層である。全部 Rust で書いた。
読みどころは 3 つの謎解きにした。「DIVIDE FAULT で死ぬのに、割り算は無罪だった」話、「Intel の公式マニュアルの疑似コードが間違っていた」話、そして 「キーを押すたびに @ が 1 文字ずつ増えていく」話である。
コードは GitHub で公開している: https://github.com/yoshiharu-ishii/rustx86
今どこにいるのか
最終ゴールは「ブラウザの中で 32bit Linux が起動して busybox のシェルが打てる」で、そこまでを Tier 1 から 7 に切ってある。x86_64 はやらないと最初に決めた。教材として一番おいしいのは 16bit から 32bit へ渡る橋の部分で、64bit はその先の別の話だからだ。
Tier 1 リアルモード8086 + co-sim ← 前回
Tier 2 装置 + BIOS + ELKS + ブラウザ ← 今回
Tier 3 ネットワーク (NE2000, L3まで)
Tier 4 32bit プロテクトモード
Tier 5 Linux 起動
Tier 6 GUI
Tier 7 (やらない)
リポジトリの構成はこうなっている。
rustx86/
├── core/ ← エミュレータ本体 (CPU・装置・BIOS)。std だけ、依存なし
├── cosim/ ← Unicorn Engine と突き合わせる検証ハーネス
├── wasm/ ← wasm-bindgen の薄いラッパー
├── web/ ← Terminal.js / machine.js / main.js
└── asm/ ← NASM で書いたテスト用ブートセクタ
core は外部クレートにまったく依存していない。教材なので、読んだときに「この行が何をしているか」がクレートの向こう側に消えないようにしたかった。
装置: 40 年前の部品が今も現役
CPU が動いても OS は起動しない。OS が要求するのは装置だ。今回入れたのは 5 つ。
┌──────────────────────┐
PIT ──IRQ0───▶ │ │
8042 ──IRQ1───▶ │ 8259 PIC │ ──▶ ベクタ番号 ──▶ CPU
UART ──IRQ4───▶ │ (優先順位づけ) │
└──────────────────────┘
MC6845 CRTC ──▶ カーソル位置・画面先頭アドレス
MC146818 ──▶ CMOS RTC
8259 PIC — 「同じポートでも何番目かで意味が変わる」
割り込みコントローラの初期化は、同じポートに 4 バイトを順番に書く。レジスタ番号を持たない代わりに、チップの中の状態機械が「今何番目か」を覚えている。ポートを節約したかった時代の設計だ。
Rust だと素直に enum になる。
/// 初期化コマンドの受け取り状態
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
enum InitState {
/// 通常運転。0x21 への書き込みは割り込みマスク
#[default]
Ready,
/// ICW1を受けた。次はICW2 (ベクタ番号のベース)
ExpectIcw2,
ExpectIcw3,
ExpectIcw4,
}
/// データポート (0x21 / 0xA1) への書き込み。
/// **初期化中かどうかで意味が変わる**のがこのチップの癖である
pub fn write_data(&mut self, val: u8) {
match self.init {
InitState::ExpectIcw2 => {
// ベクタのベース。下位3bitは無視される (8本単位で並ぶため)
self.vector_base = val & 0xF8;
self.init = InitState::ExpectIcw3;
}
InitState::ExpectIcw3 => {
self.init = if self.expect_icw4 { InitState::ExpectIcw4 } else { InitState::Ready };
}
InitState::ExpectIcw4 => self.init = InitState::Ready,
InitState::Ready => self.imr = val, // ← 通常運転ではマスクレジスタ
}
}
優先順位の処理も面白い。処理中 (ISR) より優先度の低い線は、EOI が来るまで待たされる。
/// CPUへ渡せる割り込みがあれば、そのベクタ番号を返して受理する。
///
/// 優先順位は**線の番号が若いほど高い**。処理中 (ISR) より優先度の低い線は
/// 待たされる — これが「割り込みの交通整理」の中身である。
pub fn acknowledge(&mut self) -> Option<u8> {
let pending = self.irr & !self.imr;
if pending == 0 {
return None;
}
let irq = pending.trailing_zeros() as u8;
// 処理中により優先度の高い (=若い) 線があれば待つ
if self.isr != 0 && self.isr.trailing_zeros() <= irq as u32 {
return None;
}
self.irr &= !(1 << irq);
self.isr |= 1 << irq;
Some(self.vector_base.wrapping_add(irq))
}
u8 のビット演算と trailing_zeros() だけで割り込みの交通整理が書けてしまう。この vector_base が後で事件を起こす。
なお 8259 が 2 個あるのは、割り込み線が 8 本しか無くて PC/AT で足りなくなり、2 個目を 1 個目の IRQ2 にぶら下げたからだ。IRQ2 が欠番なのはそこがスレーブとの連結に使われているためで、「IRQ9 は IRQ2 の代わり」という言い回しもここから来ている。
8254 PIT — テレビ用の水晶が 40 年生き残った
タイマの入力クロックは 1.193182 MHz という中途半端な数字だが、これは設計値ではない。初代 IBM PC が NTSC カラーサブキャリアの水晶を流用した残り (14.31818 MHz ÷ 12) である。テレビ用の部品が安かった、というだけの理由が 40 年生き残っている。
テストを見るのが一番早い。
/// 分周値で割り込み周波数が決まる
#[test]
fn pit_divisor_sets_the_interrupt_frequency() {
// 分周値0 = 65536 → DOSの 18.2 Hz
let mut p = Pit8254::new();
p.write_control(0x36); // カウンタ0、LoHi、モード3
p.write_counter(0, 0x00);
p.write_counter(0, 0x00);
assert!((p.irq0_hz() - 18.2).abs() < 0.1);
// Linuxの 100 Hz
let mut p = Pit8254::new();
p.write_control(0x36);
let div = (CLOCK_HZ / 100) as u16; // 11931
p.write_counter(0, div as u8);
p.write_counter(0, (div >> 8) as u8);
assert!((p.irq0_hz() - 100.0).abs() < 0.1);
}
「DOS の時計が 18.2 Hz」という有名な数字が 1193182 / 65536 からそのまま出てくるのが気持ちいい。
もう一つ、ラッチという仕組みがある。16bit のカウンタを 8bit のポートで 2 回に分けて読むと、読んでいる間に桁が繰り下がって壊れる。それを防ぐための写し取りだ。
/// 16bitの値を8bitポートで読むと、途中で桁が繰り下がって壊れる。
/// **ラッチはそれを防ぐための写し取り**である
#[test]
fn pit_latch_freezes_the_value_across_two_reads() {
let mut p = Pit8254::new();
p.write_control(0x36);
p.write_counter(0, 0x00);
p.write_counter(0, 0x10); // 0x1000
p.write_control(0x00); // カウンタ0をラッチ
let lo = p.read_counter(0);
p.tick(0x500); // 読んでいる間にカウンタは進む
let hi = p.read_counter(0);
assert_eq!(u16::from(hi) << 8 | u16::from(lo), 0x1000, "ラッチした瞬間の値");
}
UART 16550 — DLAB という節約術
シリアルポートは「バイトを 1 つ書けば出る」だけの装置なので、画面より先にこちらを作った。ELKS のコンソールはこの 1 本で成立する。
面白いのは通信速度の置き場所だ。8 本しかないポートに分周値を置く余裕が無かったので、LCR の bit7 (DLAB) を立てると先頭 2 つのポートの意味が化ける。
/// DLABを立てると**先頭2ポートの意味が分周値に化ける**。
/// 8本しかないポートに通信速度を置く余裕が無かった時代の節約術
#[test]
fn uart_dlab_repurposes_the_first_two_ports() {
let mut u = Uart16550::new();
u.write(3, 0x80); // LCR: DLABを立てる
u.write(0, 0x01); // 分周値の下位
u.write(1, 0x00); // 分周値の上位
assert_eq!(u.divisor, 1);
assert_eq!(u.baud(), 115_200);
u.write(3, 0x03); // DLABを下ろす (8N1)
u.write(0, b'Z'); // 同じポートが送信に戻る
assert_eq!(u.tx_string(), "Z");
}
PIC の ICW と同じ「状態で意味を変える」節約術である。当時の設計の癖がよく出ている。
謎解き 1: Intel の公式マニュアルの疑似コードが間違っていた
装置を作りながら、CPU 側にも足りない命令を足していった。そこで 前回作った co-sim が大物を捕まえた。
ENTER はスタックフレームを作る命令で、Intel SDM には疑似コードがこう書いてある。
SP ← BP − Size
素直に実装したら、co-sim が食い違いを報告してきた。しかも level > 0 のときだけずれる。
原因はこうだった。ENTER は level の数だけ「display」と呼ばれる親フレームへのポインタをスタックに積む。SDM の式はその積んだ分 (level×2 バイト) を勘定に入れていない。level = 0 のときだけたまたま正しくなる式なのだ。AMD のマニュアルと QEMU の実装は現在の SP から引いており、実機の挙動はそちらだった。
0xC8 => { // ENTER imm16, imm8
// ... display を level 個積む ...
push16(m, frame);
m.cpu.set_reg16(BP, frame);
// 最後のSP調整は「今のSP」から引く。Intel SDMの疑似コードは
// `SP <- BP - Size` と書いているが、これが正しいのは level=0 のときだけで、
// level>0 では display を積んだ分 (level*2バイト) が抜け落ちる。
// AMDのマニュアルとQEMUの実装は現在のSPから引いており、そちらが実挙動。
// co-simがこの差を捕まえた
let sp = m.cpu.reg16(SP).wrapping_sub(size);
m.cpu.set_reg16(SP, sp);
}
仕様書を読んで実装し、仕様書どおりに動くことをテストしていたら、永久に見つからないバグだった。別の実装と突き合わせる検証にはそういう力がある。前回はこの仕組みの作り方を書いたが、実際に何を釣り上げたかを書けたのは今回が初めてである。
BIOS を自分で書く
装置が揃っても、まだ OS は起動しない。BIOS が要る。
ただし本物の ROM は載せない。教材の目的は「BIOS が何をしているか」を自分で書くことなので、ブラックボックスを増やしたくなかった。必要なサービスだけを Rust の関数として肩代わりする (HLE / 高位エミュレーション)。
電源投入時にやることは 3 つに集約された。
pub(crate) fn power_on_self_test(&mut self) {
self.install_bios_vectors(); // IVT に BIOS の入口を置く
self.install_bios_data_area(); // 0x400-0x4FF にマシンの説明書を置く
self.install_pic_defaults(); // PIC を IBM PC の既定配置に初期化する
}
BIOS サービスの呼び出しは、CS が特殊なセグメント値だったら Rust の関数へ振り分ける形にした。Machine::step() の全体はこうで、この 5 ステップが 1 サイクルの全部である。
pub fn step(&mut self) {
// 1. 保留中のハードウェア割り込み (IFが立っているときだけ)
if self.pending_irq.is_some() && self.cpu.flag(cpu::IF) {
let vec = self.pending_irq.take().unwrap();
self.halted = false;
cpu::interrupt(self, vec);
return;
}
// 2. 装置を進める (カウントダウン方式)
self.tick_countdown -= 1;
if self.tick_countdown == 0 {
self.tick_countdown = INSTRUCTIONS_PER_TICK;
self.tick_devices();
}
if self.halted { return; }
// 3. BIOS HLE の入口
if self.cpu.sregs[cpu::CS] == BIOS_SEG {
let vec = self.cpu.ip as u8;
if self.bios_interrupt(vec) {
self.return_flags_to_caller();
cpu::iret(self);
}
return;
}
// 4. 命令実行 (TFは実行前の値)
let tf = self.cpu.flag(cpu::TF);
cpu::step(self);
// 5. トラップフラグ
if tf && !self.halted { cpu::interrupt(self, 1); }
}
ここで 1 つ工夫がある。bios_interrupt() が bool を返しているのは、「まだ IRET するな」を表現するためだ。INT 16h AH=00 (キー入力待ち) は、キーが無ければ帰ってはいけない。
0x00 | 0x10 => match self.take_key() {
Some(v) => self.cpu.regs[cpu::AX] = v as u32,
None => return false, // IRETせずに戻る → 次のサイクルでやり直し
},
false を返すと IP も CS も動かないので、次の step() でまた同じ入口に来る。エミュレータ側にブロッキング呼び出しを持ち込まずに、ゲストから見たブロッキングを実現できる。早期 return と Option だけで済むのが気に入っている。
未実装のサービスは黙って 0 を返さず即 panic させることにした。静かに間違った値を返すと、遥か後方で意味不明な暴走として現れるからだ。panic なら INT 10h AH=0x13 未実装 と名前を教えてくれる。これが後でものすごく効いた。
謎解き 2: DIVIDE FAULT で死ぬのに、割り算は無罪だった
ELKS のフロッピーイメージを食わせてみる。カーネルが動き出して、しばらくして止まった。
panic: DIVIDE FAULT
ゼロ除算である。真っ先に疑うのは自作の DIV だ。x86 の DIV は商がオペランドサイズに収まらないときも例外を出すので、実装ミスは十分ありうる。
しかし、こういうときのために診断を仕込んであった。最初に起きたフォールトの発生場所を記録するフィールドである。
fn divide_error(m: &mut Machine, start_ip: u16) {
m.cpu.ip = start_ip; // フォールトなので失敗した命令の先頭
if m.first_fault.is_none() {
m.first_fault = Some((0, m.cpu.sregs[CS], start_ip));
}
interrupt(m, 0);
}
見てみると、first_fault は None だった。
つまり、こちらの DIV は一度も例外を出していない。ベクタ 0 のハンドラは呼ばれているのに、それを呼んだのは DIV ではない。誰かがベクタ 0 を、ゼロ除算以外の理由で叩いている。
犯人はさっきの vector_base だった。IBM PC の BIOS は起動時に PIC を初期化して IRQ0 をベクタ 0x08 に置く。こちらはそれをやっていなかったので vector_base が 0 のまま。タイマ割り込み (IRQ0) が、ベクタ 0 = ゼロ除算例外として CPU に届いていた。ELKS から見れば「割り算していないのに DIVIDE FAULT」である。
修正は power_on_self_test() に 1 つ足すだけだった。
/// マスタをベクタ 0x08-0x0F、スレーブを 0x70-0x77 に置くのがPC/ATの決まりである。
fn install_pic_defaults(&mut self) {
for (i, base, icw3) in [(0usize, 0x08u8, 0x04u8), (1, 0x70, 0x02)] {
let p = &mut self.devices.pic[i];
p.write_command(0x11); // ICW1: 初期化開始 + ICW4あり
p.write_data(base); // ICW2: ベクタのベース
p.write_data(icw3); // ICW3: カスケードの結線
p.write_data(0x01); // ICW4: 8086モード
p.write_data(0xFF); // 全マスク。OSが必要な線だけ開ける
}
}
ちなみに 0x08 という配置は、プロテクトモードでは CPU の例外番号 (#DF) と衝突する。Linux が起動時にわざわざ 0x20 へ付け替えるのはこのためで、同じチップの同じレジスタが Tier 4 でもう一度顔を出すことになる。
この後も同じ調子で穴が出てきた。画面が真っ白だったのは BIOS データエリアが空で、カーネルのコンソールドライバが「画面の桁数 0」として動いていたから。A20 で無限ループしたのは 8042 がコマンド 0xD0 に応答しなかったから。
面白いのは、踏んだ穴が全部「サービス」ではなかったことだ。ELKS は BIOS サービスをほとんど呼ばず、8042 も VRAM も直接叩く OS である。それでも、ハードウェアの立ち上げとマシンの説明書が欠けているだけで 3 回死んだ。
「OS が直接ハードを叩くなら BIOS は要らないのでは」とよく言われるが、要らなくなったのは肩代わりの部分だけで、立ち上げと説明書の役割は死んでいないことがそのまま実験で出た形になった。UEFI に ExitBootServices() という「もう自分でやるからサービスは要らない」を宣言する関数があるのも、この 2 つの役割が別物だからである。
ブラウザへ: wasm-bindgen で 80×25 を渡す
ここまでは CLI で動く。ブラウザに持っていくのは wasm-bindgen の薄い層だけだ。
#[wasm_bindgen]
pub struct Emulator {
m: Machine,
}
#[wasm_bindgen]
impl Emulator {
/// ディスクイメージ (フロッピー) から起動する
pub fn from_disk(image: &[u8]) -> Result<Emulator, JsError> {
let mut m = Machine::new();
m.boot_from_disk(image.to_vec()).map_err(|e| JsError::new(&e))?;
Ok(Emulator { m })
}
/// 指定した命令数だけ進める。**1フレーム分の仕事**として呼ぶ。
/// HLTで止まっていても抜けない — タイマ割り込みで起きるのを待つ必要があるため。
/// アイドル中のOSは「HLTして割り込みを待つ」を繰り返している
pub fn run_slice(&mut self, instructions: f64) {
for _ in 0..(instructions as u64) {
self.m.step();
}
}
/// テキストVRAM (80×25、文字と属性が交互) の先頭ポインタ。
/// JS側はwasmのメモリを直接読む — コピーを作らないため
pub fn text_vram_ptr(&self) -> *const u8 {
self.m.text_vram().as_ptr()
}
/// キーの上げ下げを送る。`code` は `KeyboardEvent.code` (物理キーの識別子)。
///
/// 文字ではなく**キーの位置**を渡すのが要点である。こうすると Ctrl も Esc も
/// 矢印も特別扱いが要らず、修飾キーの組み立てはゲストのOSがやる
pub fn key(&mut self, code: &str, down: bool) -> bool {
self.m.devices.keyboard.key(code, down)
}
/// 画面が書き換わったか。**読むと下りる**ので描画の要否判定に使う
pub fn take_vram_dirty(&mut self) -> bool {
self.m.take_vram_dirty()
}
}
VRAM はポインタだけ返して JS 側から直接読む。80×25×2 = 4000 バイトを毎フレームコピーするのは無駄だからだ。全体の絵はこうなる。
ブラウザ (JS) WASM (Rust)
┌────────────────┐ ┌────────────────────┐
│ Terminal.js │ key(code, down) │ Emulator │
│ 80x25 描画 │ ───────────────────▶│ └ Machine │
│ 選択・コピー │ │ ├ Cpu │
│ スクロールバック│◀────────────────── │ ├ Devices │
└────────────────┘ text_vram_ptr() │ │ ├ Pic │
▲ (コピーなし) │ │ ├ Pit │
│ │ │ ├ Kbd8042│
┌────────────────┐ │ │ └ Crtc │
│ machine.js │ run_slice(n) │ └ Memory │
│ フレームループ │ ───────────────────▶└────────────────────┘
└────────────────┘
Terminal.js はエミュレータのことを何も知らない。onKey / onChar のコールバックを持つだけの端末で、machine.js が両者を繋ぐ。この分離のおかげで、端末の作り込み (選択とコピー、カーソル、スクロールバック、Homebrew のカラーテーマ) を CPU の話と混ぜずに進められた。
キーを文字ではなく位置で渡しているのも効いた。KeyboardEvent.code をそのままスキャンコードに変換すれば、Ctrl も Esc も矢印も特別扱いが要らない。修飾キーの組み立てはゲストの OS がやる仕事だからだ。実機のキーボードが送っているのも文字ではなく位置である。
750 倍速くなった話
ELKS の起動テストが 389 秒かかっていた。原因はすぐ分かった。「画面に login: が出るまで走らせる」テストが、1 命令ごとに 80×25 文字の String を組み立てていた。
fn run_until(m: &mut Machine, needle: &str, budget: u64) -> bool {
for _ in 0..budget {
m.step();
// 画面を毎命令組み立ててはいけない。1命令ごとに80x25文字のStringを
// 作ることになり、起動が数百倍遅くなる (実際にやって390秒かかった)。
// dirty フラグで、**書き換わったときだけ**見る
if m.take_vram_dirty() && m.text_screen_string().contains(needle) {
return true;
}
}
false
}
take_vram_dirty() を挟むだけで 0.52 秒になった。750 倍。ブラウザ側の描画も同じフラグで間引いている。
謎解き 3: キーを押すたびに @ が 1 文字ずつ増える
ブラウザで動くようになって、いざキーを打つと妙なことが起きた。打った文字の前に @ が 1 つ入る。ls と打つと @l@s になる。
奇妙なのは、CLI 版では再現しなかったことだ。CLI のテストは文字列を一気に流し込むが、ブラウザは 1 キーずつ送る。この差が鍵だった。
原因は割り込みのレベルトリガとエッジトリガの違いだった。当時のキーボードコントローラの実装は「バッファにデータがあれば IRQ1 を上げる」になっていた。すると、
- キーを 1 つ押す → スキャンコードがバッファに入る → IRQ1
- ELKS がハンドラでバッファを読む → バッファが空になる
- 次の命令でまた「データがある?」を評価 → タイミングによってはもう一度 IRQ1
- ELKS がもう一度読みに行く → 空のバッファを読む
そして空を読んだときに返していた値が 0 で、ELKS はそれをスキャンコード 0 として解釈し、US 配列の対応表を引いて @ を出していた。
直し方は 2 つ。まず IRQ をエッジトリガにする。
/// 今このタイミングでIRQ1を上げるべきか。**1バイトにつき1回だけ真を返す**
pub fn take_irq(&mut self) -> bool {
if self.has_data() && !self.irq_asserted {
self.irq_asserted = true;
true
} else {
false
}
}
そして空読みの戻り値を変える。
// 空を読まれたら 0xFF を返す。0 は正当なスキャンコードと紛らわしい
self.keys.pop_front().unwrap_or(0xFF)
「バルクでは出ず、1 つずつだと出る」という症状が、そのまま原因 (エッジ vs レベル) を指していたのが気持ちよかった。再現条件が答えそのものだった例である。
おまけ: WASM のメモリが伸びると JS の Uint8Array が死ぬ
もう 1 つ、WASM 特有の落とし穴を踏んだので書いておく。
スナップショット機能 (機械の状態をまるごと保存して後から戻す) を足したところ、保存ボタンを押すと画面が真っ黒になるという報告が来た。保存自体は成功しているのに、だ。
犯人は save_state() が数 MB の Vec<u8> を確保することだった。WASM の線形メモリが伸びると、JS 側が持っていた Uint8Array のビューは detach される。text_vram_ptr() で作った VRAM のビューが無効になり、描画が空を読んでいた。
/// 機械の状態をまるごと書き出す (CPU・装置・メモリ・ディスク)。
/// JSONに束ねるのは呼び出し側の仕事で、ここは中身だけを返す
pub fn save_state(&self) -> Vec<u8> {
self.m.save_state()
}
Rust 側は何も悪くない。JS 側で「メモリが伸びうる呼び出しの後はビューを作り直す」ようにして直した。
これは自分のテストでは捕まえられなかった。「保存が成功したか」は見ていたが、「保存した後も画面が生きているか」は見ていなかったからだ。状態を変える操作のテストは、副作用の外側まで見ないといけない。
なお、保存形式は Rust 側で素朴なバイナリ + 連長圧縮 (RLE) にしてある。JSON にしないのはメモリが大きいからで、1MB のバイト配列を JSON の数値配列にすると数 MB に膨れる。エミュレートしているメモリはほとんどがゼロなので、RLE だけで 1MB が数 KB になる。
/// ゼロの海が実際に縮むこと (これが目的)
#[test]
fn zeros_compress_hard() {
let packed = rle_encode(&vec![0u8; 1 << 20]);
assert!(packed.len() < 10_000, "1MBのゼロが {} バイト", packed.len());
}
今の状態
$ cargo test --workspace --exclude rustx86-cosim
58 passed; 0 failed
$ cargo test -p rustx86-cosim
15 passed; 0 failed (Unicorn との突き合わせ)
一番好きなテストはこれである。実 OS を動かすこと自体がテストになっている。
/// カーネルがルートをマウントし、loginプロンプトまで到達する
#[test]
fn elks_boots_to_login_prompt() {
let Some(mut m) = boot() else {
eprintln!("images/fd1440.img が無いのでスキップ");
return;
};
assert!(run_until(&mut m, "login:", 100_000_000), "loginプロンプトに到達せず");
let screen = m.text_screen_string();
assert!(screen.contains("ELKS 0.9.1"), "バージョン表示が無い:\n{screen}");
assert!(screen.contains("Mounted root device"), "ルートがマウントされていない:\n{screen}");
}
前回作った co-sim は 1 命令単位なので、装置の状態遷移も割り込みの受付タイミングも検証できない。そこから先は、本物の OS が起動するかどうかが唯一の判定基準になる。検証手法が階段状に切り替わったのが今回の一番大きな変化だった。
次: ネットワークと 32bit へ
Tier 3 でネットワーク (NE2000 と L3 まで)、Tier 4 で 32bit プロテクトモード、Tier 5 で Linux。
装置が増えても BIOS は増えない見込みでいる。装置を足すコストはバスと装置のコードであって、BIOS のコードではないからだ。BIOS が増えるのは「ドライバが存在する前に走る誰かが居る」ときと「マシンの形を教える必要がある」ときだけで、どちらも今回踏んだ「死なない側の役割」に当たる。
Rust で書いていて一番良かったのは、enum と match が 40 年前のハードウェアと相性が良すぎることだった。PIC の初期化状態も、オペランドの種別も、BIOS サービスの分岐も、全部そのまま代数的データ型になる。当時のチップ設計者がビットで表現していた「状態で意味が変わる」構造が、型として素直に書ける。
謎解きの道具としても優秀で、今回の 3 つはどれも「診断を先に仕込んでおいたから 1 発で分かった」類のものだった。first_fault が None だったから割り算は無罪だと即断できたし、エッジトリガの症状は再現条件そのものが答えだった。
エミュレータは、書いた分だけコンピュータの成り立ちが手に入る。おすすめである。


コメント