3日目です. 前回はこれ Golangでファミコンエミュを作る 2日目
少し雑談
前の更新から2週間ほど経ってしまいました. あまり時間が取れなかったのが原因です. 空いた時間は少しコード書いたりしてました(GitHubはたまに更新してた).
ひとまず落ち着いたので(卒研と論文がありますが), また再開していきたいと思います.
ここから本編
前回はNESファイル全体を読み込んでヘッダー, プログラムROM, キャラクターROMを切り出し, NESファイルフォーマットの話をしました.
ここからどうやってエミュレータを完成させていくか(どういう順に実装して行くか)の目処がまだ立っていないので今回はCPUまわりの解説をしたいと思います.
CPU
詳細はこちらが参考になります. NES on FPGA
レジスタ
まずはCPUに搭載されているレジスタです.
15 - 8 | 7 - 0 | レジスタ名 |
---|---|---|
A | アキュムレーターレジスタ | |
X | インデックスレジスタ | |
Y | インデックスレジスタ | |
PC | PC | プログラムカウンタ |
0x01 | SP | スタックポインタ |
P | プロセッサステータスレジスタ |
スタックポインタは2バイトですが, 上位は 01
固定です.
プログラムカウンタ(インストラクションポインタ)は2バイトで, 現在実行している命令のアドレスを指します.
ステータスレジスタ
上の表のPレジスタです. これは割り込み発生時や演算結果によって変化します.
bit | フラグ | 詳細 |
---|---|---|
7 | N (Negative) | 演算結果のビット7をストア(負の時にセット) |
6 | V (oVerflow) | 演算によってオーバーフローが起きた時にセット |
5 | R (Reserved) | 常に1 |
4 | B (Break) | BRK命令による割り込みが発生した時にセット |
3 | D (Decimal) | セットされていれば10進演算が行われる |
2 | I (Interrupt) | 割り込みが発生するとセット |
1 | Z (Zero) | 演算結果がゼロだった時にセット, そうでない時にクリア |
0 | C (Carry) | 桁上げが起きた時にセット |
セットは該当フラグを1に, クリアは0にします.
メモリマップ
CPUのメモリで, どの番地にどの情報が配置されるかを示しています.
アドレス | サイズ | マッピングされるもの |
---|---|---|
0x0000 ~ 0x07FF | 0x0800 | WRAM |
0x0800 ~ 0x1FFF | WRAM ミラー | |
0x2000 ~ 0x2007 | 0x0008 | PPUレジスタ |
0x2008~0x3FFF | PPUレジスタ ミラー | |
0x4000 ~ 0x401F | 0x0020 | APU, PAD |
0x4020 ~ 0x5FFF | 0x1FE0 | ExRAM |
0x6000 ~ 0x7FFF | 0x2000 | ExROM |
0x8000 ~ 0xFFFF | 0x8000 | ROM |
0x8000 ~ 0xFFFF
がROMになっていますね. ここにNESファイルから切り出したPRG-ROMが配置されます.
ここまでを踏まえて, レジスタとCPUの構造体を表すコードを示します.
type Registers struct { A uint8 // Accumulator X uint8 // Index Register Y uint8 // Index Register P uint8 // Status Register SP uint16 // Stack Register PC uint16 // Program Counter } type CPU struct { Registers Memory [0x10000]uint8 }
CPU構造体の中にレジスタとメモリを定義しています.
NESから切り出したPRG-ROMはメモリに配置されます.
ということで配置のための関数が必要になります.
func (cpu *CPU) MemoryMapping(val []uint8, start int) { for i := 0; i < len(val); i++ { cpu.Memory[start + i] = val[i] } }
メモリに配置させたいデータ(配列)と開始アドレスを渡すと, そのアドレスを起点にデータが配置されます. start + len(val) > 0x10000
の場合は配置ができないことに注意しましょう.
スタック
レジスタにSP(スタックポインタ)がありました, 上位バイトは0x01固定で, 下位バイトが0x00~0xFFまで変化します.
つまり, SPの取り得る範囲は 0x0100 ~ 0x01FF
となります.
SPの値はあくまでアドレスで, そのアドレスはどこを指すのかというとCPUのメモリになるわけです. 上のメモリマップを確認するとWRAMの領域になっていますね.
スタック操作は次の2つがあります.
- PUSH
SPが指すアドレスにデータをストアし, SPをデクリメント - POP
SPをインクリメントし, SPが指すアドレスにあるデータをフェッチ
スタックは上位アドレスから下位アドレスに成長していきます.
PUSHするたびにSPは小さくなっていき, POPするとSPは大きくなります. (この表現はなんか気になりますが)
pushとpopのコードは以下のようになります.
func (cpu *CPU) StackPush(val uint8) { cpu.Memory[cpu.SP] = val cpu.SP -= 1 } func (cpu *CPU) StackPop() uint8 { cpu.SP += 1 return cpu.Memory[cpu.SP] }
割り込み
割り込みにはRESET, NMI, IQR, BRKがあります.
今回はRESETのみ解説をします. 他の割り込みについてはやはり NES on FPGA こちらを参照するのがよいです.
RESETは電源を入れた時, リセット時に発生します.
PレジスタのIフラグをセットし, PCの下位バイトをメモリの0xFFFCから, 上位バイトを0xFFFDからフェッチします.
0xFFFC | 0xFFFD |
---|---|
0x00 | 0x80 |
メモリに上の表のようにデータが配置されていた場合, PCには0x8000(ROMの開始アドレス)が入るので順にROMが読まれていくわけです.
PC // 2 bytes CPU.Memory[0xFFFC] // 1 byte CPU.Memory[0xFFFD] // 1 byte PC = CPU.Memory[0xFFFC] | (CPU.Memory[0xFFFD] << 0x08)
フェッチ部分の擬似コードはこんな感じです.
プログラムカウンタ
プログラムカウンタ(PC)は現在実行中の命令のアドレスを指すレジスタです.
PRG-ROMはメモリの0x8000番地から配置されているので, PCを0x8000から処理を進めることでプログラムが動きます.
PCが指すアドレスにあるデータをフェッチし, そのデータに該当する処理を... 難しいのでこの部分はひとまずおいておくことにします.
フェッチとPCをインクリメントするコードは以下のようになります.
func (cpu *CPU) Fetch() uint8 { defer cpu.IncrementPC() // cannot use statement cpu.Memory[cpu.PC++] return cpu.Memory[cpu.PC] } func (cpu *CPU) IncrementPC() { if cpu.PC < 0xFFFF { cpu.PC += 1 } else { // Raise panic for now panic("End of Memory") } }
フェッチした後でPCをインクリメントするようにしています.
とりあえずROMを全部読み込んでみる
実行はしません(できません)が, ひとまず読み込みだけ.
func (cpu *CPU) Run() { for ; cpu.PC <= 0xFFFF; { opecode := cpu.Fetch() // opecodeから実行する処理を決定し // 必要であればフェッチを行う fmt.Printf("%02X ", opecode) } }
Run()を実行するとPCが0xFFFFになるまでループが続きます.
func main() { ... 略 cpu.MemoryMapping(programROM, 0x8000) fmt.Printf("%X\n", cpu.Memory[0x8000 : 0x8080]) cpu.Run() }
CPU.Memoryに配置されたPRG-ROMが順にフェッチされ表示されます.
まとめ
今回はCPUのレジスタやメモリマッピング等の解説をしました.
コードはここにあります https://github.com/ykm11/fc-emulator/tree/master/03_day
実装が増えてきたのでそろそろテストコードを書きたいと思います.