NES 基礎知識 - CPU

今回は CPU について扱います。NES の PPU や APU はかなり複雑ですが、CPU は単純なので理解は容易です。CPU さえ理解しておけば乱数などの「目に見えない値」を探したり、ダメージ計算などの内部ロジックを追うには十分です。

ロジックを追う際は別にアセンブリ全体を読む必要はなく、デバッガで適当にブレークを仕掛けてその周辺を読む程度で大抵は事足ります。NES ゲームは大半がアセンブラ直書きだと思われるので、人間には読めないようなコードはそうそう出てこないはずです。PPU や APU を扱うコードを除けばそんなに凝ったアルゴリズムも使われていない様子です。

NES の CPU は 6502 カスタムの 2A03 です。オリジナル 6502 からの変更点は:

  • Decimal mode (10進演算)の削除
  • サウンド、DMA, コントローラ制御機能追加

といったところですが、コードを読むだけなら 6502 と同様に扱えます(以下、特に区別する必要がなければ 2A03 ではなく 6502 と書いてしまいます)。

まず 6502 の基本事項をまとめておきます:

  • 命令長は 1~3 バイト
  • スタックは上位アドレスから下位アドレスへ伸びる
  • ポインタはリトルエンディアン
  • 負数は 2 の補数で表す

プログラム側でのエンディアンは特に CPU に合わせなくても不都合は生じないので、16bit数をビッグエンディアンで管理したりしているゲームもあります。ただ全体的にはリトルエンディアンで統一しているゲームの方が多いようです。負数は 2 の補数を採用しているゲームが多く、その方が便利だと思いますが、たまに符号ビット+絶対値方式などを採用しているゲームもあります(『アストロロボSASA』など)。

レジスタ

6502 には以下のレジスタがあります:

名前 サイズ 用途
PC 16bit プログラムカウンタ。実行中アドレスを指す
A 8bit アキュムレータ。演算用
X 8bit インデックス。配列アクセスやその他雑用
Y 8bit X とほぼ同じ
S 8bit スタックポインタ
P 8bit ステータスレジスタ。後述

プログラム中で明示的に扱うのはほぼ A, X, Y のみです。PC はジャンプ命令などで暗黙的に変更されます。S は大抵 RESET ハンドラ先頭で初期設定され、以降は意識することはありません。P は主に分岐命令で暗黙的に参照されます。

ステータスレジスタ

ステータスレジスタ P は主に条件分岐用で、ビットレイアウトは以下の通りです(実質 6bit)*1:

bit 76543210
    NV..DIZC
bit 内容
N ネガティブフラグ。値が負(最上位ビットが立っている)なら 1, さもなくば 0
V オーバーフローフラグ。符号付きオーバフローが発生したら 1, さもなくば 0
D デシマルフラグ。2A03 には decimal mode がないので無意味
I IRQ 割り込み禁止フラグ。1 で禁止、0 で許可
Z ゼロフラグ。値が 0 なら 1, さもなくば 0
C キャリーフラグ。符号なしオーバーフローが発生したら 1, さもなくば 0

最もよく使われるのは N, Z フラグで、これらは大抵の命令において処理した値に応じて変化します。次によく使われるのが C フラグで、主に加減算とビットシフト/ローテート用です。とりあえず以上の 3 つだけ覚えておけば大体事足ります。

NES ゲームでは符号付き数はそこまで多用されないので V フラグはあまり使われません。I フラグは IRQ 割り込みを使わないゲーム(結構多い)では意識する必要はありません。

命令

本来はここでアドレッシングモードの説明を入れるところですが、具体例がないとわかりにくいので後回しにします。ここでは基本的に absolute アドレッシングモード(要するに 16bit アドレス指定)のみを使います。

ここでは非公式命令は扱いません。ほとんどの商用ゲームは非公式命令を使っていないので、普通は意識する必要はないはずです。暴走系のバグ技を使うなどの理由で必要な場合は NesDevWiki の資料などを参照。

リファレンス形式の資料としては http://obelisk.me.uk/6502/reference.html などがあります。また、たまに機械語の読み書きが必要になることがあるので http://www.oxyron.de/html/opcodes02.html のオペコード表の存在も覚えておくと便利かもしれません。

レジスタへのロード

LDA, LDX, LDY 命令で A, X, Y レジスタへ値をロードできます。"LOAD A", “LOAD X”, “LOAD Y” と覚えるといいです。

lda $0700 ; A = mem[0x0700]

ロード命令は N, Z フラグを書き換えます:

  • N: ロードした値の最上位ビット
  • Z: ロードした値が 0 であるか

メモリへのストア

STA, STX, STY 命令で A, X, Y レジスタの値をメモリへ書き込めます。"STORE A", “STORE X”, “STORE Y” と覚えるといいです。

sta $0700 ; mem[0x0700] = A

ストア命令はフラグを変更しません。

レジスタ間コピー

T** 命令でレジスタ間コピーができます。"Transfer A to X" のように覚えるといいです。

  • TAX: A -> X
  • TAY: A -> Y
  • TXA: X -> A
  • TXS: X -> S
  • TYA: Y -> A
  • TSX: S -> X

スタックポインタ S の設定/取得は TXS, TSX 命令を用いて X レジスタ経由で行うことになります。

TXS はフラグを変更しません。その他の命令は N, Z フラグを書き換えます:

  • N: コピーした値の最上位ビット
  • Z: コピーした値が 0 であるか

スタック操作

PHA, PHP で A, P レジスタをスタックへ push します。"PUSH A", “PUSH P” と覚えるといいです。
PLA, PLP で A, P レジスタをスタックから pop します。"PULL A", “PULL P” と覚えるといいです。

PLA は N, Z フラグを書き換えます:

  • N: pop した値の最上位ビット
  • Z: pop した値が 0 であるか

A, P 以外のレジスタやメモリ上の値は直接 push/pop できないので、いったん A へコピーしてから push/pop します:

; PUSH(X)
txa
pha

...

; X = POP()
pla
tax

6502 はスタックサイズが小さいため、これらの命令で明示的にスタックを操作することはあまりありません。ただし NMI/IRQ 割り込みハンドラでは割り込みから正しく復帰するために A, X, Y レジスタを push/pop するのが普通です。

フラグ操作

V, D, I, C フラグはプログラムから明示的に操作できます。V, D, I は割とどうでもいいですが、C の操作は頻出するので覚えておく必要があります。"SET C", “CLEAR C” のように覚えるといいです。

  • SEC: C = 1
  • CLC: C = 0
  • SEI: I = 1
  • CLI: I = 0
  • SED: D = 1
  • CLD: D = 0
  • CLV: V = 0

インクリメント/デクリメント

メモリ上の値に対しては INC, DEC を使います。
X レジスタに対しては INX, DEX を使います。
Y レジスタに対しては INY, DEY を使います。

これら全ての命令は N, Z フラグを書き換えます(加減算命令と異なり、V, C フラグは変化しないことに注意):

  • N: 演算結果の最上位ビット
  • Z: 演算結果が 0 であるか

A レジスタに対するインクリメント/デクリメント命令は存在しないので、加減算命令で代用します。

加算

ADC 命令でキャリー付き加算ができます。"ADD with Carry" と覚えるといいです。

これは A に対象を加算し、さらにキャリーフラグ C を加算します。ここでは「キャリー」=「繰り上がり」と考えるとわかりやすいです。要するに繰り上がりがあればさらに 1 を加えるというだけです。

通常の(キャリーなし)加算がしたければ、事前に C フラグをクリアしておく必要があります:

; A += mem[0x0700]
clc
adc $0700

ADC 命令は N, V, Z, C フラグを書き換えます:

  • N: 加算結果の最上位ビット
  • V: 符号付きオーバーフローが発生したか(同符号の値を加算した結果符号が変わったら真)
  • Z: 加算結果が 0 であるか
  • C: 繰り上がりが発生したら 1, さもなくば 0

キャリー付き加算は多倍長演算のためにあります。例えば、16bit 値 $0700 にもう 1 つの 16bit 値 $0702 を加えるには以下のようにします(リトルエンディアンを仮定):

; 下位バイトを加算(キャリーなし)
lda $0700
clc
adc $0702 ; ここで繰り上がりがあれば C = 1, さもなくば C = 0 となる
sta $0700
; 上位バイトを加算(キャリー付き)
lda $0701
adc $0703 ; 繰り上がりがあればさらに 1 加える(clc を行っていないことに注意)
sta $0701

24bit や 32bit の値なら更にキャリー付き加算を繰り返すだけです。この形のコードは頻出するので、慣れれば一目で「これは 16bit 値だな」などとわかるようになります。つまりサブピクセルなどが探しやすくなるわけです。

なお、他の CPU と異なり、キャリーなし加算を行う専用の命令はありません。

減算

SBC 命令でキャリー付き減算ができます。"SUBTRACT with Carry" と覚えるといいです。

これは A から対象を減算し、さらにキャリーフラグ C の否定を減算します。これだと少しわかりにくいので、「ボロー」という概念を導入すると理解しやすいです。「ボロー」は「キャリー」の反対、つまり繰り下がりです。要するに繰り下がりがあればさらに 1 を引くというだけです。

通常の(ボローなし)減算がしたければ、事前に C フラグをセットしておく必要があります(ADC の時とは逆):

; A -= mem[0x0700]
sec
sbc $0700

SBC 命令は N, V, Z, C フラグを書き換えます:

  • N: 減算結果の最上位ビット
  • V: 符号付きオーバーフローが発生したか(異符号の値を減算した結果符号が変わったら真)
  • Z: 減算結果が 0 であるか
  • C: 繰り下がりが発生したら 0, さもなくば 1

ADC の時と同様に、ボロー付き減算によって多倍長演算ができます:

; 下位バイトを減算(ボローなし)
lda $0700
sec
sbc $0702 ; ここで繰り下がりがあれば C = 0, さもなくば C = 1 となる
sta $0700
; 上位バイトを減算(ボロー付き)
lda $0701
sbc $0703 ; 繰り下がりがあればさらに 1 を引く(sec を行っていないことに注意)
sta $0701

ADC 同様、ボローなし減算を行う専用の命令はありません。

比較

CMP, CPX, CPY 命令で A, X, Y レジスタと対象の比較ができます。指定レジスタから対象を(ボローなし)減算し、結果を捨ててフラグだけ更新していると考えるとわかりやすいかもしれません。

これらの命令は N, Z, C フラグを書き換えます(V は変化なし):

  • N: 減算結果の最上位ビット
  • Z: 減算結果が 0 であるか
  • C: 減算で繰り下がりが発生したら 0, さもなくば 1

普通は Z, C フラグのみを用いて大小関係を判定します。例えば CMP 命令の場合:

  • Z == 0 ならば A == (対象)
  • C == 1 ならば A >= (対象)
  • C == 0 ならば A < (対象)

ビット演算

AND, ORA, EOR で and, or, xor 演算ができます。

  • AND: A &= (対象)
  • ORA: A |= (対象)
  • EOR: A ^= (対象)

これらの命令は N, Z フラグを書き換えます:

  • N: 演算結果の最上位ビット
  • Z: 演算結果が 0 であるか

not 演算を行う命令はありませんが、EOR で代用できます:

eor #$FF ; A ^= 0xFF, つまり A = ~A

ビットシフト

ASL で左シフトができます(空いた部分には 0 が入る)。"Arithmetic Shift Left" です。
LSR で右シフトができます(空いた部分には 0 が入る)。"Logical Shift Right" です。

これらの命令は A またはメモリ上の値が対象です。A をシフトする場合、オペランドを省略します:

asl       ; A <<= 1
lsr $0700 ; mem[0x0700] >>= 1

これらの命令は N, Z, C フラグを書き換えます:

  • N: 結果の最上位ビット
  • Z: 結果が 0 であるか
  • C: はみ出たビット

6502 には乗除算命令がないので、シフトを用いて定数乗除算を行うコードは頻出します。

シフト回数は 1 で固定です。回数指定シフト命令はありません。

ビットローテート

C フラグを含めた 9bit ローテートができます。

ROL で左ローテートができます(左シフトを行い、空いた部分に C が入る)。"ROTATE Left" です。
ROR で右ローテートができます(右シフトを行い、空いた部分に C が入る)。"ROTATE Right" です。

これらの命令は N, Z, C フラグを書き換えます:

  • N: 結果の最上位ビット
  • Z: 結果が 0 であるか
  • C: はみ出たビット

シフトとローテートを組み合わせることで多倍長シフトができます。例えば、16bit 値 $0700 (リトルエンディアン)を 1 回右シフトするには:

; 上位バイトを右シフト
lsr $0701 ; はみ出たビットが C に入る
; 下位バイトを C フラグ込みで右ローテート
ror $0700

ジャンプ

JMP 命令で無条件ジャンプができます。例えば jmp $8000 とすると $8000 へジャンプします。ポインタ経由の間接ジャンプもできますが、これはアドレッシングモードの項で述べます。

JMP 命令はフラグを変更しません。

分岐

N, V, Z, C フラグに基づく分岐ができます。分岐先オフセットは符号付き 8bit なので、範囲は [-128, 127] に制限されます。この範囲外へ飛びたい場合は JMP 命令などを併用する必要があります。

  • BMI: N == 1 なら分岐。"Branch if MINUS"
  • BPL: N == 0 なら分岐。"Branch if PLUS" (0 も正とみなす)
  • BVS: V == 1 なら分岐。"Branch if oVerflow Set"
  • BVC: V == 0 なら分岐。"Branch if oVerflow Clear"
  • BEQ: Z == 1 なら分岐。"Branch if EQUAL" (等しい <-> 減算結果が 0)
  • BNE: Z == 0 なら分岐。"Branch if Not Equal" (等しくない <-> 減算結果が非 0)
  • BCS: C == 1 なら分岐。"Branch if Carry Set"
  • BCC: C == 0 なら分岐。"Branch if Carry Clear"

分岐命令はフラグを変更しません。

いくつか例を見てみます:

; VBLANK を待つ。頻出
wait_vblank:
        lda $2002       ; VBLANK フラグが立っていれば最上位ビットが 1 になる
        bpl wait_vblank
; if(mem[0x0700] >= mem[0x0780])
;   ...
        lda $0700
        cmp $0780
        bcc less  ; mem[0x0700] < mem[0x0780] ならば less へ飛ぶ
        ...
less:
        ...
; 典型的ループ。頻出

; for(X = 5; X != 0; --X)
;   ...
        ldx #$05  ; X = 5 (即値ロード。アドレッシングモードの項で述べる)
loop:
        ...
        dex
        bne loop

分岐命令を if や for といった擬似コードに置き換えるのは多少慣れが必要ですが、内容的には難しくないので地道に考えれば問題ないと思います。あまりにも長大なコードだったり、分岐がスパゲッティ化していると多少しんどいですが…。慣れるまではいったん機械的に goto に置き換え、それを goto なしのコードに書き換えるようにするのも良いでしょう。

サブルーチン呼び出し

JSR 命令でサブルーチンを呼び出せます。"Jump to SubRoutine" です。

建前上は JSR で呼び出したサブルーチンからは必ず RTS で復帰するということになっていますが、実際には変則的なスタック操作を行っているゲームが普通に存在するため、この仮定は必ずしも成り立ちません。よって、内部的にどのようなスタック操作が行われるのか把握しておく必要があります。

JSR 命令が実行されると、JSR 命令のアドレス に 2 を加えたアドレス(これを「戻りアドレス」と呼ぶ)をスタックに push した後、オペランドのアドレスへジャンプします。例えば、

C000 : jsr $8000 ; JSR 命令は 3 バイト
C003 : ...

というコードは、0xC002 を push し、$8000 へジャンプします。大抵はこの後ルーチン $8000 が RTS で復帰して $C003 から実行が継続されますが、ルーチン $8000 内で変則的なスタック操作が行われている場合、$C003 に戻ってこないケースもありえます。

JSR 命令はフラグを変更しません。

サブルーチンからの復帰

RTS 命令でサブルーチンから復帰します。"RETURN from Subroutine" です。

建前上は JSR からの復帰に使うということになっていますが、実際には自分で戻りアドレスをスタックに積んでそこへジャンプする、などといった使い方もあります(RTS Trick)。

RTS 命令が実行されると、戻りアドレスを pop し、そのアドレスに 1 を加えたアドレスへジャンプします。

RTS 命令はフラグを変更しません。

割り込みハンドラからの復帰

RTI 命令で NMI/IRQ 割り込みハンドラから復帰します。"RETURN from Interrupt" です。

これに関しても一応内部的なスタック操作を把握しておく方がいいです。

  • NMI/IRQ 割り込みが発生すると、割り込み完了後に復帰すべきアドレスそのもの(JSR と異なり、1 を引かない)を push し、さらにステータスレジスタ P を push し、割り込みハンドラへジャンプします。
  • RTI 命令が実行されると、ステータスレジスタ P を pop し、復帰すべきアドレスを pop し、そのアドレスへジャンプします。

ビットテスト

BIT 命令でビットテストを行えます。レジスタを破壊せずに値を読めるので VBLANK 待ちなどでたまに使われますが、使用頻度は低いです。

BIT 命令は N, V, Z フラグを書き換えます:

  • N: 対象の bit7
  • V: 対象の bit6
  • Z: A と対象を and した結果が 0 であるか

BIT 命令のオペランド内に別の命令を埋め込んでおき、命令の途中へジャンプするというテクニックが稀に使われることがあります。

NOP

NOP は何もせずサイクルのみ消費する命令です。タイミング制御などで稀に使われます。不可解な NOP があったらハードウェア(PPU/APU など)周りの事情が関係している可能性があるかもしれません。

NOP はプログラムにパッチを当てる際に便利です。例えば、分岐命令を NOP で潰して分岐しないようにしてみる、などといったことができます。

アドレッシングモード

ここまでは基本的に 16bit アドレス指定のみを使ってきましたが、別の形式でオペランドを指定することもできます。これを「アドレッシングモード」と呼びます。

全ての命令で全てのアドレッシングモードが使えるわけではありませんが、大体不便なく使えるようにはなっています(この概念を「命令セットの直交性」と言うようです)。

オペランドなし (Implied / Accumulator)

CLC など、そもそもオペランドをとらない命令もあります。また、ASL などはオペランドを省略すると A レジスタが対象となります。一応公式的には前者を “Implied”, 後者を “Accumulator” と呼んで区別していますが、実用上は「オペランドなし」でまとめて覚えてしまって問題ありません。

Immediate

「即値」モードです。つまり、アドレスでなく値そのものを対象とします。例えば:

lda #$FF ; A = 0xFF

Zeropage

ゼロページアドレスを対象とするモードです。例えば:

lda $FF ; A = mem[0xFF]

Zeropage,X

ゼロページアドレスにインデックス X を加えたアドレスを対象とするモードです。加算結果がページ境界をまたぐ場合、ゼロページ内でループします。例えば:

ldx #$05  ; X = 5
lda $00,X ; A = mem[0x00+X],          ここでは $05 を読み取る
lda $FF,X ; A = mem[(0xFF+X) & 0xFF], ここでは $04 を読み取る

Zeropage,Y

Zeropage,X と同様ですが、インデックスとして X の代わりに Y を使うモードです。LDX, STX 命令でのみ使えます。

Absolute

16bit アドレスを対象とするモードです。例えば:

lda $0700 ; A = mem[0x0700]
lda $0000 ; ゼロページを指定しても構わない(が、コードサイズとサイクル数の無駄)

Absolute,X

16bit アドレスにインデックス X を加えたアドレスを対象とするモードです。加算結果がページ境界をまたいでも構いません。例えば:

ldx #$05    ; X = 5
lda $0600,X ; A = mem[0x0600+X], ここでは $0605 を読み取る
lda $06FF,X ; A = mem[0x06FF+X], ここでは $0704 を読み取る

Absolute,Y

Absolute,X と同様ですが、インデックスとして X の代わりに Y を使うモードです。

(Indirect,X)

ゼロページアドレスにインデックス X を加えたアドレスからポインタを取得し、それが指すアドレスを対象とするモードです。ポインタ取得時にページ境界をまたぐ場合、ゼロページ内でループします。例えば:

; X   = 5
; ptr = mem[0x00+X] | (mem[0x00+X+1] << 8), ここでは mem[0x05] | (mem[0x06] << 8)
; A   = mem[ptr]
ldx #$05
lda ($00,X)

このモードは大して便利でもなく、滅多に使われないので忘れてしまっても構いません。

(Indirect),Y

ゼロページアドレスからポインタを取得し、それが指すアドレスにインデックス Y を加えたアドレスを対象とするモードです。ページ境界については、

  • ポインタ取得時にページ境界をまたぐ場合、ゼロページ内でループします。
  • Y を加算した結果はページ境界をまたいでも構いません。

例えば:

; ptr = mem[0x00] | (mem[0x01] << 8)
; A   = mem[ptr]
; X   = mem[ptr+1]
ldy #$00
lda ($00),y
iny
ldx ($00),y

このモードは構造体アクセスやメモリコピーなどでよく使われるので慣れておくべきです。

Relative

分岐命令専用のモードです。機械語では符号付き 8bit の分岐オフセットがオペランドとなりますが、アセンブリ上ではアドレスまたはラベルで表記されるのが普通です。

(Indirect)

間接 JMP 命令専用のモードです。16bit アドレスからポインタを取得し、それが指すアドレスにジャンプします。例えば:

; ptr = mem[0x0700] | (mem[0x0701] << 8)
; jmp(ptr)
jmp ($0700)

次回は実際のゲームで使われる 6502 コードをいくつか読んでいきます。

*1:bit5, bit4 は物理的に存在せず、P がスタックに積まれたときにのみ目に見える形で現れます。これについては NesDevWiki に 解説がありますが、NES ゲームで BRK 命令が使われているのは見たことがないので特に意識する必要はないと思います