suminworld

concept

[Virtual Memory] x86-64 함수 호출 & Virtual Memory 기반 스택 프레임 정리 (SysV ABI)

숨usm 2025. 10. 5. 03:03

Virtual Memory 강의자료 기반
이 글은 System V AMD64 ABI(Linux, macOS) 기준입니다. Windows x64는 하단 비교표 참고


🎯 TL;DR - 핵심 요약

┌─────────────┬──────────────────────────────────────┐
│ Prologue    │ push %rbp; mov %rsp, %rbp            │
├─────────────┼──────────────────────────────────────┤
│ Body        │ mov/add (레지스터 연산)              │
│             │ 지역변수: %rbp - N                   │
├─────────────┼──────────────────────────────────────┤
│ Epilogue    │ pop %rbp; ret                        │
└─────────────┴──────────────────────────────────────┘

📌 인자 전달: %rdi, %rsi, %rdx, %rcx, %r8, %r9
📌 반환값: %rax
📌 Caller-saved: %rax, %rcx, %rdx, %rsi, %rdi, %r8–%r11
📌 Callee-saved: %rbx, %rbp, %r12–%r15

📋 목차

  1. 함수 호출 3단계 구조
  2. 0단계: call 명령 직후
  3. 1단계: Prologue - 스택 프레임 생성
  4. 2단계: Body - 함수 본문 실행
  5. 3단계: Epilogue - 스택 복원
  6. 레지스터 체계 완전 분석
  7. Caller-saved vs Callee-saved
  8. 어셈블리 명령어 정리
  9. 심화 주제
  10. Windows x64 차이점

1. 함수 호출 3단계 구조

┌─────────────┬──────────────────────────────┐
│ 단계        │ 역할                         │
├─────────────┼──────────────────────────────┤
│ 1. Prologue │ 스택 프레임 생성             │
│ 2. Body     │ 함수 코드 실행               │
│ 3. Epilogue │ 스택 프레임 복원 및 반환     │
└─────────────┴──────────────────────────────┘

2. 0단계: call 명령 직후

스택은 높은 주소에서 낮은 주소로 성장

높은 주소 (예: 0x500)
┌──────────────────┐
│ 이전 함수의 프레임│
├──────────────────┤ ← %rbp = 0x500 (이전 기준점)
│   ...            │
├──────────────────┤ ← call 직전 %rsp
│ 리턴 주소        │ ← call이 자동으로 push
├──────────────────┤ ← %rsp = 0x4f8 (현재 top)
│                  │
└──────────────────┘
낮은 주소

call 명령의 동작

call function_name

내부 동작:

  1. push %rip (다음 명령 주소 저장)
  2. jmp function_name (함수로 점프)

결과:

  • %rip: 호출된 함수의 시작 주소
  • %rsp: 8바이트 감소 (리턴 주소 push)
  • %rbp: 아직 이전 함수의 기준점

3. 1단계: Prologue

(1) 이전 프레임 포인터 저장

0x0: pushq %rbp

동작:

%rsp -= 8
[%rsp] = %rbp

스택 구조:

┌──────────────────┐
│ 이전 함수 프레임  │
├──────────────────┤
│ 리턴 주소        │
├──────────────────┤
│ 이전 %rbp (0x500)│ ← pushq 실행
├──────────────────┤ ← %rsp = 0x4f0
│                  │
└──────────────────┘

(2) 새 기준점 설정

0x1: movq %rsp, %rbp

동작:

%rbp = %rsp

스택 구조:

┌──────────────────┐
│ 리턴 주소        │
├──────────────────┤
│ 이전 %rbp (0x500)│
├──────────────────┤ ← %rsp = %rbp = 0x4f0 (새 기준점)
│                  │
│ (지역 변수 영역) │
└──────────────────┘

요약:

  • push %rbp: 이전 기준점 백업
  • mov %rsp, %rbp: 새 기준점 확립

4. 2단계: Body

(1) 함수 인자 저장 (System V AMD64)

┌─────────┬───────────┬──────────────────┐
│ 인자    │ 레지스터  │ 저장 위치        │
├─────────┼───────────┼──────────────────┤
│ 1번째   │ %rdi/%edi │ %rbp - 4         │
│ 2번째   │ %rsi      │ %rbp - 16        │
│ 지역변수│ -         │ %rbp - 20 (예시) │
└─────────┴───────────┴──────────────────┘

예제: int main(int argc, char *argv[])

0x4: movl $0x0, %eax              # return 0 준비
0x9: movl %edi, 0xfffffc(%rbp)    # argc 저장 (32비트)
0xc: movq %rsi, 0xfffff0(%rbp)    # argv 저장 (64비트)

메모리 레이아웃:

├──────────────────┤ ← %rbp (0x4f0)
│ argc (32bit)     │ ← %rbp - 4
│ (padding)        │ ← 8바이트 정렬
│ argv (64bit)     │ ← %rbp - 16
├──────────────────┤

(2) 지역 변수 연산: x = x + 3

0x10: movl 0xffffec(%rbp), %edi   # x를 레지스터로 로드
0x13: addl $0x3, %edi              # 레지스터에서 계산
0x19: movl %edi, 0xffffec(%rbp)   # 결과를 메모리에 저장

과정:

1. 메모리(%rbp-20) → %edi (로드)
   %edi = 0

2. 레지스터 연산
   %edi = %edi + 3 = 3

3. %edi → 메모리(%rbp-20) (저장)
   x = 3

핵심 원리:

  • 모든 계산은 레지스터에서 수행
  • 메모리 접근은 로드/스토어 시에만

5. 3단계: Epilogue

(1) 기준점 복원

0x1c: popq %rbp

동작:

%rbp = [%rsp]
%rsp += 8

스택 구조:

┌──────────────────┐
│ 이전 함수 프레임  │ ← %rbp = 0x500 (복원)
├──────────────────┤
│ 리턴 주소        │
├──────────────────┤ ← %rsp = 0x4f8
│ (현재 함수 자료)  │
└──────────────────┘

(2) 함수 종료

0x1d: ret

동작:

%rip = [%rsp]   (리턴 주소 로드)
%rsp += 8       (스택 복원)

최종 상태:

  • %rbp = 0x500 (이전 함수 기준점)
  • %rsp = 0x500 (call 직전 값)
  • %rip = (이전 함수 코드 주소)

6. 레지스터 체계 완전 분석

레지스터 크기별 이름

┌───────────┬──────────────────────┬─────────────┐
│ 비트 크기 │ 레지스터 이름        │ 설명        │
├───────────┼──────────────────────┼─────────────┤
│ 64bit     │ %rax, %rbx, %rdi...  │ 전체 64비트 │
│ 32bit     │ %eax, %ebx, %edi...  │ 하위 32비트 │
│ 16bit     │ %ax, %bx, %di...     │ 하위 16비트 │
│ 8bit      │ %al, %bl, %dil...    │ 하위 8비트  │
└───────────┴──────────────────────┴─────────────┘

비트 구조 (%rax 예시):

63                    32 31              16 15     8 7      0
├─────────────────────┼─────────────────┼────────┼────────┤
│    상위 32비트       │      %eax       │  %ah   │  %al   │
└─────────────────────┴─────────────────┴────────┴────────┘
                      └─────────────────────────────────────┘
                                %rax (64bit)

왜 %rdi 대신 %edi를 쓸까?

이유 1: 데이터 타입 크기

int argc      → 32비트 → %edi
char *argv[]  → 64비트 → %rsi

이유 2: 자동 제로 확장 (Zero Extension)

32비트 레지스터(목적지)에 값을 쓰면, 상위 32비트가 자동으로 0

movl $3, %edi

실행 전:

%rdi = 0x12345678ABCDEF00

실행 후:

%rdi = 0x0000000000000003
       ↑↑↑↑↑↑↑↑ 자동으로 0

⚠️ 주의: 목적지가 메모리일 때는 해당 크기만큼만 씁니다.

장점:

  • 별도로 상위 비트 클리어 불필요
  • C 언어 타입과 자연스러운 대응

레지스터 역할 요약

┌──────────┬────────────────────────┬───────────┐
│ 레지스터 │ 의미                   │ 예시      │
├──────────┼────────────────────────┼───────────┤
│ %rip     │ Instruction Pointer    │ 0x10      │
│ %rsp     │ Stack Pointer (변동)   │ push/pop  │
│ %rbp     │ Base Pointer (고정)    │ 기준점    │
├──────────┼────────────────────────┼───────────┤
│ %rdi/%rsi│ 함수 인자 (1~6번째)    │ argc,     │
│ %rdx/%rcx│ System V 규약          │ argv      │
│ %r8/%r9  │                        │           │
├──────────┼────────────────────────┼───────────┤
│ %rax     │ 함수 리턴값 (64bit)    │ return    │
│ %eax     │ 함수 리턴값 (32bit)    │ return 0  │
└──────────┴────────────────────────┴───────────┘

7. Caller-saved vs Callee-saved

문제 상황

int foo(int x) {
    int y = bar(x);
    return y + 1;
}

질문: bar()가 %rdi를 덮어쓰면 foo()의 x는?

Caller-saved: "내가 저장하고 복원할게"

호출자가 책임지는 레지스터:

%rax, %rcx, %rdx, %rsi, %rdi, %r8-r11

foo()의 책임:

pushq %rdi          # x 보존 (백업)
call bar            # bar() 호출
popq %rdi           # x 복원

구조도:

call bar 전:
Stack               Registers
┌──────┐            %rdi: x (foo의 값)
│      │
└──────┘

pushq %rdi:
Stack               Registers
┌──────┐            
│  x   │ ← push    %rdi: x
└──────┘

bar() 실행:
Stack               Registers
┌──────┐            
│  x   │            %rdi: (bar가 자유롭게 사용)
└──────┘

popq %rdi:
Stack               Registers
┌──────┐            
│      │            %rdi: x (복원!)
└──────┘

Callee-saved: "내가 쓰더라도 되돌려줄게"

피호출자가 책임지는 레지스터:

%rbx, %rbp, %r12-r15

bar()의 책임:

bar:
    pushq %rbx      # 저장 (프롤로그)
    # ... %rbx 사용
    popq %rbx       # 복원 (에필로그)
    ret

비유로 이해하기

┌─────────────────┬────────────────────┬──────────────┐
│ 구분            │ 역할               │ 비유         │
├─────────────────┼────────────────────┼──────────────┤
│ Caller-saved    │ "내 물건은 내가    │ 내 가방 챙김 │
│                 │  챙긴다"           │              │
├─────────────────┼────────────────────┼──────────────┤
│ Callee-saved    │ "남의 물건은 내가  │ 빌린 건      │
│                 │  책임진다"         │ 돌려줌       │
└─────────────────┴────────────────────┴──────────────┘

전체 정리표

┌────────────────┬──────────────────────────────┬────────────┐
│ 구분           │ 레지스터                     │ 누가 보존? │
├────────────────┼──────────────────────────────┼────────────┤
│ Caller-saved   │ %rax, %rcx, %rdx,            │ 호출자     │
│                │ %rsi, %rdi, %r8-r11          │            │
├────────────────┼──────────────────────────────┼────────────┤
│ Callee-saved   │ %rbx, %rbp, %r12-r15         │ 피호출자   │
└────────────────┴──────────────────────────────┴────────────┘

용도별:
┌────────────────┬──────────────────────────────┐
│ Caller-saved   │ 임시 계산, 인자 전달         │
├────────────────┼──────────────────────────────┤
│ Callee-saved   │ 지역 변수, 루프 상수 등      │
│                │ 장기 유지 값                 │
└────────────────┴──────────────────────────────┘

8. 어셈블리 명령어 정리

데이터 이동 명령어

movq - Move Quadword (64비트)

movq %rsp, %rbp     # %rsp의 값을 %rbp로 복사
movq %rsi, -16(%rbp) # %rsi를 메모리 [%rbp-16]에 저장

movl - Move Long (32비트)

movl $0x0, %eax           # 상수 0을 %eax에 저장
movl %edi, -4(%rbp)       # %edi를 메모리 [%rbp-4]에 저장
movl -20(%rbp), %edi      # 메모리 [%rbp-20]을 %edi로 로드

스택 명령어

pushq - Push Quadword

동작:
1. %rsp -= 8
2. [%rsp] = source
실행 전:              실행 후:
├──────┤ ← %rsp      ├──────┤
│      │              │ %rbp │ ← push된 값
                      ├──────┤ ← %rsp

popq - Pop Quadword

동작:
1. destination = [%rsp]
2. %rsp += 8
실행 전:              실행 후:
├──────┤              ├──────┤ ← %rsp
│ 0x500│ ← 저장된 값  │ 0x500│
├──────┤ ← %rsp      ├──────┤

산술 명령어

addl - Add Long (32비트)

addl $0x3, %edi     # %edi = %edi + 3
addl %eax, %ebx     # %ebx = %ebx + %eax

제어 흐름 명령어

ret - Return

동작:
1. %rip = [%rsp]    (리턴 주소 꺼냄)
2. %rsp += 8        (스택 포인터 증가)
3. 해당 주소로 점프
실행 전:                    실행 후:
├──────────┤                ├──────────┤ ← %rsp
│ 리턴 주소 │ ← [%rsp]      │ 리턴 주소 │
├──────────┤ ← %rsp        ├──────────┤

%rip = 리턴 주소 (점프!)

명령어 크기 접미사

┌─────────┬─────────┬──────────┐
│ 접미사  │ 크기    │ 예시     │
├─────────┼─────────┼──────────┤
│ b       │ 8비트   │ movb     │
│ w       │ 16비트  │ movw     │
│ l       │ 32비트  │ movl     │
│ q       │ 64비트  │ movq     │
└─────────┴─────────┴──────────┘

9. 심화 주제

1. 스택 정렬 (Stack Alignment)

System V x86-64 ABI 규약:

call 직후 진입한 함수에서:
%rsp ≡ 8 (mod 16)

이유:

  • SSE 명령어 최적화 (16바이트 정렬 필요)
  • ABI 규약 준수

구현:

# 다른 함수를 호출하기 전에 16바이트 정렬
sub $8, %rsp    # 또는 sub $24, %rsp (16의 배수)
call function
add $8, %rsp

Leaf 함수(다른 함수를 호출하지 않는 함수)는 정렬 조정 생략 가능

2. 프레임 포인터 생략

-fomit-frame-pointer 최적화 옵션:

차이점:
- %rbp를 범용 레지스터로 사용
- %rsp 기준으로만 지역 변수 접근
- 프롤로그/에필로그 단순화

장점:

  • 레지스터 하나 더 사용 가능
  • 코드 크기 감소

단점:

  • 디버깅 어려움 (스택 추적 불가)
  • 프로파일링(perf, frame pointer unwinding) 제약
  • 특히 디버깅/프로파일링 목적이라면 프레임 포인터 유지 권장

3. Red Zone (레드존)

System V x86-64 전용 (⚠️ Windows x64에는 없음)

정의:
- %rsp 아래 128바이트 영역
- Leaf 함수가 임시 사용 가능
- push/pop 없이 사용 가능

조건:

✅ call을 하지 않는 함수 (leaf function)
✅ 일반 사용자 모드 (비동기 시그널/인터럽트 주의)
❌ 커널 모드
❌ 시그널 핸들러

예시:

addl %edi, -8(%rsp)  # Red Zone 사용
ret

⚠️ 주의: 비동기 시그널/인터럽트가 red zone을 덮어쓸 수 있으므로, 커널이나 시그널-헤비 코드에서는 기대하지 말 것


10. Windows x64 차이점

System V와 Windows x64 ABI 비교:

┌──────────────────┬─────────────────────┬──────────────────┐
│ 항목             │ System V (Linux)    │ Windows x64      │
├──────────────────┼─────────────────────┼──────────────────┤
│ 인자 전달        │ RDI, RSI, RDX,      │ RCX, RDX, R8, R9 │
│ (1~4번째)        │ RCX, R8, R9         │ (+스택)          │
├──────────────────┼─────────────────────┼──────────────────┤
│ Callee-saved     │ RBX, RBP, R12-R15   │ RBX, RBP, RDI,   │
│                  │                     │ RSI, R12-R15     │
├──────────────────┼─────────────────────┼──────────────────┤
│ Red Zone         │ 128바이트 있음      │ 없음             │
├──────────────────┼─────────────────────┼──────────────────┤
│ Shadow Space     │ 없음                │ 32바이트 필요    │
└──────────────────┴─────────────────────┴──────────────────┘

주요 차이점:

  1. 인자 순서: Windows는 RCX가 첫 번째
  2. RSI/RDI: Windows에서는 callee-saved
  3. Red Zone: Windows에는 없음
  4. Shadow Space: Windows는 호출자가 32바이트 예약 필요

📌 최종 정리

핵심 패턴

# Prologue
pushq %rbp              # 1. 이전 기준점 저장
movq %rsp, %rbp         # 2. 새 기준점 설정

# Body
movl %edi, -4(%rbp)     # 3. 인자 저장 (32bit)
movl -20(%rbp), %edi    # 4. 지역변수 로드
addl $3, %edi           # 5. 레지스터 연산
movl %edi, -20(%rbp)    # 6. 결과 저장

# Epilogue
popq %rbp               # 7. 기준점 복원
ret                     # 8. 복귀

원칙

1. 지역 변수는 항상 %rbp - N 형태 (음수 오프셋)
2. 모든 계산은 레지스터에서 수행
3. 메모리 접근은 로드/스토어 시에만
4. 스택은 높은 주소 → 낮은 주소로 성장

call/ret 주소 흐름

call function:
  1. push %rip (다음 명령 주소)
  2. jmp function

ret:
  1. %rip = [%rsp] (리턴 주소 로드)
  2. %rsp += 8
  3. 복귀

참고 자료

  • System V AMD64 ABI Specification
  • Intel® 64 and IA-32 Architectures Software Developer's Manual


버전: 2.0 (스택 정렬, Red Zone, Windows 비교 추가)

💡 Tip: 실제 어셈블리 코드는 최적화 레벨(-O0, -O2, -O3)에 따라 크게 달라집니다. 학습할 때는 -O0 옵션으로 컴파일하는 것을 권장합니다!