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으로 컴파일하면 명령어는 같지만 표기가 조금 다르다. 차이는 맨 아래에 정리했다.
add x0, x1, x2 // x0 = x1 + x2 (x86처럼 피연산자 하나를 덮어쓰지 않음)
sub x0, x0, #16 // 상수는 #
mov x0, x1
| 역할 | 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으로 채워진다.
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을 곱하는 것과 같다.
!(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 | 오프셋이 음수이거나 크기의 배수가 아닐 때 |
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: // 여기의 주소에 .L3 이라는 이름을 붙임
add x2, x2, #1
...
b .L3 // .L3 위치로 점프
.L2, .L3 같은 이름은 컴파일러가 if, for, while을 번역하면서 자동으로 붙인 일련번호이고, 숫자 자체에 의미는 없다. .L 접두사는 GNU 어셈블러의 관례로 “이 파일 안에서만 쓰는 로컬 레이블”을 뜻해서 최종 심볼 테이블에 남지 않는다. 반면 sum:이나 main:처럼 점이 없는 레이블은 함수 이름으로, 다른 파일에서 bl sum으로 호출할 수 있게 심볼로 남는다.
기계어에는 레이블 이름이 없다. 어셈블러가 “현재 명령어에서 목적지까지 몇 바이트 떨어져 있는가”를 계산해 그 오프셋을 명령어에 넣는다.
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/cbnzCompare 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을 낸다. 한쪽에 함수 호출처럼 부수효과나 큰 비용이 있으면 그 코드를 무조건 실행할 수는 없으므로 일반 분기를 쓴다.
| 플래그 | 이름 | 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의 별칭이다.
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의 회로상 정의는 “두 피연산자의 부호가 같은데 결과의 부호가 다르다”이다.
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 / ret |
스택에 push (메모리 쓰기 발생) | 스택에서 pop |
ARM64 bl / ret |
x30 레지스터 (메모리 접근 없음) |
x30으로 점프 |
다른 함수를 호출하는 함수(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이다.
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는 이 중 앞 조각, 즉 대상이 속한 페이지의 시작 주소를 만드는 명령이다.
_start:
adrp x0, far // 0x400000 에 배치됨
add x0, x0, :lo12:far // 0x400004
...
far: // 0x41fa38 에 배치됨
.quad 0
여기서는 “far의 페이지 주소를 구해라”, “far의 하위 12비트를 더해라”라고 이름으로만 적는다.
링커는 모든 것의 배치가 끝난 뒤 두 주소를 알고 있으므로 페이지 차이를 계산한다.
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
Rd: 첫 번째 피연산자(목적지 레지스터 번호)immhi:immlo: 두 번째 피연산자가 변환된 결과. 두 필드를 이어 붙인 21비트 부호 있는 정수 = 페이지 차이adr(바이트 단위), 1이면 adrp(페이지 단위)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만으로는 주소가 완성되지 않으므로 보통 짝이 되는 명령이 따라온다.
| 필요한 것 | 뒤따르는 명령 | 결과 |
|---|---|---|
| 주소 자체 (문자열이나 배열의 포인터) | add x0, x0, #0xa38 |
x0 = &far |
| 그 주소에 있는 값 (전역 변수 읽기) | ldr x0, [x0, #0xa38] |
x0 = far |
ldr 쪽은 주소 지정 방식의 오프셋 자리에 하위 12비트를 넣어서, 주소 완성과 메모리 읽기를 한 번에 처리한다.
add나 ldr의 12비트 칸에 정확히 들어간다.0xaaaaaaaa0000에 로드되면 같은 명령어가 0xaaaaaaaa0000 + 0x1f000 = 0xaaaaaaabf000을 만든다. 코드와 데이터의 간격만 유지되면 어디에 로드되든 맞는 주소가 나오므로 ASLR(실행할 때마다 주소가 바뀌는) 환경에서 코드를 수정할 필요가 없다.명령어와 레지스터는 동일하고, 어셈블러 표기만 다르다.
| 항목 | 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