← SEARCHABLE ARCHIVE
Assembly · 2026-08-15

x86-64 함수 호출과 ABI 실전

System V와 Windows x64의 인자, 보존 레지스터, 스택 정렬과 언와인드

INTERACTIVE READING MODE →

x86-64 함수 호출과 ABI 실전

본 블로그 작성 글들은 강의를 목표로 제작되었습니다. 이 글은 일반적인 System V AMD64와 Microsoft x64 ABI를 비교합니다. 운영체제·도구 체인 확장, 커널·시스템 콜, vectorcall 같은 별도 규약은 대상 문서를 추가로 확인해주세요.

CPU의 call 명령은 복귀 주소를 저장하고 다른 주소로 이동할 뿐이다. “첫 번째 인자는 어느 레지스터인가”, “누가 rbx를 보존하는가”, “스택을 몇 바이트 정렬하는가”는 CPU 명령 자체가 아니라 ABI(Application Binary Interface)가 정한다.

같은 x86-64 기계에서도 Linux의 일반 C 함수와 Windows의 일반 C 함수는 다른 인자 레지스터와 스택 규칙을 사용한다. 이 계약을 어기면 단순한 함수에서도 입력이 바뀌고, 최적화·예외·디버깅 때만 재현되는 손상이 생긴다.

ISA, 호출 규약, ABI를 구분한다

  • ISA: mov, call, ret 같은 명령과 레지스터의 하드웨어 의미
  • calling convention: 인자, 반환값, 보존 레지스터, 스택 책임
  • ABI: calling convention을 포함해 타입 레이아웃, object format, symbol, unwind 등 binary 경계 전반

extern "C"는 C++ name mangling과 언어 연결을 제어하지만 그 자체가 Windows 코드를 System V 규약으로 바꾸지는 않는다. 대상 플랫폼 ABI가 별도로 적용된다.

가장 자주 쓰는 두 규약 비교

일반적인 정수·포인터 인자 기준이다.

항목System V AMD64Microsoft x64
정수 인자 1~6rdi, rsi, rdx, rcx, r8, r9위치 1~4에 rcx, rdx, r8, r9
FP 인자xmm0~xmm7 중심위치 1~4에 xmm0~xmm3
정수 반환raxrax
FP 반환xmm0xmm0
호출자 예약 공간별도 shadow space 없음32바이트 shadow space
red zone사용자 공간 leaf에 128바이트없음
주요 비휘발 GPRrbx, rbp, r12~r15, rsprbx, rbp, rdi, rsi, r12~r15, rsp
비휘발 XMM없음xmm6~xmm15의 하위 128비트

이 표만으로 aggregate, vector, variadic, 반환용 hidden pointer의 모든 경우를 판단할 수는 없다. 구조체는 크기와 구성에 따라 여러 class로 분류되거나 메모리로 전달될 수 있다.

caller-saved와 callee-saved

volatile 또는 caller-saved 레지스터는 호출 뒤 값이 바뀌어도 된다. 호출자가 필요하면 호출 전에 보존한다. nonvolatile 또는 callee-saved 레지스터는 피호출자가 사용했다면 원래 값을 복원해야 한다.

; 개념 예시
push rbx          ; 원래 값 보존
mov rbx, rcx
; ... rbx 사용 ...
pop rbx           ; 호출자에게 돌아가기 전 복원
ret

보존은 “항상 push해야 한다”는 뜻이 아니다. 레지스터를 사용하지 않으면 저장할 필요가 없고, stack slot에 mov로 저장할 수도 있다. 핵심은 함수 경계를 지나 관찰되는 값이다.

System V AMD64에서 단순 호출 읽기

long combine(long a, long b, long c);
long answer = combine(10, 20, 30);

가능한 호출 코드는 다음과 같다.

mov edi, 10
mov esi, 20
mov edx, 30
call combine
; rax = 반환값

작은 정수 상수에 32비트 register write를 쓰면 상위 비트가 0이 된다. long의 크기는 ABI에 따라 다르다. LP64인 일반 System V 환경에서는 64비트지만 Windows LLP64에서는 long이 32비트다. “x64니까 C의 long도 64비트”라고 가정하면 안 된다.

Windows x64에서 같은 호출 읽기

sub rsp, 40
mov ecx, 10
mov edx, 20
mov r8d, 30
call combine
add rsp, 40

왜 40바이트일까?

  • 32바이트는 호출자가 준비하는 shadow space다.
  • 함수 진입 시 call이 넣은 8바이트 복귀 주소 때문에 rsp 정렬 상태가 바뀐다.
  • 40바이트를 빼면 다음 call 경계의 16바이트 정렬과 shadow space를 함께 만족하는 대표 패턴이 된다.

shadow space는 인자가 네 개보다 적어도 호출자가 예약한다. 피호출자는 필요하면 register parameter의 home address로 사용할 수 있지만 반드시 인자를 그곳에 저장하는 것은 아니다.

스택 정렬은 호출 지점의 계약이다

System V에서는 call 직전 정렬과 복귀 주소 push를 고려해 함수 진입 상태를 해석한다. Microsoft x64에서는 프롤로그와 에필로그 외 일반 코드 영역에서 rsp를 16바이트 정렬 상태로 유지해야 한다. leaf 함수의 제한된 예외가 있다.

정렬을 추적할 때 “항상 rsp % 16 == 0” 한 문장만 외우지 않는다. 다음 네 시점을 나눈다.

  1. 호출자의 call 직전
  2. 복귀 주소가 push된 피호출자 진입 직후
  3. 피호출자 프롤로그 이후
  4. 피호출자가 다시 call하기 직전

한 지점을 다른 지점의 규칙으로 검사하면 8바이트 차이로 혼란이 생긴다.

System V의 red zone

System V AMD64 사용자 공간에서는 rsp 아래 128바이트 영역을 signal/interrupt handler가 훼손하지 않는다고 규정한다. leaf 함수는 rsp를 움직이지 않고 작은 임시 공간으로 사용할 수 있다.

; System V 사용자 공간의 개념 예시
mov qword ptr [rsp - 8], rax
; call을 하지 않는 동안 제한적으로 사용

다른 함수를 호출하면 그 함수의 스택 사용과 겹칠 수 있다. 커널 코드, Windows x64, red zone을 끈 컴파일 옵션에는 같은 가정을 적용하지 않는다.

Windows x64의 unwind 정보

Windows x64의 non-leaf 함수가 stack pointer나 비휘발 레지스터를 변경하면 예외 처리와 stack walk가 상태를 복원할 수 있도록 unwind metadata가 필요하다. MASM에서는 실제 프롤로그와 frame directive를 맞춘다.

Work PROC FRAME
    push rbx
    .pushreg rbx
    sub rsp, 32
    .allocstack 32
    .endprolog

    ; 함수 본문

    add rsp, 32
    pop rbx
    ret
Work ENDP

.pushreg, .allocstack, .endprolog는 위의 push, sub를 대신 실행하지 않는다. unwind 정보를 만든다. 실행 명령과 지시어가 불일치하면 정상 경로의 ret은 우연히 성공해도 예외 처리, profiler, debugger stack walk가 깨질 수 있다.

다섯 번째 이후 인자

Windows x64에서 첫 네 위치를 넘는 인자는 stack으로 전달된다. 피호출자 진입 시 현재 rsp에는 복귀 주소가 있고 그 위에 32바이트 home area가 있으므로 다섯 번째 인자의 위치를 계산할 때 이 둘을 포함해야 한다.

프롤로그에서 rsp를 더 줄였다면 offset도 그만큼 달라진다. [rsp+40]이라는 숫자를 무조건 다섯 번째 인자로 외우지 말고 함수 진입 기준인지 프롤로그 이후 기준인지 확인한다.

System V도 register class에 들어가지 않거나 레지스터 수를 넘는 인자를 stack으로 전달한다. aggregate 분류는 단순히 “7번째부터”라는 규칙보다 복잡하다.

부동소수점 인자는 위치와 타입을 함께 본다

System V AMD64의 일반 함수는 scalar FP 인자를 xmm0~xmm7에 전달한다. Windows x64에서는 첫 네 인자 위치가 정수 레지스터와 XMM 레지스터 슬롯을 공유하는 관점이다.

void f(int a, double b, int c, float d);

Windows x64에서는 위치별로 a -> rcx, b -> xmm1, c -> r8, d -> xmm3 형태가 된다. “첫 번째 실수니까 무조건 xmm0”이라고 세면 틀린다.

variadic 함수는 규칙이 추가된다

printf처럼 ...를 받는 함수는 고정 함수보다 전달 정보가 더 필요하다.

  • System V AMD64에서는 vector register로 전달한 가변 인자의 수와 관련된 정보를 al에 둔다.
  • Microsoft x64에서는 가변 인자 FP 값을 해당 위치의 integer register에도 복제하는 규칙이 있다.
  • C의 default argument promotions로 float는 double로, 작은 정수는 int 계열로 승격된다.

호출 규약을 틀리면 함수 원형은 맞아 보여도 가변 인자 함수가 다른 register save area를 읽는다. 형식 문자열과 실제 승격 타입도 반드시 일치해야 한다.

구조체 인자는 표 하나로 끝나지 않는다

struct Pair { double x, y; };
struct Mixed { double x; int y; };

System V는 값을 eightbyte 단위 class로 분류해 가능한 경우 정수·SSE 레지스터에 나눠 전달한다. Windows x64의 기본 규약은 특정 크기의 aggregate를 값처럼 전달하고 그 밖에는 caller가 할당한 임시 객체를 가리키는 포인터를 전달하는 경우가 있다.

따라서 C++ 구조체 경계를 손으로 ASM에 맞출 때는 다음을 권장한다.

  1. 가능한 경우 단순 고정 폭 scalar 인자로 경계를 만든다.
  2. extern "C" free function으로 symbol을 단순화한다.
  3. sizeof, alignof, offsetof를 static_assert한다.
  4. 대상 compiler가 만든 호출부와 피호출부 disassembly를 양쪽 모두 본다.
  5. Debug와 Release, LTCG/LTO 설정에서 테스트한다.

숨은 반환 포인터

레지스터로 반환할 수 없는 큰 구조체는 호출자가 반환 공간을 준비하고 숨은 포인터를 인자로 전달할 수 있다. 이 포인터가 첫 번째 명시 인자보다 앞에 들어오면 뒤의 인자 레지스터가 한 칸씩 달라진 것처럼 보인다.

Large make_large(int seed);

소스에는 인자가 하나지만 어셈블리에는 반환 버퍼 주소와 seed가 함께 보일 수 있다. constructor, member function의 this, aggregate return을 동시에 다룰 때는 compiler 출력으로 실제 ABI lowering을 확인한다.

tail call은 현재 프레임을 재사용한다

함수의 마지막 작업이 다른 함수 호출 결과를 그대로 반환하는 것이라면 compiler가 call 후 ret 대신 jmp를 사용할 수 있다.

wrapper:
    ; 인자를 목표 ABI 위치에 준비
    jmp target

이때 새 복귀 주소를 push하지 않는다. target이 끝나면 원래 wrapper의 호출자로 직접 돌아간다. tail call이 가능하려면 호출 규약, stack 정리, 반환 형식 등의 조건이 맞아야 한다.

ASM에서 C++ 함수를 호출하는 Windows 예제

EXTERN AddBias:PROC
PUBLIC AsmTwiceThenBias

AsmTwiceThenBias PROC FRAME
    sub rsp, 40
    .allocstack 40
    .endprolog

    add ecx, ecx        ; 첫 번째 int 인자 value * 2
    ; edx에는 두 번째 int 인자 bias가 이미 있다고 가정
    call AddBias

    add rsp, 40
    ret
AsmTwiceThenBias ENDP

C++ 선언은 단순하게 둔다.

extern "C" int AddBias(int value, int bias);
extern "C" int AsmTwiceThenBias(int value, int bias);

이 예제는 ecx, edx가 volatile이고 호출 전까지 값이 유효하다는 계약을 사용한다. 실제 프로젝트에서는 null, 범위, overflow 계약과 unwind 검증을 테스트에 추가한다.

호출 경계 디버깅 순서

함수 호출이 의심되면 다음 순서로 좁힌다.

  1. caller와 callee가 같은 아키텍처와 ABI로 빌드되었는가?
  2. symbol 철자와 C/C++ language linkage가 맞는가?
  3. 호출 직전 각 인자 위치의 비트와 타입이 맞는가?
  4. stack 인자 offset은 어느 시점의 rsp 기준인가?
  5. call 직전 스택 정렬과 Windows shadow space가 맞는가?
  6. callee가 사용한 모든 nonvolatile register를 복원하는가?
  7. Windows frame directive가 실제 프롤로그와 일치하는가?
  8. 반환값을 올바른 폭의 rax 또는 xmm0에서 읽는가?

자주 하는 실수

  • x86-64 호출 규약이 운영체제와 무관하게 하나라고 생각한다.
  • Windows에서 shadow space를 피호출자가 예약한다고 생각한다.
  • System V red zone을 Windows나 커널 코드에 사용한다.
  • stack 정렬을 함수 진입과 call 직전 중 어느 시점인지 구분하지 않는다.
  • volatile register의 값이 다른 함수 호출 뒤에도 남는다고 기대한다.
  • 비휘발 register를 저장했지만 일부 반환 경로에서 복원하지 않는다.
  • extern "C"가 구조체 레이아웃과 예외 경계까지 단순화한다고 생각한다.
  • 정상 반환 테스트만 통과했다고 unwind metadata도 맞다고 결론 낸다.

검증 실습

정수 6개, double 4개, 작은 구조체 2개를 받는 C 함수를 만든다. GCC/Clang의 System V 빌드와 MSVC x64 빌드에서 호출부를 비교한다.

다음 산출물을 남긴다.

  • 인자별 register 또는 stack 위치 표
  • 함수 진입부터 첫 nested call까지의 rsp 추적
  • volatile/nonvolatile register 목록과 실제 save/restore
  • 큰 구조체 반환 시 hidden pointer 여부
  • variadic 호출 하나의 승격과 추가 ABI 규칙

정리

call과 ret만으로는 언어 함수가 완성되지 않는다. ABI가 인자와 반환값의 위치, 누가 어떤 레지스터를 보존하는지, 호출 지점의 스택 정렬, 구조체와 가변 인자의 전달, unwind 방법을 정한다.

System V AMD64는 정수 인자에 rdi부터 여섯 레지스터를 사용하고 red zone을 제공한다. Microsoft x64는 위치 기반 네 register slot, 32바이트 shadow space, 비휘발 XMM과 엄격한 unwind 규칙을 사용한다. 손으로 ASM을 연결할 때는 표를 외우는 데서 끝내지 말고 실제 caller·callee disassembly, 경계값 테스트, stack walk로 계약을 증명해야 한다.

공식 참고 자료