프로시져는 함수와 비슷한 의미로 이해할 수 있다.
프로그램을
실행하기 위한 하나의 부분으로 ‘인자’를 받아서 특정
기능을 수행하고 ‘반환값’을 반환하는 역할을 한다.
이러한 역할은 일종의 추상화(abstraction)으로 볼 수
있다.
프로시저가 담당하는 역할을 실제로 구현하기
위해서는 프로시저 내에서 여러 복잡한 구현이 이루어져 있을 수
있지만, 이를 사용하는 프로그래머는 그것의 기능만 생각하고
사용할 수 있다. 즉, 여러 복잡한 단계들을 하나의 기능을
담당하는 역할로 추상화했다고 보는 것이다.
(프로시저는
여러 언어에서 함수, 메소드, 서브루틴, 핸들러 등으로 불릴 수
있지만 특징들은 비슷하다)
우리는 기계 수준에서 프로시져를 어떻게 다루는지 보려는
것이다. 이걸 보기 위해 다음과 같이 프로시져 P
가
Q
를 호출하고 이로 인해 Q
가
실행되고 반환하여 다시 P
로 돌아오는 상황을
가정해본다. 이러한 상황은 다음의 메커니즘을 통해
이루어진다(각각의 메커니즘이 여러 번 이루어질 수도 있다).
- 제어권 전달(Passing control) : 프로그램 카운터(다음
실행할 명령어의 주소가 기록된 곳)가
Q
코드의 주소로 설정되어Q
로 진입할 수 있게 하고,Q
가 반환(종료)할 때는Q
를 호출하는 바로 다음 명령의 주소로 프로그램 카운터가 설정되어야 한다. - 데이터 전달(Passing data) :
P
는 한 개 이상의 매개변수를Q
에게 넘길 수 있어야 하며,Q
는P
에게 값을 반환할 수 있어야 한다. - 메모리 할당과 반납(Allocating and deallocating memory) :
Q
는 지역 변수를 위해 시작할 때 공간을 할당하고 반환하기 직전에 공간을 반납해야 한다.
이를 x86-64프로세서에서는 레지스터와 프로그램 메모리 등을 활용하여 효율적으로 이를 구현한다.
여기서는 제어, 데이터 전달, 메모리 관리의 순서로 이를 구체적으로 볼 것이다.
3.7.1 런타임 스택(The Run-Time Stack)
여러 언어에서 프로시져를 호출하는 메커니즘의 특징은 스택으로 대표되는 후입선출(LIFO; Last-In, First-Out)의 메모리 관리 원칙이다.
앞에서 보았듯이 P
가 Q
를 호출하였을
때 P
는 잠시 일시정지한다. 그리고
Q
는 전달받은 인자만을 가지고 Q
만의
지역변수를 위한 공간을 할당받아 일을 처리하고 프로시져를
종료할 때는 메모리를 다시 반납한다. 즉, Q
와
관련된 일을 처리할 때는 가장 마지막(후입)에 Q
를
위해 할당된 공간만을 사용하고 P
보다 먼저
메모리를 반납(선출)하게 된다.
\(\therefore\) 후입선출의
스택 자료 구조를 통해 프로시져 메모리 관리를 할 수 있다.
P
가 Q
를 호출하면 스택의 끝에
데이터 정보가 추가되고 Q
가 반환할 때 정보는
스택에서 해제된다.
3.4.4에서 보았듯이 x86-64에서 스택은 낮은 메모리 주소의
방향으로 자라고, 스택 포인터 %rsp
는 스택의
최상위 원소(가장 낮은 주소)를 가리킨다.
스택에 데이터를
저장하는 행위는 pushq
와 popq
명령을
통해 실행된다. 아직 데이터 값을 알지 못해서 초기화를
못하지만 공간만 할당하려면 스택 포인터의 값을 낮춰서 할당된
공간을 늘리는 효과를 볼 수 있고, 반대로 공간을 해제할 때는
스택 포인터의 값을 높여서 할당된 공간을 줄이는 효과를 볼 수
있다.
x86-64의 프로시져에서 레지스터보다 더 많은 공간을 요구하면 스택에 공간을 할당하고 이를 프로시져의 스택 프레임이라 부른다.
다음은 일반적인 스택 프레임 구조이다.
현재 실행하는 프로시져의 프레임은 항상 스택의
꼭대기(top of the stack)에 있다(그림에서는 아래를 가리키고
있어서 헷갈리지 않게 주의. 스택의 꼭대기(가장 나중에 들어온
원소)는 메모리 상에서는 보통 더 낮은(작은) 주소).
우선 Q
의 지역 변수들을 Q
에 할당된
스택 프레임에 저장한다.
또한 Q
를
실행하면서 P
에서 썼던 레지스터의 값을 덮어 씌울
수 있기 때문에 몇몇 레지스터 값은 Q
의 스택
프레임에 저장한다.
반면 Q
가 종료하여 반환할 때 P
에서
실행하게 될 코드의 위치 반환 주소(Return address)는
P
와 관련된 것이므로 P
의 스택
프레임에 저장한다.
또 새 프로시져가 요구하는 7번째
이상의 인자는 P
의 스택 프레임에 저장된다.
기본적으로 프로시져는 6개의 인자까지는 레지스터에 저장할 수
있다.
이러한 스택 프레임은 프로시저가 시작될 때 고정된 크기를
할당받는다.
하지만 몇몇 경우(VLA(가변 길이 배열)와 같은
경우)에는 크기가 변할 수 있는 스택 프레임이 필요하고 이에
대해서는 3.10.5에서 다룰 것이다.
3.7.2 제어의 이동
제어의 이동은 가장 익숙한 용어로 설명해보면 어떤 함수
P
에서 다른 함수 Q
를 호출하는
것이고, 이는 결국 다음 명령을 실행할 주소(프로그램 카운터;
PC)를 함수 Q
의 시작 주소로 설정하는
것이다(프로세서는 프로그램 카운터를 참고하여 다음 명령을
실행한다).
이러한 동작을 위해 x86-64머신에서는 call Q
의
명령을 통해 프로시저 Q
를 호출한다.
call Q
를 실행하면 현재 call Q
를 실행한 주소의 다음
주소 A를 스택에 푸시하고(나중에 Q
가 끝났을 때
돌아오기 위해), 프로그램 카운터를 Q
의 시작
주소로 설정한다.
여기서 푸시된 주소 A를
리턴주소라 부르고 이는 call
명령 바로
다음 명령의 주소이다.
call
과 ret
명령의 일반적 형태는
다음과 같다.
명령 | 설명 |
---|---|
call Label |
프로시저 호출 |
call *Operand |
프로시저 호출 |
ret |
호출에서 반환 |
(objdump를 통해 역어셈블하는 경우 보통 callq 와 retq 로 써지는데 q 가 다른 의미를 갖는 것은 아니고 단순히 IA32가 아닌 x86-64버전의 명령임을 강조할 뿐이다.) |
|
call 명령의 타겟은 호출된 프로시져가 시작하는 명령의 주소이다. |
|
어셈블리 코드에서 직접 호출의 타겟은 레이블로 주어지고, 간접 호출의 목적지는 ‘*‘과 3단원 초반에 그림 3.3으로 주어졌던 피연산자의 형태가 붙어있는 형태로 주어진다. |
다음은 3.2.2절에 나왔던 main
과
multstore
함수와 관련하여 call
과
ret
명령의 실행을 보여준다.
우선 400563에서 multstore
를 호출하면
%rsp
에 저장되어 있는 주소값이 8바이트 감소하여
스택 크기를 늘려준다. 또한 %rip
를
multstore
첫번째 명령의 위치로 설정하여 다음
명령은 multstore
를 실행할 수 있게 해준다. 또한
multstore
가 끝났을 때 돌아올 위치를 스택에
푸시해준다.
새로운 예시를 통해 명령과 스택 프레임이 어떻게
변화하는지 살펴 보자.
(표에서 state values는 명령이
실행되기 직전의 값이다.)
우선 callq
를 통해 top(100)
을
호출하기 직전에 이미 %rdi
에 인자로 넘겨줄 값
100이 들어있다. 이때 %rsp
는 아직 늘어나지
않았다.
그 후 callq
가 실행되면 스택포인터는 8바이트
감소하여 스택이 증가하고 늘어난 스택의
자리(*%rsp
에는 top(100)
이 끝나고
실행할 명령의 주소가 저장된다.
그 다음 top(100)
에서 sub
를 통해
다른 일을 하고 또 다시 callq
를 통해
leaf(x - 5)
를 호출한다.
여기서도
마찬가지로 스택포인터가 감소하고 그 자리에 반환시 돌아올
명령의 주소가 기록된다.
마지막 leaf
에서는 반환하기 전 명령에서 반환할
값을 %rax
에 저장하게 되고, 그 다음 번 명령인
ret
을 통해 반환을 시행한다. 반환을 수행하고
나면 프로그램 카운터는 스택에 저장되어 있던 명령어의 주소로
바뀌게 되어서 다음 명령은 top
에서
callq
명령어의 다음 줄에 있던 명령을 실행하게
되고 스택포인터도 증가하여 스택의 크기도 다시 원래대로
감소한다.
그 뒤에 있는 ret
도 비슷한
방식으로 동작한다.
3.7.3 데이터 전송
프로시져를 호출할 때 단순히 명령의 제어를 넘기는 것 외에
데이터 전송도 일어날 수 있다.
함수에 인자를 넘기고
함수로부터 반환값을 받는 행위 등은 모두 데이터 전송에
해당한다.
x86-64에서 프로시져 간에 데이터를 전송하는
행위는 보통 레지스터를 이용해서 이루어진다.
이 때 사용되는 레지스터는 표에 나온 순서대로
사용되며 인자 크기가 64비트보다 작은 경우 표에 나온 32, 16,
8에 해당하는 레지스터 이름으로 접근된다.
호출하는 프로시져에 넘겨야 할 인자가 6개를 초과하는 경우
호출하는 프로시져의 스택 프레임에 저장해야 한다.
P
가 Q
를 호출하는데 n개의 인자를 넘겨야 하는
경우 7번째 인자부터 n번째 인자는 P
의 스택에
저장해야 하고 스택의 top에는 7번째 인자를 저장하게 된다.
위의 예시에서 proc
은 6개의 인자를
필요로 하고 스택에는 7, 8번째 인자인 a4p
와
a4
가 저장된다.
3.7.4 스택 내의 지역 저장 공간(Local Storage on the Stack)
지역 변수들을 레지스터에 저장하여 처리할 수도 있지만 다음과 같은 경우에는 메모리에 저장하여야 한다.
- 레지스터에 지역 데이터를 전부 담을 수 없는 경우
- 지역 변수에 대해
&
연산자가 사용되는 경우 메모리 주소를 돌려줘야 하기 때문에 메모리에 저장해야 한다. - 배열이나 구조체 같은 경우에 배열/구조체 참조 방식을 통해
접근되어야 하기 때문에 메모리에 저장되어야 함(뒷 절에서
배움)
위의 예시는caller
의 지역변수arg1
과arg2
의 주소를 넘겨줘야 하는 예시이다.
이 때%rsp
에 대해subq
를 실행하여 두 지역 변수를 위한 스택 공간을 마련하고movq
를 통해 지역변수의 값을 저장한다.
그 다음caller()
내의 나머지 명령을 수행하고 나서%rsp
에 대해addq
를 실행해서 지역 변수를 위해 할당된 스택 공간을 해제한다.
(스택 공간 할당은 함수에서 필요한 순간에 할당하고 필요가 없어지면 해제하는 듯. 흔히 컴파일 단계에서 스택 공간이 결정된다고 말하는 것은 이러한 스택 할당 명령들이 컴파일 단계에서 기계어 코드를 작성하는 순간에 결정되기 때문?)
위의 예시도 지역변수를 위해 스택 공간을 할당하고,
인자를 레지스터에 넘기는 과정을 보여준다.(7, 8번째 인자는
스택에 저장하는 행위가 인자를 넘기는 행위라 보면 될 것 같다)
3.7.5 레지스터에 있는 지역 저장 공간(Local Storage in
Registers)
레지스터는 모든 프로시져가 공유.
따라서 한 프로시져가
레지스터를 수정하면 다른 프로시져에서도 영향이 간다.
프로시져를
호출한 프로시져(caller)가 피호출된 프로시져(callee)가
레지스터 값을 바꿈으로 인해 필요한 데이터에 접근하지 못하게
되는 경우를 막기 위해 x86-64에서는 레지스터 사용에 대한 몇
가지 관습을 채택하고 있다.
우선 %rbx
, %rbp
,
%r12-%r15
는
callee-saved registers(피호출자 저장 레지스터)로
불린다.
프로시져 P
가 Q
를
호출한 경우에 Q
가 종료하는 경우에
P
가 Q
를 호출했던 때와 동일하게
피호출자 저장 레지스터에 있는 값들을 원상 복구해놓아야
한다.
프로시져 Q
는
- 그냥 해당 레지스터의 값을 건들지 않거나
- 원래 값을 스택에 푸시하였다가 프로시져 반환 직전에
스택에서 다시 팝해오는 방식으로
피호출자 저장 레지스터의 값을 보존한다.
나머지 레지스터 중 스택 포인터 %rsp
를 제외한
모든 레지스터는
caller-saved registers(호출자 저장 레지스터)로
불린다.
호출당하는 프로시져 Q
는 이
레지스터를 바꿔도 되기 때문에 P
가 해당
레지스터에 있는 값을 Q
의 호출 뒤에도 똑같은
값으로 사용하고 싶다면 먼저 저장할 의무가 있다.
예시를 보면 프로시져 P
는
%rbp
와 %rbx
를 보존할 의무가
있으므로 스택에 푸시하여 값을 저장한다.
그 후 다음번
Q(x)
의 호출을 위해서 x
의 값을
보존해야 하므로 %rbp
에 저장하여
Q
가 이 값을 보존하게 만든다.
3.7.6 재귀적 프로시져(Recursive Procedures)
재귀도 별 다를 거 없다. 그냥 똑같은 스택 생성 관습을 따를뿐이다. 단지 동일한 프로시져를 호출한다는 점만 특별할 뿐이다.