디스어셈블리 코드로 살펴보는 가상 함수 테이블
디스어셈블리 코드를 통해 가상 함수 테이블이 어떻게 동작하는지 분석한다
1. 클래스와 멤버 함수
A라는 이름의 클래스를 선언하고 이 클래스의 인스턴스를 여러 개 생성하였다고 가정해 보자.
이때 내부에 있는 멤버 함수들은 어떻게 처리 되어질까? 인스턴스를 생성한 만큼 이 멤버 함수들 또한 생성 되어져야 할까?
대답은 no 다.
인스턴스가 여러 개 생성되어졌다고 해서 클래스 내의 함수들 또한 그만큼 추가 생성 되어져야 한다면 동일한 함수 코드가 그만큼 코드 영역에 중복 생성되어져 결국에는 많은 메모리 공간 낭비의 문제로 까지 이어질 수 밖에 없을 것이다.
그렇기 때문에 컴파일러는 이 중복되는 클래스내의 함수들을 메모리 영역 한곳에 1회 생성해 놓고 이를 각 인스턴스 객체들로 하여금 공유하게 한다.
하지만 이 경우에 어떤 인스턴스가 해당 함수를 호출 했는지에 대한 정보가 없기 때문에 이를 구별할 수 있는 방법이 필요하다.
바로 이 방법론의 실체가 가상 함수 테이블로써 컴파일러가 클래스 함수를 컴파일할 때, 내부적으로 클래스 인스턴스에 대한 포인터를 클래스 함수의 매개변수 목록에 추가하는 방법이다.
아래에서 디스어셈블리 코드를 통해 보게 되겠지만 인스턴스에 대한 포인터를 매개변수 목록에 추가한다는 이야기는 다소 추상적인 개념처럼 들릴지도 모른다.
실제로 어셈블리 코드를 통해 살펴 보면 함수를 호출한 인스턴스의 주소를 통상적인 매개변수 전달 방식인 push가 아닌 ecx 레지스터를 통해 호출된 함수의 스택 영역에 넘겨 주는 방식을 통해 최종적으로 호출된 인스턴스에게 호출자에 대한 정보를 명시해 준다.
이것이 우리가 익히 알고 있는 this 포인터의 실체이기도 하다.
2. this 포인터
그럼 위에서 설명한 호출자의 주소가 어떤 방식으로 호출된 인스턴스의 멤버로 넘겨지고 이것이 어떻게 this 포인터로서 기능하게 되는지 아래의 디스어셈블리 코드를 통해 살펴 보도록 하자.
00031F21 mov ecx,dword ptr [p_ptr]
-> p_ptr변수에 들어있는 p의 주소를 ecx로 복사.
00031F24 mov eax,dword ptr [edx]
-> vfptr이 가리키고 있는 테이블 요소중 Parent::prn(void) 함수의 주소를 eax로 복사.
00031F26 call eax
-> eax에 들어있는 함수의 주소에 따라 Parent::prn (void)함수 호출.
위의 코드에서 주의 깊게 살펴봐야 할 부분은 빨간색 텍스트로 쓰여진 ecx로 호출자인 인스턴스 p(p_ptr이 가리키고 있다)의 주소가 복사되는 부분이다.
다음은 호출된 함수의 코드이다.
1
2
3
4
5
6
7
8
9
void Parent::prn()
{
00031CB0 push ebp
00031CB1 mov ebp,esp
00031CB3 sub esp,0CCh
00031CB9 push ebx
00031CBA push esi
00031CBB push edi
00031CBC push ecx
-> ecx가 스택내 지역 공간에 대한 초기화 작업에서 카운터 레지스터로 쓰이므로 이 작업 전에 ecx에 들어있는 인스턴스 p의 주소를 스택 지역변수 범위 밖의 스택 공간으로 퇴피.
1
2
3
4
5
00031CBD lea edi,[ebp-0CCh]
00031CC3 mov ecx,33h
00031CC8 mov eax,0CCCCCCCCh
00031CCD rep stos dword ptr es:[edi]
00031CCF pop ecx
-> 현재 esp가 가리키고 있는 위치에 있는 인스턴스 p의 주소를 팝하여 ecx로 다시 적재.
1
00031CD0 mov dword ptr [ebp-8],ecx
-> ecx에 들어있는 인스턴스 p의 주소를 ebp-8h 위치의 지역 공간에 복사.
이로써 현재 prn()의 스택 영역의 기준인 ebp로 부터 하위 8바이트 상의 인스턴스 p의 주소는 this 포인터 변수로 활용 된다.
위의 스크린샷을 통해 this 포인터 변수의 주소 0x0043f794와 ebp-08h(0x0043f794)의 값이 일치함을 확인할 수 있다.
이는 호출자 인스턴스에 대한 this 포인터 변수가 지역 변수로써 호출된 함수 내에서 취급된다는 말과도 같다.
3. 가상 함수와 가상 함수 테이블
가상 함수란, 기반 클래스 내에서 유효한 함수를 파생 클래스에서 재정의 하여 사용할 수 있는 함수를 말하며 함수 선언시 제일 앞에 virtual 키워드를 붙여 선언한다.
가상함수를 쓰는 이유는 정적 바인딩과 동적 바인딩 문제 때문인데 이에 대해서는 설명하자면 글이 더욱더 길어질 것이니 바로 본론으로 넘어가도록 하겠다.
클래스에 가상 함수가 하나라도 존재하면 클래스에 가상 함수 테이블이라는 것이 생성된다.
이 가상 함수 테이블은이라는 것은 해당 클래스내에서 유효한 함수들의 주소 목록을 보관하고 있는 배열인데 기반 클래스의 가상 함수를 파생 클래스에서 재정의한 경우 이 재정의된 함수의 주소가, 그렇지 않은 경우에는 기반 클래스의 함수 주소를 갖게 된다.
그리고 컴파일 과정에서 해당 인스턴스에 새로 추가되는 vfptr이라는 이름의 포인터 변수가 가상 함수 테이블을 가리키게 된다.
이 포인터 변수 덕분에 클래스와 그 인스턴스의 크기는 4byte(32bit 기준) 더 늘어나게 되지만 클래스에 가상함수가 여러 개 존재하여 가상 함수 테이블의 크기가 늘어난다 하더라도 가상 함수 테이블에 대한 포인터 vfptr은 각 클래스 별로 오직 1개 씩만 생성되어 진다는 사실을 주의해야 한다.
4. 디스어셈블리 코드 분석
다음은 간단한 c++ 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Parent{
public:
int i;
virtual void prn();
virtual void func();
};
void Parent::prn()
{
cout << "Parent prn() was called." << endl;
}
void Parent::func()
{
cout << "Parent func() was called." << endl;
}
class Child:public Parent{
public:
void prn();
void func();
};
void Child::prn()
{
cout << "Child prn() was called." << endl;
}
void Child::func()
{
cout << "Child func() was called." << endl;
}
int _tmain(int argc, TCHAR *argv[])
{
Parent p, *p_ptr;
Child c, *c_ptr;
p_ptr = &p;
c_ptr = &c;
p_ptr->prn();
p_ptr->func();
c_ptr->prn();
c_ptr->func();
return 0;
}
위의 코드를 VS2010 상에서 디스어셈블리 코드로 변환하여 가상 함수 테이블과 이에 대한 포인터가 어떤식으로 작동하는지 간단하게 살펴 보도록 하자.
Parent p, *p_ptr;
00031EFE lea ecx,[p]
-> Parent 타입의 인스턴스 p와 포인터 p_ptr 선언
00031F01 call Parent::Parent (3117Ch)
-> Parent의 생성자 호출
Child c, *c_ptr;
00031F06 lea ecx,[c]
-> Child 타입의 인스턴스 p와 포인터 c_ptr 선언
00031F09 call Child::Child (311A4h)
-> Child의 생성자 호출
p_ptr = &p;
00031F0E lea eax,[p]
-> 인스턴스 p의 유효 주소를 eax로 복사
00031F11 mov dword ptr [p_ptr],eax
-> eax에 있는 p의 유효주소를 p_ptr 변수로 복사
c_ptr = &c;
00031F14 lea eax,[c]
-> 인스턴스 c의 유효 주소를 eax로 복사
00031F17 mov dword ptr [c_ptr],eax
-> eax에 있는 c의 유효주소를 c_ptr 변수로 복사
여기까지는 일반적인 흐름이었다.
이제 여기서부터 실질적인 가상 함수 테이블을 다루는 모습이 나오니 주의하자.
p_ptr->prn();
00031F1A mov eax,dword ptr [p_ptr]
-> p_ptr변수에 들어있는 p의 주소를 eax로 복사.
00031F1D mov edx,dword ptr [eax]
-> 컴파일러에 의해 새로 추가된 p의 첫번째 요소인 vfptr 변수의 값을 edx로 복사.
00031F1F mov esi,esp
00031F21 mov ecx,dword ptr [p_ptr]
-> p_ptr변수에 들어있는 p의 주소를 ecx로 복사.
00031F24 mov eax,dword ptr [edx]
-> vfptr이 가리키고 있는 테이블 요소중 Parent::prn(void) 함수의 주소를 eax로 복사.
00031F26 call eax
-> eax에 들어있는 함수의 주소에 따라 Parent::prn (void)함수 호출.
00031F28 cmp esi,esp00031F2A call @ILT+525(__RTC_CheckEsp) (31212h)
파란색으로 표시된 부분을 보면 edx로 vfptr의 값이 복사되고, “[]”에 edx내의 vfptr의 값이 역참조 되어 vfptr이 가리키고 있는 첫번째 요소의 함수가 호출 되어진다.
p_ptr->func();
00031F2F mov eax,dword ptr [p_ptr]
-> p_ptr변수에 들어있는 p의 주소를 eax로 복사.
00031F32 mov edx,dword ptr [eax]
-> 컴파일러에 의해 새로 추가된 p의 첫번째 요소인 vfptr 변수의 값을 edx로 복사.
00031F34 mov esi,esp
00031F36 mov ecx,dword ptr [p_ptr]
-> p_ptr변수에 들어있는 p의 주소를 ecx로 복사.
00031F39 mov eax,dword ptr [edx+4]
-> vfptr이 가리키고 있는 테이블 요소 중 2번째(offset:+4)인 Parent::func(void)함수의 주소를 eax로 복사.
00031F3C call eax
-> eax에 들어있는 함수의 주소에 따라 Parent::func(void) 호출.
00031F3E cmp esi,esp
00031F40 call @ILT+525(__RTC_CheckEsp) (31212h)
이번엔 [edx+4]인 부분을 눈여겨 보자, 이는 vfptr이 가리키고 있는 두번째 요소의 함수의 주소를 의미한다.
다음은 인스턴스 c에 대한 코드로 위와 다를게 없으나 위에서 언급했던 부분을 재확인 차원에서 눈여겨 보자.
c_ptr->prn();
00031F45 mov eax,dword ptr [c_ptr]
-> c_ptr변수에 들어있는 c의 주소를 eax로 복사
00031F48 mov edx,dword ptr [eax]
-> 컴파일러에 의해 새로 추가된 c의 첫번째 요소 vfptr의 값을 edx로 복사
00031F4A mov esi,esp
00031F4C mov ecx,dword ptr [c_ptr]
-> c_ptr변수에 들어있는 c의 주소를 ecx로 복사
00031F4F mov eax,dword ptr [edx]
-> vfptr이 가리키고 있는 테이블 요소중 Child::prn(void) 함수의 주소를 eax로 복사.
00031F51 call eax
-> eax에 들어있는 함수의 주소에 따라 Child::prn (void)함수 호출
00031F53 cmp esi,esp
00031F55 call @ILT+525(__RTC_CheckEsp) (31212h)
c_ptr->func();
00031F5A mov eax,dword ptr [c_ptr]
-> c_ptr변수에 들어있는 c의 주소를 eax로 복사
00031F5D mov edx,dword ptr [eax]
-> 컴파일러에 의해 새로 추가된 c의 첫번째 요소 vfptr의 값을 edx로 복사
00031F5F mov esi,esp
00031F61 mov ecx,dword ptr [c_ptr]
-> c_ptr변수에 들어있는 c의 주소를 ecx로 복사
00031F64 mov eax,dword ptr [edx+4]
-> vfptr이 가리키고 있는 테이블 요소 중 2번째(offset:+4)인 Child::func(void) 함수의 주소를 eax로 복사.
00031F67 call eax
-> eax에 들어있는 함수의 주소에 따라 Child::func(void) 호출
00031F53 cmp esi,esp 00031F55 call @ILT+525(__RTC_CheckEsp) (31212h)
다음은 컴파일후 가상 함수 테이블과 이에 대한 포인터 변수 vfptr이 각 인스턴스 내에서 어떻게 자리잡고 있는지 보여주는 디버깅 모드 상에서의 스크린 샷이다.
각 인스턴스 마다 vfptr이 있고, 이 포인터 변수가 가상 함수 테이블을 가리키고 있음을 확인할 수 있다.

