なんとか無事に単位獲得して卒業できそう.
再来週は不動産屋巡りの予定.
ファミコンエミュは今4日目のエントリ書きながら5日目用のコード書いてる.
3日目です. 前回はこれ Golangでファミコンエミュを作る 2日目
前の更新から2週間ほど経ってしまいました. あまり時間が取れなかったのが原因です. 空いた時間は少しコード書いたりしてました(GitHubはたまに更新してた).
ひとまず落ち着いたので(卒研と論文がありますが), また再開していきたいと思います.
前回はNESファイル全体を読み込んでヘッダー, プログラムROM, キャラクターROMを切り出し, NESファイルフォーマットの話をしました.
ここからどうやってエミュレータを完成させていくか(どういう順に実装して行くか)の目処がまだ立っていないので今回は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は小さくなっていき, 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をインクリメントするようにしています.
実行はしません(できません)が, ひとまず読み込みだけ.
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
実装が増えてきたのでそろそろテストコードを書きたいと思います.
高●専プロコンが終わった数日後に東京でCODEBLUEの学生スタッフをした.
その週末はUECに行ったり. 家はまだ決まってない. 2月に借りてもいいよと許可が出た.
東京行った次の週はkernel/VMに参加するため金沢へ. 初北陸.
お魚が本当に美味しかった. また来たい.
"嫌な事件だったね, 卒業単位が1つまだ足りないんだろ."
学科長に聞いてみた. すぐに返事(や決定)はできないらしく.
「頼む, 1(か2)週間だけ待ってくれ!」
とのこと.
待てねえなぁ~
果たして, 俺は卒業することができるのか.
そういえばファミコンエミュの更新が止まってますが, 飽きてないですよ. ちゃんと勉強してます. 記事書くのに時間がかかってるだけです. 3日目は少し多めになりそうですね.
2日目です. 前回はこれ
Golangでファミコンエミュを作る 1日目 - ぺんぎんさんのおうち
ところでソースコードですが, 最終的にはGitHubにあげようと思ってます.
"n日で作るファミコンエミュレータ" なので30日OS本を倣って01_day, 02_day, ... みたいな感じで管理していこうかな〜とか考えたんですが, なにせ僕が何もわかっていなくて日によっては解説だけで終わっちゃいそうなんですよ. うーん..
いきなり完成してるコード見せるよりかは, 少しずつ足されていってる感じを見てもらった方が良さそうですね.
ということでリポジトリを作りました.
READMEとかまだちゃんと整備してないのでもう少しお待ちを.
(あ, あとこれ毎日投稿できるわけではないので)
さて前回はNESファイルのヘッダーを読み込んで各値を表示したところで終わりました. 念のため出力結果を再掲します.
Constant : 4E45531A
PRG ROM SIZE : 2
CHR ROM SIZE : 1
Flags6 : 00000001
Flags7 : 00000000
PRG RAM SIZE : 0
Flags9 : 00000000
Flags10 : 00000000
Flagsは今後のために二進8桁で表示しています.
フラグはひとまず置いておいて, この結果からわかるのは
プログラム部分が 2 * 16 KB = 32 KB
スプライト部分が 1 * 8 KB = 8 KB
ということです.
なんとなくreadNextBytesを使って32 KBと8 KBの読み込みをすればいいのかな〜と気づきますね.
ただ, それぞれのROMのサイズがわかっていても, 途中で別のデータが含まれている可能性があるのでまだ読み込みをするのは早いです. もし何か含まれていたらその分だけズレて読み込んでしまいますからね.
ではどうするかというと, NESファイルフォーマットを確認します.
前回はヘッダー部分だけを見ましたが, 今回はNESファイル全体のフォーマットに注目します.
- Header (16 bytes)
- Trainer, if present (0 or 512 bytes)
- PRG ROM data (16384 * x bytes)
- CHR ROM data, if present (8192 * y bytes)
- PlayChoice INST-ROM, if present (0 or 8192 bytes)
- PlayChoice PROM, if present (16 bytes Data, 16 bytes CounterOut)
こうなってました.
最初の16bytesがヘッダーなのは前回確認しました.
CHR ROM以降は無視するとしてPRG ROMの前に何かやっかいなのがいます.
Trainer というセクションが 0 or 512 bytes です. あるかもしれないし, ないかもしれない. (面倒ですね)
どこかに情報がないものかと探していると, Flags6に書いてありました.
Flags 6
76543210 | | | | | | | | | | | | | | | +- Mirroring: 0: horizontal (vertical arrangement) (CIRAM A10 = PPU A11) | | | | | | | 1: vertical (horizontal arrangement) (CIRAM A10 = PPU A10) | | | | | | +-- 1: Cartridge contains battery-backed PRG RAM ($6000-7FFF) or other persistent memory | | | | | +--- 1: 512-byte trainer at $7000-$71FF (stored before PRG data) | | | | +---- 1: Ignore mirroring control or above mirroring bit; instead provide four-screen VRAM ++++----- Lower nybble of mapper number
Flags6の第2ビットが1であるときにtrainerが512bytes含まれているようです.
$7000-$71FF の位置(PRGの前)っていうのが今の所よくわかりませんが, Flags6の値は00000001だったのでとりあえず考えなくて良さそうです(trainerセクションがないので).
ただ第0ビットが1なので "Mirroring" が vertical であることはメモしておきましょう.
ここまでで, NESファイルの構造を簡単に表してみます.
最初の16bytesがヘッダー, Trainerは含まれていないのでヘッダーの直後にPRG-ROMが32 KB, その後ろにCHR-ROMが8 KBです.
| Header [16 bytes] | PRG ROM [32 KB] | CHR ROM [8 KB] |
ではNESの読み込みをします.
読み込むバイト数はわかっているので, readNextBytesを使って
programROM := readNextBytes(f, int(header.PRG_ROM_SIZE) * 0x4000)
characterROM := readNextBytes(f, int(header.CHR_ROM_SIZE) * 0x2000)
それぞれのROMサイズは構造体でuint8として定義していたので, intにキャストしてあげないと怒られちゃいます.
uint8が 0 ~ 255 なので * 0x4000 は許されないっぽいですね. * 0x2000 も同様なのでキャストします.
読み込まれたバイト数を確認します.
fmt.Printf("Program ROM size : %x\n", len(programROM))
fmt.Printf("Chatacter ROM size : %x\n", len(characterROM))
### 実行結果 ###
Program ROM size : 8000
Chatacter ROM size : 2000
2 * 0x4000 = 0x8000
1 * 0x2000 = 0x2000
なのでちゃんと読み込みができています.
ちなみにCHR-ROMを読み込んだあとに1バイト読み込もうとすると「もう読み込めるものがないよ(EOF)~」とエラーが出ます.
やはり読み込んだからにはROMの中身を見てみたいですよね. 見ますか.
以下のコードを足して実行するとバイト列がぶわーっと出てきます.
fmt.Printf("Program ROM : %x\n", programROM)
fmt.Printf("Character ROM : %x\n", characterROM)
ぶわーと出てきても, それが本当にプログラムROMとキャラクターROMとして正しいのかってわかりませんよね. "ほんまにあっとるんか?" と疑問に思うわけです.
実はこのサンプルNESをダウンロードしたときに "character.chr" というファイルも付いてきていて, これはキャラクターROM部分だけを切り取ったものです.
見比べると最初に0が並んでいて, 途中から"1c 3e 3e 3e ..." となっているのでおそらく正しいです.
ここまででヘッダーとプログラムとキャラクターのバイト列の切り出しができました.
あとはプログラムROMを読んでバイナリのfetchとdecode, 画面の描画でHello Worldの表示ができそうな気がしますね.
言うは易し行うは難し
まだ先は長いはずです. 他にもやることはたくさんあります多分.
とりあえずサンプルNESを読み込んでPRG-ROMの表示をしたので, バイト列を眺めながら多分こんな実装が必要なんだろうな〜という想像をします.
今日はもう少し進めようと思っていましたが, 予想より帰宅が遅くなってしまい時間が取れなかったのでこのあたりで終わります.
この週末から来週にかけて予定が多めなので進捗は出せないと思うので隙間の時間でドキュメントとか読みます.
それでは.
1日目です. 前回はこれ Golangでファミコンエミュを作る 0日目 - ぺんぎんさんのおうち (投稿したのさっきですけど)
今回はHello Worldを動かすサンプルNESの読み込みをやっていきます.
※ ヘッダーとHeaderの表記揺れがあります. 特に違いはありませんがプログラムが絡んでくるような場合には"Header"を, そうでない場合には"ヘッダー"を使うようにしています(例外もありますが気にしないでください).
NESファイルはここでダウンロードできます.
とりあえずファイル読み込みをします. が, バイナリファイルの扱い方がわからずいきなり壁にぶつかってしまいました.
NESファイルの先頭にはヘッダーがあるはずなので, ヘッダー分のバイト数を読み込んだ上で構造体か何かで保持しておきたいです.
調べていると以下のような記事が見つかりました.
バイナリファイルを読んでバイト列から構造体へ変換する方法が記されていました.
ヘッダーにはNESファイルのどの位置にプログラムがあってどの位置にキャラクターがあるのか.. といった情報が書いてあります. ヘッダーの解析をしましょう.
Header部分に関しては以下のページに詳しく記載されています.
ひとまずHeader部だけを読んでみます.
The format of the header is as follows:
- 0-3: Constant $4E $45 $53 $1A ("NES" followed by MS-DOS end-of-file)
- 4: Size of PRG ROM in 16 KB units
- 5: Size of CHR ROM in 8 KB units (Value 0 means the board uses CHR RAM)
- 6: Flags 6
- 7: Flags 7
- 8: Size of PRG RAM in 8 KB units (Value 0 infers 8 KB for compatibility; see PRG RAM circuit)
- 9: Flags 9
- 10: Flags 10 (unofficial)
- 11-15: Zero filled
0 ~ 3 : 定数 4E 45 53 1A
4 : プログラムROMのサイズ (16 KB 単位)
5 : キャラクター(スプライト)ROMのサイズ(8 KB 単位)
6 : フラグ6
7 : フラグ7
8 : プログラムRAMのサイズ(8 KB 単位)
9 : フラグ9
10 : フラグ10
11 ~ 15 : 0 埋め
全体で16bytesですね.
これをGolang側で構造体として次のように定義しておきます.
type Header struct {
Constant [4]byte
PRG_ROM_SIZE uint8
CHR_ROM_SIZE uint8
Flags6 uint8
Flags7 uint8
PRG_RAM_SIZE uint8
Flags9 uint8
Flags10 uint8
_ [5]byte
}
これでNESファイルの構造(Header)が簡単にわかるようになりました.
最初の16bytesを読み込んでHeader構造体に保持してもらいましょう.
こうすればプログラムROMのサイズが知りたいときには header.PRG_ROM_SIZE, キャラクターROMのサイズが知りたいときには header.CHR_ROM_SIZE とすることができます.
ヘッダー読み込み用の関数を定義しました.
func readHeader(file *os.File) *Header {
header := Header{}data := readNextBytes(file, int(unsafe.Sizeof(header)))
buffer := bytes.NewBuffer(data)
err := binary.Read(buffer, binary.BigEndian, &header)
if err != nil {
fmt.Println("binary.Read failed", err)
}return &header
}
readNextBytes()というのが, バイナリを指定したバイト数読み込む関数です.
それと各値を確認するための関数も定義しました.
func showHeader(header *Header) {
fmt.Printf("Constant : %x\n", header.Constant)
fmt.Printf("PRG ROM SIZE : %x\n", header.PRG_ROM_SIZE)
fmt.Printf("CHR ROM SIZE : %x\n", header.CHR_ROM_SIZE)
fmt.Printf("Flags6 : %x\n", header.Flags6)
fmt.Printf("Flags7 : %x\n", header.Flags7)
fmt.Printf("PRG RAM SIZE : %x\n", header.PRG_RAM_SIZE)
fmt.Printf("Flags9 : %x\n", header.Flags9)
fmt.Printf("Flags10 : %x\n", header.Flags10)}
最後にmain()の実行部分だけを示します.
func main() {
argc := len(os.Args)
if argc < 2 {
fmt.Println("input file name.")
return
}filename := os.Args[1]
f, err := os.Open(filename)
if err != nil {
fmt.Println("Err : ", err)
return
}
defer f.Close()header := readHeader(f)
showHeader(header)
//
}
実行してみましょう.
## 実行結果 ##
Constant : 4e45531a
PRG ROM SIZE : 2
CHR ROM SIZE : 1
Flags6 : 1
Flags7 : 0
PRG RAM SIZE : 0
Flags9 : 0
Flags10 : 0
ちゃんと読み込めてるっぽいですね. ヨシッ
次はNESのファイルフォーマットの話と, プログラム部分/スプライト部分の切り分け + なにか進捗があれば.. という予定です.
明日は1限から昼まで講義なので早めに寝ます.