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

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

고웅 2025. 4. 6. 08:28

🌟 3.7장: 프로시저란 무엇인가?

프로시저는 컴퓨터 프로그램에서 어떤 일을 "묶어서" 해주는 작은 기계 같은 것이다. 예를 들어, "사과 3개를 깎는 방법"을 한 번에 설명할 수 있으면, 그걸 다른 날에도 똑같이 쓸 수 있을 것이다. 그게 바로 프로시저다.

💡 왜 좋은가?

  • 여러 번 같은 일을 하지 않도록 함.
  • 복잡한 내용을 숨기고, "무엇을 하는지"만 쉽게 보여줄 수 있음.

예시로, 엄마가 "아침에 학교 갈 준비 해!"라고 할 때, 학교 갈 준비 안에는

  1. 세수하기
  2. 옷 갈아입기
  3. 가방 챙기기
  4. 밥 먹기
    등이 포함되어 있을 것인데 이렇게 많은 일을 하나의 명령으로 묶은 게 바로 프로시저다.

프로시저는 소프트웨어에서 핵심적인 추상화(abstraction) 개념이다. 특정 기능을 수행하는 코드를 인수들과 함께 하나의 단위로 묶어놓은 것으로. 이 코드는 프로그램의 여러 지점에서 호출될 수 있고, 필요하다면 값을 반환하기도 한다.

잘 설계된 소프트웨어는 이런 프로시저를 이용해 내부 구현을 감추고, 명확한 인터페이스만 외부에 제공한다. 다양한 언어에서 함수(functions), 메서드(methods), 서브루틴(subroutines), 핸들러(handlers) 등 다양한 이름으로 불리지만 공통적으로 다음과 같은 기능을 갖고 있다.

프로시저가 머신 수준에서 수행해야 하는 주요 작업들:

  1. 제어 흐름 이동 (Passing control)
    • 호출할 때는 Q의 코드 시작 지점으로 프로그램 카운터(PC)를 설정
    • 반환할 때는 원래 호출한 P의 다음 명령어로 돌아가야 함
  2. 데이터 전달 (Passing data)
    • 호출하는 쪽(P)은 인자들을 넘겨줘야 하고, 호출된 함수(Q)는 그 값을 받아서 처리 후 결과를 돌려줘야 함
  3. 메모리 할당 및 해제 (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 일반적인 스택 프레임 구조

스택 프레임의 구성 요소

그림 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 명령어는 두 가지 일을 한다:
    1. 복귀 주소(return address)를 스택에 저장: 이는 call 명령어 바로 다음 명령어의 주소다.
    2. 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.26 call과 ret 기능의 예제

그림 3.27은 프로시저 호출과 반환이 실제로 어떻게 실행되는지를 아주 자세하게 보여주는 예시이다.

그림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)의 일부