디스어셈블리 코드로 살펴보는 함수 호출 규약
디스어셈블리 코드를 통해 __cdecl, __stdcall, __fastcall 에 대해 분석한다
1. 함수 호출규약(Calling Convention)
c/c++의 디폴트 호출규약으로 알려져 있는 __cdecl과 표준 함수 호출규약으로 알려져 있는 __stdcall 키워드를 본적이 있는가?
Windows Programming을 한번이라도 접해 봤다면,
아마도 WINAPI, APIENTRY, CALLBACK 이라는 키워드들을 본적이 있을것이다.
실제로 위의 키워드들은 다음과 같이 매크로로써 정의되어져 있다.
1
2
#define CALLBACK __stdcall
#define WINAPI __stdcall
즉,
int CALLBACK EventRoutine(void); 과 같이 선언된 함수가 있다면
이는 int EventRoutine(void) 함수가 __stdcall 함수 호출규약을 따르고 있음을 의미하는 것이다.
물론 이 두 규약 외에도 다른 규약들이 있다.
다음은 MSDN에 정의되어져 있는 segment word size가 32bit인 함수 호출규약 정의 테이블이다.
| 호출 규약 | 설명 |
|---|---|
| __cdecl | parameter들을 역순으로((수식의)오른쪽에서 왼쪽 방향으로) 스택에 푸쉬한다. 스택 프레임 반환 작업은 호출자(Caller: 함수를 호출한 프로시저)가 한다. |
| __clrcall | parameter들을 순서대로(왼쪽에서 오른쪽 방향으로) CLR 수식 스택에 적재한다. 스택 프레임 반환 작업을 하지 않는다. |
| __stdcall | parameter들을 역순으로(오른쪽에서 왼쪽 방향으로) 스택에 푸쉬한다. 스택 프레임 반환 작업은 피호출자(Callee: 호출을 받은 함수)가 한다. |
| __fastcall | parameter들 중 처음 2개까지만 레지스터(ecx, edx)에 저장하고, 나머지는 스택에 푸쉬한다. 스택 프레임 반환 작업은 피호출자가 한다. |
| __thiscall | this 포인터만 레지스터 ecx에 저장하고, 나머지는 스택에 푸쉬한다. 스택 프레임 반환 작업은 피호출자가 한다. |
32bit 라는 접두사가 의미 하듯이, 이 규약에 의한 스택들은 모두 32비트 단위로 작동한다.
64bit 호출규약들의 경우에는 32bit와 다르게 레지스터 사용량과 빈도가 높아 디버깅시 어려움이 많이 따른다고 한다.
이런 이유에서 64bit 호출 규약에 대한 포스팅은 다음 기회(필자가 더욱 공부하여 좀더 숙련 되었을때^^;)에 다루기로 하고
이 글에서는 현재 가장 널리 쓰이고 있는__cdecl과 stdcall에 대한 내용을 다루어보기로 하자.
2. 스택 프레임(stack frame)과 반환 작업(stack cleanup)이란?
함수 호출 과정에서 할당되는 메모리 블록(지역변수의 선언으로 인해 할당되는 메모리 세그먼트 블록)을 가리켜 스택 프레임이라 한다.
그리고 함수 호출이 종료되어 프로시저가 함수를 빠져 나갈때 코드 세그먼트의 값과 그 오프셋 값을 참조하여 올바른 위치로 복귀 할 수 있도록 하는 일련의 과정을 일컬어 반환 작업이라고 한다.
물론 여기서 말하는 스택이란 아래에서 위로 진행하는 자료구조 상에서의 추상 스택(stack abstrat data type)이 아닌 높은 메모리 주소값의 위치를 bottom으로 하여 낮은 주소로 sp(stack pointer)가 이동되는 실제 컴퓨터 구조상에서의 실행시간 스택(runtime stack)이다.
__cdecl 호출 규약 함수의 예
c/c++ 상에서 제공되어지는 ANSI표준 함수나 사용자 정의 함수 모두 별도의 키워드 없이
기본적으로 __cedecl 호출 규약을 따른다.
다음은 디스어셈블리 코드이다.
지금 부터 이를 분석해 보도록 하자.
00FD13C0 push ebp
→ _tmain() 함수 호출전의 우리가 모르는 어떠한 스택 프레임(운영체제의 kernel내에서 사용되는 어떤 시스템 함수의 스택 프레임일 확률이 굉장히 높다.)에 대한 기준 포인터인 ebp 레지스터의 값을 스택에 퇴피 시킨다.
00FD13C1 mov ebp,esp
→ 현재 esp가 가리키고 있는 스택의 위치 값을 레지스터 ebp로 복사하여, ebp 레지스터를 _tmain() 스택프레임에 대한 새로운 기준 포인터로 삼는다.
00FD13C3 sub esp,0C0h
→ 현재 bottom을 가리키고 있는 esp의 값에서 16진수 0C0h값 만큼의 감산 연산(실제 실행시간 스택은 상위 메모리에서 하위 메모리 방향으로 진행 된다.)을 수행하여, _tmain() 스택 프레임 내에 지역 변수(데이터 세그먼트 공간이 아니다.)를 위한 공간을 확보한다.
00FD13C9 push ebx
00FD13CA push esi
00FD13CB push edi
→ 나머지 레지스터들의 값 손실에 대비하여 이들을 스택으로 퇴피 시킨다.
00FD13CC lea edi,[ebp-0C0h]
00FD13D2 mov ecx,30h
00FD13D7 mov eax,0CCCCCCCCh
00FD13DC rep stos dword ptr es:[edi]
→ visual studio 2010 컴파일러의 고유 작업 수행으로써 리턴값을 담을 eax 레지스터와 위에서 확보한 스택 프레임 영역 내의 쓰레기 값(garbage value)들을 16진수 값 0CCCCCCCCh로 초기화 시킨다.
fCdecl(10);
00FD13DE push 0Ah
→ 10진수 10(0Ah)을 스택에 푸쉬 한다.
00FD13E0 call fCdecl (0FD101Eh)
→ fCedcl 함수를 호출하여 0FD101Eh 주소를 경유하여 함수 내부로 점프한다. 이에 대한 어셈블리 코드는 밑에서 다루겠다.
00FD13E5 add esp,4
→ fCedecl 함수(프로시저 or 메소드)에서 main 함수의 코드 영역으로 복귀하였을때 앞서 푸쉬된 0Ah에 의해 올바르지 못한 곳으로 분기되는 것을 막기 위한 연산.
예를 들어 함수내에서의 중첩 분기와 같은 연산이 이루어질때 올바르지 못한 값이 esp에 의해 분기 값으로 pop 되어 프로그램의 흐름이 망가져 오류를 일으키게 되는 사태를 예방하기 위해 매개변수로 넘겨준 4byte 크기 만큼 esp를 덧셈을 통해 변위 시키며 최종적인 스택 프레임 반환 작업을 완료한다.
return 0;
00FD13E8 xor eax,eax
→ xor 연산으로 eax 레지스터를 0으로 클리어 하고, overflow flag와 carry flag를 0으로 set 하고, sign , zero, parity flag를 설정한다. 이 또한 Visual Studio 2010의 컴파일러가 내부적으로 삽입하여 연산 하는 부분이다.
00FD13EA pop edi
00FD13EB pop esi
00FD13EC pop ebx
→ 위에서 푸쉬 했던 레지스터 값들을 pop 연산을 통해 원상 복구 시킨다.
00FD13ED add esp,0C0h
→ _tmain을 위해 확보했던 스택 프레임 공간 내의 지역 메모리 공간을 반환한다.
00FD13F3 cmp ebp,esp
→ ebp와 esp를 비교해서 ebp가 클 경우 sign flag를 0으로, ebp가 작을 경우 1로, 같을 경우 zero flag를 1로 설정 한다.
00FD13F5 call @ILT+310(__RTC_CheckEsp) (0FD113Bh)
→ CheckEsp 함수를 호출한다.
이 함수는 바로 위의 cmp 연산을 통해 얻은 flag 값들을 이용하여 esp가 올바른 위치를 가리키고 있는지 안전성 검사를 한다.
이에 대해 좀더 자세히 알고 싶으신 분은 구글링을 통해 직접 찾아 보거나 상기 함수로 분기하여 그 코드들을 직접 분석해 보시길..
00FD13FA mov esp,ebp
→ ebp 값을 esp로 복사해, esp가 ebp를 가리키도록 한다.
00FD13FC pop ebp
→ ebp 값을 pop 하여 _tmain 함수 호출전의 본래의 ebp값을 복구시킨다.
00FD13FD ret
→ 복귀 주소를 stack에서 pop하여 _tmain 함수에서 빠져나간다.
다음은 fCdecl 함수 내의 디스어셈블리 코드이다.
00FD1380 push ebp
→ _tmain() 함수의 스택프레임 기준 값인 ebp를 보존하기 위해 스택으로 퇴피, 푸쉬 한다.
00FD1381 mov ebp,esp
→ ebp를 fCdecl() 스택 프레임에 대한 기준 포인터로 삼는다.
00FD1383 sub esp,0CCh
00FD1389 push ebx
00FD138A push esi
00FD138B push edi
→ 레지스터들을 퇴피 시킨다.
00FD138C lea edi,[ebp-0CCh]
00FD1392 mov ecx,33h
00FD1397 mov eax,0CCCCCCCCh
00FD139C rep stos dword ptr es:[edi]
→ 확보된 스택프레임내의 지역 변수 공간의 쓰레기 값들을 모두 유효 값들로 초기화 한다.
int i = nArg;
00FD139E mov eax,dword ptr [nArg]
→ nArg 변수 메모리 데이터 값을 double word의 32bit 형식으로 엑세스, 추출하여 레지스터 eax로 복사한다.
00FD13A1 mov dword ptr [i],eax
→ eax의 값을 변수 i로 복사한다.
00FD13A4 pop edi
00FD13A5 pop esi
00FD13A6 pop ebx
→ 위에서 퇴피 시켰던 레지스터 값들을 복구 시킨다.
00FD13A7 mov esp,ebp
→ 스택의 top을 가리키고 있던 esp가 ebp를 가리키게 함으로써 지역 변수를 위해 확보되었던 공간을 모두 반환한다.
00FD13A9 pop ebp
→ _tmain 스택 프레임의 기준 포인터인 ebp 값을 복구 시킨다.
00FD13AA ret
→ _tmain 함수로의 복귀를 위해 복귀 주소를 스택에서 pop 한다.
사실 위에 장황하게 써놓은 디스어셈블리 코드들 중 제일 중요한 부분은 빨간색으로 특별하게 표기되어 있는 부분으로 아래와 같다.
1
2
3
4
fCdecl(10);
00FD13DE push 0Ah
00FD13E0 call fCdecl (0FD101Eh)
00FD13E5 add esp,4
fCdecl 함수의 파라미터로 넘겨진 10(0Ah)의 값이 push된 만큼, esp에 4바이트 크기를 가산하여 최종적으로 스택프레임을 모두 반환 하는 부분이다.
사실 애초에 이것을 설명하기 위해서 특정한 코드들만 따로 추려 분석해 다룰 생각이었지만, 기타 그외의 코드들을 연습삼아 다루어 보는 것이 공부에 더 도움이 되지 않을까 하여 주저리 주저리 모두 분석해 올렸다.
정리하면, 위에서 살펴본 바와 같이 __cdecl 호출 규약은 호출자가 최종 스택 프레임 반환 작업을 한다.
__stdcall 호출 규약 함수의 예
__stdcall 호출 규약을 따르기 위해서는 호출 규약 키워드를 명시적으로 붙여줘야 한다.
다음은 디스어셈블리된 코드이다.
앞서 공부했던 __cdecl 규약의 함수와 동일한 기능을 제공하는 함수이다.
다만 __cdecl 규약 함수와 차이가 있다면
1
2
3
4
func(10);
011D13DE push 0Ah
011D13E0 call Obj::fStdcall (11D11D1h)
00FD13E5 add esp,4
회색으로 칠한 부분이 없다는 것이다.
이는 이와 같은 작업을 함수를 호출한 호출자인 _tmain 함수에서 수행하지 않고
피호출자인 func() 함수 내의 마지막 명령줄에서
011D13AA ret 4
를 통해 스택 프레임 최종 반환 작업을 수행 하고 있기 때문이다.
여기서 ret 뒤에 붙어 있는 4라는 숫자는,
ret을 통해 복귀 주소를 CS와 IP에 반환 받은 뒤에 ESP를 +4 바이트 연산한다는 의미다.
이렇듯 최종 스택 프레임 반환 작업을 피호출자인 func()함수 내에서 해주고 있는 것이다.
3. __cdecl과 __stcall은 언제 사용되어질까?
이 글을 위에서 부터 차근 차근 읽어 내려왔다면 반환 여부가 아닌 반환 위치에 어떠한 의미가 있는 것인지 의문이 생길 것이다.
이에 대한 답은 printf 함수와 class의 멤버 함수의 예에서 찾아 볼 수 있다.
_tprintf(_T(“Printing values: %d, %f, %c”), x, y, z); 와 같이
printf 함수의 인자의 갯수는 가변적이다.
C컴파일러는 인수들을 스택에 역순으로 push하고 그 다음에 실제 인수의 개수를 나타내는 카운트 인수를 push한다.
함수는 인수 카운트를 얻고 인수들을 대상으로 하나씩 접근한다.
이런식의 가변 인자를 갖는 함수의 구현은 RET 명령어에서의 상수 인코딩시 스택 정리를 그에 맞게 변화 시키지 못하기 때문에 스택 정리의 책임을 호출하는 측에 맡기는 것이라고 한다.
물론 이에 대해서 여러 의문들이 떠오를 것이다. 그러나 부끄럽게도 이 글을 작성하고 있는 본인 또한 그저 컴파일러 설계상의 어려움 때문이 아닐까 하고 추정만 하고 있을 뿐이지 위 이상의 자세한 이유는 알지 못한다.
이에 대해서는 나중에 더 공부를 하고 난 뒤에 추가적인 글을 다룰 생각이다.
어찌 되었든, __cdecl 호출 규약은 가변적인 인수의 개수를 위한 스택 연산이 가능하다.
반면 __stdcall 호출 규약은 가변적인 인수를 다룰 수 없는 대신에 서브루틴 호출을 위해서 생성하는 코드의 양을 줄여주며 호출하는 측이 스택을 정리하는 것을 잊지 않도록 해준다.
클래스 상에서는 일반적으로 멤버 함수의 인자 개수가 정해져 있고 객체지향적인 관점에서의 프로시저의 독립성을 위해서 함수 내부에서 스택 프레임이 최종 반환되는 형태로 작업이 수행되는 것이다.
좀더 알기 쉽게 정리하자면..
1
2
3
4
5
class OBJ{
public:
int func(int nArg);
};
위의 클래스 내의 멤버 함수는 __stdcall 규약을 따로 명시하지 않아도 자동으로 __stdcall 규약을 따르게 된다.
그러나
1
2
3
4
5
class OBJ{
public:
int func(int nCnt,...);
};
위와 같이 가변 인자를 가지는 멤버 함수의 경우에는 자동으로 _cdecl 규약을 따르게 된다.




