PONOS Advent Calendar 2022 15日目です。
昨日は@honeniqさんのgit cat-fileでGitとちょっと仲良くなる
でした。
NESエミュレータを作る-準備編の続きです。
今回はこちらのサイトで配布されているサンプルROM「Hello, World!」を使ってプログラムがCPU上でどのように処理されていくかを追っていきます。
この記事の目標
いきなりファミコンはこう動いている!のようなデカイ部分を見ると大変なので
この記事ではサンプルROMをどう動かすかの部分にのみ注目します。
サンプルROMに登場しない命令やファミコンのハード的な部分の説明は省きます。
プログラムがどのように処理されていくかの流れが理解できれば目標達成です。
Hello, World!
サンプルROMは起動するとHello, World!という画像を画面中央に表示するだけのものです。
iNESヘッダー
さっそくROMの中身を見ていきましょう。
# この読み込み方だと実際は10進数で表示されますがわかりにくいので16進数表示にしています
buf = File.open("./sample1.nes", "rb").read.bytes
buf[0] # 0x4e(N)
buf[1] # 0x45(E)
buf[2] # 0x53(S)
buf[3] # 0x1a(SUB)
buf[4] # 0x02
buf[5] # 0x01
ROMの先頭16ByteはiNESヘッダーと呼ばれる情報が入っています。
1〜4Byteは固定値のAsciiコードです。
重要なのは5, 6Byte目でそれぞれプログラムROMとキャラクターROMのサイズになっています。
プログラムROMは16KiB、キャラクターROMは8KiB単位になっているのでサンプルROMのプログラムROMサイズは32KiB、キャラクターROMサイズは8KiBになります。
よって、以下のように切り出すことができます。
program_rom = buf[0x10, 0x8000]
character_rom = buf[0x8010, 0x2000]
ファミコンはこのプログラムROMをCPUのメモリのアドレス0x8000〜に読み込んで逐次処理していきます。
命令セット
プログラムROMには1Byteで表された命令の種類とアドレッシングモードの組み合わせ(命令セット)が入っています。
サンプルROMで使われる命令セットは11種類(命令9種類、アドレッシングモード5種類)のみです。
LDA Immediate (0xa9)
命令セットの次のアドレスにある値をAレジスタに読み込みます。
LDA AbsoluteX (0xbd)
命令セットの次のアドレスに入っている値を下位、次の次の値を上位としたアドレスにXレジスタの値を足したアドレスにある値をAレジスタに読み込みます。
LDX Immediate (0xa2)
命令セットの次のアドレスにある値をXレジスタに読み込みます。
LDY Immediate (0xa0)
命令セットの次のアドレスにある値をYレジスタに読み込みます。
STA Absolute (0x8d)
命令セットの次のアドレスに入っている値を下位、次の次の値を上位としたアドレスにAレジスタの値を書き込みます。
TXS Implied (0x9a)
Xレジスタの値をスタックポインタにコピーします。
DEY Implied (0x88)
Xレジスタの値をデクリメントする。
INX Implied (0xe8)
Yレジスタの値をインクリメントする。
JMP Absolute (0x4c)
次に処理するアドレスを命令セットの次のアドレスに入っている値を下位、次の次の値を上位としたアドレスに変更する。
BNE Relative (0xd0)
ステータスレジスタzeroがfalseのときのみ次に処理するアドレスを次のアドレスに入っている値を次の次のアドレスに足したアドレスに変更する。
このとき足す値は符号付き8bit値として扱う。
SEI Implied (0x78)
ステータスレジスタinterruptをtrueにする。
文字で説明してもなんのこっちゃという感じだと思うので実際にプログラムROMの処理を追っていきましょう。
プログラムROMの処理を追う
ゲームを起動した時やリセットボタンを押したときはプログラムの先頭から処理が始まります。
プログラムの先頭を示すアドレスはCPUメモリ上のアドレス0xfffc、0xfffdにあります。
# プログラムROMはCPUメモリのアドレス0x8000から配置されているので
# メモリ上のアドレスから0x8000を引いたインデックスの値を取得する
def read(address)
return program_rom[address - 0x8000]
end
lower = read(0xfffc) # 0x00
upper = read(0xfffd) # 0x80
address = lower | upper << 8 # 0x8000
サンプルROMでは0xfffcの値0x00を下位、0xfffdの値0x80を上位としたアドレス0x8000
つまりprogram_rom
として切り出した先頭から処理をはじめます。
read(0x8000) # 0x78 SEI Implied
read(0x8001) # 0xa2 LDX Immediate
read(0x8002) # 0xff
read(0x8003) # 0x9a TXS Implied
read(0x8004) # 0xa9 LDA Immediate
read(0x8005) # 0x00
read(0x8006) # 0x8d STA Absolute
read(0x8007) # 0x00
read(0x8008) # 0x20
read(0x8009) # 0x8d STA Absolute
read(0x800a) # 0x01
read(0x800b) # 0x20
まず0x8000の値は0x78なのでSEI Implied
が実行されステータスレジスタinterruptがtrueになります。
次のアドレス0x8001の値は0xa2なのでLDX Immediate
が実行され次のアドレス0x8002の値0xffがXレジスタに読み込まれます。
演算対象になったアドレスは飛ばされ次は0x8003のTXS Implied
が実行されXレジスタの値がスタックポインタにコピーされます。ここでは先程Xレジスタに読み込まれた値0xffがコピーされます。
0x8004はLDA Immediate
が実行され0x8005の値0x00がAレジスタに読み込まれます。
0x8006はSTA Absolute
が実行され0x8007を下位、0x8008を上位としたアドレス0x2000にAレジスタの値0x00を書き込みます。
0x8009も同様に0x800aを下位、0x800bを上位としたアドレス0x2001にAレジスタの値を書き込みます。
ここまでで何をしているかの説明は省きますがこのように処理が進んでいきます。
read(0x800c) # 0xa9 LDA Immediate
read(0x800d) # 0x3f
read(0x800e) # 0x8d STA Absolute
read(0x800f) # 0x06
read(0x8010) # 0x20
read(0x8011) # 0xa9 LDA Immediate
read(0x8012) # 0x00
read(0x8013) # 0x8d STA Absolute
read(0x8014) # 0x06
read(0x8015) # 0x20
続いて0x800cのLDA Immediate
でAレジスタに0x3fを読み込みます。
そして0x800eのSTA Absolute
でアドレス0x2006にAレジスタの値を書き込みます。
同様に0x8011のLDA Immediate
でAレジスタに0x00を読み込み0x8013のSTA Absolute
でアドレス0x2006にAレジスタの値を書き込みます。
ここではアドレス0x2006に値を書き込むことで描画専用のユニットであるPPUの設定をしています。
0x3f、0x00を連続で書き込むことでこれからパレットの設定をするという状態にしています。
read(0x8016) # 0xa2 LDX Immediate
read(0x8017) # 0x00
read(0x8018) # 0xa0 LDY Immediate
read(0x8019) # 0x10
read(0x801a) # 0xbd LDA AbsoluteX === ここから ===
read(0x801b) # 0x51
read(0x801c) # 0x80
read(0x801d) # 0x8d STA Absolute
read(0x801e) # 0x07
read(0x801f) # 0x20
read(0x8020) # 0xe8 INX Implied
read(0x8021) # 0x88 DEY Implied
read(0x8022) # 0xd0 BNE Relative
read(0x8023) # 0xf6 === ここまで16回ループ ===
ここからはループを使ってパレットの設定をしています。
まず0x8016のLDX Immediate
でXレジスタに0x00、0x8018のLDY Immediate
でYレジスタに0x10を読み込みます。
Yレジスタがループカウンタの役割をしていてこれが0になるまでループが続きます。
0x801aのLDA AbsoluteX
は0x801bを下位、0x801cを上位としたアドレス0x8051にXレジスタの0x00を足したアドレスの値をAレジスタに読み込みます。
最初のループではXレジスタが0x00なので0x8051の値0x0fが読み込まれます。ループの最後でXレジスタがインクリメントされることでここで読み込むアドレスが変化していきます。
0x801dのSTA Absolute
でAレジスタの値0x0fをアドレス0x2007に書き込みます。これでパレットの設定をしたことになります。
ループの最後0x8020INX Implied
、0x8021DEY Implied
でXレジスタをインクリメント、Yレジスタをデクリメントしています。
0x8022のBNE Relative
の時点でYレジスタが0になっていれば処理が0x8024に進みますが、なっていない場合は0x8023の値0xf6(符号付き8bit値の-10)と0x8024を足したアドレス0x801aに処理が戻ります。
このループを16回繰り返すと0x0f, 0x00, 0x10, 0x20, 0x0f, 0x06, 0x16, 0x26, 0x0f, 0x08, 0x18, 0x28, 0x0f, 0x0a, 0x1a, 0x2aが書き込まれ、これがパレットの値になります。
read(0x8024) # 0xa9 LDA Immediate
read(0x8025) # 0x21
read(0x8026) # 0x8d STA Absolute
read(0x8027) # 0x06
read(0x8028) # 0x20
read(0x8029) # 0xa9 LDA Immediate
read(0x802a) # 0xc9
read(0x802b) # 0x8d STA Absolute
read(0x802c) # 0x06
read(0x802d) # 0x20
ここではPPUにパレット設定を伝えた時の処理と同じように0x21, 0xc9を連続で書き込むことで
これから画面描画の処理をするという状態にしています。
read(0x802e) # 0xa2 LDX Immediate
read(0x802f) # 0x00
read(0x8030) # 0xa0 LDY Immediate
read(0x8031) # 0x0d
read(0x8032) # 0xbd LDA AbsoluteX === ここから ===
read(0x8033) # 0x61
read(0x8034) # 0x80
read(0x8035) # 0x8d STA Absolute
read(0x8036) # 0x07
read(0x8037) # 0x20
read(0x8038) # 0xe8 INX Implied
read(0x8039) # 0x88 DEY Implied
read(0x803a) # 0xd0 BNE Relative
read(0x803b) # 0xf6 === ここまで13回ループ ===
パレットの設定をしたときと同じような処理をしています。
このループを13回繰り返すと0x48, 0x45, 0x4c, 0x4c, 0x4f, 0x2c, 0x20, 0x57, 0x4f, 0x52, 0x4c, 0x44, 0x21が書き込まれ、これがキャラクターROMにある「Hello, World!」のスプライトを示す値になります。
read(0x803c) # 0xa9 LDA Immediate
read(0x803d) # 0x00
read(0x803e) # 0x8d STA Absolute
read(0x803f) # 0x05
read(0x8040) # 0x20
read(0x8041) # 0x8d STA Absolute
read(0x8042) # 0x05
read(0x8043) # 0x20
read(0x8044) # 0xa9 LDA Immediate
read(0x8045) # 0x08
read(0x8046) # 0x8d STA Absolute
read(0x8047) # 0x00
read(0x8048) # 0x20
read(0x8049) # 0xa9 LDA Immediate
read(0x804a) # 0x1e
read(0x804b) # 0x8d STA Absolute
read(0x804c) # 0x01
read(0x804d) # 0x20
read(0x804e) # 0x4c JMP Absolute
read(0x804f) # 0x4e
read(0x8050) # 0x80
色々書き込みをしたあと0x804eのJMP Absolute
で0x804eに処理を進めて無限ループに入るところでプログラムは終了です。
おわりに
今回はサンプルROMのプログラム部分がCPUでどのように処理されていくのかを細かく見ていきました。
詳細は省いていますがなんとなく流れは理解できたんじゃないかと思います。
次回に続きます。
明日は@nisei275さんです。