superkind
7/24/2018 - 1:06 AM

함수 호출 규약(Calling Convention)

#cpp

개요

비주얼 C/C++ 컴파일러는 여러가지 방법으로 함수를 호출한다. 호출 방법이 여러가지이기때문에 호출 방법에 대한 규정에 대하여 알아두고, 이해한다면 프로그램을 디버그하는 것과 코드를 어셈블리어와 링크하는 것에 도움이 될 것이다.

Caller(호출자)와 Callee(피호출자)

아래의 그림을 보면 main()함수와 Sum()함수가 있다. 여기에서 main()함수는 Sum()함수를 호출하기 때문에 호출자가 되고, Sum()함수는 호출되기 때문에 피호출자가 된다.

호출 규정에 따른 인자 전달 순서

__stdcall__cdecl호출 규정을 따르는 함수는 호출시 두개 모두 오른쪽에서 왼쪽방향으로 매개인자를 스택프레임에 삽입한다. 예를 들면 위의 그림에서 main() 함수가 Sum(a, b) 함수를 호출할 때 스택프레임에는 변수 b가 먼저 삽입된 후 그 다음에 변수 a가 삽입된다.

아래의 표는 __stdcall 호출규정과 __cdecl 호출규정을 비교한 것이다.

호출 규정에 따른 함수 호출 과정

__stdcall은 인자의 개수가 고정적인 Win32 API 함수를 호출하는데 사용하는 호출 규정이고, __cdecl은 인자의 개수가 가변적인 함수(printf() 등)를 호출하는데 사용되는 호출 규정이다. 이 두가지 호출 규정에 적용되는 함수가 다르기 때문에 이 함수들이 호출되는 과정도 다르다.

__stdcall

__stdcall 호출 규정의 경우 함수 호출시 넘어가는 매개인자의 개수가 고정적이기 때문에 스택 프레임에 삽입, 삭제되는 인자들이 항상 일정하다. 그래서 함수 호출시 피호출자는 스스로 스택을 정리한 후 함수를 실행하고, 실행을 마친 후 자신이 삽입했던 인자들을 제거하면서 스택을 정리하고 함수를 빠져 나온다.

__cdecl

반면 __cdecl 호출 규정의 경우 함수 호출시 넘어가는 매개인자의 개수가 일정하지 않기때문에 피호출자는 스택을 스스로 정리하기 어렵다. 그래서 함수 호출시 호출자가 스택을 정리한 후 함수를 실행하고, 실행을 마친 후에 다시 호출자가 자신이 삽입했던 인자들을 제거하면서 스택을 정리한다.

번외: __fastcall

이 방식은 스택이 아닌 가까운 레지스터를 사용함으로써 호출 속도가 빠르며 피호출자가 스택을 정리하나 스택을 사용하지 않고 레지스터를 이용한다. 이 호출 규칙은 x86 아키텍처에만 적용된다.

그러나 왼쪽에서 오른쪽으로 인수 목록에서 발견된 처음 두 개의 DWORD 이하 인수만 ECX 및 EDX 레지스터로 전달되고, 다른 모든 인수는 오른쪽에서 왼쪽으로 스택에 전달된다.

이름 그대로 가장 빠른 호출 속도를 가지고 있으나, 초고속 그래픽 등 극한의 스피드를 요구하는 프로그래밍 등 외에는 __fastcall로 얻을 수 있는 속도 이득은 극히 미미하다. 하지만 빈번히 호출되는 함수라면 속도 이득이 없다고 할수 없으며 잘 사용하면 최적의 속도를 내는 프로그래밍에 유리하다.

같은점과 다른점

이 두가지 호출 규저에 따른 호출 방법은 결국 실행되는 코드수와 실행속도는 같지만 어셈블리어로 변환된 실행코드(혹은 실행파일)의 크기는 다르다.

__stdcall 호출 규정을 따르는 함수가 여러번 호출될 경우, 호출자에는 "call Sum"이 중복하여 발생하고, 스택에 인자를 삽입 삭제하는 부분은 피호출자에서 공통적으로 수행한다.

이에 반해 __cdecl호출 규정을 따르는 함수가 여러번 호출될 경우, 호출자는 스택에 인자를 삽입/삭제하는 부분이 함수를 호출할 때마다 발생하기때문에 __stdcall호출 규정보다 어셈블리어로 변환된 실행 코드의 크기가 커진다.

호출 규정에 따른 함수의 이름 변환

__stdcall 호출 규정을 따르는 함수의 선언부는 컴파일러에 의해 다음과 같은 일정한 패턴에 맞추어 바뀐다. 먼저 함수의 이름 앞에는 underscore(_)가 붙고, 함수의 이름 뒤에는 "@(앳 마크)"가 붙습니다. 그리고 "@" 뒤에는 10진 정수로 매개인자의 데이터 크기의 합을 표시해준다. 간단히 예를들면 아래와 같다.

// int a의 데이터 크기가 4 이고, double b의 데이터 크기가 8 이므로 두 데이터 크기의 합은 12이다.
void __stdcall func(int a, double b)        ->        _func@12

반면 __cdecl 호출 규정의 경우 함수 이름 앞에 underscore(_)를 붙이지만 적용되는 함수가 가변인자 함수이기 때문에 매개인자의 데이터 크기의 합을 알기 힘드므로 "@"와 10진수의 데이터 크기부분을 표시하지 않는다. 위의 예를 __cdecl 에 적용하면 아래와 같다.

void __cdecl func(int a, double b)        ->        _func

__cdecl의 경우 위의 코드와 같이 function 포인터의 이름이나 함수 이름 앞에 __cdecl키워드를 위치시킨다.

컴파일러 옵션에 대하여

__cdecl 은 C naming conventions(C 명명 규칙)와 C 호출 규정의 기본이기 때문에 __cdecl 이라고 명시하지 않으면 __cdecl 이 적용된다. 기본적으로 적용되는 호출 규정을 변경하려면 다음의 세가지 컴파일러 옵션 중 하나를 설정하면 된다.

  • /Gz : 함수가 명시적으로 호출 규정을 선언하지 않았을 경우 기본으로 __stdcall 을 적용한다.
  • /Gr : 함수가 명시적으로 호출 규정을 선언하지 않았을 경우 기본으로 __fastcall 을 적용한다.
  • /Gd : 해당 함수의 호출 규정이 어떤 것이든 상관없이 강제적으로 __cdecl호출 규정을 적용한다.

#define과 호출 규정

LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)

종종 위와 같이 정의된 함수를 본다. 여기에서 LRESULT는 반환타입이고, WndProc은 함수의 이름이다. 그렇다면 CALLBACK은 뭘까?

#define CALLBACK    __stdcall
#define WINAPI      __stdcall
#define WINAPIV     __cdecl
#define APIENTRY    WINAPI
#define APIPRIVATE  __stdcall
#define PASCAL      __stdcall

#define으로 선언된 CALLBACK은 컴파일시 "__stdcall"로 대체되면서 위에 정의한 함수는

LRESULT __stdcall WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)

로 대체되게 된다. 즉 WndProc함수는 __stdcall호출 규정을 따르는 함수임을 나타내는 것이다. CALLBACK 뿐만 아니라 위에 선언된 WINAPI 역시 함수 선언에서 사용하면 함수의 호출 규정을 __stdcall 로 적용하고, WINAPIV는 함수의 선언에서 사용하면 함수를 __cdecl 호출 규정으로 적용한다. 그 외의 키워드도 함수의 호출 규정을 적용할 때 사용된다.

각 규약 정리

규약내용
__cdeclx86 구조에서 주로 사용(C/C++ 컴파일러에서 기본적으로 사용함), 함수 호출 시 오른쪽 인자부터 스택에 전달함, 호출자가 스택을 정리 함(add ESP, n)
__stdcallMS Win32API 표준 규약, 함수 호출 시 오른쪽인자부터 스택에 전달함, 호출당한 함수가 사용한 스택을 정리함(ret n)
__fastcall매개변수의 일부를 레지스터(ECX, EDX)로 전달함, 함수 호출 시 다른 규약에 비해 빠름, 호출당한 함수가 사용한 스택을 정리함(ret n)