jeongjinyeong's homepage

2026-09-18

ARM64 어셈블리 읽는 법

Apple Silicon Mac에서 C 코드를 컴파일해 어셈블리를 보면 x0, adrp, ldr, bl 같은 것들이 나온다. 이는 x86-64(AT&T/Intel 문법)가 아니라 ARM64(AArch64) 어셈블리다. 컴파일러 출력을 읽는 데 필요한 최소한의 문법을 정리한다.

전제: 이 글의 예시는 Apple Silicon Mac 위의 Linux 컨테이너(Docker)에서 gcc로 컴파일한 결과를 기준으로 하고, 표기는 objdump와 gdb가 보여 주는 형태(b.lt, #16)를 따랐다. macOS에서 직접 clang으로 컴파일하면 명령어는 같지만 표기가 조금 다르다. 차이는 맨 아래에 정리했다.

1. 기본 형태: 목적지가 맨 앞, 3-피연산자

add x0, x1, x2        // x0 = x1 + x2   (x86처럼 피연산자 하나를 덮어쓰지 않음)
sub x0, x0, #16       // 상수는 #
mov x0, x1

2. 레지스터

역할별 대응

역할 ARM64 x86-64 (System V)
정수/포인터 인자 x0~x7 (8개) rdi, rsi, rdx, rcx, r8, r9 (6개)
반환값 x0 (128비트면 x1까지) rax (128비트면 rdx까지)
큰 구조체 반환 시 결과 주소 x8 rdi (숨은 첫 번째 인자)
caller-saved 임시 x9~x15 (+ 인자 레지스터) r10, r11 (+ 인자 레지스터, rax)
링커/PLT용 임시 x16, x17 r11
플랫폼 레지스터 x18 (macOS에서는 예약, Linux에서는 임시) 없음
callee-saved x19~x28 (10개) rbx, r12~r15 (5개)
프레임 포인터 x29 rbp
복귀 주소 x30 (레지스터) 없음. 스택의 [rsp]에 저장
스택 포인터 sp rsp
프로그램 카운터 pc rip
항상 0 xzr / wzr 없음
상태 플래그 NZCV (PSTATE) SF, ZF, CF, OF (RFLAGS)
부동소수점/SIMD v0~v31 (d0=double, s0=float) xmm0~xmm15
부동소수점 인자 v0~v7 xmm0~xmm7
스레드 로컬 저장소 베이스 tpidr_el0 fs 세그먼트 베이스

하위 레지스터 뷰

폭 ARM64 x86-64
64비트 x0 rax
32비트 w0 eax
16비트 없음 ax
8비트 없음 al (일부 레지스터는 ah도)

레지스터 이름이 연산 폭을 정한다. w는 같은 레지스터의 하위 32비트이고, w에 쓰면 상위 32비트는 0으로 채워진다.

3. 메모리 접근

주소 지정 방식 5가지

ldr(load register / 메모리 → 레지스터)과 str(store register / 레지스터 → 메모리)에 공통으로 적용된다. ARM64는 load/store 구조라서 메모리 접근은 이 계열 명령으로만 가능하고, 연산은 레지스터끼리만 한다.

ldr x0, [x1]              // *(x1)
ldr x0, [x1, #8]          // *(x1 + 8)
ldr x0, [x1, #8]!         // pre-index : x1 += 8 을 먼저 하고, *(x1)
ldr x0, [x1], #8          // post-index: *(x1) 을 읽고, x1 += 8
ldr x0, [x1, x2, lsl #3]  // *(x1 + x2*8)  -> long 배열의 a[i]

lsl(logical shift left)은 비트를 왼쪽으로 n칸 밀고 오른쪽 자리를 0으로 채우는 연산으로, lsl #3은 8을 곱하는 것과 같다.

stp / ldp: push와 pop 대용

!(pre-index)와 post-index는 push/pop 대용이다. ARM64에는 push/pop 명령이 없어서 stp x29, x30, [sp, #-16]!이 push 역할을 한다.

stp/ldp는 store pair, load pair라는 의미로 레지스터 두 개를 한 번에 저장/로드하는 명령이다.

stp x29, x30, [sp, #-16]!   // sp -= 16 후 fp, lr 저장  (push)
...
ldp x29, x30, [sp], #16     // fp, lr 복원 후 sp += 16  (pop)

위 stp는 !가 붙은 pre-index이므로 sp(stack pointer)에 16을 먼저 빼서 공간을 확보하고 *sp에 x29를 저장하고 *(sp+8)에 x30을 저장하게 된다. 앞에 있는 operand가 낮은 주소에 들어간다.

stp, ldp는 5가지의 주소 지정 방식 중 세 가지만 사용 가능하다.

stp x0, x1, [sp, #16]       // 오프셋
stp x0, x1, [sp, #-16]!     // pre-index
stp x0, x1, [sp], #16       // post-index

접근 크기

메모리 접근 폭은 명령어가 정한다.

명령어 풀이 접근 크기
ldr / str LoaD Register / STore Register 레지스터 폭 (x면 8바이트, w면 4바이트)
ldp / stp LoaD Pair / STore Pair 레지스터 쌍을 동시에 처리할 때
ldrb / strb LoaD Register Byte / STore Register Byte 1바이트
ldrh / strh LoaD Register Halfword / STore Register Halfword 2바이트
ldrsw LoaD Register Signed Word 4바이트를 읽어 64비트로 부호 확장
ldur / stur LoaD Unscaled Register / STore Unscaled Register 오프셋이 음수이거나 크기의 배수가 아닐 때

4. 조건 분기

cmp x0, x1            // x0 - x1 을 계산해 플래그(NZCV)만 갱신
b.lt .L3              // signed:   eq ne lt le gt ge
b.hi .L3              // unsigned: hi hs lo ls
cbz  x0, .L5          // x0 == 0 이면 분기 (cmp 없이 한 번에)
cset w0, eq           // 조건이 참이면 1, 아니면 0
csel x0, x1, x2, lt   // x0 = lt ? x1 : x2  (분기 없는 삼항)

레이블: .L3, .L5

레이블은 코드 내 특정 위치(주소)에 붙인 이름이다. 줄 맨 앞에 이름: 형태로 정의하고, 분기 명령의 목적지로 쓴다.

.L3:                  // 여기의 주소에 .L3 이라는 이름을 붙임
    add x2, x2, #1
    ...
    b   .L3           // .L3 위치로 점프

.L2, .L3 같은 이름은 컴파일러가 if, for, while을 번역하면서 자동으로 붙인 일련번호이고, 숫자 자체에 의미는 없다. .L 접두사는 GNU 어셈블러의 관례로 “이 파일 안에서만 쓰는 로컬 레이블”을 뜻해서 최종 심볼 테이블에 남지 않는다. 반면 sum:이나 main:처럼 점이 없는 레이블은 함수 이름으로, 다른 파일에서 bl sum으로 호출할 수 있게 심볼로 남는다.

기계어에는 레이블 이름이 없다. 어셈블러가 “현재 명령어에서 목적지까지 몇 바이트 떨어져 있는가”를 계산해 그 오프셋을 명령어에 넣는다.

b와 b.조건

b는 branch, 즉 무조건 점프이다(x86의 jmp). b.lt는 b + 조건 lt로, 조건이 참일 때만 점프하고 거짓이면 그냥 다음 줄로 진행한다.

어셈블리에는 if (a < b) 같은 구문이 없어서 조건 판단이 항상 두 단계로 나뉜다.

cmp  x0, x1       // 1단계: x0 - x1 을 계산해 NZCV 플래그에 결과의 성질을 기록
b.lt .L3          // 2단계: 플래그를 읽어 "x0 < x1 이었나"를 판단, 참이면 .L3 으로

cmp는 비교 결과를 어디에도 저장하지 않고 플래그만 남기고, b.lt는 피연산자를 보지 않고 플래그만 읽는다. 둘 사이를 잇는 것이 NZCV다.

조건 코드

cmp a, b 직후를 기준으로 한 의미이다.

코드 풀이 의미 부호
eq equal a==b 무관
ne not equal a!=b 무관
lt less than a<b signed
le less or equal a<=b signed
gt greater than a>b signed
ge greater or equal a>=b signed
lo lower a<b unsigned
ls lower or same a<=b unsigned
hi higher a>b unsigned
hs higher or same a>=b unsigned

대소 비교가 두 벌인 이유는 같은 비트 패턴이 해석에 따라 다른 수이기 때문이다. 0xFFFFFFFFFFFFFFFF는 signed로는 -1, unsigned로는 약 1.8*10^19이다. 이 값과 1을 비교하면 signed로는 “작다”, unsigned로는 “크다”가 정답이다. CPU는 레지스터의 값이 어느 쪽인지 모르므로, 컴파일러가 C 변수의 타입을 보고 long이면 lt/gt 계열, unsigned long이나 포인터이면 lo/hi 계열을 골라 넣는다. less/greater는 signed, lower/higher는 unsigned라고 외우면 된다.

이 조건 코드들은 b. 뒤에만 쓰이는 게 아니라 cset, csel의 인자로도 똑같이 쓰인다.

예시:

if (x < y)
    foo();
bar();
    cmp  x0, x1
    b.ge .L2          // x >= y 이면 foo 호출을 건너뜀
    bl   foo
.L2:
    bl   bar

C에서는 <인데 어셈블리에서는 ge로, 조건에 맞지 않으면 분기하는 것을 볼 수 있다.

cbz/cbnz

Compare and Branch if Zero(cbnz는 Non-Zero). “레지스터가 0인가”는 워낙 자주 나오는 검사(NULL 체크, 루프 카운터, Boolean)라서 cmp+b.eq를 한 명령으로 합친 것이다.

cbz x0, .L5           // 아래 두 줄과 같은 동작
---
cmp  x0, #0
b.eq .L5

차이는 cbz가 NZCV 플래그를 건드리지 않는다는 점이다. if(p == NULL), while(n), if(!flag) 같은 코드에서 나온다.

cset: 비교 결과를 값으로 만들기

Conditional Set. 조건이 참이면 레지스터에 1, 거짓이면 0을 넣는다. C에서 비교식의 결과를 값으로 쓸 때 나온다.

int is_equal(long a, long b) { return a == b; }
cmp  x0, x1
cset w0, eq           // w0 = (a == b) ? 1 : 0
ret

반환 타입이 int(32비트)라서 x0이 아니라 w0이다.

csel: 분기 없는 선택

Conditional Select. csel 목적지, 참일때, 거짓일때, 조건 형식이다.

long max(long a, long b) { return a > b ? a : b; }
cmp  x0, x1
csel x0, x0, x1, gt   // x0 = (a > b) ? a : b
ret

b.le로 분기해서 써도 같은 결과가 나오는데 굳이 이런 명령이 있는 이유는 분기 예측 실패 비용 때문이다. CPU는 파이프라인을 채우기 위해 조건 분기의 방향을 미리 추측해 다음 명령들을 실행해 두는데, 추측이 틀리면 그 작업을 전부 버리고 다시 시작한다(사이클 손실). csel은 점프가 없으니 예측할 것도 없다. 두 값이 이미 레지스터에 있고 고르기만 하면 되는 경우에 컴파일러가 분기 대신 csel을 낸다. 한쪽에 함수 호출처럼 부수효과나 큰 비용이 있으면 그 코드를 무조건 실행할 수는 없으므로 일반 분기를 쓴다.

5. NZCV 플래그

플래그 이름 1이 되는 조건
N Negative 결과의 최상위 비트가 1 (signed로 보면 음수)
Z Zero 결과가 0
C Carry 덧셈: 최상위 비트 밖으로 자리올림 발생 = unsigned 오버플로
V oVerflow signed 오버플로 (결과의 부호가 수학적으로 나올 수 없는 값)

플래그를 갱신하는 명령

x86과 달리 add, sub 같은 일반 연산은 플래그를 건드리지 않는다. 플래그를 갱신하는 명령은 cmp, cmn, tst와 adds/subs/ands처럼 s가 붙은 것들이다. cmp는 결과를 버리는 subs의 별칭이다.

C와 V가 따로 있는 이유

CPU는 피연산자가 signed인지 unsigned인지 모른다. 덧셈 회로는 하나이고 비트 패턴만 더한다. 그래서 두 해석 각각에 대한 오버플로 여부를 둘 다 기록해 두고, 어느 쪽을 볼지는 뒤따르는 조건 코드가 고른다. 8비트로 예를 들면:

  0111 1111  (127)               1111 1111  (unsigned 255 / signed -1)
+ 0000 0001  (1)               + 0000 0001  (1)
-----------                    -----------
  1000 0000                    1 0000 0000 -> 0000 0000

N=1 Z=0 C=0 V=1                N=0 Z=1 C=1 V=0

왼쪽은 unsigned로는 128이라 정상(C=0)이지만, signed로는 양수 + 양수가 음수(-128)가 되었으므로 V=1이다. 오른쪽은 unsigned로는 256이 범위를 넘어 C=1이지만, signed로는 -1 + 1 = 0이 정확하므로 V=0이다. V의 회로상 정의는 “두 피연산자의 부호가 같은데 결과의 부호가 다르다”이다.

gdb에서 확인

info registers cpsr로 볼 수 있고, NZCV는 최상위 4비트(31~28)다. 예를 들어 0x60000000이면 0110이므로 Z=1, C=1이고, 이는 cmp에서 두 값이 같았을 때의 전형적인 값이다(차가 0이라 Z=1, borrow가 없어 C=1).

하는 일은 두 가지이다.

bl puts

1) x30 = PC + 4        // bl 바로 다음 명령어의 주소 = 복귀 주소
2) PC  = puts          // 점프

“link”는 돌아올 곳을 기록해 둔다는 뜻이고, 그래서 x30의 별칭이 link register(lr)이다. 짝이 되는 ret은 x30에 들어 있는 주소로 점프하는 명령이다.

x86의 call과 다른 점

x86의 call과 다른 점은 복귀 주소를 두는 위치이다.

  복귀 주소를 두는 곳 복귀
x86 call / ret 스택에 push (메모리 쓰기 발생) 스택에서 pop
ARM64 bl / ret x30 레지스터 (메모리 접근 없음) x30으로 점프

leaf 함수와 non-leaf 함수

다른 함수를 호출하는 함수(non-leaf)는 진입하자마자 x30을 스택에 저장하고, 끝에서 복원한 뒤 ret한다. 아무것도 호출하지 않는 leaf 함수는 x30이 그대로 유지되므로 저장 과정이 통째로 없다.

nonleaf:                         leaf:
    stp x29, x30, [sp, #-16]!        add x0, x0, #1
    mov x29, sp                      ret
    bl  helper
    lsl x0, x0, #1
    ldp x29, x30, [sp], #16
    ret

x86에서는 call이 자동으로 하던 “복귀 주소를 스택에 저장”을 ARM64에서는 필요한 함수만 직접 하는 구조이다.

분기 명령 계열

분기 명령 계열은 이름 규칙이 일관적이다. l이 붙으면 x30에 복귀 주소를 기록하고, r이 붙으면 목적지가 레지스터이다.

명령 목적지 x30 기록 용도
b label 레이블 X 무조건 점프, tail call
bl label 레이블 O 함수 호출
br x1 레지스터 X switch 점프 테이블
blr x1 레지스터 O 함수 포인터 호출
ret x30 X 복귀

bl의 목적지는 명령어 안의 26비트 필드에 PC 기준 상대 거리로 들어가고, 범위는 ±128MB이다.

7. adrp: 페이지 단위 PC 상대 주소

PC 기준으로 대상이 속한 4KB 페이지의 시작 주소를 계산해 레지스터에 넣는 명령으로, 페이지 내 오프셋(하위 12비트)은 뒤따르는 add나 ldr이 더한다.

한 가지 예(명령어 위치 0x400000, 대상 far = 0x41fa38)로 처음부터 끝까지 따라가 본다.

목적

far의 주소 0x41fa38을 x0에 넣고 싶다. 그런데 명령어 하나가 32비트라서 64비트 주소를 통째로 담을 수 없다. 그래서 주소를 두 조각으로 나눠 명령어 두 개로 만든다.

0x41fa38  =  0x41f000        +  0xa38
             page start         offset within page (lower 12 bits)
             -> adrp            -> add

adrp는 이 중 앞 조각, 즉 대상이 속한 페이지의 시작 주소를 만드는 명령이다.

1단계: 소스 (사람 또는 컴파일러가 작성)

_start:
    adrp x0, far                // 0x400000 에 배치됨
    add  x0, x0, :lo12:far      // 0x400004
    ...
far:                            // 0x41fa38 에 배치됨
    .quad 0

여기서는 “far의 페이지 주소를 구해라”, “far의 하위 12비트를 더해라”라고 이름으로만 적는다.

2단계: 빌드 시점 (링커가 계산)

링커는 모든 것의 배치가 끝난 뒤 두 주소를 알고 있으므로 페이지 차이를 계산한다.

page of instruction : 0x400000
page of far         : 0x41f000
page diff           : (0x41f000 - 0x400000) / 0x1000 = 0x1f   (31 pages)

그리고 이 0x1f를 adrp 명령어의 즉시값 칸에 기록한다. 하위 12비트 0xa38은 add 명령어의 즉시값 칸에 기록한다. 이 시점 이후로 far라는 이름과 0x41fa38이라는 절대 주소는 코드 어디에도 남지 않는다.

adrp x0, <+0x1f pages>
add  x0, x0, #0xa38

adrp 명령어 32비트의 비트 배치는 다음과 같다.

 31  30 29  28    24  23                 5  4      0
+---+------+--------+---------------------+--------+
| 1 | immlo| 10000  |        immhi        |   Rd   |
+---+------+--------+---------------------+--------+
      2 bit                 19 bit           5 bit

3단계: 실행 시점 (CPU가 수행)

CPU가 adrp를 실행할 때 가진 정보는 현재 PC와 명령어에 적힌 0x1f뿐이다. 이 둘로 대상 페이지를 복원한다.

adrp:   x0 = (PC & ~0xFFF) + (0x1f << 12)
           = 0x400000      + 0x1f000
           = 0x41f000                        // 하위 12비트는 항상 0

add:    x0 = 0x41f000 + 0xa38
           = 0x41fa38                        // far 의 주소 완성

PC & ~0xFFF는 현재 PC의 하위 12비트를 0으로 만들어 “지금 실행 중인 페이지의 시작 주소”를 구하는 연산이다. adrp는 메모리에 접근하지 않고 이 덧셈만 한다.

adrp 뒤에 오는 명령

adrp만으로는 주소가 완성되지 않으므로 보통 짝이 되는 명령이 따라온다.

필요한 것 뒤따르는 명령 결과
주소 자체 (문자열이나 배열의 포인터) add x0, x0, #0xa38 x0 = &far
그 주소에 있는 값 (전역 변수 읽기) ldr x0, [x0, #0xa38] x0 = far

ldr 쪽은 주소 지정 방식의 오프셋 자리에 하위 12비트를 넣어서, 주소 완성과 메모리 읽기를 한 번에 처리한다.

이렇게 설계한 이유

macOS에서 직접 컴파일했을 때의 차이

명령어와 레지스터는 동일하고, 어셈블러 표기만 다르다.

항목 Linux (GNU as) macOS (clang)
함수 심볼 sum: _sum:
로컬 레이블 .L2, .L3 LBB0_1 등
주소의 페이지 부분 adrp x0, far adrp x0, far@PAGE
주소의 오프셋 부분 add x0, x0, :lo12:far add x0, x0, far@PAGEOFF

macOS의 gcc 명령은 실제로는 clang이므로, 같은 소스라도 레지스터 선택과 명령어 순서가 이 글의 예시와 다르게 나올 수 있다.

참고

모르는 명령어가 나오면 ARM 공식 문서의 A64 Instruction Set 레퍼런스에서 찾으면 된다. 실제 컴파일러 출력에 자주 나오는 명령어는 30개 안팎이다.


부족한 내용이나 잘못된 정보가 있다면 jeong.jinyeong95@gmail.com으로 연락 바랍니다.

tags: arm64 - assembly