C++의 가상 함수 테이블 (vtable)은 언제 생성될까요?
실습을 위해 다음과 같이 간단한 상속 코드를 만들어 보겠습니다.
#include "stdafx.h"
class A
{
public:
int n1 = 1;
void func1() { n1++; }
virtual void vfunc1() { }
};
class BonA : A
{
public:
int n2 = 2;
void func2() { n2++; }
virtual void vfunc2() { }
};
class ConBonA : BonA
{
public:
int n3 = 2;
void func3() { n3++; }
virtual void vfunc3() { }
};
int main()
{
ConBonA *table1 = new ConBonA();
ConBonA *table2 = new ConBonA();
return 0;
}
이제 디버그 모드로 실행하면서 생성자 호출로 인한 코드를 한번 살펴보겠습니다.
우선, new ConBonA 코드로 메모리가 할당되고 그 위치를 ecx 포인터에 this 값으로 전달하면서 생성자를 호출합니다.
38: ConBonA *table1 = new ConBonA();
00CE1A29 8B 8D 20 FF FF FF mov ecx,dword ptr [table1]
00CE1A40 E8 CE F6 FF FF call ConBonA::ConBonA (0CE1113h)
ConBonA의 생성자를 가보면,
ConBonA::ConBonA:
00CE18E6 E8 12 FA FF FF call BonA::BonA (0CE12FDh)
...[생략]...
00CE1914 C3 ret
자신의 클래스에서 해야 하는 일보다 먼저 기반 클래스의 생성자를 호출합니다. 다시 BonA 생성자로 가면,
BonA::BonA:
00CE1876 E8 28 FA FF FF call A::A (0CE12A3h)
...[생략]...
00CE18A4 C3 ret
마찬가지로 자신의 클래스를 위한 초기 코드보다는 기반 클래스의 생성자를 호출합니다. 이제 가장 상단에 위치하는 기반 클래스 A의 생성자는,
A::A:
00CE1823 8B 45 F8 mov eax,dword ptr [this]
00CE1826 C7 00 34 7B CE 00 mov dword ptr [eax],offset A::`vftable' (0CE7B34h)
...[생략: n1 = 1과 같은 생성자 함수의 초기화 코드]...
00CE183F C3 ret
"ConBonA *table1 = new ConBonA();" 코드로 메모리가 할당된 table1 주소에 "class A"를 컴파일하면서 구성해놓았던 "vtable"의 주소를 첫 번째 4바이트에 씁니다. 그래서 위의 코드까지만 실행된 상태에서는 this 포인터 주소의 내용이 다음과 같이 구성됩니다.
[오프셋12: 필드 ConBonA::n3] - 4바이트
[오프셋 8: 필드 BonA::n2] - 4바이트
[오프셋 4: 필드 A::n1] - 4바이트
[오프셋 0: A 클래스의 vtable 포인터] - 4바이트
그리고 "A 클래스의 vtable 포인터"가 가리키는 주소를 가보면 다음과 같이 구성되어 있습니다.
오프셋 0: [A:vfunc1 함수 주소]
그러니까... 적어도 main 함수 실행 중에는 vtable의 내용이 이미 구성된 상태인 것입니다. 게다가 vtable의 값은 instance 별로 생성되는 것이 아닌, 클래스 별로 생성된다는 것을 알 수 있습니다.
조금 더 깊게 파헤쳐 볼까요? ^^
결론부터 말하면, vtable은 컴파일러가 해당 소스 코드 파일을 컴파일하면서 알아낸 정보를 바탕으로 PE 파일의 Section에 미리 기록해 둡니다. 재미 삼아서 계산까지 한번 해보겠습니다. ^^
Visual Studio에서 디버깅 모드로 예제 EXE 프로그램을 실행시킨 후, "Ctrl + Alt + U" 키를 눌러 "Modules" 창을 띄우면 EXE 파일의 이미지 로딩 주소를 알아낼 수 있습니다. (코드로는
GetModuleHandle Win32 API로 반환받은 HMODULE 값이 됩니다.) EXE 파일의 경우 IMAGE_OPTIONAL_HEADER에 지정된 "Image Base (보통 0x400000)"로 로딩이 되지만 Vista 운영체제부터는
ASLR 덕분에 주소가 가변으로 바뀝니다. 제 경우에 테스트용 ConsoleApplication1.exe의 이미지 매핑이 "00CD0000-00CEF000"로 되어 있었습니다.
그다음, "mov dword ptr [eax],offset A::`vftable' (0CE7B34h)"로 나왔던 0x00ce7b34 주소에서 0x00cd0000 기반 주소를 빼면,
0CE7B34h - 0x00cd0000 = 0x17b34
0x17b34 변위가 나오고 이를 ConsoleApplication1.exe 파일에 기록된 IMAGE_SECTION_HEADER들의 정보를 통해 어느 Section에 있는지 찾아낼 수 있습니다. 대개의 경우, Visual C++라면 다음과 같이 ".rdata" 섹션에서 찾을 수 있습니다.
.rdata 섹션의 RVA 값이 0x00017000이고 크기가 0x2564이기 때문에,
0x17000 <= 0x17b34 <= 0x19564
0x17b34 변위가 정확히 .rdata 섹션의 범위안에 드는 것을 볼 수 있습니다. 그럼, Visual C++가 .rdata 섹션에 기록한 vtable 내용을 찾아볼까요?
.rdata 섹션의 시작이 0x17000 위치이니, vtable이 위치한 주소의 섹션 내 변위는 "0x17b34 - 0x17000 = 0xb34"가 됩니다. ConsoleApplication1.exe의 PE 정보에서 .rdata 섹션의 파일 위치 정보를 가리키는 "Pointer to Raw Data" 값이 0x5c00라고 했으니 "0x5c00 + 0xb34 = 0x6734"가 됩니다. 이 위치를 찾아 보면,
오프셋 0: 0x00411361 (A::vfunc1 가상 함수를 가리키는 주소)
위와 같이 값이 담겨 있습니다. 메모리에서와 마찬가지로 A 클래스의 가상 함수가 1개이기 때문에 PE 파일의 Section 내에도 1개가 보입니다. 물론, 이 값들이 그대로 가상 함수의 주소로 사용되지는 않습니다. 왜냐하면 ASLR로 인해 컴파일러가 가정한 Image Base 주소가 0x400000에서 0x00CD0000으로 변경되었기 때문에 이미지 로딩 시에 Relocation 계산이 이뤄져서 그 값이 반영된 주소로 메모리에서는 덮어써집니다. 역시 직접 계산을 해볼까요? ^^
우선 조정된 Image Base 주소의 차이를 계산하고,
0x00CD0000 - 0x400000 = 0x8d0000
그 값에 PE 파일에서 찾아낸 vtable의 함수 주소인 0x00411361을 더하면,
0x8d0000 + 0x411361 = 0xce1361
정확히 0x00ce1361이 나옵니다. 이 값을 디버깅 중인 A 클래스의 생성자에 있던,
mov dword ptr [eax],offset A::`vftable' (0CE7B34h)
0CE7B34h 주소를 메모리 창으로 확인하면 동일한 값이 나오는 것을 볼 수 있습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]