Writing NES Emulator in Rust
Writing NES Emulator in Rustを勉強するにあたっての事前準備
現状理解していること
- Emulatorとは、ハードウェアの動作さえもソフトウェア上で再現することで、ある装置の動作を模倣するソフトウェアのこと。
- 必要な構成要素にCPUとメモリがあるということ
現状理解できていないこと
- Emulatorの作り方
- Emulatorの構成がどうなっているのか
- さらにその構成要素がどうのような動作をしているのか
- どうやってソフトウェアが動いているのか
- 低レベルのプログラミングのやり方
この勉強を通して学びたいこと
- Emulatorの作り方を学ぶ
- Emulatorの構成要素の把握
- その構成要素の動作と役割に理解
- ハードウェアとソフトウェアの繋がりを理解し、ソフトウェアがどのようにハードウェアで動作しているかを学ぶ
- 低レベルのプログラミングを学び、OS自作や組み込みプログラミングとのつながりを理解する
NES Platformの主な構成要素
- Central Processing Unit(CPU) ... 2A03という名前で、プログラムの実行を行う
- Picture Processing Unit(PPU) ... TVスクリーンに現在の状態を描画
- Ramdom Access Memory(RAM) ... 2048bytesのメモリ
- Audio Processing Unit(APU) ... 2A03の一部で、5チャネルサウンドを生成
- Cartridges ... ConsoleはOSを持たないため、Cartridgesは二つの大きなROMを持っている。一つは、ゲームのビデオグラフィックデータを保存しているCharacter ROM(CHR ROM)。もう一つは、ゲームのロジックプログラムを保存しているProgram ROM(PRG ROM)。実際、CHR ROMはPPUに、PRG ROMはCPUにそれぞれ直接繋がっている。晩年には、Cartridgeに追加ハードウェア(ROM,RAM)が組み込まれ、同一コンソールにおけるグラフィック向上に貢献した。
- Gamepads ... 8bitプラットフォームと8個のボタンは偶然じゃないよ
※ROM ... Read Only Memory。製造時に一度だけ書き込める記憶媒体。BIOSやファームウェアを保存するのに利用される。
これら全ての構成要素が独立しており、NESはシームレスなゲーム体験を作るための分散システムになっている。
CPUの役割は、
- プログラムの実行
- 他のハードウェアたちのオーケストレーション
となります。PPUやAPUは、CPUとは独立した回路で実行されていますが、これらはCPUの命令のもとで行われる。
CPUが正常に動作するために必要な2つのリソースがある。
- Memory Map
- CPU Registers(レジスタ)
※レジスタとはCPU内部にある記憶装置
Memory Mapは、1byteの連続したArrayである。
NES CPUは16bit=2bytesのメモリーアドレッシングを使用する。つまり、メモリの住所が
0x ... 16進数を表す
0x0000 ... 16進数で4桁の数字、つまり
RAMにアクセスするのは遅いので、CPUは内部にregistersと呼ばれるメモリを持っている。
registersにアクセスするのは、比較的遅延が少ない。っていうか結構違う。
NES CPUは7つのRegistersを持っている。
- Program Counter ... 次に実行される機械語の命令を保持
- Stack Pointer ... Stackに使われるメモリ領域の最上位のアドレスを保持
- Accumulator ... 論理演算や算術演算を一時的に保存しておくためのもの
- Index Register X ... 連続したデータの取り出しに使う増分値を保存するもの
- Index Register Y ... Index Regiter Xと同じ感じ
- Processor status ... 最後に実行された命令の結果のフラグを表す
ヒープ領域とスタック領域
ヒープ領域
heap ... 乱雑に積み重ねられた山
ヒープ領域とは、動的に確保と解放を繰り返せるメモリ領域
スタック領域
stack ... きちんと積み重ねられた山
ヒープ領域とスタック領域
ヒープ領域
heap ... 乱雑に積み重ねられた山
ヒープ領域とは、動的に確保と解放を繰り返せるメモリ領域
メモリを解放するのに、順番など関係なくどのアドレスのメモリも任意のタイミングで解放することができる
スタック領域
stack ... きちんと積み重ねられた山
メモリを解放するには、積み上げられた上から順番に解放する必要がある
Assemblyの勉強?
LDA #$01 .... 16進数x01という値を、レジスタAに読み出す
LDX #$0c .... 16進数x0cという値を、レジスタXに読み出す
STA $0200 ... メモリのx0200番地にレジスタAの値を保存
STX $05ff ... メモリのx05ff番地にレジスタXの値を保存
※同じ番地に値を再代入可能!
LDA #$01
STA $0200
LDA #$05
STA $0201
LDA #$08
STA $0202
LDA #$0a
STA $0203
LDX #$0c
STX $0203
LDX #$0f
STX $05ff
- レジスタAは、Accumulatorと呼ばれるレジスタで、よく算術演算や論理演算の結果を一時的に保存するのに使われる。
- レジスタA,X,Yは、1バイト(16進数で言えば一桁)の値を保存する。
- SPは、スタックポインターレジスタで、このレジスタの値は通常、スタック領域に値が保存されていくとディクリメントされていく。
- そして、スタック領域の値が取り出されると、値がインクリメントされる。
- PCは、プログラムカウンターで、現在実行されているプログラムの場所が保存されている。
- いわばコードの行番号的な
- PCは次の命令の場所って書かれていたり、現在の命令の場所って書かれたりするんだけど、どっちなん?
- プロセッサーフラグは、前に行った命令の情報を記憶するものらしい
- 1ビットが7個並んでるって書いてあるけど、7個前までの命令の状況を記憶しているってこと?(わからん)
- 7個前の命令とかではなくて、それぞれのフラグが異なる意味を持つみたい。
- 計算結果が繰り上がったフラグなど(キャリーフラグと呼ぶらしい)
- 1ビットが7個並んでるって書いてあるけど、7個前までの命令の状況を記憶しているってこと?(わからん)
アセンブリの命令
アセンブリの命令は、事前にセットされた関数の集合みたいなもん。
すべての命令は、0個もしくは1個の引数を持つ。
アセンブリのコード例
LDA #$c0 ;Load the hex value $c0 into the A register
TAX ;Transfer the value in the A register to X
INX ;Increment the value in the X register
ADC #$c4 ;Add the hex value $c4 to the A register
BRK ;Break - we're done
アセンブリの命令
アセンブリの命令は、事前にセットされた関数の集合みたいなもん。
すべての命令は、0個もしくは1個の引数を持つ。
アセンブリのコード例
LDA #$c0 ;Load the hex value $c0 into the A register
TAX ;Transfer the value in the A register to X. the value of A register is not changed
INX ;Increment the value in the X register
ADC #$c4 ;Add the hex value $c4 to the A register
BRK ;Break - we're done
ADC #$01 ;adds the value $01 to the A register
ADC $01 ; adds the value stored at memory location $01 to the A register
"#"は値そのものを表し、"#"がないとメモリの住所を表す。
zeroflag ... Processor Statusのの中の一つのフラグで、計算結果の下位1バイトがx00の時セットされる。
LDA #$c0 ;Load the hex value $c0 into the A register
SBC #$00 ;subtract the hex value $00 from the A register.
;result : A=$bf
↑はなんで?0を引いているつもりなのに、1引かれている。
LDA #$c0 ;Load the hex value $c0 into the A register
SBC #$ff ;subtract the hex value $ff from the A register.
;result : A=$c0
$ffを引いているつもりなのに、0が引かれている。なんか基準が違うっぽい?
足し算の場合は、$00が基準となって0になっているけど、
引き算の場合は、$ffが基準となって0になっている?
こう考えると、$00を引いた場合に、1引かれている理由になる?
きっとmodの計算を深く考えないと、理解できないかも
この資料の作者にTwitterで聞いてみるか
Branch (分岐)
LDX #$08
decrement: ;Label
DEX
STX $0200
CPX #$03 ; compare instruction. if the value of X register equals $03, then Z flag is set ot 1.
BNE decrement ; if the Z flag is set to 0, execution will shift to decrement label.
STX $0201
BRK
CPX,BNE,Labelは、セットで使われるっぽい。
CPXは、Xレジスタの値と引数の値を比較する。一致ならZflagが1にセットされる。それ以外は0にセットされる。
BNEは、Zflagが0なら、decrement labelの位置に処理が飛ぶ。
上の処理は、レジスタXの値が$03になるまでディクリメントされ続ける処理
BEQ decrement ; if Z flag is set to 1, execution shift to decrement label.
BCC decrement ; if Carry flag is set to 0, execution shift to decrement label.
BCS decrement ; if Carry flag is set to 1, execution shift to decrement label.
JumpingとBranching
JumpingとBranchingには2つの大きな違いがある。
- Jumpingは無条件で処理が飛ぶ
- Jumpingは2バイトの絶対アドレスを使う
小規模なプログラムでは、2番目の特徴は重要ではないけど、大規模なプログラムでは、Jumpingが処理を飛ばす唯一の方法らしい どゆこと?
JSR / RTS
JSR ... Jump to Subroutine(サブルーチンに飛ぶ?、関数呼び出し)
RTS ... Retrun from Subroutine(サブルーチンから帰る?、return文)
JSR init
JSR loop
JSR end
init:
LDX #$00
RTS
loop:
INX
CPX #$05
BNE loop
RTS
end:
BRK
リトルエンディアンとビッグエンディアン
NES CPUは、65536(2bytes=16bit)のメモリーセルを指定できる。
;リトルエンディアン
LDA $8000 <=> ad 00 80
;ビッグエンディアン
LDA $8000 <=> ad 80 00
NESのリセット処理
NESはCartridgeが新しく挿入された時、以下のようなリセット割り込み処理が行われる。
- CPUの状態をリセット(レジスタとフラグ)
- プログラムカウンタの値をメモリアドレス0xFFFCに格納
pub fn reset(&mut self) {
self.register_a = 0;
self.register_x = 0;
self.status = 0;
self.program_counter = self.mem_read(0xFFFC);
}
アドレッシングモード
アドレッシングモードとは、CPUが次の1,or 2byteをどのように解釈するかを定義するを規定しています。
公式のGithubを見てもわからないポイント
RustのXORの返り値は真偽値になっているの?
0x80 != 0って常に正じゃない?