はじめに
記念すべき Qiita 初投稿。お題は VTable についてです。
VTable の実装イメージ
VTable の目的を端的に言い表すとすれば、「あるクラスのポインターが与えられ、そのポインターから仮想関数を呼び出す際、継承関係をうまいこと考慮した上で適切なクラスのメンバー関数を呼び出せる仕組み」でしょうか。例えば、継承関係にあるクラス c1, c2 と関数 hoge が以下のように定義されていると
class c1 {
public:
virtual void func();
};
class c2 : public c1 {
public:
void func();
};
void hoge(c1 *p) {
p->func();
}
関数 hoge は、p が c1 のインスタンスであるときには c1::func を呼び、p が c2 のインスタンスであるときには c2::func を呼ぶ必要があります。これを実現するための一つの方法が VTable です。実際にどうやって実装されているかは後述するとして、イメージを簡単に説明すると以下のような仕組みです。
- クラス毎に、そのクラスの仮想関数へのポインターを集めたテーブルを作り、これを VTable と呼ぶことにします。上記例の c1, c2 であれば、以下のような配列 vtable_c1, vtable_c2 を予め作っておくイメージです。このとき、関数毎にインデックスを決めて、そのインデックスはクラス間でも共通になるようにします。ここでは例えば、func 関数は VTable の先頭に配置することにします。
vtable_c1 = {&c1::func, ...}
vtable_c2 = {&c2::func, ...}
- クラスのインスタンスを作るとき、その VTable へのポインターを隠しメンバーのような形で適当な位置に保存しておきます。ここでは例えば、インスタンスのオフセット 0 の位置に必ず vtable へのアドレスを入れることにします。
上記のように定めると、hoge 関数の実装は簡単です。引数の p の実体がなんであったとしても、オブジェクトの先頭には VTable のポインターがあり、VTable の先頭には必ず func 関数があると分かっているからです。func の呼び出しを C 言語で表すとすれば ((VTable*)p)[0]()
で済みます。
仮に VTable を使わずに仮想関数呼び出しを実装するとすれば、vtable の代わりに何らかの型情報を埋め込んでおいて、条件分岐で各クラスの関数へジャンプさせる方法がすぐに思い浮かびます。そのような実装と比べたときの利点としては、呼び出し時の条件分岐が不要なため、コードが短くなって速くなる点が挙げられます。
ただしデメリットもあります。VTable を使った関数呼び出しというのは、どうしても変数経由での呼び出しになるため、何らかの脆弱性によってその変数が外部からコントロール可能であった場合、簡単にリモートコード実行 (RCE) ができてしまいます。それを防ぐために Windows では CFG があったりするのですが、それはまた別の話。
上記のような話までは Wikipedia に書いてあります。
Virtual method table - Wikipedia
https://en.wikipedia.org/wiki/Virtual_method_table
(2017/3/6 追記)
書き始める前に過去記事の確認はしたつもりでしたが、投稿後に以下の関連記事を見つけました。
静的ポリモーフィズムの安全で簡単な実装 -動的から静的にしてパフォーマンス向上- - Qiita
http://qiita.com/Riyaaaa_a/items/887f6190e710c6410994
本記事の VTable の目的のところでは、動的ポリモーフィズムというキーワードを含めないと駄目でした。面接だったら減点ですね。
上記記事の、テンプレートを使った静的ポリモーフィズムの実装については全く思いもしませんでした。これが多くの C++ ユーザーに推奨されているというのも驚きでした。モダンな C++ のトレンドでしょうか。
それとこの部分、
代表的なものとして、スタックの圧迫、関数呼び出しのオーバーヘッドの増加、インライン展開が不可能、などが挙げられます。
本記事ではデメリットについての言及が不完全でした。確かに、VTable を使った間接的な関数呼び出しによってパフォーマンスが低下することは明白です。Wikipedia の Efficiency セクションで言及がありますが、1996 年の論文では、VTable が導入されることで、プログラム全体の実行時間のうち関数呼び出しが占める時間の割合が明らかに増える、という知見が示されています。
ただ、現時点で VTable が広く使われているのは事実ですし、VTable によるパフォーマンスの低下を気にしないといけないシビアな状況、すなわち、動的ポリモーフィズムを静的ポリモーフィズムに書き換えないとプログラムがまともに動かない、という状況は稀だと思っています。もちろんプログラムは速ければ速いほどいいんですが。いずれにせよ、静的ポリモーフィズムの実装手法は覚えておかないといけませんね。
ところで、インライン展開については Wikipedia に以下の記載があります。
Furthermore, in environments where JIT compilation is not in use, virtual function calls usually cannot be inlined.
この "usually cannot" の意図は分かりませんが、普通の C++ プログラム、すなわち JIT コンパイルはなく、仮想関数呼び出しに VTable が使われる環境において、それでもなおインライン展開が使われる状況を Visual C++ の生成したコードで見ることはあります。実現方法は簡単で、VTable のエントリと関数ポインターの値を比較して、一致すればインライン展開した関数までジャンプする、というような処理になっていました。本記事中の例を使って C で書くとすれば以下のような構造です。
void hoge(c1 *p) {
if (((VTable*)p)[0] == D::func) {
... // inlined body of D::func
}
else if (((VTable*)p)[0] == B2::func) {
... // inlined body of B2::func
}
else {
((VTable*)p)[0](); // normal function call via vtable
}
}
これでもまだ直接呼び出しと比べると、VTable エントリの読出しと比較の分だけ遅いとは思いますが、インライン展開のサポート受けられるので、例えば関数呼び出し時のパラメーターが定数で、そのパラメーターの値によって関数の実行フローが大きく変わる場合などにインライン展開の恩恵が大きくなることが予想されます。g++ に同じような最適化手法が実装されているかどうかは不明です。
g++ 5.4.0 における実装
Wikipedia では g++ 3.4.6 での実装について書かれていますが、少々古いので、手元の g++ 5.4.0 で動作を見てみることにします。g++ 6.x じゃないのかよというツッコミはなしの方向でお願いします。
コードは Wikipedia のものをそのまま使います。
class B1 {
public:
void f0() {}
virtual void f1() {}
int int_in_b1;
};
class B2 {
public:
virtual void f2() {}
int int_in_b2;
};
class D : public B1, public B2 {
public:
void d() {}
void f2() {} // override B2::f2()
int int_in_d;
};
int main() {
B2 *b2 = new B2();
D *d = new D();
return 0;
}
以下の Makefile でコンパイルしました。-fdump-class-hierarchy オプションをつけるとクラス情報をダンプしてくれるので便利です。最適化を有効にすると、main 関数は new するだけでコンストラクターを呼んでくれなかったので -O0 にしています。
CC=g++
RM=rm -f
TARGET=t
SRCS=$(wildcard *.cpp)
OBJS=$(SRCS:.cpp=.o)
override CFLAGS+=\
-Wall\
-O0\
-g\
-fPIC\
-std=c++14\
-fdump-class-hierarchy\
LFLAGS=\
INCLUDES=\
LIBDIRS=\
LIBS=\
-lpthread\
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LFLAGS) $(LIBDIRS) $^ -o $@ $(LIBS)
.cpp.o:
$(CC) $(INCLUDES) $(CFLAGS) -c $<
clean:
$(RM) $(OBJS) $(TARGET)
make して生成されたクラス情報のうち、B2 の部分は以下のようになりました。
Vtable for B2
B2::_ZTV2B2: 3u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI2B2)
16 (int (*)(...))B2::f2
Class B2
size=16 align=8
base size=12 base align=8
B2 (0x0x7fb9063c2600) 0
vptr=((& B2::_ZTV2B2) + 16u)
B2 の VTable に _ZTV2B2 というシンボル名がつけられています。TV というのは Table of Virtual functions の略か何かでしょう。その後の数字の 2 はクラス名の文字数で、B2 というのがクラス名です。_Z で始まっている理由は不明ですが、どうしても ABAP を連想してしまいます。
Wikipedia に書かれた g++ 3.4.6 の例では、VTable には B2::f2 へのポインターしか含まれないことになっていますが、g++ 5.4.0 では、B2::f2 は 3 番目になっています。先頭が 0 で、次に _ZTI2B2 という構造へのポインターが入っています。これらの意味は後述します。
Class B2 のところを見ると、B2 の先頭には _ZTV2B2 のポインターそのものではなく、_ZTV2B2 からのオフセット +16 の位置へのポインターが保存されることになっています。最初の 0 と _ZTI2B2 をスキップすることになるため、結局 Wikipedia に書かれている構造と同じように見える構造になっています。
実際に実行してみて、内部構造を gdb で確かめます。
(gdb) disas main
Dump of assembler code for function main():
0x00000000004006d6 <+0>: push %rbp
0x00000000004006d7 <+1>: mov %rsp,%rbp
0x00000000004006da <+4>: push %rbx
0x00000000004006db <+5>: sub $0x18,%rsp
0x00000000004006df <+9>: mov $0x10,%edi
0x00000000004006e4 <+14>: callq 0x4005c0 <_Znwm@plt>
0x00000000004006e9 <+19>: mov %rax,%rbx
0x00000000004006ec <+22>: movq $0x0,(%rbx)
0x00000000004006f3 <+29>: movl $0x0,0x8(%rbx)
0x00000000004006fa <+36>: mov %rbx,%rdi
0x00000000004006fd <+39>: callq 0x40077a <B2::B2()>
0x0000000000400702 <+44>: mov %rbx,-0x20(%rbp)
0x0000000000400706 <+48>: mov $0x20,%edi
0x000000000040070b <+53>: callq 0x4005c0 <_Znwm@plt>
0x0000000000400710 <+58>: mov %rax,%rbx
0x0000000000400713 <+61>: movq $0x0,(%rbx)
0x000000000040071a <+68>: movl $0x0,0x8(%rbx)
0x0000000000400721 <+75>: movq $0x0,0x10(%rbx)
0x0000000000400729 <+83>: movl $0x0,0x18(%rbx)
0x0000000000400730 <+90>: movl $0x0,0x1c(%rbx)
0x0000000000400737 <+97>: mov %rbx,%rdi
0x000000000040073a <+100>: callq 0x4007b6 <D::D()>
0x000000000040073f <+105>: mov %rbx,-0x18(%rbp)
0x0000000000400743 <+109>: mov $0x0,%eax
0x0000000000400748 <+114>: add $0x18,%rsp
0x000000000040074c <+118>: pop %rbx
0x000000000040074d <+119>: pop %rbp
0x000000000040074e <+120>: retq
End of assembler dump.
(gdb) b *0x0000000000400702
Breakpoint 1 at 0x400702: file wiki.cpp, line 22.
(gdb) r
Starting program: /data/src/cdev/t
Breakpoint 1, 0x0000000000400702 in main () at wiki.cpp:22
22 B2 *b2 = new B2();
0x00000000004006df <main()+9>: bf 10 00 00 00 mov $0x10,%edi
0x00000000004006e4 <main()+14>: e8 d7 fe ff ff callq 0x4005c0 <_Znwm@plt>
0x00000000004006e9 <main()+19>: 48 89 c3 mov %rax,%rbx
0x00000000004006ec <main()+22>: 48 c7 03 00 00 00 00 movq $0x0,(%rbx)
0x00000000004006f3 <main()+29>: c7 43 08 00 00 00 00 movl $0x0,0x8(%rbx)
0x00000000004006fa <main()+36>: 48 89 df mov %rbx,%rdi
0x00000000004006fd <main()+39>: e8 78 00 00 00 callq 0x40077a <B2::B2()>
=> 0x0000000000400702 <main()+44>: 48 89 5d e0 mov %rbx,-0x20(%rbp)
(gdb) p/x $rax
$1 = 0x613c20
(gdb) x/4xg 0x613c20
0x613c20: 0x0000000000600da0 0x0000000000000000
0x613c30: 0x0000000000000000 0x0000000000000031
(gdb) p &(((B2*)0)->int_in_b2)
$3 = (int *) 0x8
(gdb) x/4xg 0x0000000000600da0-0x10
0x600d90 <_ZTV2B2>: 0x0000000000000000 0x0000000000600df8
0x600da0 <_ZTV2B2+16>: 0x000000000040075c 0x0000000000000000
(gdb) x/2i 0x000000000040075c
0x40075c <B2::f2()>: push %rbp
0x40075d <B2::f2()+1>: mov %rsp,%rbp
(gdb) x/4xg 0x0000000000600df8
0x600df8 <_ZTI2B2>: 0x00007ffff7dc8918 0x0000000000400897
0x600e08 <_ZTI2B1>: 0x00007ffff7dc8918 0x000000000040089b
(gdb) x/4xg 0x00007ffff7dc8918
0x7ffff7dc8918 <_ZTVN10__cxxabiv117__class_type_infoE+16>: 0x00007ffff7ae0d40 0x00007ffff7ae0d60
0x7ffff7dc8928 <_ZTVN10__cxxabiv117__class_type_infoE+32>: 0x00007ffff7ae34c0 0x00007ffff7ae34c0
(gdb) x/s 0x0000000000400897
0x400897 <_ZTS2B2>: "2B2"
B2 のコンストラクターから制御が返ってきた所で止めて、b2 の中身をいろいろ見てみました。
まず B2 の VTable は 0x600d90 にあります。そして事前に確認したように、b2 の先頭には &vtable + 0x10 の値である 600da0 が入っています。&(((B2*)0)->int_in_b2)
の値が 8 であることから、B2::int_in_b2 は素直に VTable の後に配置されているようです。
VTable の中身ですが、これも事前に確認したように先頭は 0、3 番目は B2::f2 のアドレスになっています。2 番目には謎の _ZTI2B2 へのポインターが入っており、値は 600df8 です。これの中を見てみると、先頭が 0x00007ffff7dc8918 というアドレスになっています。このアドレスをさらに辿ると、<_ZTVN10__cxxabiv117__class_type_infoE+16>
というシンボルに解決されました。_ZTVで始まっていることから、これも別のクラスの VTable で、名前からどうやら型情報に関係していることが分かります。つまり、_ZTI2B2 というのは B2 の Type Info を保持していることから _ZTI で始まっているのだと推測できます。_ZTI2B2 の VTable の後に入っている値 0x0400897 は "2B2" という文字列で、これは明らかにクラス名を文字列にしたものです。
まだ VTable の先頭にある 0 の意味が謎のままですが、続いて D のクラス情報を見ます。
Vtable for D
D::_ZTV1D: 7u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI1D)
16 (int (*)(...))B1::f1
24 (int (*)(...))D::f2
32 (int (*)(...))-16
40 (int (*)(...))(& _ZTI1D)
48 (int (*)(...))D::_ZThn16_N1D2f2Ev
Class D
size=32 align=8
base size=32 base align=8
D (0x0x7fb90627f540) 0
vptr=((& D::_ZTV1D) + 16u)
B1 (0x0x7fb9063c2660) 0
primary-for D (0x0x7fb90627f540)
B2 (0x0x7fb9063c26c0) 16
vptr=((& D::_ZTV1D) + 48u)
VTable の _ZTV、型情報の _ZTI といった命名規則は同じです。今度はさらに _Zthn とかいう謎のシンボルが出てきました。
ここでのポイントは、D は B1 と B2 を多重継承しているため、D は B1 として振る舞ったり B2 として振る舞ったりしないといけないことです。前述のように、B2 の VTable の先頭 (正確には先頭の 16 バイトをスキップした上での先頭) には B2::f2 が入っていました。D を B2 として見て仮想関数 f2 を呼んだ場合には、B2::f2 ではなく D::f2 を呼ばないといけないわけですが、これを実現するためには、D の VTable の先頭に D::f2 が入っているだけでは駄目です。というのも、B1 の VTable のことも考えないといけないからです。もし、B2 のためだけに B1 の VTable の先頭の位置を永久欠番にすれば別ですが、今回の例でいけば B1 の VTable の先頭は B1::f1 のために使われているので、VTable の 1 番目のエントリを巡って競合が起きかねません。
どうやってこの競合を解決するかというと、this ポインターをずらします。上記の出力で言うと、Class D のところに B1 と B2 というセクションがあり、それぞれに 0 とか 16 とか数字が書かれていますが、これはつまり、D を B1 として見るときはオフセット 0 でそのまま、オフセット +16 を足すと B2 として見ることができるという意味です。このため、D の +0 と +16 のそれぞれの場所に VTable へのアドレスが配置されます。D の VTable は、_ZTV1D の一つだけですが、一つのテーブルを複数のオフセットから見ることで複数の型の VTable を表現できるようになっており、+16 だと B1 (及び D)、+48 だと B2 を表す VTable として使えます。B1 と D で同じものを使えるのは、B1 と D とでオーバーライドされる関数が存在しないためでしょう。
さて、B2 の VTable の先頭は D::f2 になっていてもよさそうですが、ここが _ZThn16_N1D2f2Ev というシンボルになっています。D2f2 という名前が含まれているので関連はありそうです。これもデバッガーで確かめてみます。
Breakpoint 2, 0x000000000040073f in main () at wiki.cpp:23
23 D *d = new D();
0x0000000000400706 <main()+48>: bf 20 00 00 00 mov $0x20,%edi
0x000000000040070b <main()+53>: e8 b0 fe ff ff callq 0x4005c0 <_Znwm@plt>
0x0000000000400710 <main()+58>: 48 89 c3 mov %rax,%rbx
0x0000000000400713 <main()+61>: 48 c7 03 00 00 00 00 movq $0x0,(%rbx)
0x000000000040071a <main()+68>: c7 43 08 00 00 00 00 movl $0x0,0x8(%rbx)
0x0000000000400721 <main()+75>: 48 c7 43 10 00 00 00 00 movq $0x0,0x10(%rbx)
0x0000000000400729 <main()+83>: c7 43 18 00 00 00 00 movl $0x0,0x18(%rbx)
0x0000000000400730 <main()+90>: c7 43 1c 00 00 00 00 movl $0x0,0x1c(%rbx)
0x0000000000400737 <main()+97>: 48 89 df mov %rbx,%rdi
0x000000000040073a <main()+100>: e8 77 00 00 00 callq 0x4007b6 <D::D()>
=> 0x000000000040073f <main()+105>: 48 89 5d e8 mov %rbx,-0x18(%rbp)
(gdb) p/x $rax
$5 = 0x613c40
(gdb) p sizeof(D)
$6 = 32
(gdb) x/4xg 0x613c40
0x613c40: 0x0000000000600d68 0x0000000000000000
0x613c50: 0x0000000000600d88 0x0000000000000000
(gdb) p &(((D*)0)->int_in_b1)
$7 = (int *) 0x8
(gdb) p &(((D*)0)->int_in_b2)
$8 = (int *) 0x18
(gdb) p &(((D*)0)->int_in_d)
$9 = (int *) 0x1c
(gdb) x/7xg 0x0000000000600d68-0x10
0x600d58 <_ZTV1D>: 0x0000000000000000 0x0000000000600dc0
0x600d68 <_ZTV1D+16>: 0x0000000000400750 0x0000000000400768
0x600d78 <_ZTV1D+32>: 0xfffffffffffffff0 0x0000000000600dc0
0x600d88 <_ZTV1D+48>: 0x0000000000400773
(gdb) x/4xi 0x0000000000400773
0x400773 <_ZThn16_N1D2f2Ev>: sub $0x10,%rdi
0x400777 <_ZThn16_N1D2f2Ev+4>: jmp 0x400768 <D::f2()>
0x400779: nop
0x40077a <B2::B2()>: push %rbp
例によって D のコンストラクターの直後でいろいろ見てみました。
まず、メンバー変数を含めた D の内部構造は次のようになっています。Wikipedia の記載と同じですね。
d:
+0x00: &_ZTV1D + 16 (for B1)
+0x08: int_in_b1
+0x10: &_ZTV1D + 48 (for B2)
+0x18: int_in_b2
+0x1c: int_in_d
VTable の内容から、B1 として見たときも B2 として見たときも同じ型情報である 600dc0 が参照されるようになっています。しかしそんなことより気になるのは +48 の位置にある _ZThn16_N1D2f2Ev です。値は 0x400773 で、コードを見ると rdi を -16 してから D::f2 を呼んでいます。大体予想がつきますが、以下の ABI にも書かれているとおり rdi は第一引数で、ここでは this ポインターのことです。
System V Application Binary Interface (AMD64)
https://software.intel.com/sites/default/files/article/402129/mpx-linux64-abi.pdf
つまり、D を B2 として見たときに f2 を呼び出すと、まずそのポインターを D として見るために this ポインターからオフセット分の 16 を引いて本来の D の位置に戻してから D::f2 を呼び出しているということです。これが Wikipedia のページの "Multiple inheritance and thunks" に書かれている thunk です。
では最後に、typeinfo の前にある 0 とか -16 とかの数字の謎についてですが、以下のドキュメントに書いてありました。ここまで引っ張っておいて仕様を見るのかよって話ですが・・・。
Itanium C++ ABI
https://mentorembedded.github.io/cxx-abi/abi.html
セクション 2.5.2 Virtual Table Components and Order の記載から引用します。
The offset to top holds the displacement to the top of the object from the location within the object of the virtual table pointer that addresses this virtual table, as a ptrdiff_t. It is always present. The offset provides a way to find the top of the object from any base subobject with a virtual table pointer. This is necessary for dynamic_cast<void*> in particular.
NOTE: In a complete object virtual table, and therefore in all of its primary base virtual tables, the value of this offset will be zero. For the secondary virtual tables of other non-virtual bases, and of many virtual bases, it will be negative. Only in some construction virtual tables will some virtual base virtual tables have positive offsets, due to a different ordering of the virtual bases in the full object than in the subobject's standalone layout.
要は、_ZTI1D の前にある -16 は、thunk の中で this ポインターを調整している -16 と同じように、その VTable を保持しているアドレスからオブジェクトの先頭までのオフセットを示しているようです。
Visual C++ 2015 における実装
Visual C++ での実装も見てみます。前述と全く同じ wiki.cpp を以下の Makefile でコンパイルします。Visual C++ の最適化レベルは 2 までで、O3 はありません。g++ と違って、O2 でもインスタンスをしっかり作成してくれるので O2 でいきます。-fdump-class-hierarchy のようなオプションはないので、内部構造はデバッガーで見るしかないです。GR は省略しても同じで、型情報を生成するためのオプションです。
!IF "$(PLATFORM)"=="X64"
OUTDIR=bin64
!ELSE
OUTDIR=bin
!ENDIF
OBJDIR=$(OUTDIR)
CC=cl
LINKER=link
RM=del /q
TARGET=t.exe
OBJS=\
$(OBJDIR)\wiki.obj
LIBS=
# warning C4100: unreferenced formal parameter
CFLAGS=\
/nologo\
/Zi\
/c\
/Fo"$(OBJDIR)\\"\
/Fd"$(OBJDIR)\\"\
/D_UNICODE\
/DUNICODE\
/O2\
/GR\
/EHsc\
/Wall\
/wd4100
LFLAGS=\
/NOLOGO\
/DEBUG\
/INCREMENTAL:NO\
/SUBSYSTEM:CONSOLE
all: $(OUTDIR)\$(TARGET)
clean:
-@if not exist $(OBJDIR) md $(OBJDIR)
@$(RM) /Q $(OBJDIR)\* 2>nul
$(OUTDIR)\$(TARGET): $(OBJS)
$(LINKER) $(LFLAGS) $(LIBS) /PDB:"$(@R).pdb" /OUT:$@ $**
.cpp{$(OBJDIR)}.obj:
$(CC) $(CFLAGS) $<
本題とは関係ありませんが、Wall オプションで Make (コマンドは nmake) すると、g++ より親切な警告を出してくれます。
D:\git\cdev> nmake
Microsoft (R) Program Maintenance Utility Version 14.00.24210.0
Copyright (C) Microsoft Corporation. All rights reserved.
cl /nologo /Zi /c /Fo"bin64\\" /Fd"bin64\\" /D_UNICODE /DUNICODE /O2 /EHsc /Wall /wd4100 wiki.cpp
wiki.cpp
wiki.cpp(6): warning C4820: 'B1': '4' bytes padding added after data member 'B1::int_in_b1'
wiki.cpp(12): warning C4820: 'B2': '4' bytes padding added after data member 'B2::int_in_b2'
wiki.cpp(19): warning C4820: 'D': '4' bytes padding added after data member 'D::int_in_d'
wiki.cpp(22): warning C4189: 'b2': local variable is initialized but not referenced
wiki.cpp(23): warning C4189: 'd': local variable is initialized but not referenced
wiki.cpp(3): warning C4514: 'B1::f0': unreferenced inline function has been removed
wiki.cpp(16): warning C4514: 'D::d': unreferenced inline function has been removed
link /NOLOGO /DEBUG /INCREMENTAL:NO /SUBSYSTEM:CONSOLE /PDB:"bin64\t.pdb" /OUT:bin64\t.exe bin64\wiki.obj
デバッガーの出力例がこちら。
0:000> uf t!main
t!main:
00007ff7`96641070 4053 push rbx
00007ff7`96641072 4883ec20 sub rsp,20h
00007ff7`96641076 b910000000 mov ecx,10h
00007ff7`9664107b e850000000 call t!operator new (00007ff7`966410d0)
00007ff7`96641080 488d1dd1420400 lea rbx,[t!B2::`vftable' (00007ff7`96685358)]
00007ff7`96641087 4885c0 test rax,rax
00007ff7`9664108a 7409 je t!main+0x25 (00007ff7`96641095)
t!main+0x1c:
00007ff7`9664108c 33c9 xor ecx,ecx
00007ff7`9664108e 48894808 mov qword ptr [rax+8],rcx
00007ff7`96641092 488918 mov qword ptr [rax],rbx
t!main+0x25:
00007ff7`96641095 b928000000 mov ecx,28h
00007ff7`9664109a e831000000 call t!operator new (00007ff7`966410d0)
00007ff7`9664109f 4885c0 test rax,rax
00007ff7`966410a2 7423 je t!main+0x57 (00007ff7`966410c7)
t!main+0x34:
00007ff7`966410a4 33c9 xor ecx,ecx
00007ff7`966410a6 48894808 mov qword ptr [rax+8],rcx
00007ff7`966410aa 48894818 mov qword ptr [rax+18h],rcx
00007ff7`966410ae 48894820 mov qword ptr [rax+20h],rcx
00007ff7`966410b2 488d0daf420400 lea rcx,[t!D::`vftable' (00007ff7`96685368)]
00007ff7`966410b9 488908 mov qword ptr [rax],rcx
00007ff7`966410bc 488d0db5420400 lea rcx,[t!D::`vftable' (00007ff7`96685378)]
00007ff7`966410c3 48894810 mov qword ptr [rax+10h],rcx
t!main+0x57:
00007ff7`966410c7 33c0 xor eax,eax
00007ff7`966410c9 4883c420 add rsp,20h
00007ff7`966410cd 5b pop rbx
00007ff7`966410ce c3 ret
0:000> g 00007ff7`96641080
t!main+0x10:
00007ff7`96641080 488d1dd1420400 lea rbx,[t!B2::`vftable' (00007ff7`96685358)]
0:000> r rax
rax=0000016cabdf8010
0:000> g 00007ff7`966410c7
t!main+0x57:
00007ff7`966410c7 33c0 xor eax,eax
0:000> r rax
rax=0000016cabdf8fd0
0:000> ?? sizeof(t!D)
unsigned int64 0x28
0:000> ?? sizeof(t!B2)
unsigned int64 0x10
0:000> dps 0000016cabdf8010 l2
0000016c`abdf8010 00007ff7`96685358 t!B2::`vftable'
0000016c`abdf8018 00000000`00000000
0:000> dps 0000016cabdf8fd0 l5
0000016c`abdf8fd0 00007ff7`96685368 t!D::`vftable'
0000016c`abdf8fd8 00000000`00000000
0000016c`abdf8fe0 00007ff7`96685378 t!D::`vftable'
0000016c`abdf8fe8 00000000`00000000
0000016c`abdf8ff0 00000000`00000000
0:000> dt t!B2
+0x000 __VFN_table : Ptr64
+0x008 int_in_b2 : Int4B
0:000> dt t!D
+0x000 __VFN_table : Ptr64
+0x008 int_in_b1 : Int4B
+0x010 __VFN_table : Ptr64
+0x018 int_in_b2 : Int4B
+0x020 int_in_d : Int4B
0:000> x t!B1::`vftable'
00007ff7`96685348 t!B1::`vftable' = <no type information>
0:000> x t!B2::`vftable'
00007ff7`96685358 t!B2::`vftable' = <no type information>
0:000> x t!D::`vftable'
00007ff7`96685368 t!D::`vftable' = <no type information>
00007ff7`96685378 t!D::`vftable' = <no type information>
0:000> dps 00007ff7`96685330
00007ff7`96685330 00000000`00000000
00007ff7`96685338 00000000`00000000
00007ff7`96685340 00007ff7`9668ef08 t!B1::`RTTI Complete Object Locator'
00007ff7`96685348 00007ff7`96641040 t!B1::f1
00007ff7`96685350 00007ff7`9668ef80 t!B2::`RTTI Complete Object Locator'
00007ff7`96685358 00007ff7`96641050 t!B2::f2
00007ff7`96685360 00007ff7`9668eff8 t!D::`RTTI Complete Object Locator'
00007ff7`96685368 00007ff7`96641040 t!B1::f1
00007ff7`96685370 00007ff7`9668f0a8 t!D::`RTTI Complete Object Locator'
00007ff7`96685378 00007ff7`96641060 t!D::f2
00007ff7`96685380 00007ff7`9668f0d0 t!type_info::`RTTI Complete Object Locator'
00007ff7`96685388 00007ff7`96641118 t!type_info::`scalar deleting destructor'
00007ff7`96685390 00007ff7`9668f148 t!std::exception::`RTTI Complete Object Locator'
00007ff7`96685398 00007ff7`96641614 t!std::exception::`scalar deleting destructor'
00007ff7`966853a0 00007ff7`96641698 t!std::exception::what
00007ff7`966853a8 206e776f`6e6b6e55
dt コマンドでの出力を見ると、クラスの内部構造は g++ と同じです。D の場合、+0 と +0x10 に VTable のアドレスが入っています。唯一異なるのは D::int_in_d がアラインされているため、オブジェクトのサイズが g++ より 4 バイト大きくなっていることでしょうか。
VC++ のシンボル名はずいぶんと単純明快で、VTable のシンボルは <クラス名>::`vftable' で統一されているようです。また、D を B1 として見た場合の VTable と B2 として見た場合の VTable に同じシンボル名が割り当てられているため、シンボル名からだけでは、何のクラスとして見る VTable なのか分からない欠点があります。この単純な名前付けの性質を利用して、何らかのクラスのポインターが与えられた時に、とりあえず dps でダンプしてみて vtable のシンボル名からオブジェクトの型を推測することができます。COM オブジェクトのときなどに使えます。
g++ の場合と同じように、VTable の上流に RTTI Complete Object Locator というシンボル名がつけられた型情報があるようです。この中身をちょっと見てみましたが、内部構造は類推できませんでした。
0:000> dd 00007ff7`9668f0a8
00007ff7`9668f0a8 00000001 00000010 00000000 000559b0
00007ff7`9668f0b8 0004f020 0004f0a8 00000000 00000000
00007ff7`9668f0c8 00000000 00000000 00000001 00000000
00007ff7`9668f0d8 00000000 000559c8 0004f0f8 0004f0d0
00007ff7`9668f0e8 00000000 00000000 00000000 00000000
00007ff7`9668f0f8 00000000 00000000 00000001 0004f110
00007ff7`9668f108 00000000 00000000 0004f120 00000000
00007ff7`9668f118 00000000 00000000 000559c8 00000000
0:000> dd 00007ff7`9668eff8
00007ff7`9668eff8 00000001 00000000 00000000 000559b0
00007ff7`9668f008 0004f020 0004eff8 00000000 00000000
00007ff7`9668f018 00000000 00000000 00000000 00000001
00007ff7`9668f028 00000003 0004f038 00000000 00000000
00007ff7`9668f038 0004f058 0004ef58 0004f080 00000000
00007ff7`9668f048 00000000 00000000 00000000 00000000
00007ff7`9668f058 000559b0 00000002 00000000 ffffffff
00007ff7`9668f068 00000000 00000040 0004f020 00000000
深追いはしませんが、非公式な情報ながら VC++ の RTTI はそれなりに解析されているようです。
Visual C++ RTTI Inspection
http://blog.quarkslab.com/visual-c-rtti-inspection.html
(2017/3/6 追記)
最初の投稿時には力尽きてしまって「Visual C++ の実装で Thunk はどうなっているのか」について追及していませんでした。ちゃんとやろう。
Visual C++ におけるクラス D の内部構造は、g++ が生成するものと同じように見えます。つまり、+0 と +16 に VTable のアドレスがあり、前者が B1 と D、後者が B2 として見るときのオフセットであるはずです。
さて、g++ で Thunk が必要である理由は、B2 用の VTable 経由で D::f2 を呼んだときに、実行先での this ポインターが正しく D のオブジェクトの先頭を指すように調整することでした。しかし、Visual C++ が生成する D の B2 用の VTable エントリは 00007ff7`96685378 で、そこには生の t!D::f2 である 00007ff7`96641060 が入っていて Thunk のようなものは見当たりません。もしこのまま関数を呼び出すと、D::f2 内での this ポインターは D の +10 の位置にあるため、おかしなことになりそうです。平気なのでしょうか。
これを確かめるため、wiki.cpp に少し変更を加えた以下の wiki2.cpp を使います。変更点は、D::f2 で int_in_b1 をインクリメントするようにした部分と、main 関数から D::f2 を 2 回呼び出すように追加した部分です。Makefile への変更はありません。
class B1 {
public:
void f0() {}
virtual void f1() {}
int int_in_b1;
};
class B2 {
public:
virtual void f2() {}
int int_in_b2;
};
class D : public B1, public B2 {
public:
void d() {}
void f2() { ++int_in_b1; } // override B2::f2()
int int_in_d;
};
int main() {
B2 *b2 = new B2();
D *d = new D();
static_cast<B2*>(d)->f2();
static_cast<D*>(new D())->f2();
return 0;
}
デバッガーでの確認結果は以下の通りです。
0:000> uf t!main
t!main:
00007ff6`4d611070 48895c2408 mov qword ptr [rsp+8],rbx
(..snip..)
t!main+0x33:
00007ff6`4d6110a3 b928000000 mov ecx,28h
00007ff6`4d6110a8 e88b000000 call t!operator new (00007ff6`4d611138)
00007ff6`4d6110ad 33db xor ebx,ebx
00007ff6`4d6110af 488d35c2320400 lea rsi,[t!D::`vftable' (00007ff6`4d654378)]
00007ff6`4d6110b6 488d2dcb320400 lea rbp,[t!D::`vftable' (00007ff6`4d654388)]
00007ff6`4d6110bd 4885c0 test rax,rax
00007ff6`4d6110c0 7417 je t!main+0x69 (00007ff6`4d6110d9)
t!main+0x52:
00007ff6`4d6110c2 33c9 xor ecx,ecx
00007ff6`4d6110c4 48894808 mov qword ptr [rax+8],rcx
00007ff6`4d6110c8 48894818 mov qword ptr [rax+18h],rcx
00007ff6`4d6110cc 48894820 mov qword ptr [rax+20h],rcx
00007ff6`4d6110d0 488930 mov qword ptr [rax],rsi
00007ff6`4d6110d3 48896810 mov qword ptr [rax+10h],rbp
00007ff6`4d6110d7 eb03 jmp t!main+0x6c (00007ff6`4d6110dc)
t!main+0x69:
00007ff6`4d6110d9 488bc3 mov rax,rbx
t!main+0x6c:
00007ff6`4d6110dc 4885c0 test rax,rax
00007ff6`4d6110df 488d4810 lea rcx,[rax+10h]
00007ff6`4d6110e3 480f44cb cmove rcx,rbx
00007ff6`4d6110e7 488b01 mov rax,qword ptr [rcx]
00007ff6`4d6110ea ff10 call qword ptr [rax]
00007ff6`4d6110ec b928000000 mov ecx,28h
00007ff6`4d6110f1 e842000000 call t!operator new (00007ff6`4d611138)
00007ff6`4d6110f6 4885c0 test rax,rax
00007ff6`4d6110f9 7417 je t!main+0xa2 (00007ff6`4d611112)
t!main+0x8b:
00007ff6`4d6110fb 33c9 xor ecx,ecx
00007ff6`4d6110fd 48894808 mov qword ptr [rax+8],rcx
00007ff6`4d611101 48894818 mov qword ptr [rax+18h],rcx
00007ff6`4d611105 48894820 mov qword ptr [rax+20h],rcx
00007ff6`4d611109 488930 mov qword ptr [rax],rsi
00007ff6`4d61110c 48896810 mov qword ptr [rax+10h],rbp
00007ff6`4d611110 eb03 jmp t!main+0xa5 (00007ff6`4d611115)
t!main+0xa2:
00007ff6`4d611112 488bc3 mov rax,rbx
t!main+0xa5:
00007ff6`4d611115 488d4810 lea rcx,[rax+10h]
00007ff6`4d611119 488b4010 mov rax,qword ptr [rax+10h]
00007ff6`4d61111d ff10 call qword ptr [rax]
00007ff6`4d61111f 488b5c2430 mov rbx,qword ptr [rsp+30h]
00007ff6`4d611124 33c0 xor eax,eax
00007ff6`4d611126 488b6c2438 mov rbp,qword ptr [rsp+38h]
00007ff6`4d61112b 488b742440 mov rsi,qword ptr [rsp+40h]
00007ff6`4d611130 4883c420 add rsp,20h
00007ff6`4d611134 5f pop rdi
00007ff6`4d611135 c3 ret
0:000> x t!D::`vftable'
00007ff6`4d654378 t!D::`vftable' = <no type information>
00007ff6`4d654388 t!D::`vftable' = <no type information>
0:000> dps 00007ff6`4d654388 l1
00007ff6`4d654388 00007ff6`4d611060 t!D::f2
0:000> uf 00007ff6`4d611060
t!D::f2:
00007ff6`4d611060 ff41f8 inc dword ptr [rcx-8]
00007ff6`4d611063 c3 ret
0:000> g 00007ff6`4d611060
t!D::f2:
00007ff6`4d611060 ff41f8 inc dword ptr [rcx-8] ds:000001be`37c39e78=00000000
0:000> dv
this = 0x000001be`37c39e70
0:000> r rcx
rcx=000001be37c39e80
順番が入り乱れて分かりにくいですが、アドレス 00007ff6`4d6110b6 の lea と 00007ff6`4d6110d3 の mov 命令で D の B2 用の VTable 00007ff6`4d654388 をオフセット +10 にセットしているのが確認できます。その中身を見ると、やはり生の t!D::f2 アドレスが入っています。
f2 を呼んでいるのは 00007ff6`4d6110f1 と 00007ff6`4d61111d です。rcx が this ポインターとして使われるわけですが、前者の呼び出しでは 「00007ff6`4d6110df の lea で算出されたオフセット +10 のアドレス」が this ポインターとして rcx にセットされています (その後の cmove は rax が 0 ではない場合効果はありません)。後者の呼び出しでも同様に、00007ff6`4d611115 の lea がオフセット +10 のアドレスを this ポインターとして rcx にセットしています。
次に D::f2 の中を見ると、rcx-8 の場所の dword をインクリメントしています。つまり、今回の例における Visual C++ は、Thunk によって this ポインターを調整する代わりに、D::f2 内で使うメンバー変数へのオフセットを調整しているようです。上記出力例の最後の dv と r コマンドで確認している通り、C++ のソース コード レベルの概念である this ポインターの値と、アセンブリの rcx レジスターの値は一致していません。デバッガーの dv コマンドの出力が、this ポインターの指すオブジェクトの先頭を正しく指すことができるのは、デバッグ シンボルに this と rcx とのずれに関する情報が保存されているからと考えられます。
もし先の疑問に答えるとすれば、D::f2 内での this ポインターが D の +10 の位置にあったとしてもおかしなことにはならず、全く平気であるということが分かりました。これはあくまでも単純な例なので、より複雑なプログラムで Visual C++ がどのようなコードを生成するのか、例えば g++ の Thunk のようなものが生成されるかどうか、は分かりません。
おわりに
Wikipedia に載っている単純な多重継承の例を使って、g++ と Visual C++ がどのようにクラスの内部構造が作っているのかを確認しました。本来なら clang でも確かめないといけないのですが、とりあえずここでおしまい。もう少し複雑な継承のパターン、特に仮想継承を使ったときにどうなるのかを続きとして書きたいと思います。