写在前面
(本专栏仅是个人笔记本,有胡言乱语和错漏之处, )
这篇笔记结合汇编代码和fdump-class-hierarchy
分析了GCC中多重继承下的对象在内存中的对象模型,以及虚函数表、动态绑定的底层实现。
更具体的说,将一个派生类赋给基类指针(引用)时发生了什么?使用基类指针调用虚函数的时候又发生了什么?
编译器会在这两个过程中做一些幕后工作,使得正确的虚函数被调用,正确的数据成员被访问。
看完这篇笔记,就能明白为什么Effective C++强烈建议:
- Item 36: 绝不要重定义一个 inherited non-virtual function(通过继承得到的非虚拟函数)原因: 非虚函数编译期静态绑定
- Item 37: 绝不要重定义一个函数的 inherited default parameter value(通过继承得到的缺省参数值)原因:C++的虚函数表里根本没有默认参数的位置,所以不可能在运行时动态绑定默认参数,只能在编译器决定。
还有记住一点,在本文中,引用即指针,指针即引用,在涉及到虚函数的时候,它们没有任何区别。
单重继承
单重继承比较简单,只需保证虚函数表中的虚函数地址正确即可。
多重继承下的汇编代码分析
多重继承除了保证虚函数表正确,还要保证this指针在动态绑定以及调用虚函数时指向了正确的地址。
以C继承A,B为例:
class Class_A{
public:
virtual void fa(){}
int ia;
};
class Class_B{
public:
virtual void fb(){}
int ib;
};
class Class_C : public Class_A, public Class_B {
public:
virtual void fa(){}
virtual void fb(){}
virtual void fc(){}
int ic;
};
int main(int argc, char const *argv[])
{
Class_A a;
Class_B b;
Class_C c;
int temp;
Class_A &ref_A = c;
Class_B &ref_B = c;
Class_C *pc = &c;
ref_A.fa();
temp = ref_A.ia;
ref_B.fb();
temp = ref_B.ib;
pc->fc();
temp = pc->ic;
return 0;
}
gcc 可以通过(Before gcc 8.0 : fdump-class-hierarchy; after 8.0 : -fdump-lang-class)
g++ -fdump-class-hierarchy main.cpp
由此得到内存模型:
Vtable for Class_A
Class_A::_ZTV7Class_A: 3 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Class_A)
16 (int (*)(...))Class_A::fa
Class Class_A
size=16(加上padding占用的大小) align=8
base size=12(实际占用的大小) base align=8
Class_A (0x0x402cdc8) 0
vptr=((& Class_A::_ZTV7Class_A) + 16)
Vtable for Class_B
Class_B::_ZTV7Class_B: 3 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Class_B)
16 (int (*)(...))Class_B::fb
Class Class_B
size=16 align=8
base size=12 base align=8
Class_B (0x0x402ce38) 0
vptr=((& Class_B::_ZTV7Class_B) + 16)
Vtable for Class_C
Class_C::_ZTV7Class_C: 8 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Class_C)
16 (int (*)(...))Class_C::fa
24 (int (*)(...))Class_C::fb
32 (int (*)(...))Class_C::fc
40 (int (*)(...))-16
48 (int (*)(...))(& _ZTI7Class_C)
56 (int (*)(...))Class_C::_ZThn16_N7Class_C2fbEv
Class Class_C
size=32 align=8
base size=32 base align=8
Class_C (0x0x4028ac0) 0
vptr=((& Class_C::_ZTV7Class_C) + 16)
Class_A (0x0x402cea8) 0
primary-for Class_C (0x0x4028ac0)
Class_B (0x0x402cee0) 16
vptr=((& Class_C::_ZTV7Class_C) + 56)
这里有一个有意思的地方:
C中B之前有padding,我的猜测是vptr_B要占8byte,而0xc不能被8整除,所以要从0x10开始放。
言归正传,首先明确一点,vptr(虚函数指针)指向虚函数表(vtable)第2项。
vtable for Class_C, 8 entries :在这个程序中,Class_C的虚函数表首地址为0x4e2f20。
gdb中查看memory:
在调试器中可以看到,B的虚函数表首地址vptr_B = vptr_C + 0x38(0x4e2f58),那么0x4e2f58处本应是Class_C::fb,但是为了将this指针校准到c的起始位置,所以0x4e2f58处填的不是Class_C::fb,而是一个跳转函数(好像叫做thunk),它长这样:
Dump of assembler code for function _ZThn16_N7Class_C2fbEv:
0x00000000004b1be0 <+0>:tsub $0x10,%rcx //%rcx即this
0x00000000004b1be4 <+4>:tjmpq 0x4213f0 <Class_C::fb()>
将之前给出的程序反汇编,得到如下汇编代码
0x000000000040180f callq 0x40c0f0 <__main>
//定义变量,调用构造函数
0x0000000000401814 lea -0x30(%rbp),%rax
0x0000000000401818 mov %rax,%rcx
0x000000000040181b callq 0x421350 <Class_A::Class_A()>
0x0000000000401820 lea -0x40(%rbp),%rax
0x0000000000401824 mov %rax,%rcx
0x0000000000401827 callq 0x4213a0 <Class_B::Class_B()>
0x000000000040182c lea -0x60(%rbp),%rax
0x0000000000401830 mov %rax,%rcx
0x0000000000401833 callq 0x421410 <Class_C::Class_C()>
//ref_A = c
0x0000000000401838 lea -0x60(%rbp),%rax
0x000000000040183c mov %rax,-0x8(%rbp)
//ref_B = &c + 0x10
//&c + 0: c的虚函数表地址 vptr_C(也是vptr_A)
//&c + 0x8 : A::ia
//&c + 0xc : padding吗? 好像是的
//&c + 0x10 : vptr_B
0x0000000000401840 lea -0x60(%rbp),%rax
0x0000000000401844 add $0x10,%rax
0x0000000000401848 mov %rax,-0x10(%rbp)
//pc = &c;
0x000000000040184c lea -0x60(%rbp),%rax
0x0000000000401850 mov %rax,-0x18(%rbp)
//ref_A.fa();
0x0000000000401854 mov -0x8(%rbp),%rax
0x0000000000401858 mov (%rax),%rax //%rax = vptr_A
0x000000000040185b mov (%rax),%rdx //A的虚函数表第一项
0x000000000040185e mov -0x8(%rbp),%rax //ref_A即this
0x0000000000401862 mov %rax,%rcx
0x0000000000401865 callq *%rdx
//temp = ref_A.ia;
0x0000000000401867 mov -0x8(%rbp),%rax
0x000000000040186b mov 0x8(%rax),%eax
0x000000000040186e mov %eax,-0x1c(%rbp)
//ref_B.fb();
//%rip 是指令寄存器 always points to next instruction
//%rip此时为0x0000000000401878
//%rip + 0xb0368 = 0x4b1be0
//在gdb中反汇编该地址
//>>>(gdb) disassemble 0x4b1be0
Dump of assembler code for function _ZThn16_N7Class_C2fbEv:
0x00000000004b1be0 <+0>:tsub $0x10,%rcx
0x00000000004b1be4 <+4>:tjmpq 0x4213f0 <Class_C::fb()>
// 本来%rcx == ref_B即&c + 0x10
// %rcx -= 0x10使得%rcx指向了&c
//然后jmpq到0x4213f0即Class_C:fb()
//即调用C::fb前,将this从&c+0x10移回了&c
//很好理解,毕竟调用的是Class_C的fb,那么this必须是&c
0x0000000000401871 lea 0xb0368(%rip),%rdx # 0x4b1be0 <_ZThn16_N7Class_C2fbEv>
0x0000000000401878 mov -0x10(%rbp),%rax //ref_B 即 this
0x000000000040187c mov %rax,%rcx //%rcx传参寄存器
0x000000000040187f callq *%rdx
//temp = ref_B.ib;
0x0000000000401881 mov -0x10(%rbp),%rax
0x0000000000401885 mov 0x8(%rax),%eax
0x0000000000401888 mov %eax,-0x1c(%rbp)
//pc->fc();
0x000000000040188b mov -0x18(%rbp),%rax
0x000000000040188f mov (%rax),%rax
0x0000000000401892 add $0x10,%rax //fc
0x0000000000401896 mov (%rax),%rdx
0x0000000000401899 mov -0x18(%rbp),%rax
0x000000000040189d mov %rax,%rcx
0x00000000004018a0 callq *%rdx
//temp = pc->ic
0x00000000004018a2 mov -0x18(%rbp),%rax
0x00000000004018a6 mov 0x1c(%rax),%eax
0x00000000004018a9 mov %eax,-0x1c(%rbp)
上面调用ref_B.fb()时,其实编译器做了一些优化,如果我们把代码改成:
void func(Class_B &ref_B){
ref_B.fb();
}
int main(int argc, char const *argv[])
{
Class_B b;
Class_C c;
func(c);
return 0;
}
那么汇编中的地址就不是写死的了,而是
//func(c)
0x000000000040184f lea -0x30(%rbp),%rax
0x0000000000401853 add $0x10,%rax
0x0000000000401857 mov %rax,%rcx
0x000000000040185a callq 0x401800 <func(Class_B&)>
//func的汇编代码:
0x000000000040180c mov 0x10(%rbp),%rax
0x0000000000401810 mov (%rax),%rax // 取虚函数表首地址到%rax,在gdb中查看为0x4c2f58
0x0000000000401813 mov (%rax),%rax //取虚函数表第一项,在gdb中查看为0x4b1b70 即<_ZThn16_N7Class_C2fbEv>
0x0000000000401816 mov 0x10(%rbp),%rcx
0x000000000040181a callq *%rax //调用_ZThn16_N7Class_C2fbEv, ZThn16_N7Class_C2fbEv再调用C::fb()
问题: 在多重继承中,每个虚表第一个槽中的type_info是对应base class还是全是derived class的类型? - 果冻虾仁的回答 - 知乎中写道,父类指针调用虚函数时,校准this,使其指向准确的地址,这个操作是通过this - topoffset(虚函数表第一个entry)实现的。但这里查看汇编后,发现是通过将虚函数表中的虚函数替换成一个跳转函数来实现的。哪个是对的呢?
动态绑定的实现
所谓动态绑定 ref.f() p->f() 的关键在于引用(指针)与对象绑定的时刻:
Type &ref = c; Type *p = &c;
这一刻,一个地址将会赋值给ref(p),而动态绑定完全依赖于这个地址。 比如c的类型为C,而C继承了A,B(就是上面的哪个例子) 当Type为A时,ref = &c 当Type为B时,ref = &c + offset。
而当编译器碰到p->fb()时,无论p的类型是什么,编译器都只会翻译成这样的一段代码:
0x000000000040180c mov 0x10(%rbp),%rax
0x0000000000401810 mov (%rax),%rax // 取虚函数表首地址到%rax
0x0000000000401813 mov (%rax),%rax //取虚函数表第一项
0x0000000000401816 mov 0x10(%rbp),%rcx
0x000000000040181a callq *%rax
这段代码就是执行虚函数表中的第一个函数,无论p的类型,虚函数表中填的什么,那就执行什么。所以当我们绑定的时候,p会指向一个正确的虚函数表。
总的来说,动态绑定由两个部分协作完成: 1. 绑定时刻,将指针p或引用ref指向正确的虚函数表的地址 2. 调用时刻,(*p)[i]访问第i个虚函数 只要p或ref弄对了,那么动态绑定就会访问正确的虚函数。
总结,假设我们定义了3个类:class A; class B; class C : public A, public B;
- 此时内存中有3个虚函数表,A, B,C的。C中又内含了一个A的虚函数表和B的虚函数表并重写了虚函数地址。
参考资料:
- 《C++对象模型》
- http://www.jeepxie.net/article/439958.html