[어셈블리 스터디]


호출자와 피호출자간의 약속!

함수 호출 규약(Calling Convention)




함수 호출 규약(Calling Convention)이란 호출자(Caller)와 피호출자(Callee) 간에 '함수를 호출할 때 전달되는 인자의 순서나 사용이 끝나고 나서의 스택 정리 등'에 대한 약속이라고 할 수 있습니다. 크게 3가지가 있으며, 이 3가지는 각각 __cdecl, __stdcall, __fastcall 방식입니다. 오늘은 이 세가지 방식에 대해 알아보려고 합니다.


__cdecl 방식

__cdecl 방식은 C/C++ 함수에서 기본적으로 사용되는 호출 규약이며, 호출자가 스택을 정리합니다. 그리고 인자는 오른쪽에서 왼쪽으로 전달되며 호출자가 피호출자를 호출 시에 전달되는 인자의 개수를 알고있기 때문에 가변 인수 함수를 만들 수 있다는 장점을 지니고 있습니다. 우선 아래의 코드를 빌드하고 난 뒤의 바이너리를 디버거를 통해 살펴보도록 하겠습니다.

int sum(int a, int b)
{
	return a + b;
}

int main(int argc, char* argv[])
{
	sum(5, 4);
	return 0;
}

위 코드에서는 5와 4를 sum 함수에 전달한 후에, sum 함수에서 전달된 값을 가지고 서로 더한 값을 반환하게 됩니다. main 함수의 어셈블리를 잠시 확인해보도록 합시다.

00401068  |. 6A 04          PUSH 4
0040106A  |. 6A 05          PUSH 5
0040106C  |. E8 94FFFFFF    CALL Consolas.00401005
00401071  |. 83C4 08        ADD ESP,8

위는 main 함수의 일부이며, 코드를 보시면 오른쪽에서 왼쪽으로 인자가 차례대로 스택에 올라가는 것을 볼 수 있으며, sum 함수(401005)를 호출하고 나서 아래를 보시면 ADD ESP, 8로 호출자(main 함수)에서 스택을 정리하는 것을 확인할 수 있습니다. 그리고 추가적으로 ADD ESP, 8과 PUSH문이 두번 쓰인것으로 봐서 4바이트를 차지하는 2개의 인자가 전달되었음을 확인할 수 있습니다. 이처럼, cdecl 방식은 호출자가 피호출자의 스택 프레임을 정리한다는 것을 알 수 있습니다.


__stdcall 방식

__stdcall 방식은 Win32 API에서 사용되며, 피호출자가 스택을 정리합니다. 그리고 인자는 __cdecl 방식과 마찬가지로 오른쪽에서 왼쪽으로 전달되며 Win32 API에서는 가변 인수 함수가 없기 때문에, 매개변수의 개수가 고정적입니다. 이는 호출자에서 스택을 정리하는 것보다, 피호출자가 스택을 정리하는게 더욱 효율적입니다. 우선 아래의 코드를 빌드하고 난 뒤의 바이너리를 디버거를 통해 살펴보도록 하겠습니다.

int __stdcall sum(int a, int b)
{
	return a + b;
}

int main(int argc, char* argv[])
{
	sum(5, 4);
	return 0;
}

위 예제는 제일 처음에 사용되었던 예제와 기능이 동일합니다. 단지 이번에는 __stdcall가 함수명 앞에 붙었을 뿐입니다. 한번 main 함수의 어셈블리를 확인해보도록 합시다.

00401068  |. 6A 04          PUSH 4
0040106A  |. 6A 05          PUSH 5
0040106C  |. E8 9EFFFFFF    CALL Consolas.0040100F

위는 main 함수의 일부이며, 코드를 보시면 오른쪽에서 왼쪽으로 인자가 차례대로 스택에 올라가는 것을 볼 수 있습니다. 그런데 이번에는 호출자가 아닌 피호출자에서 스택을 정리하는 것이기 때문에 호출자에 따로 스택을 정리하는 코드가 존재하지 않습니다. 피호출자를 살펴보아야 하죠.

00401020 >/> 55              PUSH EBP
00401021  |. 8BEC            MOV EBP,ESP
..
00401038  |. 8B45 08         MOV EAX,DWORD PTR SS:[EBP+8]
0040103B  |. 0345 0C         ADD EAX,DWORD PTR SS:[EBP+C]
..
00401041  |. 8BE5            MOV ESP,EBP
00401043  |. 5D              POP EBP
00401044  \. C2 0800         RETN 8

위에서 눈여겨보아야 할 부분이 함수의 마지막 부분인 401044입니다. 401044를 보시면 RETN 8 명령으로 sum 함수 내에서 스택을 정리함을 알 수 있습니다. 이렇게 반환이 끝나고 나서 ESP(스택 포인터)를 8만큼 증가시킵니다. 이처럼 __stdcall 방식은 __cdecl 방식과는 다르게 호출자에서 스택을 정리하지 않고, 피호출자에서 스택을 정리한다는 사실을 기억해두세요.


__fastcall 방식

__fastcall 방식은 이름부터가 정말 빨라보이지 않나요? 이 __fastcall 방식에선 스택이 아닌 가까운 레지스터를 사용함으로써 호출 속도가 빠르며 피호출자가 스택을 정리하나 스택을 사용하지 않고 레지스터를 이용하므로 정리할 내용이 없어 따로 정리를 하지 않습니다. 우선 아래의 코드를 빌드하고 난 뒤의 바이너리를 디버거를 통해 살펴보도록 하겠습니다.

int __fastcall sum(int a, int b)
{
	return a + b;
}

int main(int argc, char* argv[])
{
	sum(5, 4);
	return 0;
}

위 예제는 제일 처음에 사용되었던 예제와 기능이 동일합니다. 단지 이번에는 __fastcall가 함수명 앞에 붙었을 뿐입니다. 한번 main 함수의 어셈블리를 확인해보도록 합시다.

00401068  |. BA 04000000     MOV EDX,4
0040106D  |. B9 05000000     MOV ECX,5
00401072  |. E8 98FFFFFF     CALL Consolas.0040100F

위는 main 함수의 일부이며, 코드를 보시면 EDX 레지스터와 ECX 레지스터를 사용하여 전달하고 있음을 알 수 있습니다. 이 __fastcall 방식을 사용하게 되면 앞에 두개의 매개변수는 ECX와 EDX 레지스터를 이용하지만, 나머지 매개변수는 위에서 설명드린 __cdecl 방식과 __stdcall 방식과 동일하게 오른쪽부터 왼쪽으로 인자가 스택에 올라갑니다. 우선 피호출자를 확인하여 어떻게 덧셈이 이루어지는지 살펴보도록 합시다.

00401020 >|> 55              PUSH EBP
00401021  |. 8BEC            MOV EBP,ESP
..
0040103A  |. 8955 F8         MOV DWORD PTR SS:[EBP-8],EDX
0040103D  |. 894D FC         MOV DWORD PTR SS:[EBP-4],ECX
00401040  |. 8B45 FC         MOV EAX,DWORD PTR SS:[EBP-4]
00401043  |. 0345 F8         ADD EAX,DWORD PTR SS:[EBP-8]
..
00401049  |. 8BE5            MOV ESP,EBP
0040104B  |. 5D              POP EBP
0040104C  \. C3              RETN

위의 코드에서는, EDX 레지스터의 값을 EBP-8에 넣고 ECX 레지스터의 값을 EBP-4에 넣습니다. 그리고 EBP-4의 값을 EAX 레지스터에 넣고, EAX 레지스터의 값과 EBP-8의 값을 서로 더한값이 EAX 레지스터에 들어갑니다. 이처럼 __fastcall 방식은 앞에 두개의 매개변수는 ECX와 EDX 레지스터를 이용하여 호출 속도가 빠른 방식입니다.