x86-64 메모리 주소 지정과 스택 프레임
유효 주소 계산, 정렬, 스택의 성장 방향과 함수 프레임 추적하기
x86-64 메모리 주소 지정과 스택 프레임
본 블로그 작성 글들은 강의를 목표로 제작되었습니다. 예제는 Intel 문법을 사용하며 실제 스택 레이아웃은 ABI, 최적화, 디버그 정보와 컴파일러에 따라 달라질 수 있습니다.
어셈블리에서 메모리는 변수 이름보다 주소 계산식으로 보인다. player.health는 [rcx+20]이 되고, values[i]는 [rdi+rsi*4]가 된다. 함수의 지역 변수도 고정된 “스택 칸”이 아니라 그 순간의 rsp 또는 frame pointer를 기준으로 찾는다.
이 글은 x86-64 유효 주소 형식, 배열과 구조체, RIP-relative 주소, 정렬, call·ret, 스택 프레임을 하나의 주소 추적 문제로 설명한다.
메모리 피연산자는 주소를 먼저 계산한다
Intel 문법에서 대표적인 주소 형식은 다음과 같다.
[base + index * scale + displacement]
base: 기준 주소를 든 일반 목적 레지스터index: 원소 번호나 추가 offset을 든 레지스터scale: 1, 2, 4, 8 중 하나displacement: 명령에 인코딩된 상수 offset
모든 항이 반드시 필요한 것은 아니다.
mov eax, [rcx] ; 주소 rcx에서 4바이트 읽기
mov eax, [rcx + 12] ; rcx + 12
mov eax, [rcx + rdx*4] ; rcx + rdx*4
mov eax, [rcx + rdx*4 + 12]
대괄호는 “이 식의 결과를 주소로 사용해 메모리에 접근한다”는 뜻이다. lea의 대괄호만 예외적으로 주소 계산 결과 자체를 반환한다.
주소와 값은 다르다
rcx = 0x1000이고 주소 0x1008에 64비트 값 42가 있다고 하자.
lea rax, [rcx + 8] ; RAX = 0x1008
mov rax, [rcx + 8] ; RAX = 42
포인터 버그를 디버깅할 때는 “레지스터가 주소인가 값인가”를 매 단계 표시하는 것이 좋다. 어셈블리 자체는 rax 안의 비트를 포인터 타입으로 표시하지 않는다.
배열 주소는 원소 크기를 포함한다
int value = numbers[index];
int가 4바이트인 환경이라면 다음 형태가 자연스럽다.
mov eax, dword ptr [rcx + rdx*4]
rcx가 배열 시작 주소, rdx가 index라면 유효 주소는 rcx + rdx * sizeof(int)다. x86 주소 지정의 scale이 1, 2, 4, 8로 제한되므로 12바이트 구조체 배열은 계산이 더 필요할 수 있다.
; index * 12 = index * 3 * 4
lea rax, [rdx + rdx*2]
mov ecx, dword ptr [rcx + rax*4 + 4]
이 패턴에서 곱셈은 곧 구조체 크기나 다차원 배열 stride에 대한 단서다.
구조체 필드는 고정 offset 계약이다
struct Player {
uint32_t id; // offset 0
float x; // offset 4
float y; // offset 8
int32_t health; // offset 12
};
레이아웃이 이와 같다고 빌드에서 확인했다면 다음 접근이 가능하다.
mov eax, dword ptr [rcx + 12]
하지만 offset 12는 필드 이름이 아니다. C++ 선언의 순서, 타입, #pragma pack, 상속, 가상 함수, ABI가 바뀌면 의미가 바뀐다. C++와 손으로 쓴 ASM을 연결할 때는 static_assert(offsetof(Player, health) == 12)와 static_assert(sizeof(Player) == 16) 같은 검사를 경계에 둔다.
RIP-relative 주소는 위치 독립 코드의 핵심이다
x86-64에서는 전역·상수에 접근할 때 현재 명령 위치와 가까운 상대 주소를 자주 사용한다.
lea rdi, [rip + message]
mov eax, dword ptr [rip + counter]
정확한 어셈블러 표기는 도구마다 다르지만 개념은 “절대 주소를 명령에 박지 않고 다음 명령 위치를 기준으로 목표를 찾는다”는 것이다. ELF의 GOT·PLT와 PE의 import table 같은 동적 연결 구조가 추가되면 한 단계 간접 접근이 보일 수도 있다.
리틀 엔디언은 바이트 배열의 순서를 결정한다
레지스터 값 0x1122334455667788을 주소가 증가하는 방향으로 저장하면 x86-64에서는 다음 순서다.
주소 +0 +1 +2 +3 +4 +5 +6 +7
바이트 88 77 66 55 44 33 22 11
엔디언은 비트 연산의 계산 순서를 거꾸로 만들지 않는다. 멀티바이트 값을 메모리의 바이트로 표현할 때의 순서다. 디버거의 메모리 창과 레지스터 창이 다르게 보이는 이유를 여기서 찾을 수 있다.
정렬은 정확성과 성능의 서로 다른 계약이다
주소가 데이터 크기의 배수에 놓이면 자연 정렬이라고 부른다. x86-64의 많은 일반 load/store는 정렬되지 않은 주소도 처리하지만 다음은 별도로 확인해야 한다.
- 페이지나 캐시 라인을 가로지르면 비용이 커질 수 있다.
- 일부 SIMD 명령은 정렬된 주소를 요구하거나 정렬 전용 형태가 있다.
- ABI는 함수 호출 지점의 스택 정렬을 요구한다.
- C/C++에서 잘못 정렬된 포인터를 해당 타입으로 역참조하면 언어 차원의 정의되지 않은 동작이 될 수 있다.
- atomic 연산의 원자성 조건은 크기, 정렬, 캐시 경계와 명령에 따라 달라진다.
“x86이 unaligned를 허용하니 아무 주소나 안전하다”라고 결론 내리면 안 된다.
스택은 높은 주소에서 낮은 주소 방향으로 자란다
일반적인 x86-64 사용자 모드 호출 스택에서 push는 먼저 rsp를 8 줄이고 값을 저장한다.
push rbx ; rsp -= 8; [rsp] = rbx
pop rbx ; rbx = [rsp]; rsp += 8
call target은 복귀 주소를 스택에 저장하고 target으로 이동한다. ret은 현재 [rsp]의 주소를 instruction pointer로 꺼내고 rsp를 증가시킨다.
호출자에서 call 직전 RSP = S
피호출자 진입 직후 RSP = S - 8
ret 실행 직후 RSP = S
이것은 “모든 인자가 스택으로 전달된다”는 뜻이 아니다. 현대 x86-64 ABI는 많은 인자를 레지스터로 전달하지만 복귀 주소는 여전히 call이 스택에 둔다.
스택 프레임은 함수가 사용하는 구간이다
최적화되지 않은 함수는 다음처럼 보일 수 있다.
push rbp
mov rbp, rsp
sub rsp, 32
; ... [rbp-4], [rbp-16] 같은 지역 공간 사용 ...
add rsp, 32
pop rbp
ret
각 줄의 효과는 다음과 같다.
- 호출자의
rbp를 보존한다. - 현재
rsp를 이번 함수의 고정 기준점으로 삼는다. - 지역 변수와 spill을 위한 32바이트를 확보한다.
- 역순으로 원래 상태를 복구한다.
하지만 frame pointer는 필수가 아니다. 최적화된 코드는 rbp를 일반 레지스터로 쓰고 rsp 기준으로 공간을 접근하거나, 지역 변수를 전부 레지스터에 유지해 프레임 자체를 만들지 않을 수 있다.
지역 변수 이름은 사라질 수 있다
int sum(int a, int b) {
int temp = a + b;
return temp * 2;
}
최적화 결과는 다음 한 줄과 ret뿐일 수 있다.
lea eax, [rdi + rsi]
add eax, eax
ret
temp의 스택 주소를 찾을 수 없는 이유는 변수가 “삭제되어서 C 의미가 달라진 것”이 아니라, 관찰 가능한 결과를 레지스터만으로 만들었기 때문이다. 디버그 빌드의 스택 레이아웃을 Release의 고정 구조로 외우지 않는다.
스택에는 지역 변수 말고도 여러 상태가 놓인다
함수의 스택 공간은 다음 용도로 사용될 수 있다.
- 지역 배열과 주소가 필요한 지역 변수
- 레지스터가 부족할 때의 spill
- 피호출자 보존 레지스터의 원래 값
- 레지스터에 들어가지 못한 인자
- Windows x64의 shadow space
- 정렬 padding
- stack canary와 보안 메타데이터
- 동적 크기 할당을 위한 공간
따라서 [rsp+40]만 보고 “다섯 번째 지역 변수”라고 단정할 수 없다. 함수 진입 시점, 프롤로그 이후인지, ABI와 명령 사이의 rsp 변화를 먼저 확인한다.
leaf 함수와 non-leaf 함수
다른 함수를 호출하지 않는 leaf 함수는 종종 스택을 전혀 쓰지 않는다.
add_three:
lea eax, [rdi + rsi]
add eax, edx
ret
다른 함수를 호출하는 함수는 복귀에 필요한 live 값, 정렬, ABI별 호출 공간을 준비해야 한다. Windows x64에서는 호출자가 피호출자를 위해 32바이트 shadow space를 예약한다. System V AMD64에는 그 규칙이 없지만, 사용자 공간의 leaf 함수가 제한적으로 사용할 수 있는 128바이트 red zone이 있다. Windows에는 System V 방식의 red zone이 없다.
이 차이는 다음 ABI 글에서 자세히 다룬다.
버퍼 순회 예제를 주소로 읽는다
; rcx = int32_t* values, rdx = count
; 반환: rax = signed 64-bit sum
xor eax, eax
xor r8d, r8d
loop_begin:
cmp r8, rdx
jae loop_end
movsxd r9, dword ptr [rcx + r8*4]
add rax, r9
inc r8
jmp loop_begin
loop_end:
ret
주소와 계약을 분리해 읽는다.
[rcx + r8*4]는values[r8]의 후보 주소다.movsxd는 32비트 signed 원소를 64비트로 넓힌다.r8 < rdx를 unsigned로 검사하므로 음수 count라는 개념보다size_t계약에 가깝다.count > 0일 때values가 유효하고 최소count * 4바이트를 읽을 수 있어야 한다.- 합계가 64비트 범위를 넘는 경우의 계약은 코드 밖에서 정해야 한다.
ASM은 포인터의 실제 할당 크기를 알지 못한다. 주소 계산이 가능하다는 사실과 접근이 유효하다는 사실은 다르다.
디버거에서 스택을 추적하는 방법
함수 진입에 중단점을 걸고 다음 표를 채운다.
| 단계 | rsp | [rsp]의 의미 | 변경된 보존 레지스터 |
|---|---|---|---|
| 진입 직후 | 기준값 | 복귀 주소 | 없음 |
| 프롤로그 후 | 기준값 - N | 지역/저장 영역 | 예: rbx |
call 직전 | ABI 정렬 확인 | 호출용 공간 | live 값 확인 |
| 에필로그 후 | 진입 값 | 복귀 주소 | 모두 복원 |
메모리 창에서는 계산된 유효 주소와 주변 16~32바이트를 함께 본다. 주소 하나의 값만 보면 field offset과 endian 오류를 놓치기 쉽다.
스택 손상에서 자주 보이는 증상
ret가 전혀 관계없는 주소로 이동한다.- 함수는 정상 반환하지만 호출자의 비휘발 레지스터 값이 바뀐다.
- 최적화 빌드나 예외 발생 때만 stack walk가 끊긴다.
- SIMD load 또는 호출된 함수 내부에서 정렬 예외가 난다.
- 지역 버퍼 경계를 넘은 뒤 한참 후에 오류가 드러난다.
원인은 잘못된 offset, 확보한 크기와 복구한 크기의 불일치, 보존 레지스터 누락, 잘못된 unwind 정보, 버퍼 범위 초과일 수 있다.
직접 확인하는 실습
int32_t,int64_t, 12바이트 구조체 배열을 각각 순회하는 C 함수를 작성한다.- Release 디스어셈블리에서 index scale과 추가 계산을 찾는다.
- 함수 진입의
rsp를S라고 쓰고 모든push,sub rsp,call,add rsp,pop,ret뒤 값을 추적한다. - frame pointer 사용 옵션을 켜고 끈 결과를 비교한다.
- 구조체 필드 순서를 바꾸고 offset이 어떻게 바뀌는지
offsetof와 어셈블리 양쪽에서 확인한다.
정리
x86-64 메모리 접근은 [base + index*scale + displacement]로 유효 주소를 만든 뒤 정해진 폭의 바이트를 읽거나 쓰는 과정이다. 배열의 scale은 원소 크기를, 구조체의 displacement는 필드 offset을 드러낸다. call은 복귀 주소를 스택에 넣고, 함수는 필요할 때만 지역·spill·보존 공간을 확보한다.
스택 프레임 그림을 외우기보다 rsp의 실제 변화를 한 줄씩 추적해야 Debug와 Release, System V와 Windows의 차이를 안전하게 읽을 수 있다.