본문 바로가기

2.분석 및 설계

함수 호출과정 분석

C와 C++에서 함수 호출하는 과정을 어셈블리 수준에서 살펴볼려고 한다. 이는 나중에 함수 호출 규약을 살펴볼때 중요한 기반 지식이 되므로 한번은 꼭 살펴보는 것이 좋다. 이제 함수 호출 과정에 대한 여행을 떠나보자.

작성: http://ospace.tistory.com/(ospace114@empal.com), 2010.12.28

샘플 소스 코드

여기서 사용할 샘플 코드는 아래와 같다. 간단한 함수와 클래스의 맴버함수를 사용했다. 이 글에서는 C에 대한 호출과 C++에 대한 호출을 살펴볼 예정이다. 사실 기본적인 골격은 같지만 C++인 경우 this객체에 대한 처리가 추가되었을 뿐이다. 이 부분에 대한 차이만 있다.

각 함수는 간단한 인자를 더해서 그 결과를 반환하는 형태이다. 함수 호출에 대해서 단계별 설명은 참조[1]의 강의요약 9와 10에 잘 설명되어 있다.

#include <stdlib.h>

#define TestAPI

int TestAPI someFunc(int p1, int p2, int p3)
{
    return p1 +  p2  + p3;
}
class SomeClass
{
public:
    int TestAPI someMemFunc(int p1, int p2, int p3)
    {
        this->val = p1 + p2 + p3;
        return this->val;
    }
};


int _tmain(int argc, _TCHAR* argv[])
{
    int ret1 = someFunc (1, 2, 3);

    SomeClass someClass;
    int ret2 = someClass.someMemFunc (1, 2, 3);

    fprintf (stdout, "Ret1 = %d, Ret2 = %d\n", ret1, ret2);

    return 0;
}

기본적인 함수 호출 분석

어셈블러 코드 분석은 VC에서 했으며 인텔 32bit 환경에서 분석하였다. 다른 곳에서 어셈블러 코드를 볼 경우 달라질 수 있기 때문에 참고하기 바란다. 추가로 컴파일러에서 최적화 옵션을 끄는 것이 좋을 것이다.

앞으로 함수호출 과정을 살펴볼 것이다. 유념할 부분이 다음 2가지이다.

  • 중요한 부분이 인자를 스택에 저장해서 넘기는 순서
  • 함수 호출 전으로 스택을 모두 복원하는 위치

인자가 스택에 저장되는 순서는 대부분 비슷한 순서를 가지면, 일부 약간 틀린 부분이 있을뿐 전체적으로 비슷한다.

더 중요한 부분이 마지막 복원하는 것이다. 복원 대상은 스택에 저장된 인자 값을 말한다. 함수 내에서 실행하면서 스택에 저장된 값은 함수 종료 시점에서 모두 제거되기 때문이다. 함수 내에서 실행하기 전에 스택에 저장된 인자 값을 누가 제거하는 가가 중요하다.

C 함수 호출

먼저 스택을 살펴보자. 값이 저장되면 스택 포인터 위치(ESP)가 감소한다. 즉, 4Byte가 저장(push)되면 스택 포인터(ESP)에서 4가 감소한다. 그리고 값이 가져오면(pop) 가져온 바이트 수 만큼 더해진다. 간혹 이부분이 헤갈릴 수 있는데 주의가 필요하다.

  • ESP(Extended Stack Pointer) 32bit용 Stack Pointer. 스택의 최상위 주소. 시스템에 의해 관리.
  • EBP(Extended Base Pointer) 32bit용 Base/Frame pointer. 현재 함수에서 stack의 시작 위치 저장(지역변수 영역 시작위치).

일단 함수 호출 전에 스택은 아래와 같이 비어 있다고 하자. 물론 이전에 처리하면서 저장해둔 데이터에 의해서 스택 포인터(ESP)가 변경될 수 있지만, 여기서는 새로운 함수가 호출되기 때문에 현재 스택 포인터(ESP)가 처음 스택 위치라고 보면 된다. 그래서 스택은 비어있는 상태라고 할 수 있다.

[main]스택에 인자 저장

함수 호출 전에 함수에 넘겨지는 인자 값이 스택에 저장된다. 저장되는 순서는 오른쪽에서 왼쪽이다.

int ret1 = someFunc (1, 2, 3);  // (1) 일반함수 호출
00F8356E  push        3 
00F83570  push        2 
00F83572  push        1

저장된 후의 스택의 모습은 다음과 같다.

[main]함수 호출

함수에 넘겨질 인자도 스택에 저장되었으니 함수를 호출하자. 단순히 call하면 된다.

00F83574  call        someFunc (0F810E6h) 

[someFunc]스택 위치 보관과 로컬영역 할당

앞에서 호출한 함수 포인터와 아래 함수 시작 포인터 주소가 틀리다. 이는 윈도우에서 중간 jump에 의해서 이동했기 때문에 함수 포인터 위치가 틀리다. 여기서는 중요한 것이 아니기 때문에 패스~

현재 스택 시작 포인터(EBP)를 스택에 저장해 둔다. 나중에 함수 종료시 이전에 사용했던 스택을 돌려주기 위해서이다.

그리고, EBP에 ESP를 저장해둔다. 바꿔 말하면 새로운 함수 영역으로 왔기 때문에 EBP의 위치를 현재 스택 포인터(ESP)로 지정했다고 보면 된다.

ESP에서 0C0h를 뺀 것은 0C0h 바이트 만큼 스택을 비어둔 것이며, 이 영역은 함수 로켤변수를 위한 영역으로 할당한 것이다.

int TestAPI someFunc(int p1, int p2, int p3)
{
00F81390  push        ebp      // 스택 베이스 포인터 보전
00F81391  mov         ebp,esp  // 현재 스택 포인터를 새로운 베이트 포인터로 설정(새로운 스택시작)
00F81393  sub         esp,0C0h // 로컬변수할당 영역 설정(C0h=192B)

그 결과 스택의 모양은 다음과 같다. RA는 Return Address로 함수 호출한 곳으로 복원하는 위치다. 즉, 함수 호출한 곳이 00F83574h이므로 그 다음 위치로 복원하기 때문에 RA는 00F83578h가 된다.

[someFunc]현재 상태 보존

현재 상태 관련한 레지스트리 정보를 스택에 저장해둔다. 나머지 코드는 잘 모르겠다. ㅡ.ㅡ;

00F81399  push        ebx        // 호출한 함수 실행상태 보존  
00F8139A  push        esi  
00F8139B  push        edi  
00F8139C  lea         edi,[ebp-0C0h]  
00F813A2  mov         ecx,30h  
00F813A7  mov         eax,0CCCCCCCCh  
00F813AC  rep stos    dword ptr es:[edi] 

그 결과 스택 모양은 다음과 같다. 총 3개의 레지스트리가 저장되었으므로 ESP가 12Byte 만큼 감소하였다.

[someFunc]함수 실행

아래는 함수 로직을 실행하는 코드이다. 값을 반환하기 때문에 eax에 결과가 저장된다. eax는 함수 반환값 저장에 사용되기 때문이다.

return p1 +  p2  - p3;
00F813AE  mov         eax,dword ptr [ebp+8]  
00F813B1  add         eax,dword ptr [ebp+0Ch]  
00F813B4  add         eax,dword ptr [ebp+10h]
}

[someFunc]보존된 상태 복원

함수에 필요한 작업이 모두 완료되었기 때문에 이전 상태로 레지스트리를 복원한다.

00F813B7  pop         edi       //호출한 함수 실행상태 복원
00F813B8  pop         esi  
00F813B9  pop         ebx 

복원 결과 스택은 다음과 같은 모습을 가진다. 로컬 영역과 이전 ESP가 현재 함수에서 저장된 부분만 남았다.

[someFunc]스택위치 복원하고 리턴

ESP위치를 EBP위치로 지정함으로써 이전 로컬변수 영역이 제거가 된다.

그리고 ebp를 다시 꺼냄으로써 이전 스택 시작 위치가 설정된다.

함수를 리턴하게 된다.

00F813BA  mov         esp,ebp  // 스택 포인터위치를 현재 베이스 포인터 설정 (다시 호출이전 위치)
00F813BC  pop         ebp      // 스택 베이트 포인터 복원
00F813BD  ret 

함수가 리턴이 되면, 아래와 같은 스택으로 된다. 인자 값만 빼고는 모두 스택에서 제거가 되었다.

[main]스택 정리하고, 결과 저장

스택 포인터(ESP)에서 0Ch를 더함으로서 이전 인수 값도 모두 스택에서 제거하게 된다.

마지막으로 결과값을 ret1 위치에 저장한다.

00F83579  add         esp,0Ch      // 스택에 있는 인자를 제거 
00F8357C  mov         dword ptr [ret1],eax

함수 호출 전에 모습으로 비어있는 상태의 스택이 된다.

여기까지 해서 C에서 함수호출 과정을 살펴보았다. 여기서 사용된 함수호출 규약은 cdecl이다. VC에서는 별다른 선언을 하지 않으면 C에서 함수 호출은 cdecl로 된다.

cdecl의 특징은

  • 인자를 오른쪽에서 왼쪽으로 스택에 저장되며,
  • 마지막 인자에 대한 복원은 함수 호출하는 측(main함수)에서 책임

을 가진다.

C++ 멤버함수 호출

이번에서 C에 대한 멤버 함수 호출 과정을 살펴보도록 하겠다. 기본적인 내용은 C 함수 호출과정과 유사하기 때문에 다른 점을 위주로 자세히 설명하도록 하겠다.

처음 시작하므로 스택이 비어있는 상태이다.

[main]스택에 인자 저장

C 함수 호출과 동일하게 오른쪽에서 왼쪽으로 스택에 저장된다.

int ret2 = someClass.someMemFunc (1, 2, 3); // (2) 멤버함수 호출
00F83587  push        3 
00F83589  push        2 
00F8358B  push        1 

인자가 저장된 스택의 모습니다.

[main]ecx에 클래스 객체 포인터 저장하고, 함수 호출

C함수와는 다른게 추가 코드가 있다. 아래는 ecx에 someClass의 포인터가 저장된다. 이는 나중에 멤버함수에서 사용된다.

00F8358D  lea         ecx,[ebp-14h] 
00F83590  call        00F8116D 

[someMemFunc]스택 위치 보관과 로컬영역 할당

스택 시작위치를 스택에 저장하고, 현재 스택 시작위치(EBP)를 현재 스택 포인터(ESP)로 저장한다.

그리고 스택에 0CCh만큼 로컬변수을 위한 영역을 할당한다.

int TestAPI someMemFunc(int p1, int p2, int p3)
{
00F813C0  push        ebp   // 스택 베이스 포인터 보전
00F813C1  mov         ebp,esp // 현재 스택 포인터를 새로운 베이트 포인터로 설정(새로운 스택시작)
00F813C3  sub         esp,0CCh  // 로컬변수할당 영역 설정(CCh=192B)

여기까지는 앞의 C함수의 스택 모습과 동일하다.

[someMemFunc]현재 상태 저장

현재 상태 관련한 일부 레지스트리를 저장한다.

추가로 ecx 레지스트리도 스택에 저장한다.

00F813C9  push        ebx // 호출한 함수 실행상태 보존
00F813CA  push        esi 
00F813CB  push        edi 
00F813CC  push        ecx 
00F813CD  lea         edi,[ebp-0CCh] 
00F813D3  mov         ecx,33h 
00F813D8  mov         eax,0CCCCCCCCh 
00F813DD  rep stos    dword ptr es:[edi] 

스택의 모습은 C함수과는 틀리게 ecx까지 추가되었다.

[someMemFunc]ecx에 저장된 this포인터를 로컬영역에 저장

이 부분은 C++에만 있는 것으로 this 객체를 설정하는 부분이다. 앞에서 ecx에 저장된 someClass 포인터를 가져온다.

해당 포인터를 this 객체가 있는 곳에 저장을 하면, this에 의한 참조가 가능해진다.

00F813DF  pop         ecx 
00F813E0  mov         dword ptr [ebp-8],ecx  // this포인터(ecx)를 스택에 저장

스택을 모습을 보면 로컬변수 영역에 this 객체에 대한 포인터가 저장되었다.

[someMemFunc]함수 실행

함수 실행는 C함수와 동일하지만, 저장하는 대상이 this에 있는 값이므로 this 포인터 값을 획득(ecx)하고 그곳에 결과를 저장한다.

그리고 다시 결과를 반환하는 부분에서 this 포인터에서 결과 값을 획득하여 eax에 저장한다.

this->val = p1 + p2 - p3;
00F813E3  mov         eax,dword ptr [ebp+8] 
00F813E6  add         eax,dword ptr [ebp+0Ch] 
00F813E9  add         eax,dword ptr [ebp+10h] 
00F813EC  mov         ecx,dword ptr [ebp-8]  // 스택에서 this 위치를 ecx 획득
00F813EF  mov         dword ptr [ecx],eax     // ecx 포인터 위치에 결과 저장
return this->val;
00F813F1  mov         eax,dword ptr [ebp-8] // eax에 this 위치를 가져옴
00F813F4  mov         eax,dword ptr [eax]    // eax에 this에 있는 값 저장

[someMemFunc]실행이전 상태로 복원

앞애서 저장된 상태 정보를 복원한다.

}
00F813F6  pop         edi //호출한 함수 실행상태 복원
00F813F7  pop         esi 
00F813F8  pop         ebx

이 부분은 C함수 호출과 동일한다.

[someMemFunc]스택 위치도 복원

스택 시작 포인터 위치도 다시 원래 함수 시작할 때로 복원되고, 스택 포인터도 마찬가지로 복원이 된다.

00F813F9  mov         esp,ebp  // 스택 포인터위치를 현재 베이스 포인터 설정 (다시 호출이전 위치)
00F813FB  pop         ebp // 스택 베이트 포인터 복원

스택 모습도 저장된 ebp가 제거되면서 인자 값만 남게 된다.

[someMemFunc]함수리턴하고 스택도 정리

이 부분 중요하다. C함수 호출과 다른 부분이다. ret에서 뒤에 값이 오는데, 해당 값(바이트수) 만큼 스택에서 제거하게 된다.

일단 ret에서 RA값이 추출되면서 반환될 주소를 획득하고, 뒤에 바이트 수를 스택에서 제거하면서 함수가 리턴이 된다.

00F813FC  ret         0Ch  // 스택에서 0Ch바이트만큼 꺼냄(ESP보정)

그 결과로 스택을 보면, 인자값이 스택에서 모두 제거된 상태로 함수가 종료됨을 알 수 있다.

[main] 결과를 ret에 저장

C함수 호출과는 다르게 스택에 있는 인자값을 제거하는 코드가 없다. 결과를 ret2 위치에 저장하는 코드만 있다.

00F83595  mov         dword ptr [ebp-20h],eax // ret2위치에 결과값 저장

여기까지 해서 C++에서 멤버함수 호출 규약을 살펴보았다.

앞에서 C 호출과 다른 부분이 몇가지가 있다. 인자를 스택에 저장되는 순서는 똑같지만, C++에서는 this를 넘기기 위해서 ecx를 추가로 사용하는 것과 스택에 인자를 제거하는 위치가 함수를 호출하는 곳이 아니라 호출된 함수가 종료되는 시점에서 제거되는 부분이 틀리다.

이를 함수 호출 규약에서 thiscall이라고 한다.

결론

C와 C++에서 함수 호출하는 과정을 자세히 살펴보았다. 사실 모든 함수 호출는 경우의 수를 살펴보지는 못했다. 여기는 단순한 예제 수준에서 분석을 했을 뿐이다.

이를 바탕으로 필요한 부분을 더 살펴보면 쉬울거라 생각한다. 물론 어셈블리에 대한 지식이 없다면 조금 어려울 수 있다. 본인도 이 부분이 쉽지가 않다.

이제까지 살펴본 것은 함수 호출 규약(calling convertion)를 살펴보기 위한 사전지식이다. 추후에 다룰 함수 호출 규약에서는 함수 호출 과정의 기본 틀은 같지만, 일부 몇가지 틀린 부분이 있다. 이부분을 종류 별로 살펴볼려고 한다.

이렇게 까지 함수 호출 규약을 다루는 이유는 중요한 부분이기 때문이다. C 함수끼지 C++ 멤버함수 끼리, 또는 C와 C++간에 다양한 인터페이스 접점이 발생한다. 이런한 함수 호출 규약 지식이 없다면 언젠가 큰코를 다치게 된다. 이를 위한 사전 준비라고 할 수 있다. 그리고, 설계에서 고려되야하는 내용이기도 하다. 즉, 꼭 알아야한다.

모두 즐프하기 바란다. ospace.

참고

[1] 양희재, "EH204 시스템 프로그래밍", http://conet.ks.ac.kr/~hjyang/crs/sp/

[2] binoopang with Jiny, 2009, "WINAPI 키워드는 머임??", http://binoopang.tistory.com/tag/WINAPI

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

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

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

[6] Intel.com, "Intel Architecture Software Developer's Manual Volume 2", http://www.intel.com/assets/pdf/manual/253666.pdf.

[7] Intel.com, "Intel Architecture Software Developer's Manual Volume 2B", http://www.intel.com/Assets/pdf/manual/253667.pdf.

반응형

'2.분석 및 설계' 카테고리의 다른 글

MFC 메시지맵 구조  (0) 2012.07.27
errno같은 리턴 에러값 프레임워크  (0) 2012.07.26
코드 문서화  (0) 2011.01.07
설치 패키지 작성시 고려사항  (0) 2009.07.31
프로그램 버전 얻기  (0) 2009.04.13