본문 바로가기

3.구현/C or C++

함수호출 규약

언젠가 정리해야지 하면서 이제야 정리하게 되었다. C나 C++를 하면서 함수 호출 규약은 적어도 한번은 들어보았을 것이다. 사실 이부분은 그냥 넘어가기 쉬운 부분이다.

그러나 컴파일러간, C와 C, C++와 C++, C와 C++간에 반드시 확인해서 넘어가야할 부분이다. 이부분은 언제 어디서 문제가 발생하지 모르기 때문이다. 그리고 API 정의할때도 반드시 확인해야하는 부분이기도 하다.

이 글에서는 X86환경에서 VC를 사용하여 확인하였다. 다른 컴파일러나 환경에서는 다를 수 있으니 주의하기 바란다.

함수 호출 규약 종류

여기서는 주로 VC에서 사용하는 함수 호출 규약을 살펴보기로 하겠다. 나머지 호출 규약은 추후에 계속 갱신할 계획이다.

VC에서 제공하는 함수 호출 규약은

  • __cdecl
  • __fastcall
  • __stdcall
  • __thiscall

이들을 구분하는 기준은 아래 3가지에 의해서 구분된다.

  • 함수 인자 넘겨지는 방법(스택에 쌓이는 순서)
  • 스택 정리하는 위치
  • this객체 넘기는 방법(C++ only)

함 수 호출할 때에 함수에 넘겨지는 인자가 스택에 어떻게 쌓이는지 나타내는 것이다. 대부분 오른쪽에 왼쪽으로 스택에 저장되기 때문에 해당 호출 규약을 구분하기에는 모호한 부분이 있다. 그래서 공통된 이부분을 먼저 다루고 어셈블리에서 어떻게 사용되는를 살펴보겠다. 어셈블리에 대한 자세한 설명도 같이 하겠다.

하나씩 들어가기 전에 먼저 3가지 구분에 따라서 함수 호출 규약들을 정리해보았다.

구분
인자 넘기는 순서 스택 정리하는 위치 this 넘기는 방법
(C++ only)
Name Mangling 비고
cdecl <--- Caller 스택 맨 위에 저장 _someFunc
fastcall 첫번째, 두번재 인자를 각각 ECX, EDX에 저장
그 외는 <---
Callee ECX에 저장
첫번째 인자만 EDX에 저장됨
_someFunc@12
stdcall <--- Callee 스택 맨 위에 저장 @someFunc@12
thiscall <--- Callee ECX에 저장 없음 C++에서만 사용 가능

호출 규약들 간에 공통적으로 인자는 왼쪽에서 오른쪽 방향으로 스택에 저장이 된다. 단 fastcall만 몇개 인자가 레지스트리를 통해서 넘겨지는 것만 틀리다. 여기서는 다루지 않았지만 pascal이라는 함수 호출 규약이 있다. 이 함수 호출 규약이 인자는 오른쪽에서 왼쪽으로 스택에 저장되며, 스택 정리는 callee에서 수행되는 규약이다.

Caller와 Callee는 main함수에서 someFunc함수를 호출한다고 하면, someFunc함수 입장에서 보면 main함수는 caller가 되고 someFunc함수는 callee가 된다.

호출 규약 중에서 thiscall은 c 함수에서는 사용할 수 없다. 그렇기 때문에 C++에서만 확인했다.

그외 몇가지 함수 호출 규약이 있지만, 주로 사용하는 것만 다루었다. 그리고 지금은 거의 사용하지 않는 것은 다루지 않았다.

스택은 caller가 callee를 호출하기 전과 callee가 caller로 반환하기 전으로 2가지 그림을 보여줄 것이다.

cdecl

cdecl는 많은 x86 C 환경에서 사용되는 함수 호출 규약이다. 리눅스에서 de-facto 표준이라고 할 수 있다. cdecl이 가변인자를 처리할 수 있는 규약이다. 즉 모든 인자가 스택에 저장된다. VC에서는 C 함수에 별다른 함수 호출 규약을 지정하지 않으면 cdecl를 기본으로 지정된다.

cdecl호출규약의 특징은,

  • 인자는 오른쪽에서 왼쪽으로 스택에 저장되서 넘겨짐
  • 최종 스택 정리는 호출자(Caller)에서 수행함
  • this는 인자가 저장된후 스택 맨 위에 저장됨

C언어

someFunc함수 호출 전에 main함수에서 인자를 스택에 저장한다. 그리고 someFunc함수를 호출한다.

int ret1 = someFunc (1, 2, 3);  // (1) 일반함수 호출 
002A356E  push        3 
002A3570  push        2 
002A3572  push        1 
002A3574  call        002A10E6 

someFunc함수에서 연산 작업을 수행하고, 스택 포인터(ESP)도 함수 호출 전으로 복귀한다음에 반환한다.

int TestAPI someFunc(int p1, int p2, int p3)
{
00821390  push        ebp 
00821391  mov         ebp,esp 
...(중략)
}
...(중략)
008213BA  mov         esp,ebp 
008213BC  pop         ebp 
008213BD  ret 

다시 main 함수로 돌아와서 나머지 스택에 남아있는 인자도 정리한다. 즉, caller에서 스택을 정리한다.

002A3579  add         esp,0Ch     // 스택 정리 
002A357C  mov         dword ptr [ebp-8],eax 

C++언어

c 언어와 마찬가지로 인자를 스택에 저장한다. 그리고 this객체를 스택에 저장한다.

그런 후에 someMemFunc 함수를 호출한다.

int ret2 = someClass.someMemFunc (1, 2, 3); // (2) 멤버함수 호출
002A3587  push        3 
002A3589  push        2 
002A358B  push        1 
002A358D  lea         eax,[ebp-14h]  // someClass
002A3590  push        eax              //  this push
002A3591  call        002A11D6

someMemFunc 함수에서 this객체는 ebp+8를 참조해서 처리하게 된다.

int TestAPI someMemFunc(int p1, int p2, int p3)
{
002A13C0  push        ebp 
002A13C1  mov         ebp,esp 
...(중략)
return this->val;
002A13EC  mov         eax,dword ptr [ebp+8]  // this를 ebp+8에서 참조
002A13EF  mov         eax,dword ptr [eax]
}

someMemFunc 함수 연산이 종료되고 함수가 리턴 될때 스택 포인터(ESP)가 처음 호출할 때의 위치로 이동하게 된다.

...(중략)
002A13F4  mov         esp,ebp 
002A13F6  pop         ebp 
002A13F7  ret 

다시 main 함수로 돌아와 나머지 스택에 있는 인자를 정리한다. caller에서 스택 정리.

002A3596  add         esp,10h         // 스택 정리
002A3599  mov         dword ptr [ebp-20h],eax 

fastcall

fastcall은 함수 호출을 좀 더 빨리처리하기 위한 호출 규약이다. 별다른 방법이 있는 것이 아니라 인자를 스택에 넣는 것이 아니라 래지스트리에 넣는다. 당연히 레지스트리로 처리하기 때문에 빠를 수 밖에 없다. 이는 표준이 없기때문에 주의를 요한다.

단, 인자가 몇 개 이내 일경우에 유리하며, 그 이상이면 크게 차이는 없다.

fastcall의 특징을 살펴보면;

  • 인자는 오른쪽에서 왼쪽으로 스택에 저장되서 넘겨지지만, C에서 첫번째 인자와 두번째 인자는 ECX, EDX에 저장되며, C++인 경우 첫번재 인자만 EDX에 저장됨
  • 최종 스택 정리는 호출자(Callee)에서 수행함
  • this는 인자가 ECX에 저장됨

C언어

someFunc 함수 호출 전에 첫번째 인자와 두번째 인자를 각각 ECX와 EDX에 저정한다. 그 이상이 되는 인자를 스택에 저장하고 someFunc 함수를 호출한다.

int ret1 = someFunc (1, 2, 3);  // (1) 일반함수 호출
0116356E  push        3 
01163570  mov         edx,2 
01163575  mov         ecx,1 
0116357A  call        011611DB 

someFunc 함수가 실행되고, 연산이 끝나면 함수 포인터(ESP)가 함수 시작할 때 위치로 변경된다.

int TestAPI someFunc(int p1, int p2, int p3)
{
01161400  push        ebp 
01161401  mov         ebp,esp 
...(중략)
}
...(중략)
01161432  mov         esp,ebp 
01161434  pop         ebp 

마지막으로 someFunc함수에서 리턴될때에 스택 포인터를 조작한다. 즉, 스택 포인터에 있는 인자를 모두 제거하게 된다. 이는 callee에서 스택을 정리한 모습니다.

01161435  ret         4 

main함수로 돌아와서는 결과를 저장한다.

0116357F  mov         dword ptr [ebp-8],eax 

C++언어

someMemFunc함수를 호출하기 전에 인자를 레지스트리와 스택에 저장한다. C와 틀린 것은 첫번재 인자만 EDX 레지스트리에 저장하고 나머지 인자를 오른쪽에서 왼쪽으로 스택에 저장해서 넘겨지게 된다. ECX레지스트리에는 this 가 담겨서 넘겨지게 된다.

int ret2 = someClass.someMemFunc (1, 2, 3); // (2) 멤버함수 호출
0116358A  push        3 
0116358C  push        2 
0116358E  mov         edx,1 
01163593  lea         ecx,[ebp-14h]  // someClass(this)를 ecx에 저장
01163596  call        011611E0 

someMemFunc 함수가 호출되서 실행된 후에 스택 위치를 함수 처음 실행할 때 위치로 복원한다. C++이므로 this는 ecx를 참조하게 된다. 중간에 ecx를 [ebp-14h]에 저장했기 때문에 this에 대한 참조는 [ebp-14h]를 참고하면 된다.

int TestAPI someMemFunc(int p1, int p2, int p3)
{
00171500  push        ebp 
00171501  mov         ebp,esp 
...(중략)
00171523  mov         dword ptr [ebp-14h],ecx
...(중략)
return this->val;
00171534  mov         eax,dword ptr [ebp-14h]
00171537  mov         eax,dword ptr [eax] 
}
...(중략)
0017153C  mov         esp,ebp 
0017153E  pop         ebp 

C함수 호출과 마찬가지로 스택 정리를 callee(someMemFunc함수)에서 실행하고 리턴한다.

0017153F  ret         8 

main함수로 돌아과 결과를 저장한다.

0116359B  mov         dword ptr [ebp-20h],eax 

stdcall

가장 표준적인 호출 규약(?)이다. parscal이라는 함수 호출규약의 변형으로 MS의 Win32 API와 Open Watcom C++에서 사용되는 기본 호출 규약이라고 보면 된다.

stdcall의 특징을 살펴보면;

  • 인자는 오른쪽에서 왼쪽으로 스택에 저장되서 넘겨짐
  • 최종 스택 정리는 호출자(Callee)에서 수행함
  • this는 인자가 인자가 저장된 후 스택 맨위에 저장됨

C언어

someFunc함수를 호출하기 전에 인자를 오른쪽에서 왼쪽 방향으로 스택에 저장한다.

int ret1 = someFunc (1, 2, 3);  // (1) 일반함수 호출
001F356E  push        3 
001F3570  push        2 
001F3572  push        1 
001F3574  call        001F11F9

someFunc함수가 호출되서 연산이 실행된 후에 스택에서 사용했던 모든 데이터를 지우고 함수 처음 스택 위치로 이동한다.

int TestAPI someFunc(int p1, int p2, int p3)
{
001F1400  push        ebp 
001F1401  mov         ebp,esp 
...(중략)
}
...(중략)
001F142A  mov         esp,ebp 
001F142C  pop         ebp 

someFunc함수가 리턴될 때에 스택에 남아있는 모든 인자를 지우고 스택을 정리한다. callee에서 스택을 정리.

001F142D  ret         0Ch

main함수로 돌아와서 결과를 저장한다.

001F3579  mov         dword ptr [ebp-8],eax 

C++언어

someMemFunc함수를 호출하기 전에 인자를 오른쪽에서 왼쪽으로 스택에 저장한다. 그리고 this가 스택 맨 위에 저장된다.

int ret2 = someClass.someMemFunc (1, 2, 3); // (2) 멤버함수 호출
001F3584  push        3 
001F3586  push        2 
001F3588  push        1 
001F358A  lea         eax,[ebp-14h] 
001F358D  push        eax 
001F358E  call        001F11FE 

someMemFunc함수가 실행된다. this에 대한 참조는 스택에 있는 [ebp+8]를 참고하면 된다. 이는 처음 스택에 저장된 this 위치를 가리킨다.

int TestAPI someMemFunc(int p1, int p2, int p3)
{
001F1AD0  push        ebp 
001F1AD1  mov         ebp,esp 
...(중략)
return this->val;
001F1AFC  mov         eax,dword ptr [ebp+8] 
001F1AFF  mov         eax,dword ptr [eax] 
} 

someMemFunc함수가 종료될 때에 스택에 저장된 데이터를 지우고 함수가 처음 실행될 때의 스택 위치로 이동한다.

...(중략)
001F1B04  mov         esp,ebp 
001F1B06  pop         ebp

someMemFunc함수가 리턴될 때에 스택에 남아 있는 모든 인자를 제거하고 반환된다. callee에서 스택을 정리.

001F1B07  ret         10h 

main함수로 돌아와서 결과를 저장한다.

001F3593  mov         dword ptr [ebp-20h],eax

thiscall

VC에서 C++에서 정적함수가 아닌 멤버함수에만 사용가능한 함수 호출 규약이다. 이 규약은 컴파일러에 따라서, 가변 인자 사용 여부에 따라서 두가지 버전이 존재한다.

GCC인 경우 thiscall을 거의 cdecl로 인지한다. VC인 경우는 stdcall고 비슷하지만 this 처리만 다를 뿐이다. VC에서 C++ 멤버함수에 별다른 함수 호출 규약을 지정하지 않으면 thiscall를 사용한다.

thiscall의 특징을 보면;

  • 인자는 오른쪽에서 왼쪽으로 스택에 저장되서 넘겨짐
  • 최종 스택 정리는 호출자(Callee)에서 수행함
  • this는 ECX 레지스트리 저장되서 넘겨짐

C++언어

main함수에서 someMemFunc함수를 호출하기 전에 인자를 오른쪽에서 왼쪽으로 스택에 저장된다. 그리고 this도 ecx 레지스트리에 저장한다.

int ret2 = someClass.someMemFunc (1, 2, 3); // (2) 멤버함수 호출
00CC1FA6  push        3 
00CC1FA8  push        2 
00CC1FAA  push        1 
00CC1FAC  lea         ecx,[ebp-8] 
00CC1FAF  call        00CC1203

someMemFunc함수가 실행된다. this는 ecx에서 [ebp-8]에 저장되고 this는 [ebp-8]을 참조하면 된다. 그리고, 함수 연산이 완료되면 스택에 있는 모든 데이터가 제거되고 함수 호출할 때의 스택 위치로 이동한다.

int TestAPI someMemFunc(int p1, int p2, int p3)
{
00CC1B10  push        ebp 
00CC1B11  mov         ebp,esp 
...(중략)
00CC1B30  mov         dword ptr [ebp-8],ecx 
...(중략)
return this->val;
00CC1B41  mov         eax,dword ptr [ebp-8] 
00CC1B44  mov         eax,dword ptr [eax] 
}
...(중략)
00CC1B49  mov         esp,ebp 
00CC1B4B  pop         ebp 

someMemFunc함수가 리턴될 때에 스택에 남아 있는 인자를 모두 제거한다. callee에서 스택을 정리.

00CC1B4C  ret         0Ch 

main함수로 돌아와서 결과를 저장한다.

00CC1FB4  mov         dword ptr [ebp-14h],eax 

결론

여기서 다룬 내용을 맹신하지 말라. 여러가지 변수에 의해서 실제 어셈블리 코드가 달라질 수 있고, 운영체제 별로 달라질 수 있는 부분이다. 특이 fastcall인 경우 VC이 Boland와 차이가 있다. 예를 들어 VC는 2개 인자가 레지스트리로 넘겨지지만 Boland는 3개 인자가 레지스트리로 넘겨진다. 이 것은 분명히 다르다. 당연히 GCC도 차이가 발생할 것이다.

이런 부분은 본인이 직접 확인해볼 수 밖에 없다. 모든 컴파일러와 환경에 대해서 다루기는 너무 힘들기 때문이다. 이 글에서는 다른 함수 호출 규약과는 다르게 this 객체를 더 추가해서 다루었다. C++를 주로 하다보니 이런 부분이 궁금했었기 때문이다.

이를 이용하면 C++에서 C함수를 호출하면서 C++의 this를 C함수의 인자로 넘겨줄 수 도 있다. 물론 이런 별로 좋은 방법은 아니다. 그냥 그렇 수 있다는 것일 뿐이다.

이상으로 이 글을 마친다. ospace.

참조

[1] 신영진, 2006, "함수 호출 규약", http://www.jiniya.net/lecture/techbox/callconv.html

[2] ?, "함수 호출 규약, Function Calling Convention (1/2)", http://beforu.egloos.com/2117375

[3] Winapi, "16-1-다.호출 규약", http://winapi.co.kr/clec/cpp2/16-1-3.htm

[4] Declaring Attributes of Functions, http://gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html

[5] Calling convention, http://en.wikipedia.org/wiki/Calling_convention

[6] x86 calling conventions, http://en.wikipedia.org/wiki/X86_calling_conventions

[7] Name mangling, http://d.mumbi.net/cpp:name-mangling

반응형