[CSAPP] 함수 호출의 정석: 스택 프레임과 레지스터 사용 규칙 분석

1. 문제 제기 (Introduction & Problem Statement)

  • 관찰 현상 또는 질문:
    C언어 등 고급 언어에서 함수 호출은 프로그램의 핵심적인 추상화 도구입니다. func(a, b)처럼 간단한 한 줄의 코드는 어떻게 기계어 수준에서 구현될까요? 이 과정에는 인자 전달, 반환 값 수신, 실행 제어권 이전, 그리고 함수 내 지역 변수를 위한 메모리 관리 등 복잡한 작업들이 포함됩니다. "CPU는 이 모든 과정을 어떤 메커니즘을 통해 정확하고 효율적으로 처리하는가?"라는 질문에서 탐구를 시작합니다.

  • 탐구 목표:
    본 아티클은 x86-64 아키텍처에서 함수 호출이 이루어지는 구체적인 원리를 분석하는 것을 목표로 합니다. 이를 위해 프로그램 실행 시 메모리에 생성되는 런타임 스택(Runtime Stack) 과 그 안에 구성되는 스택 프레임(Stack Frame) 의 구조를 살펴볼 것입니다 . 또한, 인자 전달과 값 반환을 위해 스택과 레지스터를 어떻게 함께 사용하는지, 그리고 여러 함수가 레지스터를 공유하면서도 데이터가 엉키지 않도록 하는 호출 규약(Calling Convention) 에 대해 심도 있게 분석하고자 합니다.


2. 기술 분석 및 핵심 원리 (Technical Deep Dive)

2-1. (소주제 1: 런타임 스택과 스택 프레임)

프로그램이 실행되는 동안(런타임) 함수들의 호출 관계를 관리하기 위해 메모리의 특정 영역을 런타임 스택으로 사용합니다 . 이 스택은 후입선출(LIFO) 자료구조로 동작하며, rsp 레지스터가 항상 스택의 최상단(가장 낮은 주소)을 가리킵니다.

  • 스택 프레임(Stack Frame):
    함수가 호출될 때마다 해당 함수만을 위한 독립적인 메모리 공간인 '스택 프레임'이 런타임 스택에 생성(할당)됩니다 함수가 실행을 마치고 반환되면 이 스택 프레임은 스택에서 제거(해제)됩니다 각 스택 프레임은 다음과 같은 정보를 포함합니다
    • 반환 주소: 함수 실행이 끝난 후 돌아가야 할 호출자(caller) 코드의 위치입니다.
    • 저장된 레지스터: 호출된 함수(callee)가 값을 덮어쓰기 전에, 호출자(caller)의 상태를 보존하기 위해 백업해 둔 레지스터 값들입니다.
    • 지역 변수: 레지스터에 저장하기에는 크기가 큰 배열이나 구조체와 같은 지역 변수들이 저장됩니다.
    • 함수 인자: 레지스터만으로 전달하기에 개수가 너무 많은(7개 이상) 함수 인자들이 저장됩니다

call 명령어가 실행되면 반환 주소를 스택에 푸시하고 해당 함수로 점프하며, ret 명령어가 실행되면 스택에서 반환 주소를 팝하여 원래 위치로 복귀합니다 .

2-2. (소주제 2: 제어권 이전과 데이터 전달)

x86-64 아키텍처는 함수 호출 시 스택뿐만 아니라 레지스터를 적극적으로 활용하여 성능을 최적화합니다. 주 메모리에 위치한 스택보다 CPU 내부에 있는 레지스터가 훨씬 빠르기 때문입니다.

  • 데이터 전달 규칙:

    • 인자 전달: 최대 6개의 정수 또는 포인터 인자는 약속된 레지스터들을 통해 순서대로 전달됩니다: %rdi, %rsi, %rdx, %rcx, %r8, %r9 . 7개 이상의 인자가 필요할 경우, 7번째 인자부터는 스택을 통해 전달됩니다
    • 반환 값 전달: 함수가 계산한 결과(반환 값)는 관례적으로 %rax 레지스터에 저장되어 호출자에게 전달됩니다.
  • 제어권 이전:
    call 명령어는 단순히 함수 코드로 점프하는 것을 넘어, 다음에 실행할 명령어의 주소(반환 주소)를 스택에 자동으로 저장합니다. 그 후 프로그램 카운터(PC) 레지스터의 값을 호출될 함수의 시작 주소로 변경하여 제어권을 넘깁니다. 반대로 ret 명령어는 스택에 저장된 반환 주소를 꺼내 PC에 다시 넣음으로써 제어권을 원래 위치로 돌려놓습니다.

2-3. (소주제 3: 레지스터 사용 규칙 (Calling Convention))

함수들이 서로를 호출하는 과정에서 레지스터 값을 마구잡이로 사용하면 데이터가 손상될 수 있습니다. 이를 방지하기 위해 "누가 레지스터 값을 보존할 책임이 있는가"에 대한 규칙, 즉 호출 규약이 존재합니다.

구분 호출자 저장 ([translate:Caller-Saved]) 레지스터 피호출자 저장 ([translate:Callee-Saved]) 레지스터
해당 레지스터 %rax, %rcx, %rdx, %rsi, %rdi, %r8 ~ %r11 %rbx, %rbp, %r12 ~ %r15
규칙 호출자(Caller)가 백업 책임을 집니다. 함수 A가 함수 B를 호출할 때, A는 이 레지스터들의 값이 B에 의해 변경될 수 있다고 가정합니다. 따라서 B 호출 전에 중요한 값은 A가 직접 스택에 저장해야 합니다. 피호출자(Callee)가 백업 책임을 집니다. 함수 B는 이 레지스터들을 사용하기 전에, 원래 들어있던 A의 값을 스택에 백업해 두어야 합니다. 그리고 B는 반환 직전에 원래 값을 복원해야 합니다.
장점 피호출자(B)가 레지스터를 자유롭게 사용할 수 있어 코드가 단순해집니다. 호출자(A)는 함수 호출 후에도 이 레지스터의 값이 보존될 것이라고 믿을 수 있어 안정적입니다.
사용 시나리오 임시 값 저장이나 함수 인자 및 반환 값 전달 등, 값이 자주 바뀌는 용도로 사용됩니다. 함수 호출을 거쳐서도 유지되어야 하는 중요한 상태나 값을 저장하는 데 사용됩니다.

결론적으로, 함수 하나를 실행하는 데에는 스택과 레지스터가 모두 사용됩니다. 레지스터는 빠른 인자 전달과 값 반환, 간단한 지역 변수 처리에 사용되고, 스택은 반환 주소, 레지스터 백업, 배열/구조체 같은 무거운 지역 변수 및 초과 인자 저장에 사용됩니다.


3. 결론 및 고찰 (Conclusion & Takeaways)

  • 핵심 요약:
    • 함수 호출은 런타임 스택에 생성되는 '스택 프레임'을 통해 관리되며, 여기에는 반환 주소, 지역 변수, 인자 등이 저장됩니다.
    • x86-64에서는 성능 향상을 위해 최대 6개의 인자와 반환 값을 레지스터로 전달하며, 7개 이상의 인자와 큰 변수들은 스택을 사용합니다.
    • '호출자/피호출자 저장' 규칙은 여러 함수가 레지스터를 공유하며 발생할 수 있는 데이터 충돌을 방지하는 핵심적인 약속입니다.
  • 기술적 통찰 및 나의 생각:
    이번 학습을 통해 함수 호출이 단순한 코드 점프가 아니라, 스택과 레지스터를 유기적으로 사용하는 잘 짜인 프로토콜임을 깨달았습니다. 특히 '호출 규약'은 마치 사회적 약속처럼, 여러 함수(독립적인 주체)가 CPU의 한정된 자원(레지스터)을 질서 있게 공유하기 위한 규칙이라는 점이 인상 깊었습니다. 레지스터에 데이터를 저장할 수 없어 메모리 주소를 대신 사용하는 배열이나, 성능을 위해 메모리 정렬 규칙을 따르는 구조체의 동작 방식은 하드웨어 제약이 소프트웨어 설계에 어떻게 직접적인 영향을 미치는지 보여주는 좋은 예시입니다.
  • 향후 과제 / 추가 질문:
    • 구조체에서 데이터 정렬 규칙으로 인해 발생하는 '패딩(padding)'은 메모리 낭비를 유발하는데, 이를 최소화하기 위한 프로그래밍 기법은 무엇이 있을까?
    • 재귀 함수 호출 시 스택 프레임은 어떻게 계속 쌓이며, 이로 인해 발생하는 '스택 오버플로우'는 정확히 어떤 상황에서 발생하는 걸까?

4. 참고 자료 (References)

  • Randal E. Bryant, David R. O'Hallaron 저, 『Computer Systems: A Programmer's Perspective』
  • x86-64 System V ABI (Application Binary Interface) 문서