汇编 align_C++ GCC 对象模型 从汇编代码分析虚函数、动态绑定原理 (Part 1)

写在前面

(本专栏仅是个人笔记本,有胡言乱语和错漏之处, )

这篇笔记结合汇编代码和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)

这里有一个有意思的地方:

6e5685f7c0fe8f5a07901e4f2e3a48a6.png

C中B之前有padding,我的猜测是vptr_B要占8byte,而0xc不能被8整除,所以要从0x10开始放。


言归正传,首先明确一点,vptr(虚函数指针)指向虚函数表(vtable)第2项。

vtable for Class_C, 8 entries :在这个程序中,Class_C的虚函数表首地址为0x4e2f20。

gdb中查看memory:

f479b4915c591ba2845131d32686e229.png

在调试器中可以看到,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的虚函数表并重写了虚函数地址。

参考资料:

  1. 《C++对象模型》
  2. http://www.jeepxie.net/article/439958.html
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值