今更ながらNESエミュレーターを作成した話 CPU編
はじめに
WED株式会社でレシート買取アプリONEのバックエンドエンジニアをしているsorakoroです。
弊社では半年に1回社内LTイベントを開催しています。前回趣味で作成したNESエミュレーター上でマリオやパックマンを動かしてみたところ割と反応が良かったので、本記事では私がどのように実装を進めていったのかを何編かに分けて話したいと思います。
事前準備
これまでゲーム機のエミュレーターを作成したことがなかったので、先人の知恵を借りるべく色々参考資料を探すことにしました。最初に目に留まったのはbokuwebさんの記事でした。Hello World!を表示するサンプルプログラムを動かすところまで解説されていらっしゃいます。記事の中で紹介されている参考サイトなども見て、なんとなく実装のイメージを持つことができました。
メモリマップ
まずCPUから見たメモリ空間がどのようになっているかを把握しました。NESのメモリマップは以下のようになっています。
アドレス | サイズ | 用途 |
---|---|---|
0x0000–0x07FF | 0x0800 | WRAM |
0x0800–0x1FFF | 0x1800 | WRAMのミラー |
0x2000–0x2007 | 0x0008 | I/Oポート(PPU) |
0x2008–0x3FFF | 0x1FF8 | 0x2000–0x2007のミラー(8bit毎に繰り返す) |
0x4000–0x401F | 0x0018 | I/Oポート(APU, PAD) |
0x4020-0xFFFF | 0xBFE0 | カートリッジ側で構成される |
CPUの実装
NESには6502にオーディオ機能(Audio Processing Unit)を加えたRicohオリジナルの8ビットCPUが使われています。当時はZ80が主流だったようですが、Ricohが6502のライセンス取得の目処が立っていたことなどから任天堂に提案して採用されたようです。6502には以下のようなレジスタがあるので、まずはこれを実装していきます。
名称 | 記号 | ビット数 | 用途 |
---|---|---|---|
アキュムレーター | A | 8 | 汎用演算 |
インデックスレジスタ | X | 8 | アドレッシング, カウンタ |
インデックスレジスタ | Y | 8 | アドレッシング, カウンタ |
ステータスレジスタ | P | 8 | スタックの位置を保持 |
スタックポインタ | SP | 8 | CPUの状態を保持 |
プログラムカウンタ | PC | 16 | プログラムの実行している位置を保持 |
struct CPU {
register_a: u8,
register_x: u8,
register_y: u8,
status: u8,
stack_pointer: u8,
program_counter: u16,
bus: Bus,
}
次にアドレッシングモードの実装をしました。アドレッシングモードとは、メモリ上のデータにアクセスする方法のことです。CPUがメモリ内のどの場所からデータを読み書きするかを指定する方法が複数あり、それらをアドレッシングモードと呼んでいます。なぜこのような仕組みがあるのかというと、6502には汎用レジスタが1つしかないため、算術演算など2つの値を使う命令ではレジスタのみで処理を完結させることが出来ないからです。またプログラムを格納する領域も0x8000-0xFFFFの32KBしかないため、メモリ領域を節約する目的もあるようです。アドレッシングモードの仕様は以下のようになっています。
名称 | 命令の構成 | 長さ(byte) | 範囲 | 実行アドレス |
---|---|---|---|---|
Accumulator | 命令 | 1 | - | - |
Implied | 命令 | 1 | - | - |
Immediate | 命令, 即値 | 2 | 0x00-0x00FF | 即値 |
Zero Page | 命令, 下位アドレス | 2 | 0x0000-0x00FF | 下位アドレス |
Zero Page Index X | 命令, 下位アドレス | 2 | 0x0000-0x00FF | 下位アドレス+X |
Zero Page Index Y | 命令, 下位アドレス | 2 | 0x0000-0x00FF | 下位アドレス+Y |
Absolute | 命令, 下位アドレス, 上位アドレス | 3 | 0x0000-0xFFFF | 絶対アドレス |
Absolute Index X | 命令, 下位アドレス, 上位アドレス | 3 | 0x0000-0xFFFF | 絶対アドレス+X |
Absolute Index Y | 命令, 下位アドレス, 上位アドレス | 3 | 0x0000-0xFFFF | 絶対アドレス+Y |
Indirect | 命令, 下位アドレス, 上位アドレス | 3 | 0x0000-0xFFFF | 絶対アドレスから読み出した値 | 絶対アドレス+1から読み出した値 |
Index Indirect | 命令, 下位アドレス | 2 | 0x0000-0x00FF | 下位アドレス+Xから読み出した値 | (下位アドレス+X)+1から読み出した値 |
Indirect Index | 命令, 下位アドレス | 2 | 0x0000-0x00FF | (下位アドレスから読み出した値 | (下位アドレス+1)から読み出した値)+Y |
Relative | 命令, オフセット | 2 | PCに符号ありの8ビット整数を加算した範囲 | PC+オフセット |
#[derive(Debug, PartialEq)]
#[allow(non_camel_case_types)]
enum AddressingMode {
Implied,
Accumulator,
Immediate,
ZeroPage,
ZeroPage_X,
ZeroPage_Y,
Absolute,
Absolute_X,
Absolute_Y,
Indirect,
Indirect_X,
Indirect_Y,
RELATIVE,
}
impl CPU {
fn get_operand_address(&mut self, mode: &AddressingMode, addr: u16) -> u16 {
match mode {
AddressingMode::Implied => {
panic!("AddressingMode::Implied");
}
AddressingMode::Accumulator => {
panic!("AddressingMode::Accumulator");
}
AddressingMode::Immediate => addr,
AddressingMode::ZeroPage => self.mem_read(addr) as u16,
AddressingMode::Absolute => self.mem_read_u16(addr),
AddressingMode::ZeroPage_X => {
let pos = self.mem_read(addr);
pos.wrapping_add(self.register_x) as u16
}
AddressingMode::ZeroPage_Y => {
let pos = self.mem_read(addr);
pos.wrapping_add(self.register_y) as u16
}
AddressingMode::Absolute_X => {
let base = self.mem_read_u16(addr);
base.wrapping_add(self.register_x as u16)
}
AddressingMode::Absolute_Y => {
let base = self.mem_read_u16(addr);
base.wrapping_add(self.register_y as u16)
}
AddressingMode::Indirect => {
let base = self.mem_read_u16(addr);
self.mem_read_u16(base)
}
AddressingMode::Indirect_X => {
let base = self.mem_read(addr);
let ptr = (base as u8).wrapping_add(self.register_x);
self.mem_read_u16(ptr as u16)
}
AddressingMode::Indirect_Y => {
let base = self.mem_read(addr);
let deref_base = self.mem_read_u16(base as u16);
deref_base.wrapping_add(self.register_y as u16)
}
AddressingMode::RELATIVE => {
let jump = self.mem_read(addr) as i8;
addr.wrapping_add(1).wrapping_add(jump as u16)
}
}
}
最後に6502の各命令を実装しました。Nesdev Wikiに命令の仕様がまとまっているので、こちらを参考に実装を進めていきました。あとNESには非公式の命令があり一部のゲームで使われているため、そちらに関しても実装をしました。
// Accumulatorにデータを読み込むLDA命令はこんな感じ
fn lda(&mut self, mode: &AddressingMode) {
let addr = self.get_operand_address(mode);
let value = self.mem_read(addr);
self.register_a = value;
self.update_zero_and_negative_flags(self.register_a);
}
また、Wikiを読んでも正確な仕様がわからなかった時は、以下の6502のデバッガを利用しながら実装進めました。後々になって不具合が見つかると対応が大変だと思うので、不安になった時にも利用するといいなと思いました。
終わりに
次回は画面描画を行うPPUというコンポーネントについて話したいと思います。最後までご覧頂きありがとうございます。
Discussion