前两天读了陈皓两篇关于虚函数表的博客, 正如他在博客中说的那样, 由于年代久远, 所有的测试代码都是在32位机上跑的, 按照作者的思路, 针对64位机, 我仿写了那些代码, 在移植到64位的过程中碰到了一些坑, 也学到了一些小工具, 现在记录在这里。
1. 如何在GCC环境下得到类在内存中的布局:
只要我们在编译的时候加上-fdump-class-hierarchy
选项, 就可以在源文件件的同目录下得到一个以.class
结尾的文件, 这个文件详细的记载了源文件中的类在内存中的布局, 比如说如果有以下多继承的源代码:
class Base1 {
public:
int ibase1;
Base1():ibase1(10) {}
virtual void f() { cout << "Base1::f()" << endl; }
virtual void g() { cout << "Base1::g()" << endl; }
virtual void h() { cout << "Base1::h()" << endl; }
};
class Base2 {
public:
int ibase2;
Base2():ibase2(20) {}
virtual void f() { cout << "Base2::f()" << endl; }
virtual void g() { cout << "Base2::g()" << endl; }
virtual void h() { cout << "Base2::h()" << endl; }
};
class Base3 {
public:
int ibase3;
Base3():ibase3(30) {}
virtual void f() { cout << "Base3::f()" << endl; }
virtual void g() { cout << "Base3::g()" << endl; }
virtual void h() { cout << "Base3::h()" << endl; }
};
class Derive : public Base1, public Base2, public Base3 {
public:
int iderive;
//long iderive1 = 200;
Derive():iderive(100) {}
virtual void f() { cout << "Derive::f()" << endl; }
virtual void g1() { cout << "Derive::g1()" << endl; }
};
如果我们用g++编译的时候加上-fdump-class-hierarchy
选项, 然后在生成的.class
文件中找到类Derive
的虚函数表的信息是这样的:
Vtable for Derive
Derive::_ZTV6Derive: 16u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6Derive)
16 (int (*)(...))Derive::f
24 (int (*)(...))Base1::g
32 (int (*)(...))Base1::h
40 (int (*)(...))Derive::g1
48 (int (*)(...))-16
56 (int (*)(...))(& _ZTI6Derive)
64 (int (*)(...))Derive::_ZThn16_N6Derive1fEv
72 (int (*)(...))Base2::g
80 (int (*)(...))Base2::h
88 (int (*)(...))-32
96 (int (*)(...))(& _ZTI6Derive)
104 (int (*)(...))Derive::_ZThn32_N6Derive1fEv
112 (int (*)(...))Base3::g
120 (int (*)(...))Base3::h
Class Derive
size=48 align=8
base size=48 base align=8
Derive (0x0x7f53708fa4b0) 0
vptr=((& Derive::_ZTV6Derive) + 16u)
Base1 (0x0x7f53708794e0) 0
primary-for Derive (0x0x7f53708fa4b0)
Base2 (0x0x7f5370879540) 16
vptr=((& Derive::_ZTV6Derive) + 64u)
Base3 (0x0x7f53708795a0) 32
vptr=((& Derive::_ZTV6Derive) + 104u)
这样我们能够大概的看到虚函数表在内存中的布局信息, 美中不足的是这个文件中显示的名字已经是被编译器mangle
过的, 我们需要用c++filt
这个工具demangle
之后显示的信息才会更清晰。
我们可以在命令行键入cat mem_model.cc.002t.class | c++filt
, 现在显示的就是一些更加清晰的信息:(我的测试源文件名是mem_model.cc
所以生成的.class
文件名就是mem_model.cc.002t.class
)
Vtable for Derive
Derive::vtable for Derive: 16u entries
0 (int (*)(...))0
8 (int (*)(...))(& typeinfo for Derive)
16 (int (*)(...))Derive::f
24 (int (*)(...))Base1::g
32 (int (*)(...))Base1::h
40 (int (*)(...))Derive::g1
48 (int (*)(...))-16
56 (int (*)(...))(& typeinfo for Derive)
64 (int (*)(...))Derive::non-virtual thunk to Derive::f()
72 (int (*)(...))Base2::g
80 (int (*)(...))Base2::h
88 (int (*)(...))-32
96 (int (*)(...))(& typeinfo for Derive)
104 (int (*)(...))Derive::non-virtual thunk to Derive::f()
112 (int (*)(...))Base3::g
120 (int (*)(...))Base3::h
Class Derive
size=48 align=8
base size=48 base align=8
Derive (0x0x7f53708fa4b0) 0
vptr=((& Derive::vtable for Derive) + 16u)
Base1 (0x0x7f53708794e0) 0
primary-for Derive (0x0x7f53708fa4b0)
Base2 (0x0x7f5370879540) 16
vptr=((& Derive::vtable for Derive) + 64u)
Base3 (0x0x7f53708795a0) 32
vptr=((& Derive::vtable for Derive) + 104u)
关于thunk
这篇博客写的比较清楚了, 其实它是用来实现多重继承的, 原理也不难,比如说在上面的继承关系中
Base1 *p = new Derive();
p->f();
通过Base1
指针调用Derive
类的重载函数f()
, 因为指针就指向的是Derive
对象内存布局中的第一个字节, 所以很容易直接通过虚函数表获得f
的地址, 但是如果我们有下面的调用:
Base2 *p = new Derive();
p->f();
这个例子和上面的例子不同的是我们通过继承列表中的第二个对象指针调用派生函数, 那么在第一行的赋值中编译器会自动调整this
指针, 我们可以做以下的验证:
Derive *pd = new Derive();
Base1 *pb1 = pd;
Base2 *pb2 = pd;
我们依次输出这三个指针:
0x22ad010
0x22ad010
0x22ad020
可以看到pb2
的指针偏移了一个Base1
的大小(0x10
也就是十进制的16
), 但是现在问题来了, 编译器实现类的成员函数的时候都会隐含的加一个形式参数, 指向要调用这个成员函数的对象, 如果我们通过pb2
调用f()
, 这时候的this
指针指向的是Base2
对象,这和Derive::f
的定义是不相符的。这时候就用到了thunk
,编译器再次调整this
指针, 让他继续指向Derive
对象, 这时候就可以确定调用的就是Derive
对象里面实现的那个具体函数了。
400cf4: 48 83 ef 10 sub $0x10,%rdi
400cf8: eb 00 jmp 400cfa
到时候底层会执行类似上面的汇编代码代码, 这就实现了如何通过Base2
指针调用Derive
中实现的函数。
2. typedef void(Fun*)(void)
是一个通用调用类的成员变量的方法吗
在原博客中作者声明了一个Fun
的类型别名(typedef void(Fun*)(void)
)然后在随后遍历虚函数表的时候使用这个类型强制转换虚函数表中的项, 起到调用具体函数的目的。但我在具体实践的过程中发现这个类型别名的声明不是很好, 在多继承的情况下会产生段错误, 比如说下面的这段代码:
class B
{
public:
int ib;
char cb;
B():ib(0),cb('b') {}
virtual void f() { cout << "B::f()" << endl;}
virtual void Bf() { cout << "B::Bf()" << endl;}
};
class B1 : virtual public B
{
public:
int ib1;
char cb1;
B1():ib1(11),cb1('1') {}
virtual void f() { cout << "B1::f()" << endl; }
virtual void f1() {cout << "B1::f1()" << endl;}
virtual void Bf1() { cout << "B1::Bf1()" << endl;}
};
int main() {
typedef void(*Fun)(void);
long** pVtab = NULL;
Fun pFun = NULL;
B1 bb1;
pVtab = (long**)&bb1;
pFun = (Fun)pVtab[2][0];
pFun();
}
这段代码在我的编译器上产生了段错误, 其原因很可能就是因为函数指针Fun
被声明为无参的, 但他指向的函数是B1::virtual thunk to B1::f()
需要一个隐含的指针形参, 如果进入这个函数之后操作了不是按照惯例保存函数参数的寄存器就会产生段错误。针对这个问题可以重新声明Fun
的类型为typedef void(Fun*)(void*)
, 然后每次调用函数指针的时候传入相应的this
指针, 这样就不会产生段错误了。
3. 在移植到64位平台的时候最明显的变化就是指针从32位变成了64位, 所以在指针转换的过程中需要改变。
4. 具体的源代码如下