继续前一篇《C++ 虚函数之一:对象内存布局》,这次来分析一下虚函数表的结构和虚函数的调用过程。
虚函数表结构
如何查看虚函数表的结构?使用gdb直接查看内存固然可以,但是不够直观,那么有没有更好的方法呢?使用gcc的-fdump-class-hierarchy选项是个不错的选择,在gcc手册中对该选项的部分解释如下:
-fdump-class-hierarchy-options (C++ only)
Dump a representation of each class’s hierarchy and virtual function table layout to a file.
它能够生成类的继承层次结构和虚函数表的布局。
上篇文章已经贴过部分代码,现在我把整个源文件贴在下面:
#include <stdint.h>
#include <string.h>
class Base1
{
public:
Base1() { memset(&Base1Data, 0x11, sizeof(Base1Data)); }
virtual void A() {};
virtual void B() {};
uint64_t Base1Data;
};
class Base2
{
public:
Base2() { memset(&Base2Data, 0x22, sizeof(Base2Data)); }
virtual void C() {};
virtual void D() {};
uint64_t Base2Data;
};
class Derived : public Base1, public Base2
{
public:
Derived() { memset(&DerivedData, 0x33, sizeof(DerivedData)); }
virtual void A() {};
virtual void C() {};
uint64_t DerivedData;
};
int main()
{
Base1 *x = new Derived;
x->A();
x->B();
Base2 *y = new Derived;
y->C();
y->D();
Derived *z = new Derived;
z->A();
z->B();
z->C();
z->D();
return 0;
}
使用gcc的-fdump-class-hierarchy-options选项分析源文件:
$ g++ -c -fdump-class-hierarchy call_function.cpp
生成了call_function.cpp.002t.class文件(删掉了在外部头文件定义的类):
Vtable for Base1
Base1::_ZTV5Base1: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI5Base1)
16 (int (*)(...))Base1::A
24 (int (*)(...))Base1::B
Class Base1
size=16 align=8
base size=16 base align=8
Base1 (0x0x7ff358c9bc00) 0
vptr=((& Base1::_ZTV5Base1) + 16)
Vtable for Base2
Base2::_ZTV5Base2: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI5Base2)
16 (int (*)(...))Base2::C
24 (int (*)(...))Base2::D
Class Base2
size=16 align=8
base size=16 base align=8
Base2 (0x0x7ff358c9bea0) 0
vptr=((& Base2::_ZTV5Base2) + 16)
Vtable for Derived
Derived::_ZTV7Derived: 9 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Derived)
16 (int (*)(...))Derived::A
24 (int (*)(...))Base1::B
32 (int (*)(...))Derived::C
40 (int (*)(...))-16
48 (int (*)(...))(& _ZTI7Derived)
56 (int (*)(...))Derived::_ZThn16_N7Derived1CEv
64 (int (*)(...))Base2::D
Class Derived
size=40 align=8
base size=40 base align=8
Derived (0x0x7ff358b424d0) 0
vptr=((& Derived::_ZTV7Derived) + 16)
Base1 (0x0x7ff358cfa180) 0
primary-for Derived (0x0x7ff358b424d0)
Base2 (0x0x7ff358cfa1e0) 16
vptr=((& Derived::_ZTV7Derived) + 56)
我们得到了每个类的继承层次结构和虚函数表布局。可以看出来,这三个类每个类都有一个虚函数表。以Derived类为例,虚函数表共有9个条目,其布局为:
Vtable for Derived
Derived::_ZTV7Derived: 9 entries
0 (int (*)(...))0 #1
8 (int (*)(...))(& _ZTI7Derived) #2
16 (int (*)(...))Derived::A #3
24 (int (*)(...))Base1::B #4
32 (int (*)(...))Derived::C #5
40 (int (*)(...))-16 #6
48 (int (*)(...))(& _ZTI7Derived) #7
56 (int (*)(...))Derived::_ZThn16_N7Derived1CEv #8
64 (int (*)(...))Base2::D #9
条目1和条目6是整形常量,条目2和条目7是typeinfo for Derived,条目3、4、5和9是指向类成员函数的指针,条目8demangle后的名字是non-virtual thunk to Derived::C()。我们用gdb分析在进行虚函数调用时,是如何使用这些条目的。
虚函数调用
使用gdb反汇编,分析每个函数调用的具体过程。
x->A():
# x指针存在$rbp-0x28处,首先得到x指针,存放到rax
0x000055555555497c <+34>: mov -0x28(%rbp),%rax
# 取x指针指向的8个字节的数据存放到rax,还记得对象前8个字节存的是什么吗?是虚函数表指针
# 不过虚表指针没有指向虚函数表首地址,对于Derived对象,对象起始位置虚表指针指向虚表的起始位置+16处,也就是条目3
0x0000555555554980 <+38>: mov (%rax),%rax
# 取条目3的内容存放到rax,而条目3是Derived::A函数指针,也就是rax现在存放的是 Derived::A函数地址
0x0000555555554983 <+41>: mov (%rax),%rax
# 将x存放到rdx
0x0000555555554986 <+44>: mov -0x28(%rbp),%rdx
# 将x存放到rdi,rdi一般作为接下来函数调用的第一个参数,对于Derived::A来说,第1个参数是this指针
0x000055555555498a <+48>: mov %rdx,%rdi
# 调用Derived::A
0x000055555555498d <+51>: callq *%rax
x->B():
# 同x->A()
0x000055555555498f <+53>: mov -0x28(%rbp),%rax
# 同x->A()
0x0000555555554993 <+57>: mov (%rax),%rax
# rax被调整为指向虚函数表第4个条目:Base1::B
0x0000555555554996 <+60>: add $0x8,%rax
# 将条目4存放到rax,Base1::B的地址
0x000055555555499a <+64>: mov (%rax),%rax
# 同x->A()
0x000055555555499d <+67>: mov -0x28(%rbp),%rdx
# 同x->A()
0x00005555555549a1 <+71>: mov %rdx,%rdi
# 调用Base1::B
0x00005555555549a4 <+74>: callq *%rax
y->C()和y->D()的过程与x->A()和x->B()过程几乎完全相同,都是取Derived虚函数表条目内容,也就是函数地址,然后进行调用。你是否还记得条目8“non-virtual thunk to Derived::C()”,y->C()执行时调用了这个函数,我们看看这个函数到底是个什么:
(gdb) x/2g y #查看y指向内容,其首地址8个字节是Derived虚表指针
0x555555768eb0: 0x0000555555755d08 0x2222222222222222
(gdb) x/g 0x0000555555755d08 #得到条目8内容
0x555555755d08 <_ZTV7Derived+56>: 0x0000555555554b95
(gdb) disassemble 0x0000555555554b95 #反汇编
Dump of assembler code for function _ZThn16_N7Derived1CEv:
0x0000555555554b95 <+0>: sub $0x10,%rdi
0x0000555555554b99 <+4>: jmp 0x555555554b8a <Derived::C()>
End of assembler dump.
non-virtual thunk to Derived::C()只做了两件事,首先将this指针向前调整16个字节,调整到Derived对象首地址,然后跳转到Derived::C()执行。这么做的原因也好理解,y指针指向了Derived对象的中间部分,而传给Derived::C()的this指针必然需要是一个指向Derived对象首地址的指针,否则访问数据成员计算偏移量时会出问题。
z->A()、z->B()和z->C()都和x->A()和x->B()调用类似,z->D()则稍有不同,我把不同的地方注释了一下:
0x0000555555554a53 <+249>: mov -0x18(%rbp),%rax
# 取$rax+16存放到rdx,rax是z指针,则$rax+16则指向了Derived对象Base2部分的首地址
0x0000555555554a57 <+253>: lea 0x10(%rax),%rdx
# 其他部分都类似y->D()
0x0000555555554a5b <+257>: mov -0x18(%rbp),%rax
0x0000555555554a5f <+261>: mov 0x10(%rax),%rax
0x0000555555554a63 <+265>: add $0x8,%rax
0x0000555555554a67 <+269>: mov (%rax),%rax
0x0000555555554a6a <+272>: mov %rdx,%rdi
0x0000555555554a6d <+275>: callq *%rax
可以看出来,这个过程相当于先把z指针转型为Base2类型,然后按照Base2类型的调用过程来进行函数调用。这么做的原因类似与y->C()调用non-virtual thunk to Derived::C(),都是因为我们调用成员函数时所用的对象指针与传给成员函数的this指针类型不同,需要进行调整,以免访问数据成员时访问到了意外的内容。
虚函数表的其他内容
可以看出来,Derived虚函数表有9个条目,但我们目前只提到了5个,剩下4个是什么呢?
0 (int (*)(...))0 #1
8 (int (*)(...))(& _ZTI7Derived) #2
40 (int (*)(...))-16 #6
48 (int (*)(...))(& _ZTI7Derived) #7
剩下这4个条目用于在运行时获得某个对象指针的类型信息,例如对于前面的y指针,其其首地址是虚表指针。我们把虚表指针向前调整8个字节,就指向了条目7:typeinfo for Derived,就可以得到这个指针实际指向对象的类型;把虚表指针向前调整16个字节,就指向了条目6:-16,这个-16的含义就是,把y指针向前调整16个字节,就是其真正指向对象的首地址,x指针也是同样的道理。C++dynamic_cast的实现就依赖于这几个额外的条目,有兴趣的可自己钻研一下gcc源码。