1. 호출 규약
1.1. 스택
- 호출 규약(Calling Conversation)이란 함수를 호출하는 방식에 대한 일종의 약속이다.
- 인수는 어떻게 전달하며 리턴값을 어떻게 반환하고 인수 전달을 위해 사용한 메모리는 누가 정리할 것인지 등을 규정한다.
- 호출 규약은 컴파일러 내부에서 일어나는 일이다. (그래서 이해하기 쉽지 않다.)
- 컴파일러의 내부 동작과 함수의 호출 과정을 알게 되면 재귀호출이나 가변 인수 등의 고급 기법들을 이해할 수 있다.
- 호출 규약을 이해하기 위해서는 스택에 대해 알아야 하며 스택은 기계어 수준에서 동작하기 때문에 어셈블리 언어에 대한 개념도 필요하다.
- 스택은 시스템이 사용하는 메모리 공간이다.
- CPU가 임시적인 정보를 저장할 필요가 있을 때 이 영역을 사용한다.
- 다음은 메모리 구조다.(도스 운영체제의)
| --프로그램 코드-- | --데이터-- | ------------힙------------ | ----자유영역---- | --------------스택-------------- |
|---|
| (낮은 번지) | | 아래로 자란다 -> | | <- 위로 자란다.(높은 번지) |
- 앞부분에는 프로그램의 코드가 있고, 이어서 데이터 영역이 있으며 데이터 영역 아래쪽에는 자유 영역인 힙이 있다.
- 힙은 실행 중에 실행 중에 동적으로 할당되는 메모리 영역이며 할당이 발생하면 뒤쪽으로 이동하면서 자유 영역을 사용한다.
- 스택은 메모리의 가장 뒷부분(높은 번지)에 위치하는데 값을 넣으면 위쪽으로 이동한다.
- 힙과 스택 사이에는 자유 영역이 있어 두 영역 사이의 완충 역할을 한다.
- 만약 힘과 스택이 만나게 되면 메모리가 부족한 상태가 된다.
- 스택에 값을 저장하는 동작을 push라고 하며 저장된 값을 빼내는 동작을 pop이라고 한다.
- 스택의 현재 위치는 esp 레지스터에 기억되며 push하면 esp가 감소하면서 값이 스택으로 들어가고
pop하면 esp에 저장된 값을 빼내고 esp가 증가한다.
push A는 esp-=4, mov [esp], A와 같다.
pop A는 mov A, [esp], esp+=4 와 같다.
- 스택에 저장된 값들은 LIFO의 원칙에 따라 가장 최후에 들어간 값이 가장 먼저 나온다.
- 예를 들어 A, B, C 순으로 값을 push했다면 pop할 때는 C, B, A 순으로 읽혀진다.
- CPU의 범용 레지스터는 개수가 많지 않기 때문에 필요한 레지스터가 이미 값을 가지고 있을 때는 스택에 레지스터 값을 잠시 대피해 놓고 사용한다.
- 이때 CPU는 레지스터의 값을 스택에 push하여 저장해 놓고, 이 레지스터를 사용하며 다 사용하고 난 후 pop해서 복구한다.
- 저장된 값을 복구할 때는 푸시한 반대 순서대로 팝해야 한다.
- esp는 푸시될 때 감소하며 팝할 때 다시 증가하여 항상 다음 액세스할 위치를 가리킨다.
- 예를 들면 두 번 푸시한 후 두 번 팝하면 스택은 원래 상태로 돌아가는 특성이 있다.
- 그래서 푸시, 팝 회수만 정확하게 맞추면 얼마든지 많은 값들을 저장할 수 있으며 넣은 순서의 역순으로 꺼내기만 하면 된다.
- 또한 푸시한 대상과 팜하는 대상이 반드시 일치하지 않아도 되므로 스택을 경유하여 레지스터끼리 값을 대입할 수도 있고, 교환하는 것도 가능하다.
- 대입의 경우 push ecx를 통해 ecx의 값을 스택에 저장하고 pop eax를 통해 스택에 저장했던 ecx의 값을 eax에 저장할 수 있다.
- 교환의 경우 push ecx, push eax를 통해 exc, eax 순으로 각 레지스터의 값들을 스택에 저장하고, pop ecx, pop eax를 통해 ecx에는 이전의 eax의 값을, eax에는 이전의 ecx의 값을 저장함으로써 교환할 수 있다.
- c언어는 스택을 직접 조작하는 방법을 제공하지 않는다.
1.2. 스택 프레임
- 함수가 호출될 때 스택에는 함수 전달되는 인수, 실행을 마치고 돌아올 복귀 번지, 지역변수 등의 정보들을 저장한다.
- 스택에 저장되는 함수의 호출 정보를 스택 프레임(Stack Frame)이라고 한다.
- 또한 함수 실행 중에도 필요한 경우 임시적인 정보 저장을 위해 스택을 사용하되 이때 푸시 횟수와 팝 횟수는 일치하므로 함수가 리턴하면 정확하게 호출전의 상태로 돌아가 항상성을 유지한다.
- 함수의 실행과정을 어셈블리 단위에서 살펴보면 알 수 있는 것으로
- 인수도 함수 호출 중에만 생명이 유지되는 일종의 지역변수다. 인수의 초기화 시점은 함수가 호출될 때이며 함수 내부에서만 통용된다.
- 지역변수를 많이 선언하는 것과 함수의 실행 속도와는 직접적인 상관이 없다.
- 지역변수 영역은 esp를 필요한 양만큼 감소시켜 생성하는데 10을 빼나 20을 빼나 연산 속도는 일정하다.
- 지역변수를 많이 쓴다고해서 프로그램이 커지는 것도 아니다.
- 어차피 지역변수는 스택에 임시적으로 생겼다가 함수가 끝나면 사라지므로 프로그램의 크기와는 무관하다.
- 지역변수를 위해 esp를 위로 올려 공간만 만들 뿐이므로 별도의 초기식이 없으면 지역변수는 초기화되지 않는다.
- 이 때 원래 공간에 들어있던 값이 바로 쓰레기값이다.
- 지역변수를 초기화하면 이때는 초기화하는 시간만큼 느려지고 필요한 코드만큼 프로그램의 크기도 늘어난다.
- 함수를 호출할 때마다 스택 프레임이 생성되었다가(prolog) 사라지는 복잡한 과정을 거치므로 함수 호출에는 오버헤더가 있다.
1.3. 호출 규약 (이하 생략)
- gcc에서는 정상 작동하지 않는 것 같음.
- Windows 어플리케이션이나 x86(32비트 아키텍처)에서만 작동하는 것 같다.
- 호출 규약이 바뀌면 스택 프레임의 모양과 관리 방법도 달라질 수 있다.
- 호출 규약에 따라 인수를 전달하는 방법과 스택의 정리 책임, 함수의 이름을 작성하는 방법이 달라진다.
2. 재귀 호출
- 재귀 호출(Recursive Call)은 자지가 자신을 호출하는 형식이다.
- 자기 자신을 호출할 횟수는 가변적이다.
- 재귀 호출을 이용해서 풀 수 있는 대표적인 문제는 너무 거대해서 한 번의 함수 호출로 해결하기 힘들 때 큰 문제를 작게 나누어 각 호출시마다 점진적으로 문제를 해결할 때(Divide and Conquer, 분할 점령) 사용한다.
- 또한 다루는 자료 자체가 재귀적인 구조를 가지고 있을 때도 개쥐 호출을 사용한다.
- 팩토리얼 예시
int Factorial(int n){
if (n <= 1)
return 1;
else
return n*Factorial(n-1);
}
void main() {
printf("1~5까지의 곱=%d\n",Factorial(5));
}
- 재귀 호출보다 복잡하지도 않고 함수 호출에 대한 오버헤드가 없어 속도상으로도 훨씬 더 유리하므로 팩토리얼을 구하기 위해 재귀호출이 꼭 필요한 것은 아니다.
- 재귀 호출 구조를 가지는 함수는 이런 식으로 일반 함수로 변환할 수 있을 경우 가급적이면 재귀 호출은 쓰지 않는 것이 좋다.
- 그러나 어떤 경우는 재귀호출이 아니면 도저히 문제를 해결할 수 없는 경우도 있고 재귀호출을 쓸 때 구조상으로나 속도상으로 훨씬 더 유리한 경우도 있다.
- 같은 함수가 여러 번 호출되더라도 각 호출에 사용되는 인수나 지역변수들은 호출별로 스택에 따로 기억된다.
- 재귀 호출이 무한 호출이 되지 않는 이유는 내부에 반환점이 있기 때문이다.
- 일정한 조건이 되면 스택에 생성된 호출 인스턴스를 역으로 되짚어 돌아갈 수 있는 장치가 있기 때문에 무한히 반복되지 않는다.
- 재귀 호출은 각 호출 인스턴스마다 스택 공간을 소모하므로 호출 깊이가 너무 깊어서는 안 되며, 일정 회수를 넘지 않도록 관리해야 할 필요가 있다.
- 자신을 중복 호출하는 깊이가 수천, 수만회 이상 된다면 스택 공간이 소진되는 사태(Stack Overflow)가 발생할 수 있으므로 이점을 항상 주의해야 한다.
- 스택이란 유한한 메모리 공간이다.
- 재귀 호출 함수가 몇 번이나 재귀를 할 수 있는가는 스택의 남은 양과 함수 내부에서 사용하는 지역변수의 총 크기에 따라 달라진다.
- 스택이 많이 남아 있을수록, 지역변수 크기가 작을수록 더 많은 재귀를 할 수 있다.
- 깊은 호출을 하는 프로그램은 옵션 조정을 통해 스택을 기본값보다 더 크게 잡아주는 것이 좋다.
- 재귀 호출 함수의 지역변수는 꼭 필요한 것만 선언하여 스택 공간을 낭비하지 않도록 해야 한다.
- 최대 재귀 회수도 관리해야 한다.
- 스택 오버플로우 에러를 방지하려면 호출원에서 일정회수 이상을 넘지 않도록 하거나
- 함수 내부에서 한계값 이상은 거부하도록 조건문을 이용한 안전장치를 마련할 필요가 있다.
- 재귀 호출이 꼭 필요한 가장 대표적인 예는 디렉토리를 검색할 때이다.
- 트리 구조 순회에도 재귀 호출을 이용할 수 있다.
3.~5. 인라인 함수, 디폴드 인수, 오버로딩 (생략)
출처 : 혼자 연구하는 C/C++ 1 / 김상형 저 / 와우북스