크래프톤 정글 (컴퓨터 시스템: CSAPP)/3장 프로그램의 기계수준 표현

컴퓨터 시스템 : CSAPP 3장 정리 - 3.7 프로시저 Part.2

고웅 2025. 4. 6. 09:02

🔍 지금까지의 요약

  • 3.7 프로시저 도입: 프로시저는 제어 이동, 데이터 전달, 메모리 관리 등 세 가지 핵심 역할을 담당함.
  • 3.7.1 런타임 스택: 각 함수 호출 시 스택 프레임이 생성되며, 복귀 주소와 로컬 변수, 보존 레지스터 등을 저장.
  • 3.7.2 제어의 이동: call 명령어로 제어를 이동하고 ret 명령어로 원래 위치로 복귀. 복귀 주소는 스택에 저장됨.
  • 3.7.3 데이터 전송: 최대 6개의 인자는 레지스터로 전달되고, 초과분은 스택을 사용. 반환값은 %rax를 통해 전달됨.

3.7.4 로컬 저장소(Local Storage on the Stack)

지금까지 본 대부분의 프로시저 예제들은 로컬 저장소가 따로 필요하지 않았고, 모든 데이터가 레지스터만으로 처리될 수 있었다. 하지만 다음과 같은 경우에는 스택에 로컬 데이터를 저장해야 한다:

  1. 레지스터 수가 부족하여 모든 로컬 변수를 담을 수 없을 때
  2. & 연산자처럼 주소를 참조해야 하는 경우, 즉 변수의 주소를 알아야 할 때 레지스터에 있는 값은 메모리 주소가 아니기 때문에, 메모리에 저장된 실제 주소가 필요
  3. 배열이나 구조체처럼 메모리 상에서 직접 접근해야 하는 복합 데이터 타입을 사용할 때

이런 상황에서 프로시저는 %rsp (스택 포인터)를 감소시켜 스택 프레임에 공간을 할당한다. 이 공간은 로컬 변수들을 저장하는 데 사용되며, 이는 그림 3.25의 “Local variables” 영역에 해당한다.

📐 스택 프레임 조작 방식

  • 함수 시작 시 %rsp를 감소시켜 스택 프레임 공간 확보
subq $16, %rsp   ; 16바이트 로컬 변수 저장 공간 확보
  • 함수 종료 직전 %rsp를 복구
addq $16, %rsp   ; 스택 원복

이 공간 안에 로컬 변수들을 저장할 수 있으며, 주소도 쉽게 계산할 수 있음. 모든 접근은 상대적 오프셋을 통해 이루어짐 (예: 8(%rsp)).

📌 그림 3.31: swap_add 함수와 caller 함수 예시

지역 변수에 대한 주소 참조, 즉 & 연산자가 사용될 때 스택에 로컬 저장소가 어떻게 할당되고 사용되는지를 보여준다.

(a) C 코드 개요

long swap_add(long *xp, long *yp) {
    long x = *xp;
    long y = *yp;
    *xp = y;
    *yp = x;
    return x + y;
}

long caller() {
    long arg1 = 534;
    long arg2 = 1057;
    long sum = swap_add(&arg1, &arg2);
    long diff = arg1 - arg2;
    return sum * diff;
}

(b) 어셈블리 코드 분석

1  caller:
2      subq $16, %rsp       ; 16바이트 로컬 스택 공간 확보
3      movq $534, (%rsp)    ; arg1 저장
4      movq $1057, 8(%rsp)  ; arg2 저장
5      leaq 8(%rsp), %rsi   ; &arg2 주소 계산
6      movq %rsp, %rdi      ; &arg1 주소 계산
7      call swap_add        ; 함수 호출
8      movq (%rsp), %rdx    ; arg1 값 로드
9      subq 8(%rsp), %rdx   ; diff 계산
10     imulq %rdx, %rax     ; sum * diff 계산
11     addq $16, %rsp       ; 스택 공간 해제
 

요점

  • %rsp 기준 0, 8 오프셋에 arg1, arg2 저장
  • 주소 계산에 leaq 사용 (주소만 계산하고 메모리 접근은 안 함)
  • 변수 주소는 swap_add의 포인터 인자에 전달됨
  • 호출 후 다시 값을 로드하여 연산 수행

📌 그림 3.32: call_proc 함수의 복합 예시

이 함수는 다양한 크기의 로컬 변수와 포인터들을 관리하고, 8개의 인자를 가지는 proc 함수를 호출한다. 복잡하지만 x86-64 스택 프레임의 실제 동작을 상세히 보여주는 예시이다.

(a) C 코드 개요

long call_proc() {
    long x1 = 1; int x2 = 2;
    short x3 = 3; char x4 = 4;

    proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4);
    return (x1 + x2) * (x3 - x4);
}

(b) 어셈블리 코드 주요 부분

2   subq $32, %rsp           ; 스택 프레임 32바이트 할당
3   movq $1, 24(%rsp)        ; x1 저장
4   movl $2, 20(%rsp)        ; x2 저장
5   movw $3, 18(%rsp)        ; x3 저장
6   movb $4, 17(%rsp)        ; x4 저장
7   leaq 17(%rsp), %rax      ; &x4 → %rax
8   movq %rax, 8(%rsp)       ; 인자 8로 저장
9   movl $4, (%rsp)          ; 인자 7로 저장
10  leaq 18(%rsp), %r9       ; 인자 6: &x3
11  movl $3, %r8d            ; 인자 5: x3
12  leaq 20(%rsp), %rcx      ; 인자 4: &x2
13  movl $2, %edx            ; 인자 3: x2
14  leaq 24(%rsp), %rsi      ; 인자 2: &x1
15  movl $1, %edi            ; 인자 1: x1
16  call proc                ; 함수 호출

17–23: 로컬 변수 재로드 후 (x1+x2)*(x3-x4) 계산
24  addq $32, %rsp           ; 스택 복원
25  ret

🔍 고급 예시: call_proc 함수

이 예시에서는 다양한 크기의 로컬 변수들(long, int, short, char)이 스택에 저장되고, 각각의 주소가 포인터로서 인자로 전달된다.

포인트

  • 변수들은 8바이트 정렬에 따라 오프셋이 조정됨
  • 포인터 인자 전달 시 leaq 명령어로 주소 계산
  • 총 8개의 인자 중 앞의 6개는 레지스터, 나머지 2개는 스택을 통해 전달됨
  • 함수 호출 이후, 로컬 변수들을 다시 로드해 계산 수행

이 사례는 스택 프레임이 데이터 저장소로서 뿐만 아니라, 함수 간의 인터페이스 연결 구조로서도 사용된다는 점을 보여준다.


3.7.5 레지스터에 저장된 로컬 저장소(Local Storage in Registers)

이 절에서는 로컬 데이터를 메모리(스택)에 저장하는 대신, 레지스터에 직접 저장하여 사용하는 방식을 설명한다. 레지스터는 매우 빠르고 CPU가 직접 접근하는 공간이기 때문에 성능 면에서 유리하지만, 여러 프로시저가 동시에 레지스터를 사용할 수 없기 때문에 관리가 필요하다.

🧠 레지스터의 공유와 보존 규약

x86-64에서는 레지스터를 모든 프로시저가 공유한다. 즉, 현재 하나의 프로시저만 실행되긴 하지만, 다른 프로시저를 호출했다가 복귀할 경우를 고려해야 한다.

이 문제를 해결하기 위한 호출 규약(Calling Convention)

레지스터는 두 가지로 구분된다:

  1. Callee-saved 레지스터 (피호출자 보존):
    • %rbx, %rbp, %r12 ~ %r15
    • 피호출자(Q)가 보존 책임을 가짐
      • Q가 값을 사용하려면 먼저 push로 스택에 저장하고, 나중에 pop으로 복원해야 함
      • 그래서 스택 프레임 내 "Saved Registers" 영역이 생김
    • 이렇게 하면 호출자(P)는 이 레지스터를 안전하게 저장소로 사용 가능
  2. Caller-saved 레지스터 (호출자 보존):
    • 그 외 대부분의 레지스터 (%rax, %rdi, %rsi, %rdx, %rcx, %r8 ~ %r11)
    • 피호출자(Q)는 자유롭게 변경 가능
    • 따라서 P가 해당 레지스터 값을 사용 중이라면, Q 호출 전 반드시 스택에 저장하고, 복귀 후 복원해야 함

🔍 예시 분석: 함수 P가 Q를 두 번 호출

long P(long x, long y) {
    long u = Q(y);
    long v = Q(x);
    return u + v;
}

 

이 함수는:

  • 첫 번째 Q 호출 전에는 x를 보존
  • 두 번째 Q 호출 전에는 Q(y)의 결과 u를 보존

어셈블리 코드:

1 P:
2   pushq %rbp            ; %rbp 저장
3   pushq %rbx            ; %rbx 저장 (callee-saved 사용)
4   subq $8, %rsp         ; 스택 정렬
5   movq %rdi, %rbp       ; x 저장
6   movq %rsi, %rdi       ; y → Q 인자
7   call Q
8   movq %rax, %rbx       ; Q(y) 결과 → u 저장
9   movq %rbp, %rdi       ; x → Q 인자
10  call Q
11  addq %rbx, %rax       ; u + v
12  addq $8, %rsp         ; 스택 복원
13  popq %rbx             ; %rbx 복원
14  popq %rbp             ; %rbp 복원
15  ret

요점

  • %rbx와 %rbp는 callee-saved 레지스터 → P 함수가 직접 저장하고 복원
  • %rax는 caller-saved 레지스터 → 결과 반환에 사용되므로 보존 필요 없음

📌 이 절의 핵심 요약

  1. 레지스터는 빠르지만 공유 자원이므로 프로시저 간 호출 시 주의가 필요
  2. callee-saved / caller-saved 구분을 통해 충돌을 방지
  3. 성능을 위해 가능한 경우 레지스터를 적극 활용하며, 필요한 경우 스택에 백업

3.7.6 재귀 프로시저 (Recursive Procedures)

🧩 재귀 호출에서의 주요 과제

재귀 함수는 자기 자신을 다시 호출하는 함수이다. 따라서 동시에 여러 개의 호출이 스택 위에 중첩되어 있는 상태가 된다. 이때 중요한 점은:

  • 각 호출은 자신만의 독립적인 변수 저장 공간이 있어야 하고
  • 리턴할 때는 정확히 자신이 호출된 지점으로 돌아가야 하며
  • 이전 상태의 레지스터 값이 보존되어야 함

x86-64에서 사용하는 스택 기반 호출 체계와 레지스터 보존 규약은 이러한 요구를 충실히 만족시킨다.

🔍 예시: 재귀 팩토리얼 함수 rfact

 

C 코드

long rfact(long n) {
    long result;
    if (n <= 1)
        result = 1;
    else
        result = n * rfact(n - 1);
    return result;
}

어셈블리 코드 분석

rfact:
  pushq %rbx            ; 이전 %rbx 값을 스택에 저장 (callee-saved)
  movq %rdi, %rbx       ; 인자 n → %rbx에 저장
  movl $1, %eax         ; 기본 반환값 1 세팅
  cmpq $1, %rdi         ; n <= 1?
  jle  .Ldone           ; 조건 만족 시 바로 종료
  leaq -1(%rdi), %rdi   ; n-1 계산
  call rfact            ; 재귀 호출: rfact(n-1)
  imulq %rbx, %rax      ; 반환된 결과 * n
.Ldone:
  popq %rbx             ; %rbx 복원
  ret

주요 포인트

  • %rbx는 callee-saved이므로 rfact 함수 내에서 사용하기 전에 push, 종료 전 pop
  • 재귀 호출 이전의 %rbx 값은 스택에 안전하게 보관됨
  • 각 재귀 호출은 독립적인 스택 프레임을 갖고 있으므로 n이나 리턴 주소가 충돌하지 않음
  • 재귀 호출 후 결과는 %rax에 있으며, 이 값을 현재 %rbx(즉, n)와 곱함

✅ 3.7.4 ~ 3.7.6 핵심 요약: 로컬 저장소와 함수 호출의 실제 구조

🔹 3.7.4 스택 내 로컬 저장소 (Local Storage on the Stack)

  • 레지스터만으로는 로컬 데이터를 저장하거나 주소를 참조할 수 없을 때, 스택이 보조 저장소 역할을 함.
  • 스택 프레임에 변수 값을 저장하고, 그 주소를 다른 함수에 넘기거나 포인터 연산에 사용 가능.
  • %rsp를 감소시켜 공간 확보 → 함수 종료 시 원상 복구.
  • 함수 호출에 따른 변수 위치, 주소 계산, 복원 로직이 어셈블리 수준에서 정교하게 수행됨.

🔹 3.7.5 레지스터 내 로컬 저장소 (Local Storage in Registers)

  • 레지스터는 매우 빠른 저장소이므로, 가능한 한 로컬 변수는 레지스터에 보관하는 것이 이상적.
  • 그러나 레지스터는 공유 자원이기 때문에, 함수 호출 간의 충돌을 막기 위해 호출 규약(calling convention) 필요.
  • 레지스터는 callee-savedcaller-saved로 구분되며, 각 레지스터는 누가 보존 책임을 질지 규정되어 있음.
  • 레지스터 기반 저장은 성능 최적화에 필수적이며, 스택 프레임 없이도 많은 함수를 처리할 수 있게 함.

🔹 3.7.6 재귀 프로시저 (Recursive Procedures)

  • 재귀 호출에서도 일반 함수 호출과 동일하게 스택 프레임을 통해 독립적 실행 환경 유지.
  • 각 호출은 자기만의 로컬 저장소와 복귀 주소를 보유함으로써 재귀 깊이에 관계없이 정확한 실행 가능.
  • 레지스터 보존 규칙과 함께 스택 디서플린(stack discipline)을 적용하면, 재귀 함수도 안전하게 구현 가능.

📌 세 절의 통합 메시지

"x86-64 아키텍처의 함수 호출은 스택 프레임과 레지스터 사용의 정교한 규약에 기반하여, 로컬 변수 관리, 포인터 처리, 재귀 실행 등 복잡한 제어 흐름을 안정적이고 효율적으로 지원한다."