컴퓨터 시스템 : CSAPP 3장 정리 - 3.7 프로시저 Part.1
🌟 3.7장: 프로시저란 무엇인가?
프로시저는 컴퓨터 프로그램에서 어떤 일을 "묶어서" 해주는 작은 기계 같은 것이다. 예를 들어, "사과 3개를 깎는 방법"을 한 번에 설명할 수 있으면, 그걸 다른 날에도 똑같이 쓸 수 있을 것이다. 그게 바로 프로시저다.
💡 왜 좋은가?
- 여러 번 같은 일을 하지 않도록 함.
- 복잡한 내용을 숨기고, "무엇을 하는지"만 쉽게 보여줄 수 있음.
예시로, 엄마가 "아침에 학교 갈 준비 해!"라고 할 때, 학교 갈 준비 안에는
- 세수하기
- 옷 갈아입기
- 가방 챙기기
- 밥 먹기
등이 포함되어 있을 것인데 이렇게 많은 일을 하나의 명령으로 묶은 게 바로 프로시저다.
프로시저는 소프트웨어에서 핵심적인 추상화(abstraction) 개념이다. 특정 기능을 수행하는 코드를 인수들과 함께 하나의 단위로 묶어놓은 것으로. 이 코드는 프로그램의 여러 지점에서 호출될 수 있고, 필요하다면 값을 반환하기도 한다.
잘 설계된 소프트웨어는 이런 프로시저를 이용해 내부 구현을 감추고, 명확한 인터페이스만 외부에 제공한다. 다양한 언어에서 함수(functions), 메서드(methods), 서브루틴(subroutines), 핸들러(handlers) 등 다양한 이름으로 불리지만 공통적으로 다음과 같은 기능을 갖고 있다.
프로시저가 머신 수준에서 수행해야 하는 주요 작업들:
- 제어 흐름 이동 (Passing control)
- 호출할 때는 Q의 코드 시작 지점으로 프로그램 카운터(PC)를 설정
- 반환할 때는 원래 호출한 P의 다음 명령어로 돌아가야 함
- 데이터 전달 (Passing data)
- 호출하는 쪽(P)은 인자들을 넘겨줘야 하고, 호출된 함수(Q)는 그 값을 받아서 처리 후 결과를 돌려줘야 함
- 메모리 할당 및 해제 (Allocating and deallocating memory)
- 로컬 변수 등을 위한 메모리가 필요하면 할당하고, 반환할 때 해제
x86-64 아키텍처에서는 이 모든 작업을 특수 명령어들과 일련의 규칙(conventions)을 통해 효율적으로 수행합니다. 즉, 필요에 따라 최소한의 오버헤드만 발생하도록 설계된 미니멀리스트 전략(minimalist strategy)을 따른다.
🧭 3.7.1장: 런타임 스택 (실행 중 쌓이는 정보 저장소)
컴퓨터가 프로시저를 실행할 때 사용하는 중요한 장소가 있다. 그것은 런타임 스택(run-time stack)이다.
런타임 스택은 C 언어를 포함한 대부분의 언어에서 프로시저 호출 시 필요한 메모리 관리를 위해 사용되는 핵심 구조이다. 이 스택은 후입선출(Last-In, First-Out, LIFO) 구조로, 현재 실행 중인 함수만 스택에 데이터를 추가할 수 있고, 반환할 때는 가장 나중에 추가된 데이터를 먼저 제거한다.
📚 어떻게 쓰이는가?
- 프로시저 P에서 또 다른 프로시저 Q를 부르면, 컴퓨터는 P를 "잠깐 멈추고", Q를 실행한다.
- 그러면 Q가 끝났을 때 어디로 돌아가야 할지 알아야 하니까, 그 정보를 스택에 넣는다.
- Q가 필요로 하는 임시 저장 공간(로컬 변수)도 스택에 만든다.
- Q가 끝나면, 그 공간은 다시 비워진다.
스택의 구조와 동작
- x86-64에서 스택은 높은 주소에서 낮은 주소 방향으로 성장한다.
- 레지스터 %rsp는 현재 스택의 최상단(top)을 가리키고 있다.
- 데이터를 저장하려면 pushq 명령어를 사용해 스택에 데이터를 추가하고, popq로 데이터를 제거할 수 있다.
- 초기화되지 않은 데이터를 위한 공간은 %rsp를 수동으로 감소시켜 확보한다.
스택 프레임의 구성 요소
그림 3.25에서 볼 수 있는 일반적인 스택 프레임 구조는 다음을 포함할 수 있다:
- 인자(arguments)
- 반환 주소(return address): Q가 끝나면 다시 P로 돌아가기 위한 정보
- 로컬 변수(local variables)
- 보존된 레지스터(saved registers)
현재 실행 중인 함수의 스택 프레임은 항상 스택의 최상단에 위치한다. Q가 P에 의해 호출되면, P의 반환 주소가 스택에 저장되고, 그 위에 Q의 스택 프레임이 추가된다.
최적화 전략
- x86-64에서는 필요한 부분만 최소한으로 스택 프레임에 할당하는 전략을 따른다.
- 예를 들어, 인자가 6개 이하이면 레지스터로 전달되므로 스택에 별도 공간을 만들지 않아도 된다.
- 심지어 로컬 변수 없이 다른 함수도 호출하지 않는 경우에는 아예 스택 프레임을 만들지 않기도 한다 (이런 함수는 leaf procedure라고 한다).
🧭 3.7.2 제어의 이동:
우리가 컴퓨터한테 "이제 이걸 해!"라고 말하는 건, 사실 프로그램 안에서 어디를 실행할지 위치를 바꾸는 것이다. 이걸 우리는 제어의 이동(control transfer)이라고 부른다.
프로시저 P가 프로시저 Q를 호출할 때 가장 중요한 일 중 하나는 제어 흐름(control flow)을 Q로 이동시키는 것이다. 이를 위해 프로그램 카운터(PC)는 Q의 시작 주소로 설정되어야 하고, Q의 실행이 끝나면 다시 P로 돌아올 수 있도록 P의 다음 실행 주소도 기억해야 한다.
call과 ret 명령어
- call 명령어는 두 가지 일을 한다:
- 복귀 주소(return address)를 스택에 저장: 이는 call 명령어 바로 다음 명령어의 주소다.
- PC를 Q의 시작 주소로 설정해서 제어를 이동
- ret 명령어는:
- 스택에서 저장해둔 복귀 주소를 꺼내서 PC에 설정함으로써, 다시 P의 실행 위치로 제어를 돌려준다.
즉, call과 ret 명령어 한 쌍을 이용하면 함수 호출과 복귀가 완전하게 구현된다.
호출 방식의 종류
- 직접 호출 (Direct Call)
예: call func처럼 레이블을 통해 정해진 주소로 이동 - 간접 호출 (Indirect Call)
예: call *%rax처럼 레지스터나 메모리 값을 참조하여 주소 결정
이러한 간접 호출은 함수 포인터나 동적 디스패치(dynamic dispatch) 등에서 사용된다.
예시 및 동작 흐름
그림 3.26에서는 main 함수에서 multstore를 호출하고, 다시 돌아오는 과정을 시각적으로 보여준다:
- call 명령어 실행 전: %rip는 call 명령어 위치, %rsp는 현재 스택 최상단
- call 명령어 실행 후: 복귀 주소가 스택에 저장되고, %rip는 multstore 시작 지점을 가리킴
- ret 실행 후: %rip가 복귀 주소로 설정되어 다시 main의 다음 명령어부터 실행이 재개된다
그림 3.27은 프로시저 호출과 반환이 실제로 어떻게 실행되는지를 아주 자세하게 보여주는 예시이다.
(a) 어셈블리 코드: main
, top
, leaf
함수
- leaf(long y)
%rdi
에 인자y
가 전달됨lea 0x2(%rdi), %rax
:y + 2
값을%rax
에 계산 (예:95 + 2 = 97
)retq
: 복귀
- top(long x)
%rdi
에 인자x
가 전달됨sub $0x5, %rdi
:x - 5
로 변환 (예:100 - 5 = 95
)call leaf
: leaf 함수 호출add %rax, %rax
: 반환된 결과를 두 배로 (예:97 \* 2 = 194
)retq
: 복귀
- main
call top(100)
:top
함수 호출mov %rax, %rdx
: 결과를%rdx
에 저장
(b) 실행 추적: 호출 순서와 레지스터/스택 변화
레이블 | PC 주소 | 명령어 | %rdi | %rax | %rsp | *%rsp | 설명 |
M1 | 0x40055b | callq | 100 | — | 0x7fffffffe820 | — | top(100) 호출 |
T1 | 0x400545 | sub | 100 | — | 0x7fffffffe818 | 0x400560 | x-5 = 95 |
T2 | 0x400549 | callq | 95 | — | 0x7fffffffe818 | 0x400560 | leaf(95) 호출 |
L1 | 0x400540 | lea | 95 | — | 0x7fffffffe810 | 0x40054e | rax = 97 계산 |
L2 | 0x400544 | retq | — | 97 | 0x7fffffffe810 | 0x40054e | leaf 종료 |
T3 | 0x40054e | add | — | 97 → 194 | 0x7fffffffe818 | 0x400560 | 결과 두 배로 |
T4 | 0x400551 | retq | — | 194 | 0x7fffffffe818 | 0x400560 | top 종료 |
M2 | 0x400560 | mov | — | 194 | 0x7fffffffe820 | — | main 복귀 후 재개 |
핵심 포인트 요약
- 각 함수가 호출될 때 복귀 주소가 스택에 저장되고, retq가 그 값을 꺼내서 제어를 복귀시킴
- %rsp 스택 포인터는 각 함수 호출 시 스택 프레임을 할당할 수 있도록 감소되고, 복귀 시 복원됨
- 이 구조는 후입선출(LIFO) 방식이기 때문에 함수 호출과 복귀가 정확히 일어날 수 있다
🧭 3.7.3 데이터 전송(Data Transfer)
인자 전달 (Passing Arguments)
x86-64에서는 최대 6개의 정수형 인자(정수 또는 포인터)를 다음 레지스터들을 통해 전달한다:
인자 번호 | 64 비트 | 32 비트 | 16 비트 | 8 비트 |
1 | %rdi | %edi | %di | %dil |
2 | %rsi | %esi | %si | %sil |
3 | %rdx | %edx | %dx | %dl |
4 | %rcx | %ecx | %cx | %cl |
5 | %r8 | %r8d | %r8w | %r8b |
6 | %r9 | %r9d | %r9w | %r9b |
함수 호출 전에 호출자(P)는 인자들을 적절한 레지스터에 넣고 call 명령어를 실행합니다. 피호출자(Q)는 이 레지스터들을 통해 인자를 받아 처리한다. 반환값은 %rax에 저장되어 호출자로 다시 전달된다.
7개 이상의 인자: 스택 사용
만약 인자가 6개를 초과한다면, 7번째 인자부터는 스택을 통해 전달된다. 이때:
- 인자 1~6 → 지정된 레지스터에 저장
- 인자 7~n → 스택에 저장, 인자 7이 스택의 최상단에 위치
- 모든 인자는 8바이트 단위로 정렬됨 (alignment)
Q 함수가 다른 함수를 다시 호출할 경우를 대비해 자신의 스택 프레임 내에 추가 인자를 위한 공간(argument build area)을 마련해야 한다.
예제: 다양한 타입의 인자를 가진 함수
책에서는 8개의 인자를 가진 C 함수 proc을 예로 들어 설명한다:
void proc(char a1, short a2, int a3, long a4,
char *a5, short *a6, int *a7, long *a8);
이 함수에서:
- 앞의 6개 인자 → 레지스터 사용
- 뒤의 2개 인자(a7, a8) → 스택을 통해 전달됨
요약
- 인자 전달은 가능한 한 레지스터로, 초과 시 스택으로 처리
- 반환값은 %rax에 저장
- 모든 데이터는 8바이트 정렬되어야 함
- 이러한 규칙은 ABI(Application Binary Interface)에서 정의된 호출 규약(calling convention)의 일부