多重继承中虚拟方法的反汇编。vtable是如何工作的?

7
假设有如下的C++源代码文件:
#include <stdio.h>

class BaseTest {
  public:
  int a;

  BaseTest(): a(2){}

  virtual int gB() {
    return a;
  };
};

class SubTest: public BaseTest {
  public:
  int b;

  SubTest(): b(4){}
};

class TriTest: public BaseTest {
  public:
  int c;
  TriTest(): c(42){}
};

class EvilTest: public SubTest, public TriTest {
  public:
  virtual int gB(){
    return b;
  }
};

int main(){
  EvilTest * t2 = new EvilTest;

  TriTest * t3 = t2;

  printf("%d\n",t3->gB());
  printf("%d\n",t2->gB());
  return 0;
}

-fdump-class-hierarchy 告诉我:

[...]
Vtable for EvilTest
EvilTest::_ZTV8EvilTest: 6u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI8EvilTest)
16    (int (*)(...))EvilTest::gB
24    (int (*)(...))-16
32    (int (*)(...))(& _ZTI8EvilTest)
40    (int (*)(...))EvilTest::_ZThn16_N8EvilTest2gBEv

Class EvilTest
   size=32 align=8
   base size=32 base align=8
EvilTest (0x0x7f1ba98a8150) 0
    vptr=((& EvilTest::_ZTV8EvilTest) + 16u)
  SubTest (0x0x7f1ba96df478) 0
      primary-for EvilTest (0x0x7f1ba98a8150)
    BaseTest (0x0x7f1ba982ba80) 0
        primary-for SubTest (0x0x7f1ba96df478)
  TriTest (0x0x7f1ba96df4e0) 16
      vptr=((& EvilTest::_ZTV8EvilTest) + 40u)
    BaseTest (0x0x7f1ba982bae0) 16
        primary-for TriTest (0x0x7f1ba96df4e0)

拆卸显示:

34  int main(){
   0x000000000040076d <+0>: push   rbp
   0x000000000040076e <+1>: mov    rbp,rsp
   0x0000000000400771 <+4>: push   rbx
   0x0000000000400772 <+5>: sub    rsp,0x18

35    EvilTest * t2 = new EvilTest;
   0x0000000000400776 <+9>: mov    edi,0x20
   0x000000000040077b <+14>:    call   0x400670 <_Znwm@plt>
   0x0000000000400780 <+19>:    mov    rbx,rax
   0x0000000000400783 <+22>:    mov    rdi,rbx
   0x0000000000400786 <+25>:    call   0x4008a8 <EvilTest::EvilTest()>
   0x000000000040078b <+30>:    mov    QWORD PTR [rbp-0x18],rbx

36    
37    TriTest * t3 = t2;
   0x000000000040078f <+34>:    cmp    QWORD PTR [rbp-0x18],0x0
   0x0000000000400794 <+39>:    je     0x4007a0 <main()+51>
   0x0000000000400796 <+41>:    mov    rax,QWORD PTR [rbp-0x18]
   0x000000000040079a <+45>:    add    rax,0x10
   0x000000000040079e <+49>:    jmp    0x4007a5 <main()+56>
   0x00000000004007a0 <+51>:    mov    eax,0x0
   0x00000000004007a5 <+56>:    mov    QWORD PTR [rbp-0x20],rax

38    
39    printf("%d\n",t3->gB());
   0x00000000004007a9 <+60>:    mov    rax,QWORD PTR [rbp-0x20]
   0x00000000004007ad <+64>:    mov    rax,QWORD PTR [rax]
   0x00000000004007b0 <+67>:    mov    rax,QWORD PTR [rax]
   0x00000000004007b3 <+70>:    mov    rdx,QWORD PTR [rbp-0x20]
   0x00000000004007b7 <+74>:    mov    rdi,rdx
   0x00000000004007ba <+77>:    call   rax
   0x00000000004007bc <+79>:    mov    esi,eax
   0x00000000004007be <+81>:    mov    edi,0x400984
   0x00000000004007c3 <+86>:    mov    eax,0x0
   0x00000000004007c8 <+91>:    call   0x400640 <printf@plt>

40    printf("%d\n",t2->gB());
   0x00000000004007cd <+96>:    mov    rax,QWORD PTR [rbp-0x18]
   0x00000000004007d1 <+100>:   mov    rax,QWORD PTR [rax]
   0x00000000004007d4 <+103>:   mov    rax,QWORD PTR [rax]
   0x00000000004007d7 <+106>:   mov    rdx,QWORD PTR [rbp-0x18]
   0x00000000004007db <+110>:   mov    rdi,rdx
   0x00000000004007de <+113>:   call   rax
   0x00000000004007e0 <+115>:   mov    esi,eax
   0x00000000004007e2 <+117>:   mov    edi,0x400984
   0x00000000004007e7 <+122>:   mov    eax,0x0
   0x00000000004007ec <+127>:   call   0x400640 <printf@plt>

41    return 0;
   0x00000000004007f1 <+132>:   mov    eax,0x0

42  }
   0x00000000004007f6 <+137>:   add    rsp,0x18
   0x00000000004007fa <+141>:   pop    rbx
   0x00000000004007fb <+142>:   pop    rbp
   0x00000000004007fc <+143>:   ret

现在你已经有足够的时间从第一个代码块中恢复过来,下面是实际问题。

当调用t3->gB()时,我看到以下反汇编代码(t3是类型为TriTestgB()是虚方法EvilTest::gB()):

   0x00000000004007a9 <+60>:    mov    rax,QWORD PTR [rbp-0x20]
   0x00000000004007ad <+64>:    mov    rax,QWORD PTR [rax]
   0x00000000004007b0 <+67>:    mov    rax,QWORD PTR [rax]
   0x00000000004007b3 <+70>:    mov    rdx,QWORD PTR [rbp-0x20]
   0x00000000004007b7 <+74>:    mov    rdi,rdx
   0x00000000004007ba <+77>:    call   rax

第一个mov指令将vtable移动到rax寄存器,下一个指令对其进行解引用(现在我们进入了虚函数表)。

接下来的指令对其进行一次解引用,以获取函数指针,并在最后执行call操作。

到目前为止还不错,但这引出了几个问题。

this指针在哪里?
我猜测通过+70和+74处的mov指令将this指针加载到rdi寄存器中,但这个指针与vtable相同,这意味着它是指向一个TriTest类的指针,该指针根本不应具有SubTests b成员。在linux的thiscall约定中,调用方法内部是否处理了虚拟转换,而不是在外部进行?

Rodrigo在这里回答了这个问题

如何反汇编虚拟方法?
如果我知道这个,我就可以自己回答上一个问题了。disas EvilTest::gB给出:

Cannot reference virtual member function "gB"

call之前设置断点,运行info reg raxdisas,会得到以下结果:
(gdb) info reg rax
rax            0x4008a1 4196513
(gdb) disas 0x4008a14196513
No function contains specified address.
(gdb) disas *0x4008a14196513
Cannot access memory at address 0x4008a14196513

为什么虚函数表(看起来)只相隔8个字节?
fdump显示第一个和第二个&vtable之间有16个字节(这符合64位指针和2个整数),但是从第二个gB()调用的反汇编结果是:

   0x00000000004007cd <+96>:    mov    rax,QWORD PTR [rbp-0x18]
   0x00000000004007d1 <+100>:   mov    rax,QWORD PTR [rax]
   0x00000000004007d4 <+103>:   mov    rax,QWORD PTR [rax]
   0x00000000004007d7 <+106>:   mov    rdx,QWORD PTR [rbp-0x18]
   0x00000000004007db <+110>:   mov    rdi,rdx
   0x00000000004007de <+113>:   call   rax

[rbp-0x18]距离前一个调用([rbp-0x20])仅有8个字节的差距。这是怎么回事?

在评论中由500解答

我忘记了对象是在堆上分配的,只有它们的指针在栈上。


1
[rbp-0x18] 距离上一个调用仅有 8 字节的距离,这是否超出你的预期 - 在 main()、t2 和 t3 中都存在两个本地指针。这些是实际的对象指针(this),而不是它们的虚函数表(VMT)。 - 500 - Internal Server Error
你正在询问编译器实现细节的问题,但没有说明使用的是哪个编译器?展示汇编代码,但没有说明使用的是哪种架构?请提出一个不需要心理阅读技能的问题。 - Ben Voigt
@500-服务器内部错误 当然!我是C++的新手,经常忘记new会把东西放在堆上,以为它是栈分配的。感谢你的提醒! - J V
3个回答

9
免责声明:我并非GCC内部的专家,但我会尝试解释我认为正在发生的事情。请注意,您不是使用虚继承,而是普通的多继承,因此您的EvilTest对象实际上包含了两个BaseTest子对象。通过在EvilTest中尝试使用this->a,您将获得一个模棱两可的引用错误。
首先请注意,每个VTable在负偏移量中有2个值:
-2:this偏移量(稍后会更详细地讨论)。
-1:指向该类的运行时类型信息的指针。
然后,从0开始,将存在指向虚函数的指针:
有了这个理解,我将写出各个类的VTable,并使用易于阅读的名称:

BaseTest的VTable:

[-2]: 0
[-1]: typeof(BaseTest)
[ 0]: BaseTest::gB

SubTest的VTable:

[-2]: 0
[-1]: typeof(SubTest)
[ 0]: BaseTest::gB

TriTest的虚函数表

[-2]: 0
[-1]: typeof(TriTest)
[ 0]: BaseTest::gB

到目前为止还没有太有趣的东西。

EvilTest 的 VTable

[-2]: 0
[-1]: typeof(EvilTest)
[ 0]: EvilTest::gB
[ 1]: -16
[ 2]: typeof(EvilTest)
[ 3]: EvilTest::thunk_gB

这很有趣!现在更容易看到它的工作方式:

EvilTest * t2 = new EvilTest;
t2->gB();

这段代码调用了函数VTable[0],它其实就是EvilTest::gB,一切都很顺利。

但是当你执行以下操作时:

TriTest * t3 = t2;

由于TriTest不是EvilTest的第一个基类,因此t3的实际二进制值与t2不同。也就是说,强制转换会使指针向前移动N个字节。编译器在编译时可以确定确切的数量,因为它仅取决于表达式的静态类型。在您的代码中,它是16个字节。请注意,如果指针为NULL,则不得推进它,因此在反汇编器中存在分支。
此时有趣的是查看EvilTest对象的内存布局:
[ 0]: pointer to VTable of EvilTest-as-BaseTest
[ 1]: BaseTest::a
[ 2]: SubTest::b
[ 3]: pointer to VTable of EvilTest-as-TriTest
[ 4]: BaseTest::a
[ 5]: TriTest::c

如您所见,当您将 EvilTest* 转换为 TriTest* 时,您必须将 this 推进到元素 [3],在64位系统中,即8+4+4 = 16个字节。

t3->gB();

现在您可以使用指针调用。这是使用VTable的元素[0]完成的,就像以前一样。但由于该函数实际上来自,因此必须在调用之前将指针向后移动16个字节。这是的工作,它是一个小函数,读取值并将该值减去。现在一切都匹配了!
值得注意的是,的完整VTable是的VTable和的VTable的连接。

很好的回答!所以 thunk_gB() 实际上是一个中间函数,它移动了 this 然后调用了原始的 gB 函数?我在 -fdump* 中看到这个 this 偏移实际上是 [-2],而 [-1] 似乎是指向虚表开始的指针。有没有办法解引用虚方法或者它的 thunk? - J V
1
@JV:是的。问题在于 EvilTest::gb() 函数期望一个指向对象开始位置的 this 指针,但是 t3 指向其中间位置。而且编译器不能在调用虚函数之前移动 t3,因为该对象可能是一个不需要移动的普通 TriBase。而 thunk_gB() 可以解决所有问题。 - rodrigo
1
@JV:还要注意,在t2->gB()t3->gB()两种情况下,调用代码是相同的,因为它们都调用了BaseTest::gB(),而且它对多重继承一无所知。 - rodrigo
是的,我知道它是如何工作的,我只是好奇这个指针算术发生在哪里,因为我在“-fdump*”中看到了它,但在反汇编中没有看到任何内容,并且无法反汇编虚拟方法或thunk。 - J V
@JV:您可以使用“g++ -save-temps”命令查看完整的反汇编代码。我建议您使用“c++filt”过滤输出以使其更易读。 - rodrigo

2

首先:对象不包含虚函数表,而是包含指向虚函数表的指针。你所说的第一个mov指令并没有加载虚函数表,而是加载了this指针。第二个mov指令加载了指向虚函数表的指针,该指针似乎位于对象的偏移量0处。

其次:在多重继承中,你会得到多个虚函数表,因为每个从一种类型到另一种类型的转换都需要this具有与被转换类型兼容的二进制布局。在这种情况下,你将EvilTest*转换为TriTest*。这就是add rax,0x10指令的作用。


我理解对象包含指向虚函数表的指针,也理解指针赋值时的add操作,但我的问题更多地涉及虚函数如何被解引用。在从TriTest调用虚函数时,this是如何转换回EvilTest *的?如何反汇编虚函数?为什么看起来两个虚函数表只相隔8字节,而不是16字节? - J V

0

How do I disassemble the virtual method? If I knew this I could answer the previous question myself. disas EvilTest::gB gives me:

Cannot reference virtual member function "gB"

我曾经遇到过同样的问题,并通过使用断点信息来获取方法地址以便进行反汇编来解决它:

(gdb) disassemble cSimpleChannel::deliver(cMessage*, double)
Cannot reference virtual member function "deliver"
(gdb) break cSimpleChannel::deliver
(gdb) info breakpoints
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x000000000003ef50 in cSimpleChannel::deliver(cMessage*, double) at libs/sim/cchannel.cc:345
(gdb)  disassemble 0x000000000003ef50
Dump of assembler code for function cSimpleChannel::deliver(cMessage*, double):
...
...


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接