가상 함수, vtable, vptr, 다중 상속, 가상 상속이 Itanium C++ ABI에서 실제로 어떻게 구현되는지 살펴봅니다.
가상 함수는 C++의 핵심 기능 중 하나로, 런타임 다형성을 가능하게 합니다. 대부분의 C++ 프로그래머는 이를 정기적으로 사용하지만, 실제로 이것이 어떻게 동작하는지 이해하는 사람은 많지 않습니다. 함수를 virtual로 선언하면 컴파일러는 실제로 무엇을 생성할까요? 프로그램은 런타임에 어떤 구현을 호출해야 하는지 어떻게 알아낼까요? vtable 데이터는 실제로 어디에 저장될까요? 이 블로그 글은 이런 질문에 답하는 데 초점을 맞춥니다.
C++ 표준은 동작은 규정하지만 구현은 규정하지 않습니다. 이 글은 Microsoft MSVC라는 눈에 띄는 예외를 제외하고 대부분의 플랫폼에서 사용되는 Itanium C++ ABI를 설명합니다.
가상 함수가 있는 다음 클래스들을 생각해 봅시다:
struct Base {
virtual void foo() { __builtin_printf("Base::foo\n"); }
virtual void bar() { __builtin_printf("Base::bar\n"); }
virtual ~Base() {}
};
struct Derived : Base {
void foo() override { __builtin_printf("Derived::foo\n"); }
};
void call_foo(Base* b) {
b->foo(); // 어떤 foo()가 호출될까?
}
int main() {
Base base;
Derived derived;
call_foo(&base); // Base::foo를 호출해야 함
call_foo(&derived); // Derived::foo를 호출해야 함
}
컴파일러는 call_foo()에서 b가 무엇을 가리키는지 컴파일 시점에는 알지 못합니다. 이 함수는 객체의 실제 런타임 타입에 따라 올바른 foo() 구현으로 디스패치해야 합니다. 바로 이것을 vtable(virtual table의 줄임말)이 가능하게 합니다.
이제 vtable이 실제로 어떻게 생겼는지 봅시다. GCC에는 클래스와 vtable 배치를 덤프해 주는 유용한 플래그 -fdump-lang-class가 있습니다:
g++ -fdump-lang-class example.cpp
GCC는 클래스와 vtable 덤프 정보가 들어 있는 a-example.cpp.001l.class라는 파일을 생성해야 합니다.
Clang도 비슷한 정보를 덤프할 수 있지만, 인터페이스는 다릅니다:
clang++ -Xclang -fdump-record-layouts -Xclang -fdump-vtable-layouts example.cpp
이 내용은 Dumping a C++ object’s memory layout with Clang에서 알게 되었습니다.
제 생각에는 GCC 쪽 출력 형식이 약간 더 보기 좋으므로, 아래에 인용한 모든 출력은 gcc -fdump-lang-class의 것입니다.
우리의 Base 클래스에 대해 GCC는 다음을 출력합니다:
Vtable for Base
Base::_ZTV4Base: 6 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI4Base)
16 (int (*)(...))Base::foo
24 (int (*)(...))Base::bar
32 (int (*)(...))Base::~Base
40 (int (*)(...))Base::~Base
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x23116c0) 0
vptr=((& Base::_ZTV4Base) + 16)
여기서 보고 있는 내용을 하나씩 풀어보겠습니다.
엔트리를 보기 전에 심벌 이름 _ZTV4Base에 주목해 봅시다. 이것은 vtable의 맹글링된 이름입니다.
_Z 접두사는 Itanium 이름 맹글링을 나타냅니다T는 특수 이름 접두사입니다(vtable, typeinfo, thunk, 그 밖의 컴파일러 생성 심벌에 사용됨)V는 vtable을 뜻합니다(I는 typeinfo)4는 뒤에 오는 이름의 길이입니다Base는 클래스 이름입니다비슷하게:
_ZTV7Derived = Derived용 vtable_ZTI4Base = Base용 typeinfo_ZTV4Base라는 이름의 vtable에는 6개의 엔트리가 있으며, 각 엔트리는 8바이트 간격으로 배치됩니다(64비트 시스템 기준). 구조는 다음과 같습니다:
Offset 0: offset-to-top = 0
Offset 8: typeinfo pointer = &_ZTI4Base
Offset 16: foo() = &Base::foo
Offset 24: bar() = &Base::bar
Offset 32: ~Base() = &Base::~Base (D1)(complete object destructor)
Offset 40: ~Base() = &Base::~Base (D0)(deleting destructor)
클래스에 선언된 각 가상 함수는 선언 순서대로 vtable에 엔트리를 하나씩 갖습니다. Base에 선언된 함수는 foo(), bar(), 그리고 소멸자입니다.
Itanium ABI에서 소멸자는 vtable 엔트리를 두 개 가집니다:
operator delete를 호출해 메모리를 해제합니다. delete ptr를 쓸 때 사용됩니다.세 번째 변종인 **기반 객체 소멸자 (D2)**도 있는데, 이는 vtable에는 나타나지 않으며 파생 클래스 소멸자에서만 직접 호출됩니다. 가상 상속을 볼 때 왜 이것이 존재하는지 보게 될 것입니다.
이 필드는 vptr이 완전 객체의 시작점으로부터 얼마나 떨어져 있는지를 기록합니다. 단일 상속에서는 값이 0이라 별로 흥미로워 보이지 않습니다. 하지만 다중 상속이 들어오면 중요해집니다.
typeinfo 포인터는 런타임 타입 식별(RTTI)을 가능하게 합니다. 이것은 클래스에 대한 std::type_info 객체를 가리키며, dynamic_cast, typeid, 예외 처리에서 사용됩니다.
-fno-rtti를 사용하면 이 필드는 null입니다.
vtable은 바이너리의 .rodata 섹션에 존재하는 정적 데이터 구조입니다. 하지만 실제로 어느 번역 단위가 그것을 생성할까요?
Itanium ABI는 키 함수 규칙을 사용합니다. vtable은 첫 번째 비인라인 가상 함수를 정의한 번역 단위에서 생성됩니다.
// base.h
struct Base {
virtual void foo() { /* ... */ }; // 인라인, 키 함수가 아님
virtual void bar(); // 비인라인 - 이것이 키 함수
virtual ~Base();
};
// base.cpp
void Base::bar() { /* ... */ } // 키 함수 - vtable은 이 TU에 존재함
// Base의 vtable은 base.cpp에서 생성됨
이 규칙은 흔히 보게 되는 링크 오류의 원인이기도 합니다:
undefined reference to `vtable for Base'
이것은 키 함수가 선언만 되고 어디에서도 정의되지 않았을 때 발생합니다.
모든 가상 함수가 인라인이면 키 함수가 없습니다. 이 경우 vtable은 해당 클래스를 사용하는 모든 번역 단위에서 weak linkage로 생성됩니다.
가상 함수를 가진 각 객체는 virtual table pointer(줄여서 vptr)라고 하는 숨겨진 멤버를 포함합니다. 여기서는 단일 상속만 사용하는 단순한 경우이므로, 이것은 객체의 offset 0에 위치합니다. 가상 디스패치는 *(void**)this를 읽어 vtable을 찾습니다.
다중 상속에서는 vptr이 여러 개 있습니다(기반 클래스마다 하나). 이 부분은 뒤에서 다루겠습니다.
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x22606c0) 0 nearly-empty
vptr=((& Base::_ZTV4Base) + 16)
여기서 vptr은 vtable의 시작을 가리키지 않는다는 점에 주의하세요. 대신 vtable의 address point를 가리킵니다. 이 경우 이는 vtable의 첫 번째 함수 엔트리(+16)입니다. typeinfo 포인터와 offset-to-top은 address point로부터 음수 오프셋에 존재합니다.
개념적으로는 컴파일러가 이것을:
struct Base {
virtual void foo();
int x;
};
이런 식으로 바꾼다고 생각할 수 있습니다:
struct Base {
void** __vptr; // vtable을 가리킴
int x;
};
파생 클래스가 가상 함수를 오버라이드하면, 기반 클래스 배치를 바탕으로 자신의 vtable을 갖게 됩니다:
struct Derived : Base {
void foo() override;
virtual void baz();
};
vtable for Derived:
[offset-to-top] = 0
[typeinfo] = &typeinfo for Derived
[foo()] = &Derived::foo (overridden)
[bar()] = &Base::bar (inherited)
[~Derived()] = &Derived::~Derived (complete object destructor)
[~Derived()] = &Derived::~Derived (deleting destructor)
[baz()] = &Derived::baz (new virtual function)
상속받은 슬롯은 같은 위치에 그대로 남습니다. offset 0의 함수는 여전히 foo()이지만, 단지 다른 구현을 가리킬 뿐입니다.
따라서 foo()는 Derived::foo로 대체되고, bar()는 여전히 기반 구현을 가리키며, baz()는 끝에 추가됩니다.
이제 가상 함수를 호출할 때 실제로 무슨 일이 일어나는지 봅시다:
void call_foo(Base* b) {
b->foo();
}
컴파일러는 이것을 대략 다음처럼 바꿉니다:
void call_foo(Base* b) {
// 객체에서 vptr을 읽는다
void** vtable = *(void***)b;
// vtable에서 함수 포인터를 읽는다
// foo()는 index 0에 있다
void (*func)(Base*) = (void(*)(Base*))vtable[0];
// 함수를 호출한다
func(b);
}
다음은 -Os로 컴파일했을 때 GCC가 call_foo에 대해 생성한 코드입니다:
call_foo(Base*):
movq (%rdi), %rax # vptr 읽기
jmp *(%rax) # vtable의 첫 번째 함수 호출
만약 foo가 vtable의 두 번째 함수였다면 호출은 다음처럼 보였을 것입니다:
jmp *8(%rax) # 두 번째 함수 호출
call_foo는 가상 함수가 반환된 후에 별도로 할 일이 없기 때문에, GCC는 이것을 tail call(call 대신 jmp)로 바꾸었습니다. 멋지죠.
지금까지 모델은 단순했습니다. 객체 시작 부분에 vptr 하나가 있고, 그 객체에 대응하는 vtable 하나가 있었습니다. 다중 상속은 여기에 더 많은 복잡성을 추가합니다.
다음을 생각해 봅시다:
struct Left {
virtual void left_func() {}
int left_data;
};
struct Right {
virtual void right_func() {}
int right_data;
};
struct MultiDerived : Left, Right {
void left_func() override {}
[[gnu::noinline]] void right_func() override { right_data = 42; }
};
int main() {
MultiDerived md;
MultiDerived* md_ptr = &md;
Right* r_ptr = md_ptr; // 캐스팅이 16바이트를 더함
__builtin_printf("MultiDerived* : %p\n", (void*)md_ptr);
__builtin_printf("Right* : %p\n", (void*)r_ptr);
}
MultiDerived 객체는 Left*로도, Right*로도 동작해야 합니다. 이는 객체 안에 Left 부분과 Right 부분이 모두 들어 있어야 한다는 뜻입니다. ABI에서는 이렇게 내장된 기반 클래스 부분을 **기반 서브객체(base subobject)**라고 부릅니다.
실제 배치는 대략 다음과 같습니다:
struct MultiDerived {
// Left 기반 서브객체
void** __vptr_Left;
int left_data;
// Right 기반 서브객체
void** __vptr_Right;
int right_data;
};
컴파일러가 MultiDerived*를 갖고 있다면, 이 배치는 컴파일 시점에 이미 알고 있으므로 md_ptr->left_data와 md_ptr->right_data 같은 접근은 완전 객체의 시작점으로부터의 고정 오프셋일 뿐입니다.
어려운 부분은 기반 포인터들도 제대로 동작하게 만드는 것입니다:
Left*는 객체의 Left 부분을 가리켜야 합니다Right*는 객체의 Right 부분을 가리켜야 합니다이 예제에서 Left 부분은 offset 0에 있으므로 MultiDerived*와 Left*는 같은 주소를 가집니다. Right 부분은 그로부터 16바이트 뒤에 있으므로, MultiDerived*를 Right*로 캐스팅하면 포인터에 16바이트가 더해집니다. 포인터를 printf한 결과가 바로 이것을 보여 줍니다.
두 기반 서브객체가 모두 다형적이기 때문에, Left*는 Left와 호환되는 vtable을 Left 부분의 시작점에서 찾아낼 수 있어야 하고, Right*는 Right와 호환되는 vtable을 Right 부분의 시작점에서 찾아낼 수 있어야 합니다. 그래서 객체에는 vptr이 두 개 있습니다.
GCC의 -fdump-lang-class로 배치를 살펴보며 이것이 어떻게 구현되는지 봅시다:
Vtable for MultiDerived
MultiDerived::_ZTV12MultiDerived: 7 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI12MultiDerived)
16 (int (*)(...))MultiDerived::left_func
24 (int (*)(...))MultiDerived::right_func
32 (int (*)(...))-16
40 (int (*)(...))(& _ZTI12MultiDerived)
48 (int (*)(...))MultiDerived::_ZThn16_N12MultiDerived10right_funcEv
Class MultiDerived
size=32 align=8
base size=28 base align=8
MultiDerived (0x0x23aa000) 0
vptr=((& MultiDerived::_ZTV12MultiDerived) + 16)
Left (0x0x2311960) 0
primary-for MultiDerived (0x0x23aa000)
Right (0x0x23119c0) 16
vptr=((& MultiDerived::_ZTV12MultiDerived) + 48)
클래스 덤프는 vptr이 두 개 있음을 보여 줍니다. 하나는 Left 부분용으로 offset 0에 있고, 다른 하나는 Right 부분용으로 offset 16에 있습니다.
두 vptr 모두 같은 _ZTV12MultiDerived 심벌을 가리키지만, 서로 다른 오프셋을 가리킵니다. 이 심벌은 **가상 테이블 그룹(virtual table group)**입니다. 즉, 하나의 주 가상 테이블(primary virtual table) 뒤에, 비주 기반 클래스마다 하나씩 **보조 가상 테이블(secondary virtual table)**이 이어집니다.
주 가상 테이블 (Left용, offsets 0-24의 엔트리):
Offset 0: offset-to-top = 0
Offset 8: typeinfo pointer
Offset 16: left_func = &MultiDerived::left_func
Offset 24: right_func = &MultiDerived::right_func
보조 가상 테이블 (Right용, offsets 32-48의 엔트리):
Offset 32: offset-to-top = -16
Offset 40: typeinfo pointer
Offset 48: right_func = &MultiDerived::_ZThn16_N12MultiDerived10right_funcEv (thunk)
Right 서브객체는 MultiDerived 안에서 offset 16에 존재합니다. MultiDerived*를 Right*로 캐스팅하면, 컴파일러는 포인터에 16바이트를 더해 멤버 접근 예를 들어 r->right_data(이는 this로부터의 고정 오프셋입니다)가 올바른 필드에 도달하게 합니다. 이제 포인터는 Right vptr을 가리키고, 그 vptr은 대응하는 가상 테이블로 들어갑니다.
보조 가상 테이블이 흥미로운 부분입니다. Right*를 통한 호출은 객체의 Right 부분을 가리키는 포인터에서 시작하므로, Right vptr과 Right 관점의 가상 테이블을 사용해야 합니다.
Right 보조 가상 테이블의 right_func 슬롯은 MultiDerived::right_func를 직접 가리키지 않습니다. 대신 비가상 thunk를 가리킵니다. 이는 this를 Right 서브객체에서 완전 객체 쪽으로 다시 조정한 뒤 실제 구현을 호출하는 아주 작은 래퍼입니다. 이 클래스 배치에서는 그 조정이 고정되어 있으므로 thunk 안에 하드코딩할 수 있습니다. 그래서 “비가상”입니다.
그 맹글링된 이름은 다음처럼 분해됩니다:
_Z - Itanium 맹글링 접두사T - 특수 이름의 시작(vtable의 TV에서와 같은 의미)h - 비가상 call-offset thunk(가상 thunk라면 대신 v)n16 - 조정값: n 접두사는 음수를 뜻하므로 -16_ - call-offset의 끝N12MultiDerived10right_funcEv - 대상 함수(MultiDerived::right_func())GCC는 다음 코드를 생성합니다:
.set .LTHUNK0,_ZN12MultiDerived10right_funcEv
_ZThn16_N12MultiDerived10right_funcEv:
subq $16, %rdi
jmp .LTHUNK0
C++로 쓰면 대략 다음과 같습니다:
void non_virtual_thunk_to_MultiDerived_right_func(Right* this) {
MultiDerived* complete = (MultiDerived*)((char*)this - 16);
MultiDerived::right_func(complete);
}
.set은 MultiDerived::right_func에 대한 로컬 별칭을 만듭니다. 그러면 thunk는 외부 심벌을 직접 호출하는 대신 그 별칭을 호출하게 되며, Linux에서는 PLT를 거치지 않아도 됩니다.
offset-to-top 필드는 런타임 코드가 기반 서브객체 포인터로부터 완전 객체의 시작점을 찾을 수 있게 해 줍니다. 대표적인 사용자는 dynamic_cast입니다. dynamic_cast<void*>(r)는 보조 vtable에서 offset-to-top을 읽고 그것을 this에 더해 가장 파생된 객체를 찾습니다.
정리하면, 다중 상속에 대해 적절한 정신 모델은 다음과 같습니다:
this를 복구하는 thunk가 필요할 수 있다offset-to-top은 런타임 코드가 어떤 기반 서브객체 포인터로부터도 완전 객체의 시작점을 복구할 수 있게 해 준다가상 상속은 C++이 다이아몬드 문제를 해결하는 방식입니다. 다음을 생각해 봅시다:
struct Base {
virtual void f();
int x;
};
struct Left : virtual Base {
virtual void g();
};
struct Right : virtual Base {
virtual void h();
};
struct Derived : Left, Right {
void f() override;
};
Left : virtual Base와 Right : virtual Base의 virtual 키워드는 컴파일러에게 Base가 _가상 기반(virtual base)_이라는 것을 알려 줍니다. 즉, 상속 그래프의 모든 경로가 하나의 Base 서브객체를 공유합니다. 이것이 없다면 Derived는 서로 다른 Base 복사본을 두 개(Left를 통해 하나, Right를 통해 하나) 포함하게 되므로, Derived를 통해 Base::x에 접근하는 것은 모호해집니다. 이것이 다이아몬드 문제입니다.
기반을 공유하는 것이 우리가 원하는 바지만, 이것은 앞 절의 thunk가 기대하던 가정을 깨뜨립니다. 일반적인 다중 상속에서는 모든 기반 서브객체가 완전 객체로부터 고정된 오프셋에 있으므로, 비가상 thunk가 그 조정을 하드코딩할 수 있습니다. 하지만 가상 상속에서는 가상 기반까지의 오프셋이 최종적인 가장 파생된 타입에 따라 달라집니다. Left는 자신이 Derived 안에 들어갔을 때 Base가 어디에 배치될지 알지 못합니다. 그것은 완전 객체를 배치할 때에만 알 수 있습니다.
이로 인해 여러 결과가 생깁니다:
this 조정이 필요합니다다음 절들에서는 ABI가 이것을 처리하기 위해 사용하는 여러 전략을 살펴봅니다.
앞 절에서는 비가상 thunk가 조정값(subq $16, %rdi)을 하드코딩했습니다. 가상 상속에서는 가상 기반까지의 오프셋이 완전 타입에 따라 달라지므로 이것이 불가능합니다. 해결책은 새로운 종류의 vtable 슬롯 두 개와, 런타임에 그 슬롯을 읽는 thunk입니다.
먼저 Derived 계층에 대한 GCC 출력을 봅시다:
Class Derived
size=32 align=8
base size=16 base align=8
Derived (0x0x77a8cf3dc000) 0
vptridx=0 vptr=((& Derived::_ZTV7Derived) + 24)
Left (0x0x77a8cf20e3a8) 0 nearly-empty
primary-for Derived (0x0x77a8cf3dc000)
subvttidx=8
Base (0x0x77a8cf3d52a0) 16 virtual
vptridx=40 vbaseoffset=-24 vptr=((& Derived::_ZTV7Derived) + 96)
Right (0x0x77a8cf20e410) 8 nearly-empty
subvttidx=24 vptridx=48 vptr=((& Derived::_ZTV7Derived) + 64)
Base (0x0x77a8cf3d52a0) alternative-path
배치는 다음과 같습니다:
Left 서브객체Right 서브객체Base 서브객체(vptr은 16에, int x는 24에 있음)Derived의 vtable 그룹은 세 구역으로 나뉩니다:
Vtable for Derived
Derived::_ZTV7Derived: 13 entries
0 16 <- vptr[-3]: vbase offset (Base는 Left의 vptr에서 +16)
8 (int (*)(...))0 <- vptr[-2]: offset-to-top = 0
16 (int (*)(...))(& _ZTI7Derived) <- vptr[-1]: typeinfo
24 (int (*)(...))Left::g <- vptr[0]: address point
32 (int (*)(...))Derived::f <- vptr[1]
40 8 <- vptr[-3]: vbase offset (Base는 Right의 vptr에서 +8)
48 (int (*)(...))-8 <- vptr[-2]: offset-to-top = -8
56 (int (*)(...))(& _ZTI7Derived) <- vptr[-1]: typeinfo
64 (int (*)(...))Right::h <- vptr[0]: address point
72 -16 <- vptr[-3]: Base의 f() thunk용 vcall offset
80 (int (*)(...))-16 <- vptr[-2]: offset-to-top = -16
88 (int (*)(...))(& _ZTI7Derived) <- vptr[-1]: typeinfo
96 (int (*)(...))Derived::_ZTv0_n24_N7Derived1fEv <- vptr[0]: f()용 virtual thunk
Left와 Right 구역은 각각 vptr[-3]에 vbase offset으로 시작합니다. vbase offset은 서브객체에서 그 가상 기반으로 이동할 때 사용됩니다. 즉, “이 vptr을 기준으로 Base는 어디에 있는가?”에 답합니다. Left에서는 +16이고, Right에서는 +8입니다. Left*나 Right*에서 Base로 가야 하는 코드는 런타임에 이 슬롯을 읽습니다.
반면 Base 구역은 vptr[-3]에 vcall offset을 가집니다: -16입니다. vcall offset은 반대 방향, 즉 가상 기반에서 다시 완전 객체 쪽으로 이동합니다. 이것은 virtual thunk만 읽으며, 실제 구현으로 점프하기 전에 Base*를 Derived 쪽으로 다시 조정하는 데 사용됩니다.
이제 virtual thunk용으로 생성된 코드를 봅시다:
virtual thunk to Derived::f(): # _ZTv0_n24_N7Derived1fEv
movq (%rdi), %r10 # vptr 읽기
addq -24(%r10), %rdi # vptr[-3]에서 vcall offset을 읽어 this 조정
jmp .LTHUNK0 # Derived::f()로 점프
C++로 쓰면 대략 다음과 같습니다:
void virtual_thunk_to_Derived_f(Base* this) {
void** vptr = *(void***)this;
this = (Base*)((char*)this + ((ptrdiff_t*)vptr)[-3]); // vcall offset 읽기
Derived::f(this);
}
virtual thunk의 맹글링된 이름(_ZTv0_n24_N7Derived1fEv)은 다음처럼 분해됩니다:
Tv — virtual thunk0 — vcall offset을 읽기 전에 비가상 this 조정은 없음n24 — vcall offset이 address point보다 24바이트 앞에 있음(vptr[-3])N7Derived1fEv — 대상은 Derived::f()각 vtable 구역은 가상 기반마다 하나의 음수 슬롯을 갖습니다. 비가상 기반 구역에서는 vbase offset이고, 가상 기반 자신의 구역에서는 vcall offset입니다.
어떤 클래스는 동시에 가상 기반이기도 하고 자신만의 가상 기반을 가질 수도 있습니다. 그런 경우 그 구역에는 먼저 vbase offset들(상속받는 가상 기반마다 하나)이 오고, 그 뒤에 vcall offset들(thunk가 필요한 가상 함수마다 하나)이 옵니다.
C++는 생성자 본문 안에서도 가상 디스패치가 동작함을 보장하므로, 생성자 본문이 실행되기 전에 vptr이 유효해야 합니다. 하지만 앞서 보았듯이 Left는 최종 완전 타입이 배치되기 전까지 Base가 어디에 놓일지 알 수 없습니다.
Left의 vtable은 컴파일 시점에 알 수 있었던 내용을 기록합니다. 독립적인 Left에서는 Base가 8바이트 떨어져 있습니다. 하지만 Derived 안에 들어가면 Base는 16바이트 떨어진 곳에 놓입니다. Left의 생성자는 자기 자신의 vtable만 설치할 수 있는데, 그 vtable은 여전히 8이라고 말합니다. 따라서 Derived 안에서 Left가 생성되는 동안 virtual thunk가 실행되면 Base의 주소를 잘못 계산하게 됩니다.
해결책은 construction vtable입니다. 이것은 실제로 생성 중인 완전 객체에 맞게 오프셋을 수정한 Left vtable의 문맥별 복사본입니다. Left의 생성자 본문이 실행되기 전에 vptr에 설치되고, Left의 생성자가 반환되면 실제 vtable로 덮어써집니다. GCC는 가상 기반을 가진 각 기반 클래스마다 하나를 생성합니다. 이 계층에서는 _ZTC7Derived0_4Left와 _ZTC7Derived8_5Right입니다.
맹글링된 이름 _ZTC7Derived0_4Left는 다음처럼 분해됩니다:
TC — construction vtable7Derived — 완전 클래스0 — Derived 안에서 Left가 차지하는 바이트 오프셋4Left — 기반 클래스_ZTC7Derived8_5Right도 같은 의미입니다. Right는 Derived 안에서 offset 8에 있습니다.
이제 각 기반 생성자에게 올바른 construction vtable을 전달할 방법이 필요합니다. 그것이 바로 VTT(Virtual Table Table)의 목적입니다. VTT는 특정 생성 문맥에 대한 vtable 포인터 배열이며, 설정해야 하는 각 서브객체 vptr마다 하나의 엔트리를 가집니다.
가상 기반을 가진 클래스의 생성자는 소멸자와 비슷하게 두 가지 변종으로 나뉩니다:
Left와 Right는 모두 Base를 가상 기반으로 가지므로 C2가 sub-VTT를 받습니다. Base는 가상 기반이 없으므로 C2/C1 분리도 없고 sub-VTT도 없습니다.
VTT for Derived
Derived::_ZTT7Derived: 7 entries
0 ((& Derived::_ZTV7Derived) + 24) <- [0] Derived 자신의 vtable (Derived가 또 다른 클래스의 서브객체일 때 사용)
8 ((& Derived::_ZTC7Derived0_4Left) + 24) <- [1] Left::C2용 sub-VTT: Left의 vptr
16 ((& Derived::_ZTC7Derived0_4Left) + 56) <- [2] Base의 vptr
24 ((& Derived::_ZTC7Derived8_5Right) + 24) <- [3] Right::C2용 sub-VTT: Right의 vptr
32 ((& Derived::_ZTC7Derived8_5Right) + 56) <- [4] Base의 vptr
40 ((& Derived::_ZTV7Derived) + 96) <- [5] Base용 영구 vptr
48 ((& Derived::_ZTV7Derived) + 64) <- [6] Right용 영구 vptr
VTT에는 세 개의 논리적 영역이 있습니다:
[0] — Derived 자신의 주 vtable 포인터입니다. Derived가 또 다른 클래스의 기반일 때만 사용되며, 여기서는 사용되지 않습니다.[1]-[4] — sub-VTT 조각들로, 필요한 기반 클래스마다 하나씩 있습니다. 각각은 해당 기반의 C2에 전달되는 연속된 vtable 포인터 블록입니다. Left의 조각은 [1]-[2](Left 자신의 vptr + Base의 vptr)이고, Right의 조각은 [3]-[4]입니다.[5]-[6] — 모든 기반 생성자가 끝난 뒤 기록되는, Derived 자신의 vtable 안을 가리키는 영구 vptr들입니다. Base와 Right는 각각 하나의 슬롯을 가집니다. Left는 없습니다. Left는 Derived의 주 기반이므로, 두 vptr은 offset 0의 같은 메모리 위치를 공유하고, 거기에 Derived의 vtable을 설치하면 둘 다 한 번에 해결되기 때문입니다.의사코드로 흐름을 쓰면 다음과 같습니다:
// Left의 C2 — 호출자가 넘겨준 construction vtable들을 설치한다
void Left_C2(Left* this, void** vtt) {
this->vptr = vtt[0]; // Left의 construction vtable
ptrdiff_t off = ((ptrdiff_t*)this->vptr)[-3]; // vbaseoffset
((Base*)((char*)this + off))->vptr = vtt[1]; // Base의 construction vtable
// ... 생성자 본문 ...
}
// Derived의 C1 — 전체 시퀀스를 주도한다
void Derived_C1(Derived* this) {
base->vptr = &Base::vtable; // Base에 일단 유효한 것을 심어 둔다
Left_C2 ((Left*)this, &VTT[1]); // Left는 VTT[1..2]를 사용
Right_C2((Right*)(this + 8), &VTT[3]); // Right는 VTT[3..4]를 사용
// 모든 기반 생성이 끝나면 최종 vtable 포인터들을 설치한다
left->vptr = &Derived::vtable[Left_section];
right->vptr = &Derived::vtable[Right_section];
base->vptr = &Derived::vtable[Base_section];
// ... 생성자 본문 ...
}
이제 Left::Left C2의 실제 어셈블리를 봅시다:
Left::Left() [base object constructor]: # _ZN4LeftC2Ev
movq (%rsi), %rax # VTT에서 Left의 construction vtable ptr 읽기
movq %rax, (%rdi) # Left의 vptr에 설치
movq -24(%rax), %rax # vbaseoffset 읽기: 이 객체 안에서 Base의 vptr까지의 바이트 오프셋
movq 8(%rsi), %rdx # VTT에서 Base의 construction vtable ptr 읽기
movq %rdx, (%rdi,%rax) # Base의 vptr에 설치
# ... 생성자 본문 ...
ret
그리고 전체 시퀀스를 주도하는 Derived::Derived C1입니다:
Derived::Derived() [complete object constructor]: # _ZN7DerivedC1Ev
movq %rdi, %rbx
leaq 16+_ZTV4Base(%rip), %rax
movq %rax, 16(%rdi) # Base 생성: offset 16에 Base의 vtable 설치
leaq 8+_ZTT7Derived(%rip), %rbp # %rbp = &VTT[1]
movq %rbp, %rsi
call _ZN4LeftC2Ev # Left::C2(this, &VTT[1])
leaq 16(%rbp), %rsi # %rsi = &VTT[3]
leaq 8(%rbx), %rdi
call _ZN5RightC2Ev # Right::C2(this+8, &VTT[3])
leaq 24+_ZTV7Derived(%rip), %rax
movq %rax, (%rbx) # Left/Derived의 vptr(offset 0)에 최종 vtable 설치
leaq 72(%rax), %rax
movq %rax, 16(%rbx) # Base의 vptr(offset 16)에 설치
leaq -32(%rax), %rax
movq %rax, 8(%rbx) # Right의 vptr(offset 8)에 설치
# ... 생성자 본문 ...
ret
가상 기반은 비가상 기반보다 먼저 생성되므로 Base가 먼저 갑니다. Base는 가상 기반이 없으므로 GCC는 생성자 호출을 생성하는 대신 vtable 설치를 직접 인라인합니다. 그런 다음 Left_C2와 Right_C2가 각각 대응하는 sub-VTT와 함께 실행됩니다. 두 함수 모두 자기 sub-VTT의 엔트리를 사용해 Base의 vptr에 construction vtable 포인터를 설치합니다. 두 sub-VTT는 모두 Derived 안에서 Base의 실제 위치에 맞춰 vcall offset이 수정된 construction vtable을 가리킵니다. 두 C2가 모두 끝나면 C1이 Derived의 최종 vtable을 세 개의 vptr 모두에 설치합니다.
D2소멸에도 비슷한 문제가 있습니다. 공유된 Base 서브객체는 한 번만 파괴되어야 합니다.
독립적인 Left를 파괴할 때는 Left::~Left가 Base도 함께 파괴합니다. 그것을 해 줄 다른 주체가 없기 때문입니다. 하지만 Derived를 파괴할 때는 Derived::~Derived가 Base를 책임지고, Left와 Right의 소멸자는 이를 건너뛰어야 합니다.
이것이 앞서 언급한 D2(기반 객체 소멸자)의 역할입니다. 즉, 이 서브객체만 파괴하고 가상 기반은 그대로 두는 것입니다.
delete derived_ptr에 대한 호출 체인은 다음과 같습니다:
D0 (삭제 소멸자): D1을 호출한 뒤 operator deleteD1 (완전 객체 소멸자): Derived 자신의 멤버를 파괴하고, Left::~Left와 Right::~Right를 D2로 호출한 뒤, Base를 파괴D2 (기반 객체 소멸자): 해당 서브객체 자신의 멤버만 파괴하고 멈춤 — 가상 기반은 건드리지 않음가장 파생된 소멸자가 가상 기반을 책임집니다. 그 외의 모든 것은 D2를 사용합니다.
이 내용을 제대로 이해하기 위해 컴파일러 출력과 ABI 명세를 오랫동안 파고들었고, 이 블로그 글은 그 조사 결과입니다. 이 내용을 이렇게 모두 적어 둔 것이 다른 사람들에게도 이 주제를 이해하는 데 도움이 되기를 바랍니다.
이 글이 이전에는 분명하지 않았던 무언가를 이해하는 데 도움이 되었다면, 댓글로 들려주시면 정말 기쁠 것 같습니다. 반대로 제가 무언가를 잘못 이해했거나 오해를 부를 정도로 지나치게 단순화했다면, 꼭 정정해 주세요.
제 설명이 완전히 와닿지 않았다면, Nimrod가 이 주제의 일부를 다루는 글 두 편을 썼는데 그것들이 여러분에게 더 잘 맞을 수도 있습니다:
표준 문체의 저수준 세부 사항을 보려면, 공식 Itanium C++ ABI 명세는 https://itanium-cxx-abi.github.io/cxx-abi/abi.html에서 확인할 수 있습니다.