🔍 지금까지의 요약
- 3.7 프로시저 도입: 프로시저는 제어 이동, 데이터 전달, 메모리 관리 등 세 가지 핵심 역할을 담당함.
- 3.7.1 런타임 스택: 각 함수 호출 시 스택 프레임이 생성되며, 복귀 주소와 로컬 변수, 보존 레지스터 등을 저장.
- 3.7.2 제어의 이동: call 명령어로 제어를 이동하고 ret 명령어로 원래 위치로 복귀. 복귀 주소는 스택에 저장됨.
- 3.7.3 데이터 전송: 최대 6개의 인자는 레지스터로 전달되고, 초과분은 스택을 사용. 반환값은 %rax를 통해 전달됨.
3.7.4 로컬 저장소(Local Storage on the Stack)
지금까지 본 대부분의 프로시저 예제들은 로컬 저장소가 따로 필요하지 않았고, 모든 데이터가 레지스터만으로 처리될 수 있었다. 하지만 다음과 같은 경우에는 스택에 로컬 데이터를 저장해야 한다:
- 레지스터 수가 부족하여 모든 로컬 변수를 담을 수 없을 때
- & 연산자처럼 주소를 참조해야 하는 경우, 즉 변수의 주소를 알아야 할 때 레지스터에 있는 값은 메모리 주소가 아니기 때문에, 메모리에 저장된 실제 주소가 필요
- 배열이나 구조체처럼 메모리 상에서 직접 접근해야 하는 복합 데이터 타입을 사용할 때
이런 상황에서 프로시저는 %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)
레지스터는 두 가지로 구분된다:
- Callee-saved 레지스터 (피호출자 보존):
- %rbx, %rbp, %r12 ~ %r15
- 피호출자(Q)가 보존 책임을 가짐
- Q가 값을 사용하려면 먼저 push로 스택에 저장하고, 나중에 pop으로 복원해야 함
- 그래서 스택 프레임 내 "Saved Registers" 영역이 생김
- 이렇게 하면 호출자(P)는 이 레지스터를 안전하게 저장소로 사용 가능
- 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 레지스터 → 결과 반환에 사용되므로 보존 필요 없음
📌 이 절의 핵심 요약
- 레지스터는 빠르지만 공유 자원이므로 프로시저 간 호출 시 주의가 필요
- callee-saved / caller-saved 구분을 통해 충돌을 방지
- 성능을 위해 가능한 경우 레지스터를 적극 활용하며, 필요한 경우 스택에 백업
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-saved와 caller-saved로 구분되며, 각 레지스터는 누가 보존 책임을 질지 규정되어 있음.
- 레지스터 기반 저장은 성능 최적화에 필수적이며, 스택 프레임 없이도 많은 함수를 처리할 수 있게 함.
🔹 3.7.6 재귀 프로시저 (Recursive Procedures)
- 재귀 호출에서도 일반 함수 호출과 동일하게 스택 프레임을 통해 독립적 실행 환경 유지.
- 각 호출은 자기만의 로컬 저장소와 복귀 주소를 보유함으로써 재귀 깊이에 관계없이 정확한 실행 가능.
- 레지스터 보존 규칙과 함께 스택 디서플린(stack discipline)을 적용하면, 재귀 함수도 안전하게 구현 가능.
📌 세 절의 통합 메시지
"x86-64 아키텍처의 함수 호출은 스택 프레임과 레지스터 사용의 정교한 규약에 기반하여, 로컬 변수 관리, 포인터 처리, 재귀 실행 등 복잡한 제어 흐름을 안정적이고 효율적으로 지원한다."
'크래프톤 정글 (컴퓨터 시스템: CSAPP) > 3장 프로그램의 기계수준 표현' 카테고리의 다른 글
컴퓨터 시스템 : CSAPP 3장 정리 - 3.8 배열의 할당과 접근 Part.2 (0) | 2025.04.06 |
---|---|
컴퓨터 시스템 : CSAPP 3장 정리 - 3.8 배열의 할당과 접근 Part.1 (0) | 2025.04.06 |
컴퓨터 시스템 : CSAPP 3장 정리 - 3.7 프로시저 Part.1 (0) | 2025.04.06 |
컴퓨터 시스템 : CSAPP 3장 정리 - 3.6 장 제어문 Part.2 (0) | 2025.04.05 |
컴퓨터 시스템 : CSAPP 3장 정리 - 3.6 장 제어문 Part.1 (0) | 2025.04.05 |