본문으로 건너뛰기

닌텐도 DS모르는 용어는 용어집에서

21장 ARM 어셈블리 기초

ARM 명령 4바이트를 읽고, 조건 실행·리터럴 풀·시프트 붙은 주소·BL과 LR을 살펴보고, 고스트 트릭의 글꼴 고르기 함수를 한 줄씩 실행해 봅니다. 문자열에서 거슬러 올라가 함수를 찾는 법도 다룹니다.

ARM 명령 읽는 법 쉽게

ARM 명령은 4바이트이고, 작은 자리 바이트부터 적혀 있습니다(20장). 4바이트를 하나의 32비트 값으로 읽으면 맨 위 4비트가 조건이고, 나머지에 무엇을 할지와 어느 레지스터·값을 쓸지가 적혀 있습니다. 이 장에서 읽을 명령은 몇 가지뿐입니다.

  • mov r2, #4: r2에 4를 넣습니다.
  • cmp r1, #3: r1 − 3을 계산하되 결과는 버리고, 결과가 0인지·음수인지 같은 플래그만 남깁니다.
  • ldr r0, [r0, #4]: r0 + 4 번지에서 4바이트를 읽어 r0에 넣습니다. str은 반대로 레지스터를 그 번지에 씁니다.
  • bl 주소: 다음 명령의 주소를 lr에 남기고 적힌 주소로 건너뜁니다. bx lr는 lr로 돌아갑니다.

이 장의 예는 고스트 트릭 ARM9 프로그램의 실제 함수입니다. ARM9 프로그램은 롬 안에서 대부분 압축되어 있어서(20장), 압축을 푼 뒤에 읽었습니다.

조건 실행: 건너뛰지 않고 고르기 쉽게

65c816에서는 「조건이 맞으면 저쪽으로 건너뛰라」는 분기 명령으로 갈래를 나눴습니다(5장). ARM은 거의 모든 명령에 조건을 붙일 수 있습니다. strlt는 「앞의 비교에서 작았으면 쓴다」, moveq는 「같았으면 넣는다」입니다. 조건이 맞지 않으면 그 명령은 아무 일도 하지 않고 다음 명령으로 넘어갑니다. 그래서 짧은 갈래는 분기 없이 명령 몇 개에 조건만 붙여 씁니다.

조건은 상태 레지스터의 플래그 넷으로 판단합니다. N(결과가 음수), Z(결과가 0), C(자리올림), V(부호 있는 넘침)입니다. cmp r1, #3에서 r1이 1이면 1 − 3 = −2라 N = 1, V = 0이고, lt(작으면)는 N ≠ V일 때 맞습니다.

고스트 트릭 글꼴 고르기 함수 쉽게

0x02012BA0의 함수는 글꼴 번호를 받아 그 글꼴의 파일 이름을 고릅니다. r0에는 호출한 쪽 구조체의 주소, r1에는 글꼴 번호가 들어옵니다. 함수가 하는 일을 말로 하면 이렇습니다.

  1. 구조체 +4 칸에 일단 4(고른 글꼴 없음)를 씁니다.
  2. 번호가 3보다 작으면 +4 칸에 번호를 쓰고, 글꼴 표에서 그 번호의 파일 이름 주소를 꺼내 +0x14 칸에 씁니다.
  3. +4 칸이 4가 아니면 1, 4면 0을 돌려줍니다.

ARM 명령 한 줄씩 실행

고스트 트릭 ARM9 프로그램의 0x02012BA0 함수를 실제 바이트에서 풀어 실행합니다. r0은 호출한 쪽 구조체의 주소(여기서는 0x02100000), r1은 글꼴 번호입니다. 글꼴 번호를 고르고 「다음 명령」을 눌러 보세요. 조건이 붙은 명령은 플래그에 따라 실행되거나 건너뜁니다.

  1. 02012BA004 20 A0 E3mov r2, #0x4늘 실행r2 ← 0x4
  2. 02012BA404 20 80 E5str r2, [r0, #0x4]늘 실행[0x02100004] ← r2 = 0x00000004
  3. 02012BA803 00 51 E3cmp r1, #0x3늘 실행r1 − 0x3 = −2. N=1 Z=0 C=0 V=0
  4. 02012BAC1C 20 9F B5ldrlt r2, [pc, #0x1C]작으면(N ≠ V)r2 ← [0x02012BD0] = 0x020A6EB0
  5. 02012BB004 10 80 B5strlt r1, [r0, #0x4]작으면(N ≠ V)[0x02100004] ← r1 = 0x00000001
  6. 02012BB481 11 92 B7ldrlt r1, [r2, r1, lsl #3]작으면(N ≠ V)r1 ← [0x020A6EB8] = 0x020A6E88(balloon_font.imb의 주소)
  7. 02012BB814 10 80 B5strlt r1, [r0, #0x14]작으면(N ≠ V)[0x02100014] ← r1 = 0x020A6E88
  8. 02012BBC04 00 90 E5ldr r0, [r0, #0x4]늘 실행r0 ← [0x02100004] = 0x00000001
  9. 02012BC004 00 50 E3cmp r0, #0x4늘 실행r0 − 0x4 = −3. N=1 Z=0 C=0 V=0
  10. 02012BC400 00 A0 03moveq r0, #0x0같으면(Z = 1)건너뜀
  11. 02012BC801 00 A0 13movne r0, #0x1다르면(Z = 0)r0 ← 0x1
  12. 02012BCC1E FF 2F E1bx lr늘 실행돌아갈 곳 0x020125EC, 돌려주는 값 r0 = 1
  13. 02012BD0B0 6E 0A 02.word 0x020A6EB0데이터(리터럴 풀)
r0
0x00000001
r1
0x020A6E88
r2
0x020A6EB0
NZCV
1000

글꼴 번호 1로 끝까지 실행한 결과입니다. 돌려주는 값은 r0 = 1입니다.

3번 줄 cmp r1, #0x3이 플래그를 정하고, 그 뒤 lt가 붙은 네 명령이 함께 실행되거나 함께 건너뜁니다. 글꼴 번호를 3으로 바꿔 실행하면 네 명령을 모두 건너뛰고 0을 돌려줍니다.

리터럴 풀: 먼 주소 불러오기 쉽게

mov가 바로 넣을 수 있는 값은 8비트 값을 짝수 자리만큼 돌린 것뿐이라, 0x020A6EB0 같은 임의의 32비트 주소는 넣지 못합니다. 그래서 컴파일러는 흔히 함수 끝 바로 뒤에 그 값을 4바이트로 적어 두고(리터럴 풀), PC를 기준으로 읽어 옵니다.

ldrlt r2, [pc, #0x1C]는 0x02012BAC에 있습니다. ARM 상태에서 PC를 읽으면 지금 명령의 주소 + 8이 나오므로, 읽는 자리는 0x02012BAC + 8 + 0x1C = 0x02012BD0입니다. 함수의 마지막 명령 bx lr 바로 뒤의 4바이트 B0 6E 0A 02, 곧 0x020A6EB0입니다. 역어셈블러는 이 4바이트도 명령인 줄 알고 엉뚱한 명령으로 풀기 쉬워서, 함수 끝 뒤의 값은 데이터로 따로 읽어야 합니다.

표에서 꺼내기: 시프트 붙은 주소 쉽게

0x020A6EB0의 글꼴 표는 칸 하나가 8바이트로, 앞 4바이트가 파일 이름 문자열의 주소, 뒤 4바이트가 번호입니다. 넷째 칸은 빈 이름에 번호 4라, 함수가 「고른 글꼴 없음」으로 쓰는 4와 맞습니다. 함수는 칸의 뒤 4바이트를 읽지 않습니다.

칸 위치이름 주소파일 이름번호
0x020A6EB00x020A6E9Cfonts2bpp_new.imb0
0x020A6EB80x020A6E88balloon_font.imb1
0x020A6EC00x020A6E7Cfont_db.imb2
0x020A6EC80x020A6E78(빈 문자열)4

ldrlt r1, [r2, r1, lsl #3]은 r2(표 주소) + r1 × 8(lsl #3은 왼쪽으로 3비트 밀기, 곧 × 8)에서 읽습니다. 번호 하나로 8바이트 칸을 바로 찾는 계산을 명령 하나로 합니다. 슈퍼 패미콤 포인터 표에서 번호 × 2를 하던 것(8장)과 같은 생각입니다.

부르고 돌아오기: BL과 LR 쉽게

이 함수를 0x020125E8과 0x02012678 두 곳에서 부릅니다. 앞의 것은 이렇습니다.

020125E0  mov r0, r6          ; r0 = 구조체 주소
020125E4  mov r1, r5          ; r1 = 글꼴 번호
020125E8  bl  0x02012BA0      ; lr = 0x020125EC, 함수로
020125EC  cmp r0, #0          ; 돌려받은 값이 0인지
020125F0  beq 0x02012600      ; 0이면 건너뜀

bl은 돌아올 주소(바로 다음 명령 0x020125EC)를 lr에 넣고 건너뜁니다. 함수는 끝에서 bx lr로 돌아옵니다. 컴파일러들이 따르는 호출 규약(AAPCS)에서 넘기는 값은 r0–r3(넘치면 스택), 돌려주는 값은 r0입니다. 그래서 호출한 쪽은 곧바로 cmp r0, #0으로 결과를 봅니다. 이 함수는 다른 함수를 부르지 않아서 lr을 스택에 저장해 둘 필요가 없습니다. 바로 앞의 0x02012B1C 함수는 다른 함수를 부르므로 push {r4, lr}로 시작해 pop {r4, pc}로 돌아갑니다.

더 깊이이 함수를 찾은 순서
  1. ARM9 프로그램(arm9.bin)의 BLZ 압축을 풉니다. 롬의 0x80628바이트가 0xD2118바이트가 됩니다.
  2. 푼 프로그램에서 문자열 fonts2bpp_new.imb를 찾으면 0x020A6E9C에 있습니다.
  3. 그 주소를 4바이트로 적은 9C 6E 0A 02를 찾으면 0x020A6EB0, 글꼴 표의 첫 칸이 나옵니다.
  4. 표 주소 B0 6E 0A 02를 다시 찾으면 0x02012BD0, 리터럴 풀이 나옵니다. 그 앞을 역어셈블하면 이 함수입니다.
  5. 함수 첫 주소 0x02012BA0으로 가는 bl을 찾으면 부르는 곳 두 군데가 나옵니다.

문자열 → 그 주소를 담은 데이터 → 그 데이터를 읽는 코드 순서로 거슬러 올라가는 방법은 파일 이름, 메시지처럼 눈에 띄는 문자열이 있는 기능을 찾을 때 두루 씁니다.

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

고스트 트릭: 폰트를 이름으로 고르는 코드

이 함수가 고르는 것은 파일 번호가 아니라 이름 문자열입니다. 이름을 고르는 명령을 바꾸면 실제로 다른 폰트 파일로 글자가 그려지는 것을 다음 장에서 확인합니다. 한글패치 v1.1은 코드를 고치지 않고 폰트 파일 세 개를 3,203자로 키워 같은 이름으로 넣었습니다(17장). 롬을 다시 묶으며 파일 번호가 바뀌어도(15장) 게임이 이름으로 찾으니 문제가 없었던 것과 맞습니다(노트).

직접 해 보기 직접 해 보기

ndspy와 capstone(pip install ndspy capstone)으로 압축을 풀고 함수를 읽습니다. 일본판 롬 기준 주소입니다.

krpatch nds unpack game.nds work/
python3 - <<'EOF'
import capstone, ndspy.codeCompression
code = ndspy.codeCompression.decompress(open('work/arm9.bin', 'rb').read())
print(hex(0x02000000 + code.find(b'fonts2bpp_new.imb')))
md = capstone.Cs(capstone.CS_ARCH_ARM, capstone.CS_MODE_ARM)
for i in md.disasm(code[0x12BA0:0x12BD0], 0x02012BA0):
    print(hex(i.address), i.mnemonic, i.op_str)
EOF

이 장에서

  1. ARM 명령 읽는 법
  2. 조건 실행: 건너뛰지 않고 고르기
  3. 고스트 트릭 글꼴 고르기 함수
  4. 리터럴 풀: 먼 주소 불러오기
  5. 표에서 꺼내기: 시프트 붙은 주소
  6. 부르고 돌아오기: BL과 LR
  7. 실제 패치에서
  8. 고스트 트릭: 폰트를 이름으로 고르는 코드
  9. 직접 해 보기