본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

호출 규약과 스택 프레임

작성 2026. 10. 14.추가
목차6개 항목
출처와 참고

팔만코딩경(42Seoul 커뮤니티 위키)의 「[C] 가변인자와 호출규약」, 「[C] 스택의 방향을 판단하는 코드 작성하기」, 「[C] 재귀함수와 꼬리 재귀함수」, 「심플한 어셈블리 분석하기」, 「컴파일만하면 다 해주는게 아니다.?! (어셈블러 찍먹)」을 바탕으로 재구성한 노트다.

스택 프레임

함수가 호출될 때마다 스택에 그 호출만의 공간인 스택 프레임(stack frame)이 생긴다. 반환 주소, 이전 프레임의 기준 주소, 지역 변수, 레지스터에 다 못 실은 인자가 여기 들어간다. 호출마다 프레임이 따로 생기므로 재귀 함수도 서로 간섭하지 않는다(재진입성).

flowchart TB
  hi["높은 주소"] ~~~ f1["호출한 쪽의 프레임<br/>(스택으로 넘긴 인자)"] ~~~ f2["반환 주소<br/>← call 명령이 push"] ~~~ f3["이전 rbp<br/>← 프롤로그가 push, rbp가 여기를 가리킴"] ~~~ f4["지역 변수"] ~~~ f5["...<br/>← rsp (스택의 꼭대기)"] ~~~ lo["낮은 주소<br/>(x86-64, ARM64에서는 이쪽으로 자란다)"]

컴파일러는 함수 앞뒤에 프레임을 만들고 치우는 코드를 넣는다.

push rbp          ; 프롤로그: 이전 기준 주소 저장
mov  rbp, rsp     ;           새 기준 주소 설정
sub  rsp, 0x20    ;           지역 변수 공간 확보
...
leave             ; 에필로그: rsp = rbp; pop rbp
ret               ;           반환 주소로 점프

어셈블리를 읽을 때 프롤로그·에필로그를 먼저 떼어 내면 본문이 보인다. 지역 변수는 [rbp - 오프셋], 스택으로 넘어온 인자는 [rbp + 오프셋]으로 접근한다.

스택은 어느 방향으로 자라나

x86-64와 ARM64에서 스택은 높은 주소에서 낮은 주소로 자란다. 하지만 C 표준은 스택이라는 개념조차 규정하지 않으므로, 방향을 확인하는 C 코드는 본질적으로 플랫폼과 컴파일러에 의존한다. 확인 방법마다 숨은 가정이 있다.

방법숨은 가정깨지는 경우
한 함수 안의 지역 변수 두 개 주소 비교선언 순서대로 스택에 놓인다배치 순서는 컴파일러 마음이다. 실제로 반대로 놓이기도 한다
다른 함수를 불러 그 지역 변수 주소와 비교호출마다 새 프레임이 쌓인다-O1 이상에서 인라인되면 프레임이 생기지 않는다
인라인 어셈블리로 push 전후의 rsp 비교아키텍처에 push가 있다최적화와 무관하게 정확하지만 더는 이식 가능한 C가 아니다

서로 다른 객체의 주소를 <로 비교하는 것 자체가 C 표준에서는 미정의 동작이다(같은 배열 안의 원소끼리만 정의된다). 이 문제는 답보다 "어떤 가정이 깨질 수 있는가"를 설명하는 과정이 핵심이다.

호출 규약

호출 규약(calling convention)은 함수를 부르는 쪽(caller)과 불리는 쪽(callee)이 지키는 약속이다. 인자를 어디에 어떤 순서로 둘지, 반환값을 어디에 둘지, 누가 스택을 정리할지, 어떤 레지스터를 보존해야 하는지를 정한다. 다른 컴파일러로 만든 라이브러리끼리 링크될 수 있는 것은 같은 규약(ABI)을 따르기 때문이다.

규약쓰이는 곳인자 전달스택 정리
cdecl32비트 x86의 C 기본모두 스택, 오른쪽 인자부터 push호출한 쪽
stdcall32비트 Win32 API스택, 오른쪽부터불린 쪽 (가변 인자 불가)
System V AMD6464비트 리눅스·macOS·BSD정수·포인터 6개는 rdi rsi rdx rcx r8 r9, 실수 8개는 xmm0~7, 나머지는 스택호출한 쪽
Microsoft x6464비트 윈도rcx rdx r8 r9 4개 + 스택, 호출 전 32바이트 그림자 공간호출한 쪽
AAPCS64ARM64(Apple Silicon 포함)x0~x7, 실수는 v0~v7호출한 쪽

반환값은 x86에서 eax/rax, ARM64에서 x0에 담긴다. cdecl의 예를 보면 구조가 단순하다.

; int res = add(1, 3);  (32비트 cdecl)
push 3              ; 마지막 인자부터
push 1
call add            ; 반환 주소를 push하고 점프
add  esp, 8         ; 호출한 쪽이 인자 8바이트를 정리

호출한 쪽이 스택을 정리한다는 점이 중요하다. 인자가 몇 개인지는 호출한 쪽만 알기 때문에, 이 방식이어야 가변 인자 함수를 만들 수 있다.

가변 인자 함수

printf(const char *fmt, ...)처럼 인자 개수가 정해지지 않은 함수는 <stdarg.h>로 인자를 꺼낸다.

#include <stdarg.h>
 
int sum(int count, ...)
{
    va_list ap;
    int     total = 0;
 
    va_start(ap, count);              /* 마지막 고정 인자 다음부터 */
    for (int i = 0; i < count; i++)
        total += va_arg(ap, int);     /* 타입을 알려 주고 하나씩 꺼냄 */
    va_end(ap);
    return total;
}

불린 쪽은 인자가 몇 개인지도, 각각 무슨 타입인지도 모른다. 개수는 count나 서식 문자열, 끝 표시(NULL) 같은 약속으로 알아내고, 타입이 맞는지는 전적으로 호출한 쪽 책임이다. 틀리면 미정의 동작이다.

  • cdecl에서 va_list는 사실상 스택을 가리키는 포인터다. va_start는 고정 인자 바로 다음 칸을, va_arg는 현재 칸의 값을 읽고 워드 단위로 반올림한 크기만큼 다음 칸으로 옮긴다.
  • System V AMD64에서는 인자가 레지스터와 스택에 나뉘어 있어서, va_list가 "레지스터 저장 영역에서 몇 번째까지 읽었는지"와 "스택의 어디까지 읽었는지"를 함께 기록하는 구조체다. 그래서 va_list를 대입으로 복사하지 말고 va_copy를 쓴다.
  • ... 부분에는 기본 인자 승격이 적용된다. char·short는 int로, float는 double로 넘어오므로 va_arg(ap, char)나 va_arg(ap, float)는 쓰면 안 된다. 승격 규칙의 유래는 C의 역사와 표준에 있다.

64비트 환경에서는 인자 하나가 8바이트 칸(레지스터나 스택 슬롯)을 차지하므로, int와 int *를 섞어 넘겨도 칸이 밀리지 않는다. 다만 va_arg로 꺼낼 때 넘긴 타입과 같은 타입을 지정해야 값이 맞다.

재귀와 꼬리 호출

재귀 호출은 깊이만큼 프레임을 쌓는다. 스택 크기(리눅스 메인 스레드 기본 8MB)를 넘으면 스택 오버플로로 죽는다.

꼬리 호출(tail call)은 함수의 마지막 동작이 다른 함수 호출이고 그 결과를 그대로 반환하는 형태다. 이때는 현재 프레임이 더 필요 없으므로, 컴파일러가 call 대신 jmp로 바꿔 프레임을 재사용할 수 있다(꼬리 호출 최적화, TCO).

int fact(int n)           { return n <= 1 ? 1 : n * fact(n - 1); }   /* 곱셈이 남아 꼬리 호출 아님 */
int fact_acc(int n, int a) { return n <= 1 ? a : fact_acc(n - 1, a * n); } /* 꼬리 호출 */

C 표준은 TCO를 보장하지 않는다. GCC·Clang은 -O2에서 대부분 해 주지만 -O0이나 디버그 빌드에서는 하지 않는다. Clang은 __attribute__((musttail))로 강제할 수 있다. 깊이가 클 수 있는 재귀는 반복문으로 바꾸는 것이 안전하다. 재귀 자체의 설계는 재귀에 있다.

정리

  • 호출마다 스택 프레임이 생기고, 프롤로그·에필로그가 만들고 치운다.
  • 스택 방향, 변수 배치, 인라인 여부는 모두 구현 의존이다. C 표준은 스택을 말하지 않는다.
  • 호출 규약은 인자 위치·반환 위치·스택 정리 주체를 정한다. 64비트에서는 인자가 레지스터로 간다.
  • 가변 인자는 호출한 쪽이 개수와 타입을 책임지고, 기본 인자 승격이 적용된다.
  • 꼬리 호출은 최적화될 수 있지만 보장되지 않는다.

관련 노트: CPU 명령어, C 디버깅 도구, 포인터와 메모리, 재귀

원본 기록6

댓글

GitHub 계정으로 댓글을 남길 수 있어요.