📚

今更ながらNESエミュレーターを作成した話 CPU編

2024/10/07に公開

はじめに

WED株式会社でレシート買取アプリONEのバックエンドエンジニアをしているsorakoroです。
弊社では半年に1回社内LTイベントを開催しています。前回趣味で作成したNESエミュレーター上でマリオやパックマンを動かしてみたところ割と反応が良かったので、本記事では私がどのように実装を進めていったのかを何編かに分けて話したいと思います。

事前準備

これまでゲーム機のエミュレーターを作成したことがなかったので、先人の知恵を借りるべく色々参考資料を探すことにしました。最初に目に留まったのはbokuwebさんの記事でした。Hello World!を表示するサンプルプログラムを動かすところまで解説されていらっしゃいます。記事の中で紹介されている参考サイトなども見て、なんとなく実装のイメージを持つことができました。
https://qiita.com/bokuweb/items/1575337bef44ae82f4d3

メモリマップ

まず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 プログラムの実行している位置を保持
cpu.rs
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+オフセット
cpu.rs
#[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には非公式の命令があり一部のゲームで使われているため、そちらに関しても実装をしました。
https://www.nesdev.org/obelisk-6502-guide/reference.html

cpu.rs
// 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のデバッガを利用しながら実装進めました。後々になって不具合が見つかると対応が大変だと思うので、不安になった時にも利用するといいなと思いました。
https://skilldrick.github.io/easy6502/

終わりに

次回は画面描画を行うPPUというコンポーネントについて話したいと思います。最後までご覧頂きありがとうございます。

WED Engineering Blog

Discussion