본문으로 건너뛰기

슈퍼 패미컴

5장 어셈블리 기초

LDA·STA·분기·JSR을 하나씩 읽고, 패치 노트에 나온 코드 조각을 직접 해석합니다.

어셈블리는 바이트에 붙인 이름 쉽게

롬 안의 프로그램은 바이트일 뿐입니다. A9 34 12를 보고 바로 뜻을 알기는 어렵습니다. 그래서 바이트마다 사람이 읽을 이름을 붙여 LDA #$1234처럼 적습니다. 이것이 어셈블리입니다. 바이트를 어셈블리로 바꾸는 도구가 역어셈블러, 그 반대가 어셈블러입니다.

명령의 구조A9 34 12에서 A9는 LDA 즉시값 명령, 34 12는 값 $1234를 작은 자리부터 적은 피연산자다. 합쳐서 LDA #$1234다.A93412명령(옵코드)피연산자 $1234(작은 자리 먼저)LDA#$1234A에 넣어라이 값을16비트 A일 때
명령 하나 = 무엇을 할지(1바이트) + 무엇으로 할지(0-3바이트)

3장에서 본 것처럼 피연산자 길이는 M·X 설정에 따라 달라지고, 주소는 작은 자리부터 적힙니다(2장). 이 두 가지만 기억하면 바이트를 손으로도 어셈블리로 바꿀 수 있습니다.

자주 쓰는 명령 열두 개 쉽게

65c816 명령은 90여 개지만, 패치 노트에 나오는 코드 대부분은 아래 열두 개로 읽을 수 있습니다.

  • LDA A에 넣기

    LDA #$05

    A에 값을 넣습니다. LDX·LDY는 X·Y에 넣습니다.

  • STA 메모리에 쓰기

    STA $0A00

    A의 값을 메모리에 적습니다. STX·STY도 있습니다.

  • ADC 더하기

    ADC #$05

    A에 더합니다. 올림(C)까지 더하므로 앞에 CLC를 씁니다.

  • INX 1 늘리기

    INX

    X를 1 늘립니다. DEX는 1 줄입니다.

  • CMP 비교하기

    CMP #$10

    A에서 빼 보기만 하고 결과는 버립니다. 플래그만 바뀝니다.

  • BEQ 같으면 이동

    BEQ $8011

    Z가 1이면(0이었거나 같았으면) 그 주소로 갑니다.

  • BNE 다르면 이동

    BNE $8006

    Z가 0이면 그 주소로 갑니다. 반복에 씁니다.

  • BRA 무조건 이동

    BRA $8005

    플래그와 상관없이 갑니다.

  • JSR 서브루틴 부르기

    JSR $8012

    돌아올 곳을 스택에 적고 서브루틴으로 갑니다.

  • RTS 서브루틴에서 돌아오기

    RTS

    스택에 적어 둔 곳으로 돌아갑니다.

  • REP 16비트로 바꾸기

    REP #$30

    P의 칸을 끕니다. #$20은 A, #$10은 X·Y.

  • SEP 8비트로 바꾸기

    SEP #$30

    P의 칸을 켭니다.

값과 주소 구별하기 쉽게

같은 LDA라도 뒤에 무엇이 붙느냐에 따라 뜻이 완전히 다릅니다. 이 차이를 주소 지정 방식이라고 하고, 옵코드 바이트가 방식마다 다릅니다.

세 가지 주소 지정 방식LDA #$12는 숫자 $12를 A에 넣는다. LDA $12는 메모리 $12 칸에 있는 값 $7F를 넣는다. LDA $0A00,X는 X가 2일 때 $0A02 칸의 값을 넣는다.LDA #$12A=$12값 그대로LDA $12$12 칸7FA=$7F칸 안의 값LDA $0A00,XX = 2일 때$0A00F3$0A0108$0A02B5$0A0306+X(2칸)
#은 '이 값', #이 없으면 '이 주소에 있는 값', ,X는 '이 주소에서 X만큼 더 간 곳'

# 하나 차이로 "숫자 $12"와 "$12번 칸에 든 것"이 갈립니다. 어셈블리를 읽을 때 가장 먼저 확인할 것이 이 #입니다. ,X는 표의 몇 번째 칸을 읽을 때 씁니다. X를 늘려 가며 같은 명령을 되풀이하면 표를 차례로 훑을 수 있습니다.

분기와 반복 쉽게

CPU는 "만약"을 직접 묻지 못합니다. 대신 명령을 실행할 때마다 결과를 P의 플래그에 남기고, 분기 명령이 그 플래그를 보고 갈 곳을 정합니다. DEX가 X를 0으로 만들면 Z 플래그가 켜지고, 바로 뒤의 BNE는 Z가 꺼져 있을 때만 앞쪽 명령으로 되돌아갑니다. 이렇게 "세 번 반복"이 만들어집니다.

반복 흐름X에 3을 넣고 A에 5를 더한 뒤 X를 1 줄인다. X가 0이 아니면 더하기로 돌아가고, 0이면 아래로 내려가 결과를 적는다.LDX #$03CLC / ADC #$05DEXBNEX≠0(Z=0)X=0 (Z=1)STA $10
DEX와 BNE로 만드는 반복. 분기는 바로 앞 명령이 남긴 플래그를 봅니다.

서브루틴: JSR과 RTS 쉽게

여러 곳에서 쓰는 코드는 한 군데에 두고 불러 씁니다. 이것이 서브루틴입니다. JSR은 "다녀올 테니 돌아올 곳을 적어 둬"라며 PC 값을 스택에 쌓고 서브루틴으로 갑니다. 서브루틴 끝의 RTS는 스택 맨 위에 적힌 곳을 꺼내 돌아갑니다.

JSR과 RTS의 스택JSR $8012를 실행하면 돌아올 곳 $8010의 위 바이트 80과 아래 바이트 10이 차례로 스택에 쌓이고 S가 $1FFD로 내려간다. RTS는 두 바이트를 꺼내 1을 더한 $8011로 돌아간다.JSR $8012 실행 뒤 스택$1FFF80$1FFE10$1FFD← S (다음에 쌓을 자리)먼저 쌓은 위 바이트나중에 쌓은 아래 바이트RTS: 10, 80을 꺼내 $8010 + 1 = $8011로
스택은 접시 더미입니다. 나중에 올린 것을 먼저 꺼냅니다.

스택은 주소가 큰 쪽에서 작은 쪽으로 쌓이고, 쌓을 때마다 S가 줄어듭니다. 돌아올 곳으로 JSR 명령 자신의 마지막 바이트 주소($8010)를 적어 두고, RTS가 1을 더해 다음 명령($8011)으로 가는 것이 65c816의 방식입니다. 아래 「명령 한 줄씩 실행하기」에서 JSR 전후로 메모리 $7E:1FFE·$7E:1FFF와 S가 바뀌는 것을 확인해 보세요.

명령 한 줄씩 실행하기: 반복과 서브루틴

5를 세 번 더해 저장하고, 서브루틴을 불러 그 값을 두 배로 만듭니다. 「한 줄 실행」을 누를 때마다 ▶ 표시가 붙은 명령 하나를 실행합니다.

시작 상태: 이미 네이티브 모드로 바꾼 뒤이고, A·X·Y는 8비트(M=1, X=1), DB와 PB는 $80, S는 $1FFF입니다. 전원을 켠 직후(에뮬레이션 모드, DB·PB·D가 모두 0)와는 다릅니다.

  1. 8000E2 30SEP #$30A와 X·Y를 모두 8비트로 맞춥니다.
  2. 8002A2 03LDX #$03X에 반복 횟수 3을 넣습니다.
  3. 8004A9 00LDA #$00A를 0으로 시작합니다.
  4. 800618CLC더하기 전에 올림(C)을 지웁니다.
  5. 800769 05ADC #$05A에 5를 더합니다.
  6. 8009CADEXX를 1 줄입니다. 0이 되면 Z 플래그가 켜집니다.
  7. 800AD0 FABNE $8006Z가 꺼져 있으면(X가 아직 0이 아니면) $8006으로 돌아갑니다.
  8. 800C85 10STA $10다 더한 A(15 = $0F)를 $10에 적습니다.
  9. 800E20 12 80JSR $8012$8012의 서브루틴으로 갑니다. 돌아올 주소를 스택에 적어 둡니다.
  10. 8011DBSTPSTP. 서브루틴에서 돌아오면 여기서 멈춥니다.
  11. 801218CLC서브루틴 시작. 올림을 지웁니다.
  12. 801365 10ADC $10$10에 적어 둔 값을 A에 한 번 더 더합니다.
  13. 801585 11STA $11두 배가 된 값을 $11에 적습니다.
  14. 801760RTS스택에 적어 둔 주소로 돌아갑니다.

아직 아무 명령도 실행하지 않았습니다.

A
B $00 · A $00
X
$00
Y
$00
S
$1FFF
PC
$80:8000
DB
$80

P(상태 플래그) — 대문자·1이 켜진 비트

  1. n0
  2. v0
  3. M1
  4. X1
  5. d0
  6. I1
  7. z0
  8. c0

M=1: A 8비트 · X=1: X·Y 8비트

메모리(WRAM)

  • $7E:001000방금 바뀜
  • $7E:001100방금 바뀜
  • $7E:1FFE00방금 바뀜
  • $7E:1FFF00방금 바뀜

끝까지 실행한 뒤(명령 22개)

A
B $00 · A $1E
X
$00
Y
$00
S
$1FFF
PC
$80:8012
DB
$80

M=1: A 8비트 · X=1: X·Y 8비트

  • $7E:0010 0F
  • $7E:0011 1E
  • $7E:1FFE 10
  • $7E:1FFF 80

패치에 들어간 코드 실행해 보기 쉽게

이제 실제 패치에 들어간 코드를 읽을 수 있습니다. 돌격편 v1.2는 엔딩 화면에 글자를 띄우려고 "롬의 문자열을 0이 나올 때까지 WRAM으로 복사하는" 짧은 루프를 넣었습니다. 아래는 그 루프를 줄인 것입니다. 16비트 모드라 한 번에 글자 하나(2바이트)를 옮기고, X를 두 번 늘려 다음 글자로 갑니다.

명령 한 줄씩 실행하기: 문자열 복사

롬에 있는 2바이트 글자 코드를 0000이 나올 때까지 WRAM으로 옮깁니다. 돌격편 패치의 복사 루프를 줄인 것입니다. 「한 줄 실행」을 누를 때마다 ▶ 표시가 붙은 명령 하나를 실행합니다.

시작 상태: 이미 네이티브 모드로 바꾼 뒤이고, A·X·Y는 8비트(M=1, X=1), DB와 PB는 $80, S는 $1FFF입니다. 전원을 켠 직후(에뮬레이션 모드, DB·PB·D가 모두 0)와는 다릅니다.

  1. 8000C2 30REP #$30A와 X·Y를 모두 16비트로 바꿉니다.
  2. 8002A2 00 00LDX #$0000X를 0부터 셉니다. 16비트라 피연산자가 00 00 두 바이트입니다.
  3. 8005BD 12 80LDA $8012,X$80:8012 + X 자리의 글자 코드 2바이트를 A에 읽습니다.
  4. 8008F0 07BEQ $8011읽은 값이 0000(끝 표시)이면 $8011로 빠져나갑니다.
  5. 800A9D 00 0ASTA $0A00,X읽은 코드를 $0A00 + X에 적습니다. 뱅크 $80의 $0A00은 WRAM $7E:0A00과 같은 자리입니다.
  6. 800DE8INXX를 1 늘립니다.
  7. 800EE8INX글자 하나가 2바이트라 한 번 더 늘립니다.
  8. 800F80 F4BRA $8005무조건 $8005로 돌아갑니다.
  9. 8011DBSTPSTP. 복사가 끝났습니다.
  10. 8012F3 08 B5 06 27 05 00 00명령이 아니라 데이터입니다. 2바이트씩 「어」「떻」「게」, 그리고 끝 표시 0000.

아직 아무 명령도 실행하지 않았습니다.

A
B $00 · A $00
X
$00
Y
$00
S
$1FFF
PC
$80:8000
DB
$80

P(상태 플래그) — 대문자·1이 켜진 비트

  1. n0
  2. v0
  3. M1
  4. X1
  5. d0
  6. I1
  7. z0
  8. c0

M=1: A 8비트 · X=1: X·Y 8비트

메모리(WRAM)

  • $7E:0A0000방금 바뀜
  • $7E:0A0100방금 바뀜
  • $7E:0A0200방금 바뀜
  • $7E:0A0300방금 바뀜
  • $7E:0A0400방금 바뀜
  • $7E:0A0500방금 바뀜

끝까지 실행한 뒤(명령 23개)

A
$0000
X
$0006
Y
$0000
S
$1FFF
PC
$80:8012
DB
$80

M=0: A 16비트 · X=0: X·Y 16비트

  • $7E:0A00 F3
  • $7E:0A01 08
  • $7E:0A02 B5
  • $7E:0A03 06
  • $7E:0A04 27
  • $7E:0A05 05

더 깊이주소 지정 방식 전부, 스택과 인터럽트, 사이클

주소 지정 방식

데이터시트는 24가지로 나누지만, 실제로 자주 만나는 것은 아래와 같습니다. 바이트는 16비트 A가 아닌 경우의 예입니다.

방식표기바이트읽는 곳
즉시값LDA #$12A9 12값 $12 자체
절대LDA $1234AD 34 12DB:$1234에 있는 값
절대 긴LDA $7E1234AF 34 12 7E$7E:1234에 있는 값
다이렉트 페이지LDA $12A5 12$00:(D+$12)에 있는 값
절대 + XLDA $1234,XBD 34 12DB:$1234 + X
긴 주소 + XLDA $7E1234,XBF 34 12 7E$7E:1234 + X
간접LDA ($12)B2 12$00:(D+$12)에 적힌 2바이트 주소(뱅크는 DB)의 값
긴 간접LDA [$12]A7 12$00:(D+$12)에 적힌 3바이트 주소의 값
간접 + YLDA ($12),YB1 12$00:(D+$12)의 2바이트 주소(뱅크는 DB) + Y
스택 상대LDA $03,SA3 03S + 3에 있는 값
상대BNE $8006D0 FA다음 명령 위치 + 부호 있는 1바이트
블록 이동MVN $80,$7E54 7E 80뱅크 $80의 X 위치에서 뱅크 $7E의 Y 위치로 C+1바이트 복사

"긴"(long) 방식은 3바이트 주소라 DB와 상관없이 어느 뱅크든 가리킵니다. 분기 명령의 상대 주소는 1바이트 2의 보수라 −128–+127바이트까지만 갈 수 있고, BRL은 2바이트로 뱅크 안 어디든 갑니다.

간접 주소: 메모리에 적힌 주소 따라가기

LDA ($12)는 다이렉트 페이지 $00:(D+$12)와 그다음 칸에서 2바이트 주소를 꺼내고, 그 주소에 DB를 뱅크로 붙인 곳의 값을 읽습니다. 대사 포인터처럼 "표에서 꺼낸 주소의 내용"을 읽을 때 씁니다. [$12]는 뱅크까지 3바이트를 꺼내고, ($12),Y는 꺼낸 주소에 Y를 더합니다.

간접 주소$00:0012와 $00:0013에 00, 90이 있어 주소 $9000이 된다. 뱅크 DB $7E를 붙인 $7E:9000에 있는 값 42가 A에 들어간다.$00:001200$00:001390주소 $9000DB $7E를 붙임$7E:900042→ A = $42포인터도 작은 자리 먼저(2장): 00 90 → $9000
LDA ($12): D=0, DB=$7E일 때 $12·$13에 적힌 주소를 따라가 값을 읽습니다.

블록 이동 MVN·MVP

MVN은 X가 가리키는 곳에서 Y가 가리키는 곳으로 C(16비트 A) + 1바이트를 한꺼번에 복사합니다. 어셈블리로는 MVN $80,$7E처럼 "보내는 뱅크, 받는 뱅크" 순서로 적지만, 바이트로는 54 7E 80처럼 받는 뱅크가 먼저 옵니다. 끝나면 DB가 받는 뱅크로 바뀌어 있으니 뒤 코드가 DB에 기대고 있다면 조심해야 합니다. MVP는 뒤에서부터 복사합니다.

BCC·BCS는 부호 없는 비교

CMP 뒤의 BCC("작으면")·BCS("크거나 같으면")는 값을 0–255(16비트면 0–65535)의 부호 없는 수로 비교합니다. $FF는 −1이 아니라 255로 취급되어 $01보다 큽니다. 부호 있는 수로 비교하려면 N·V 플래그를 함께 봐야 합니다.

스택 더 보기

  • PHA/PLA, PHX/PLX는 레지스터를 잠깐 맡기고 되찾습니다.
  • PHP/PLP는 P를 맡깁니다. 서브루틴이 REP·SEP로 폭을 바꾼 뒤 원래대로 돌려놓을 때 씁니다.
  • JSL/RTL은 다른 뱅크로 다녀오는 쌍입니다. PB까지 3바이트를 쌓습니다. JSR로 간 곳에서 RTL로 돌아오면 스택이 어긋나 게임이 멈춥니다.
  • PHK/PLB는 "지금 코드 뱅크를 데이터 뱅크로" 맞출 때 자주 짝지어 나옵니다.

인터럽트와 NMI

인터럽트는 CPU가 하던 일을 잠깐 멈추고 정해진 루틴을 실행하게 하는 신호입니다. 슈퍼 패미컴에서 가장 중요한 것은 화면을 다 그리고 다음 화면을 그리기 전(수직 귀선 기간)에 오는 NMI입니다. CPU는 PB, PC, P를 스택에 쌓고 네이티브 모드 벡터 $00:FFEA에 적힌 주소로 갑니다. 단, 게임이 레지스터 $4200의 비트 7을 켜 두었을 때만 NMI가 옵니다. 루틴은 RTI로 끝나 원래 자리로 돌아갑니다. 화면 데이터는 이 짧은 시간에만 안전하게 바꿀 수 있어서, 글자 그림을 VRAM에 올리는 코드를 여기에 끼워 넣는 일이 많습니다(6·7장).

사이클

명령마다 걸리는 사이클 수가 다릅니다. 예를 들어 LDA #는 2사이클이고 16비트 A면 1사이클이 더 듭니다. 다만 슈퍼 패미컴에서는 한 사이클의 길이가 읽는 곳에 따라 기준 클록(21.477MHz)의 6·8·12배로 달라서, 사이클 수만으로 시간이 정해지지는 않습니다. 같은 코드도 FastROM 영역에서 더 빨리 돕니다. NMI 안처럼 시간이 빠듯한 곳에 코드를 더하면 화면이 깨질 수 있으니, 데이터시트의 사이클 표와 영역별 속도로 대략의 비용을 셈해 둡니다.

실제 패치에서 실제 패치에서

두 바이트로 디버그 메뉴 열기(돌격편)

돌격편은 전원을 켜면 도는 루프 안에 개발자용 디버그 메뉴를 부르는 코드가 남아 있고, 바로 앞의 BRA가 늘 그것을 건너뜁니다.

$80:8061  80 04        BRA $8067     ; 다음 명령에서 4바이트 앞으로 건너뜀
$80:8063  22 B0 86 82  JSL $8286B0   ; 디버그 메뉴 (늘 건너뜀)
$80:8067  22 00 80 82  JSL $828000   ; 제품판이 가는 곳

80 04를 아무것도 안 하는 명령 EA EA(NOP NOP)로 바꾸면 건너뛰지 않고 메뉴가 열립니다. BRA의 04는 "다음 명령 위치($8063)에서 4바이트 더"라서 $8067입니다. 이 메뉴의 시나리오 선택 기능이 패치를 확인하는 바탕이 됐습니다.

한 바이트로 전투 디버그 메뉴 열기(슈퍼 마리오 RPG)

$C1:06A9  AF AE FF 00  LDA $00FFAE   ; 어떤 값을 읽어
$C1:06AD  05 80        ORA $80       ; $80 칸과 합친 결과가
$C1:06AF  F0 01        BEQ $C106B2   ; 0이면 RTS를 건너뛰어 메뉴로
$C1:06B1  60           RTS           ; 아니면 그냥 돌아감

제품판에서는 결과가 0이 아니라 늘 RTS로 돌아갑니다. F0(BEQ)을 80(BRA)으로 바꾸면 조건 없이 RTS를 건너뛰어 슈퍼 마리오 RPG의 전투 중 Start로 메뉴가 열립니다. 롬 해킹의 많은 일이 이렇게 "분기 하나 바꾸기"입니다.

줄바꿈 판단(돌격편)

$B7:9846  CLC / ADC $0208     ; 이번 글자 폭 + 지금 x 위치
$B7:984A  CMP $020A           ; 창 폭과 비교
$B7:984D  BEQ / BCC $B79890   ; 같거나 작으면 그대로 그림
$B7:9851  INC $32 ×2          ; 크면 다음 줄로

CMP 뒤의 BCC는 "C가 0이면", 곧 "A가 비교한 값보다 작으면" 갑니다. 그래서 글자 폭 합이 창 폭과 같은 줄까지는 한 줄에 들어갑니다. 한글 번역은 이 규칙대로 모든 줄의 폭을 미리 계산해 넘치는 줄이 없게 했습니다.

직접 해 보기 직접 해 보기

위의 두 코드는 툴킷 역어셈블러로 그대로 볼 수 있습니다. 분기 주소는 뱅크까지 붙여 24비트로 나옵니다.

$ krpatch snes dis totsugeki.sfc 808061 3 --mapper lorom
80:8061  80 04        BRA $808067
80:8063  22 b0 86 82  JSL $8286B0
80:8067  22 00 80 82  JSL $828000

$ krpatch snes dis smrpg.sfc C106A9 4 --mapper sa1
C1:06A9  af ae ff 00  LDA $00FFAE
C1:06AD  05 80        ORA $80
C1:06AF  f0 01        BEQ $C106B2
C1:06B1  60           RTS

아는 명령이 나오는 곳에서 시작해 몇십 줄씩 읽어 보세요. 모르는 옵코드가 연달아 나오면 폭(M·X)이 틀렸거나 코드가 아닌 데이터를 읽고 있는 것입니다.