프로그래밍/C++

#24. 함수 호출 규약 ( Calling Convention )

코딩하는상후니 2022. 7. 18. 22:52

 

* 함수 호출 규약

 

'함수를 호출할 때 파라미터를 어떤 식으로 전달하는지' 에 대한 규칙을 정의한다.

 

 
@Caller ( 호출자 )  :  함수를 호출한 곳.
@Callee ( 피호출자 )  :  호출 당하는 함수.
 

 

=> 속성 페이지 -> 고급 -> 호출 규칙 수정 가능.

 

 


( cdecl, stdcall, fastcall 모두 x86 환경에서 진행해야함. )


 

*cdecl

 

=> Caller ( 호출자 ) 에서 매개변수 처리.

 
호출자에서 매개변수를 push 로 스택에 저장한다.
 
그림에서 보는 것처럼 호출자에서 esp 위치를 더하며 매개변수를 처리하고 있다.
여기서 더한다는 의미는 현재 스택에 저장된 매개 변수 메모리 주소는 의미가 없기 때문에
더하면서 현재 메모리 위치 ( esp ) 를 옮긴다.
( stack 은 메모리 영역 끝 위치부터 쌓여가므로 스택에 추가하면 메모리 위치는 줄어든다. )
 
여기서 eax, ebx, ecx .. 등은 레지스터를 의미한다. 간단하게 되짚어 보자.
중요하게 볼 것은 3 가지 eip, ebp, esp 정도이다.
eip 는 현재 실행될 코드를 가리킨다. 즉, cpu 가 현재 실행되고 있는 프로그램에서 읽고 있는 코딩줄을 의미한다.
ebp 는 base pointer 로써, 함수의 시작 부분을 결정한다.
esp 는 현재 함수에서 가리키고 있는 스택의 포인터가 되겠다.
 
전체적인 과정으로는
eip 에 해당하는 코드 영역을 실행하면서 함수에 돌입할 때 해당 함수의 ebp 를 정하고 현재 스택 위치를 esp 로 관리한다.
 
추가적으로,
함수 안의 지역 변수의 메모리 위치는 ebp 의 위치부터 쌓여간다.
나중에 살펴보겠지만,
32bit 환경에서는 ebp 를 중심으로 위로 올라가면서 매개변수를 저장하는 메모리 위치가 줄지만,
64bit 환경에서는 ebp 중심으로 내려가는 방식을 취하면서 메모리 위치가 증가한다.


 
 
 
 

 

우리가 살펴본 스택 메모리 과정에서 보았던 것처럼 함수가 쓸 영역을 잡아 놓는다. ( sub esp, 100h )

 
lea edi [ ebp - 40h ] 부분이 조금 의아하다.
밑에 나오는 rep stos 의 명령을 수행하기 위해서 쓰여지는 것 같아보인다.
 
rep stos 를 나눠 설명하면
rep 은 ecx 에 저장된 횟수만큼 반복한다는 의미이며
stos 는 al, ax, eax 의 값을 edi 가 가리키는 메모리 공간에 복사하는 의미이다.
 
이를 토대로 빨간줄 표시 아래쪽에
ecx, 10h  /  eax, 0CCCCCCCCh 가 mov 된다.
 
결과적으로,
10h ( ecx ) 만큼 edi 에 저장된 메모리 주소에 0CCCCCCCCh 를 반복해 복사한다.
 
 
 
 
 
 
 
 

* 0xCCCCCCCCh 를 복사하는 이유는 무엇일까 ??

 
일종의 '컴파일러의 표시' 로 지역 변수로 쓰여질 영역을 해당 값으로 채운다.
이에 따라 해당 영역의 메모리 이외의 영역이 참조되어지는지 구별할 수 있겠다.
 
 
 
 
여기서 ebp 와 esp 레지스터를 기준으로 해당 함수의 지역 변수가 쓰이는 영역을
'스택 프레임 ( Stack Frame )' 이라고 한다.
 
 
 
 

 

 

 


 

 

*stdcall

 
Callee (피호출자) 에서 매개변수 처리.
Win32 API 에서 주로 사용됨.

 

int Func(int a, int b)
{
	int ret = a + b;
	return ret;
}

 

 

 

 

전반적으로 cdecl 과 같은 매커니즘으로 동작하지만,
피호출자에서 마지막 빠져나갈 때, ret ( 매개변수 크기 ) 로 동작된다.
 
 

 


 

*fastcall

 

=> 2개의 매개변수를 레지스터를 이용해 데이터 복사.
 

 

=> cdecl 과 다르게 스택에 push 하는 과정이 없다.

 

=> 인자 2개까지 레지스터를 사용하고 이외에는 push 하는 것을 볼 수 있다.

 

 

 
 
cdecl, stdcall 과 마찬가지로,
지역변수가 쓰일 크기만큼 위치를 지정해서 0xCCCCCCCCh 값을 채워넣는다.
해당 위치를 더해가며 데이터를 저장.
 

 

 

 

 
 
만약 2개 이상의 매개변수가 들어와 레지스터를 사용하지 못하고 스택에 저장될 때,
stdcall 방식처럼 ret 4로 피호출자에서 반환해주는 과정을 살펴볼 수 있다.

 

 

 

 


 
 

*thiscall

 
클래스와 관련된 호출 규약이다.
어떤 클래스 객체의 함수 호출 시, 해당 클래스의 주소를 스택에 '복사' 한다.
즉,
함수 호출 시, 어느 객체에서 호출했는지 알기 위함이다.
 
class Player
{
public:
	Player() {}

public:
	static int Lv;
	static const int Num = 50;

public:
	void PrintLv()
	{
		cout << Lv << endl;
	}

public:
	int Item[Num] = { 0, };
};

 
P 를 동적할당 이후 함수 호출 시, rcx 에 [P] 의 주소를 '복사' 하는 것을 볼 수 있다.

 

 

 

*x64 호출 규칙

 
 
기본적으로 '4 레지스터 fastcall 규칙' 을 사용한다.
매개 변수의 저장 방향은 왼쪽에서 오른쪽으로 저장된다.
또,
32bit 여러가지 호출 규약과는 상반된 방식으로 동작한다.
 
 
 
int Func(int a, int b, int c, int d, int e, int f) 
{ 
	int ret = a + b + c + d + e + f; 
	return ret; 
}

int main() 
{ 
	int result = Func(4, 5, 2, 10, 9, 8); 
	int a = 10; 
	return 0; 
}
 

 

*Func 함수 호출 전

 

 

 
맨 오른쪽 매개변수 '8', '9' 는 현재 스택 영역에 저장된다. 나머지는 레지스터에 복사된다. 
 
 
 
 
 
 
 

*Func 함수 호출 후

 

 

현재 스택 영역을 잡기 전에 레지스터의 값들을 이전 스택 영역에 '복사' 한다.

 

 
 

*매개변수 처리 이후 과정

 

 

 

첫번째 그림에서, 마찬가지로 sp 를 빼면서 영역을 잡는데,
x64 에선 bp 의 동작이 다르다.
 
이전 x86 호출 규약들에선,
ebp 를 esp 에 먼저 위치시키고 esp 를 빼면서 함수의 영역을 잡는 방식이라면
x64 호출 규약에서는 먼저 rsp 를 빼고 난 후의 위치에 rbp 를 위치시킨다.
이 때 한 가지 더 중요한 것은 4개의 레지스터의 메모리를 위해 그림에서 보는 것처럼 ( lea rbp, [ rsp+20h] )
총 0x20h ( 32bit ) 의 공간을 확보한 후 rbp 를 위치시킨다.
 
이에 따라,
x86 호출 규약에선 ebp 기준 '위로' 지역 변수가 위치되고
x64 호출 규약에선 rbp 기준 '아래로' 지역 변수가 위치된다.
 
 
 
 

 

함수 종료 시, eax 에 결과 값을 저장한다.
 
마지막으로,
( lea   rsp, [ rbp + 0E8h ] ) 명령어 수행으로 함수 안으로 들어온 처음 위치를 계산한다.
( 108h - 20h = 0E8h. )
 
 
 
 
 
 

* 의문점

 
 
x64 호출 규약에서 몇 가지 의아한 점은
 
"함수 호출 이전에서 [ rsp + 20h ] 위치에 9를 저장했는데,
왜 함수 안으로 들어와 [ rsp + 20h ] 위치에 다시 r9d 레지스터 값을 저장하는가 ??" 이다.
 
이러한 이유는함수에 진입하기 전에 스택에 되돌아올 주소가 저장되기 때문이다.
 
 
또 하나는
x64 호출 규약에서는 5번째 인자부터는 스택을 사용하게 된다.
이 때, 궁금한 점은
"레지스터를 사용해 스택에 저장하는 것과 그렇지 않은 것의 어떤 차이가 존재할까 ??"
"만약 레지스터를 사용해서 저장하는 것이 더 빠르다면 왜 빠른 것인가 ??" 이다.
 
 
x86 에서 스택에 push 하는 과정과 x64 에서 레지스터를 사용하는 과정을 비교해보자.
( 이전과 다르게 파라미터 유형도 지역 변수를 섞었다. )
 
int Func(int a, int b, int c, int d, int e, int f)
{
	int ret = a + b + c + d + e + f;

	return ret;
}

int main()
{
	int AA = 111, BB = 222, CC = 333, DD = 44, FF = 55;
	int result = Func(AA, BB + 1, CC + 2, DD + 3, FF, 8);
	int a = 10;
	return 0;
}
 
 
 
왼쪽은 x86 의 cdecl 호출 규약을 사용하는 방식이고 오른쪽은 x64 레지스터 4개를 사용하는 호출 규약이다.
 
여기서 알 수 있는 사실은 나는 x64 호출 규약에선 함수의 파라미터 4개까지 레지스터를 사용한다고 알고 있었는데
5,6, 혹은 그 이상의 파라미터들도 레지스터를 이용한다.
단지, 함수를 들어가기 전에 호출자가 4개 인자를 저장하는 곳 아래 영역 메모리에 해당 값을 mov 한다.
Window 환경에서는 스택 프레임을 잡을 때, 4개의 레지스터 영역을 비워두는 작업이 수행되기 때문이다.
 
 
오른쪽 그림의 x64 함수 호출 과정을 정리하면
FF, 8 인자는 함수 호출 전에 어떤 영역에 저장되는데 해당 영역이 '스택 프레임 영역' 이다.
그래서 5번째 인자부터는 스택을 사용한다고 말하는 것처럼 보인다.
이 후, 함수 안에 들어가선 4개의 레지스터의 값들을 일전에 호출자가 스택 프레임 영역을 잡을 때 비워둔
esp 와 ebp 사이의 0x20h 영역에 저장한다.
 
반면 왼쪽 그림의 x86 함수 호출 과정을 보면
eax, ecx, edx 레지스터를 이용해 해당 값을 push 하는 것을 볼 수 있다.
 
 
 

* 두 과정의 차이점은 무엇일까 ??

 
첫번째는
x64 호출 과정에서는 사용하는 레지스터를 늘어난 점이다. ( r9d, r8d.. )
 
다른 호출 규약들이 레지스터를 사용하지 않는 것이 아니다.
x64 호출 규약처럼 사용하는 레지스터를 늘려 쓰는 것이 성능이 향상되는가 ??
만약 r9d, r8d 레지스터가 긴밀하게 메모리에 더 빨리 저장할 수 있는 레지스터라면 성능은 향상될 것이다.
 
또,
x86 에서는 함수 호출 전 모든 값들을 스택에 push 하지만,
x64 에서는 5번째 인자부터 호출 전 스택 영역에 저장시키고 4번째 인자까지는 함수 안에 들어갔을 때 호출자가 비워둔 4개의 레지스터 영역으로 이동시킨다.
굳이 2단계를 거쳐야할 필요가 있었을까 ?? 내부적으로 어떤 이유인지는 찾지 못했다.
알 수 있는 것은 함수를 들어가기 전 해당 파라미터에 해당하는 레지스터와 짝지어줘야하는 점이다.
 
 
하지만, 한 가지 이점은 확실하게 보인다. 바로 스택 크기가 x64 호출 규약이 적용되면 더 적다는 것이다.
왜냐하면,
단순하게 스택에 push 하는 것이 아닌 호출자에서 파라미터들의 인자 영역들을 잡고 함수를 호출하기 때문에
( 4개의 레지스터 영역을 위해 rsp + 0x20h 에 rbp 를 잡는 작업 )
반복적인 함수 호출 시, 해당 영역을 재사용함으로써 함수 호출마다 스택이 파라미터만큼 계속 늘어날 수 있는 상황을 해결한다.
 
 
 
결론적으로, 나는 스택 영역이 줄어든다는 위 사실 외에
아직 x64 호출 규약이 4개의 레지스터를 사용한다고 해서 이것이 효율적인 사실을 찾을 수 없었다.
 
 
최종적으로 알 수 있는 사실은
Window MS 에서 x64 호출 규약은 함수 인자 4개까지 매칭될 수 있는 전용 레지스터들이 존재하고 초과되는 인자들은 함수 호출 전 스택 영역에 저장된다.
또, 4개의 레지스터 매개 변수를 저장할 수 있는 충분한 공간을 호출자가 할당해야한다는 것이다.
( Linux 에서는 사용되는 레지스터 수가 다르다고 한다. )
 
즉,
x64 호출 규약은 '4개의 레지스터를 사용하는 방식' 에 최적화 되어있다.
 
 
 
 
 
 

 

 

 

 

 

'프로그래밍 > C++' 카테고리의 다른 글

#26. 호출 스택  (0) 2022.07.18
#25. 값 전달  (0) 2022.07.18
#23. #define & typedef & Rand & 열거형  (0) 2022.07.17
#22. 반복문  (0) 2022.07.17
#21. 분기문  (0) 2022.07.17