「Writing NES Emulator in Rust」に沿って6502の命令セットを実装してみてハマったこと

ファミコンのエミュレーターをRust🦀で作る

こんちは、GWに暇があったんで、ファミコンのエミュレータをRustで作ろうとしましたが、結果めちゃくちゃ苦しみながらまだCPUの命令セットしか実装できませんでした。

ハマりどころや困った点が大量にあったので一旦まとめます。

何をしたの?

ファミコンのエミュレータをRustで作ろうとしました。

ゲームのエミュレータなんて全く作ったことないので、個人で公開されているWriting NES Emulator in Rustというサイトの内容をもとに実装を進めました。

今はチャプター3まで終わっている段階です

  • 環境
    • Mac
      • Mac mini (M1, 2020) Mac OS Monterey
    • Rust
      • rustc 1.62.0-nightly (4c60a0ea5 2022-05-04)
      • nightly-aarch64-apple-darwin

以下、ハマりどころを書いていきます

ハマったところ・困ったこと

実装が正しいか正しくないかが判断しにくい

このサイトでは、2つ3つの命令セットの仕様が丁寧に解説されていますが、その他の全ての命令セットに対する仕様は一切書かれていません。また、テストもほとんど書かれていません。

なので最初のうちはゴリゴリ進めれていたのですが、途中で急に、あとはわかるやろ?頑張れ!みたいな感じで突き放してきますw。著者自身がサイトでこのmemeを載せていて思わず笑いました

https://bugzmanov.github.io/nes_ebook/chapter_3_3.html

結局ネットでいろんなサイトから断片的に情報を収集するしかありませんでした。

主に以下のサイトが参考になりました。

また、実装し終わった後に知ったのですがこのサイトが一番参考になりそうです。

http://vdmgr.g2.xrea.com/module_6502.html

テストがないのでいざ動かした時にどこがエラーか全く判断がつかない

前述の通り、数十もの命令セットを一切のテストなしで実装し、テスト用のROMを読み込むと当然必ずどこかでエラーが出ます。

しかもターミナルに丁寧にエラーの内容が出るわけではないので地道にエラー内容を特定する必要があるのですが、テストがない分どこが原因かめちゃくちゃ調査が大変です

僕は各命令実行前にレジスタなど出力する関数を仕込んでおいて、その標準出力をログに保存しておき、参考実装にも同様の関数を仕込んでログ保存し、それらを比較して地道にデバッグしました、

    fn debug(&mut self, label: String) {
        println!("{:20}... code: {:#06x} a: {:#06x} x: {:#06x} y: {:#06x} pc: {:#06x} sp: {:#06x} status: {:#10b}", label, self.memory_read(self.program_counter), self.register_a, self.register_x, self.register_y, self.program_counter, self.stack_pointer, self.status);
    }
let mut i = 0;
cpu.run_with_callback(move |cpu| {
    cpu.debug(format!("loop {}", i).to_string()); // here
    handle_user_input(cpu, &mut event_pump);

VSCodeのdiffを使うと差分がとてもわかりやすく、捗ります

/img/2022-05-11-01.png

実行時にsdl2が存在しないとエラーが出る

チャプター3.4で発生した問題です。このガイドの中でsdl2をプロジェクトの依存関係に加えて画面表示する手順があったのですが、sdl2が存在しないという旨のエラーが出てしまいました。 以下のようにbrewでインストールし、なおかつパスを通す必要があります

brew install sdl2
# for zsh
echo 'export LIBRARY_PATH="$LIBRARY_PATH:/opt/homebrew/lib"' >> ~/.zshrc

https://github.com/PistonDevelopers/rust-empty/issues/175

もしかしたらM1Macだけの問題かもしれません

スネークゲームのプログラムがGitHubとサイトで微妙に差異がある

    let game_code = vec![
        // ...
        0x91, 0x00, 0x60, 0xa6, 0x03, 0xa9, 0x00, 0x81, 0x10, 0xa2, 0x00, 0xa9, 0x01, 0x81, 0x10,
+       0x60, 0xa6, 0xff, 0xea, 0xea, 0xca, 0xd0, 0xfb, 0x60,
-       0x60, 0xa2, 0x00, 0xea, 0xea, 0xca, 0xd0, 0xfb, 0x60,        
    ];

比較してみたら、最初のボタン入力があるまでヘビの動きが遅くなるかどうかの違いがありました。

参考実装と各ループで実行されるオペコードを比較すると急に差分が出るので注意が必要です

スタックポインタとプログラムカウンタの初期値がわからない

スタックポインタとプログラムカウンタの初期値は記載がないですが、

プログラムカウンタは処理が進むとインクリメントされていきます、プログラムは0x0600から配置されるため、先頭から舐める必要があるのでプログラムカウンタは0x0600になります。

また、スタックポインタは逆にデクリメントするため、スタックポインタの領域0x0100~0x1FFのうちの0x100が最初にスタックポインタに指定される必要があります。 このゲームのプログラムで最初にスタックにポップされるのは、先頭の命令0x20(=JSR)です。これはメモリアドレスを2つ消費するため、0x01FF-2=0x01FD最初にスタック領域として指定されるのは0x01FD領域です。しかし、スタックレジスタは8bitであるため0x00~0xFFしか表現できません。なのでスタックポインタには末尾の2桁しか格納されません。

したがって、スタックポインタの初期値は0x00FDとなる訳です。ちなみにプログラムカウンタは16bitであるため気にする必要がありません

ちなみに、ch3.4のスネークゲームの場合のRAMの中身はこのような配置になっています。

アドレス 内容
00FE 乱数ジェネレータ
0xFF 最後に押されたボタンのコード
0x0100 ~ 0x01FF スタック領域
0x0200 ~ 0x05FF 画面の状態
0x0600 ~ 0x0x?? プログラムコード

JSR命令でメモリに格納するアドレスはpc+2ではなくpc+1

JSR命令では、ジャンプ後に元の箇所に戻るために次の命令のアドレスをスタック領域に保存します。なので、オペランド分だけプログラムカウンタを+2ずらしてアドレスをスタックに保存しますが、正確には+2ではなくその前のアドレスをスタックに保存しておき、サブルーチンから戻るRTS命令時にポップしたアドレスに+1するみたいです。未定義動作と言われるものらしく、公式のデータシートには記載がないようです。

ジャンプサブルーチン命令(JSR)によってスタックに格納する復帰アドレスは、 次の命令の一つ前のアドレス(JSRの最後のバイト)であり、 リターンサブルーチン命令(RTS)によってインクリメントします。

https://pgate1.at-ninja.jp/NES_on_FPGA/nes_cpu.htm

NESの正確な動作の仕様は上記ですが、JSR命令では+2で保存しRTS命令では+1しないという実装にしてもプログラム自体は問題なく動きます。

ですが、参考実装では前者の実装方法を取っており、知らないと参考実装との比較時にスタックに保存される値が異なり混乱します

[[追記]] この後のnestest.romでのテスト時に差分もでてしまうので注意

http://vdmgr.g2.xrea.com/module_6502.html

ブランチする場合はプログラムカウンタを+1しなくて良い

そのままです。BNE命令やBVS命令では特定の条件に一致した際にブランチしますが、この時はオペランド分だけプログラムカウンタをインクリメントしてはいけません。

条件に一致しない場合はインクリメントする必要があります。

CMPなどでのキャリーフラグの付け方

CMPやCPX,CPY命令などではA/X/Yレジスタとオペランドを減算し、その結果によってキャリーフラグをつける必要があります。

このとき以下の点に注意する必要があります。

  • A/X/Yレジスタ から オペランド を減算する
  • 減算時にキャリーフラグがつくのは、繰り下がりが起こらなかった場合

なので結局は、 A/X/Yレジスタ >= オペランド であればキャリーフラグがつき、そうでなければキャリーフラグがつきません。等価だった場合にもキャリーフラグがつくことは注意です

ブランチ時、プログラムカウンタへは加算だけではなく減算を行う場合もある

ブランチに関しては以下の記事が分かりやすかったです。

https://taotao54321.hatenablog.com/entry/2017/04/09/151355

ただ、メモリから読み取った値を元に加算だけでなく減算もするので注意が必要です。

例えば何も考えずに以下のように実装すると、targetは0~255の値しか取り得ず、プログラムカウンタは加算しかされません。

let target = self.memory_read(self.program_counter);
let target_addres = self
    .program_counter
    .wrapping_add(1)
    .wrapping_add(target as u16);
self.program_counter = target_addres;

以下のようにキャストして0~255を-128~127に変換する必要があります

- let target = self.memory_read(self.program_counter);
+ let target = self.memory_read(self.program_counter) as i8;

所感

  • スネークゲームを正常に動作させるまでは終わりが見えない戦いだったので精神的に疲れた
    • 実行しても即座にpanicするので進捗がある感じが全くしなかった
  • 日本語での6502に関する記事が少なくてエミュレータ作成の敷居が高く感じた
  • 1の補数や2の補数やキャリーなど普段気にしないことを気にする必要があり頭を使う感じがする…
    • 特に減算時にキャリーするかどうかは毎回頭が混乱していた
tech  NES  Rust  6502 

See also