低レイヤーな同僚に触発されて、RustでChip8エミュレータを作ってみた。
Chip8とは?
Chip8は1970年代につくられた小さな仮想マシンで、多くのコンピュータに移植された。日本ではwikipediaの日本語訳もないしググってもあまり情報がでてこないが、海外では簡単に実装できるエミュレータとして人気なようだ。仕様書を読んで一から作るのは実はそんなに難しくないが、仕様書に書いてないところやエミュレータ初心者がハマそうなところもあるので、作り方を解説してみようと思う。
出典:https://en.wikipedia.org/wiki/CHIP-8
Chip8の仕様
- メモリ
- 合計4K(4098)バイト
- 0x000から0x1FFはインタプリタが使うプリセットのスプライトが格納されているので、Chip8プログラムは使えない
- CPU(レジスタ)
- 16個の8bit汎用レジスタ(
v
) - メモリアドレスを差すのに使われる16bitレジスタ(
i
) - サブルーチンの戻りのアドレスを格納するスタック(
stack
) - スタックの先頭を差すスタックポインタ(
sp
) - プログラムの実行位置を示すプログラムカウンタ(
pc
) - 60HzのDelayタイマー(
dt
) - 同じく60Hzのサウンドタイマー(
st
)
- 16個の8bit汎用レジスタ(
- ディプレイ
- 64x32ピクセルのモノクロディスプレイ
- キーボード
- 1~Fまでのキーがある
- サウンド
- 何かしらBeep音を出せばよいらしい
今回作るChip8エミュレータの特徴
-
ターミナルで動く
-
今後、WebAssembly化や別のGUIライブラリで表示周りを作り直すかもしれないので、IO(ディスプレイ、キーボード、サウンド)とコアの部分を明確に分離しておく
-
キーボード
- QWERTYキーボードで打ちやすいように以下のようにマッピングした(括弧内がChip8のキー)
1 2 3 4(C) Q(4) W(5) E(6) R(D) A(7) S(8) D(9) F(E) Z(A) X(0) C(B) V(F) - ESCでプログラムを強制終了する
Chip8のざっくりした動き
- fontsetのスプライトをメモリに読み込む(0x000~0x1FF)
- ROMをメモリに読み込み(0x200~)、プログラムカウンタ(
pc
)に0x200をセットする -
pc
のアドレスから2バイト読み、Chip8の命令にデコードする - デコードした命令を実行する
- プログラムカウンタ(
pc
)をインクリメントする - Sleepをはさむ
- 3に戻る(無限ループ)
これと同時に以下を行う
- DelayTimerを60Hzで更新する
- Soundを鳴らす
- キー入力
- ディプレイにスプライトを描画する
これらタイマーやIO系はスレッドが使える環境なら別スレッド上で実行した方が良いと思う。最初、全てシングルスレッドで書いていたが、DelayTimerが60Hzにならなかったため(どこかのIO待ちかな?)別スレッドで動かすようにした。
ポイント解説
CPU
仕様通りにRustのstructにする。
#[derive(Debug)]
pub struct Cpu {
v: [u8; 16], /// 8bit general purpose Registers.
i: u16, /// Index register.
stack: [u16; 16], /// Stack.
sp: u16, /// Stack pointer.
pub pc: u16, /// Program counter.
pub dt: DelayTimer, /// Delay timer.
key: Option<Key>, /// Key being entered.
}
命令のフェッチと実行
// `pc`から2byteフェッチして、4bitずつに分ける
let o1: u8 = ram.buf[pc] >> 4;
let o2: u8 = ram.buf[pc] & 0xf;
let o3: u8 = ram.buf[pc + 1] >> 4;
let o4: u8 = ram.buf[pc + 1] & 0xf;
// RustのMatchを使って命令にデコードする
let res = match (o1, o2, o3, o4) {
// ここで00E0のChip8命令を実行する
(0x0, 0x0, 0xE, 0x0) => {
...
}
// ここで00EEのChip8命令を実行する
(0x0, 0x0, 0xE, 0xE) => {
...
}
...
pcのインクリメント
Rustのenumはvariantなので、戻り値をこのようにきれいに表現できるのがすばらしい。
/// 命令実行の戻り値.
pub enum Res {
Next, // `pc`を2インクリメントする
Skip, // `pc`を4インクリメントする
Jump(u16), // `pc`に指定の値をセットする
}
// 命令を実行する
let res = match (o1, o2, o3, o4) {
...
}
// Enumの要素の型によって次の`pc`を決める。
match res {
Next => { self.pc += 2; }
Skip => { self.pc += 4; }
Jump(loc) => { self.pc = loc; }
}
できたもの
# Rustをインストールしてない人はこれを実行
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
$ source $HOME/.cargo/env
$ git clone https://github.com/yukinarit/chip8.git
$ cd chip8
$ cargo run ./roms/BRIX
これからつくってみる人へのアドバイス
-
真っ先に毎フレームにChip8のレジスタ類をログ出力しておく
-
ゲームが表示されるとテンション上がってくるので、命令は演算系、描画系、その他IO系の順番で実装するといい
-
cargo-watchで、ファイルが更新される度にビルドが走るようにする
$ cargo watch -x build
-
バグの原因が分からなくなったら、いろんなROMを試してみる
実装後の楽しみ
Chip8はシンプルなので、応用範囲も広い。一通り実装し終えたら何か新しいものに挑戦してみると良い。たとえば、
-
wasm-rust-chip8
- WebAssemblyでChip8を動かす
-
chip8book
- 冒頭で言及した同僚作。stdを全く使わないベアメタルChip8。すごい。
-
c8c
- Chip8のコンパイラ!C言語風のプログラミング言語で書いたプログラムをChip8で動かせる!
さいごに
今回のソースコードはこちらに置いてある。なるべく分かりやすいように英語でコメントを付けたので、実装に困ったら見てみてほしい。また、日本語のChip8マニュアルが無かったように思えたので、一番有名どころを和訳しておいた。もし誤字や間違いがあったらプルリクを出してくれるとありがたい。
References
- Cowgod's Chip-8 technical reference v1.0
- 上記Chip-8マニュアルの日本語訳