LoginSignup
2
0

More than 1 year has passed since last update.

NESエミュレータを作る-CPUちょっと理解編

Last updated at Posted at 2022-12-15

PONOS Advent Calendar 2022 15日目です。
昨日は@honeniqさんのgit cat-fileでGitとちょっと仲良くなる
でした。

NESエミュレータを作る-準備編の続きです。
今回はこちらのサイトで配布されているサンプルROM「Hello, World!」を使ってプログラムがCPU上でどのように処理されていくかを追っていきます。

この記事の目標

いきなりファミコンはこう動いている!のようなデカイ部分を見ると大変なので
この記事ではサンプルROMをどう動かすかの部分にのみ注目します。
サンプルROMに登場しない命令やファミコンのハード的な部分の説明は省きます。
プログラムがどのように処理されていくかの流れが理解できれば目標達成です。

Hello, World!

サンプルROMは起動するとHello, World!という画像を画面中央に表示するだけのものです。
helloworld.png

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さんです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0