一个类只有一张虚表吗

intro

毫无疑问,每个对象只有(最多)一个虚函数表指针,但是每个类是否只有一个虚函数表呢?
从两个比较常用的C++功能可以引申到这个疑问:

  1. 虚函数调用

一个派生类对象转换为基类指针后,通过该基类指针来调用虚函数,调用到的是派生类的虚函数。这个通过虚函数表很容易实现,但是调用虚函数的时候不仅仅是找到虚函数地址的问题,更重要的是要将调用时编译器维护的this指针转换为派生类的地址,因为派生类虚函数中,访问内存是基于派生类的内存布局访问的

  1. dynamic_cast

dynamic_cast底层通过this指针指向的虚函数表(前面部分内存)来进行动态转换,这样依赖,特定的基类虚函数表中应该要包含派生类的相关信息。这样,一个类在不同派生类中,它的虚函数表就不能相同。

验证

测试代码

#include <stdio.h>

struct B1
{
        virtual int foo(int)
        {
                return 0;
        }

        virtual int bar()
        {
                return 1;
        }
};

struct B2
{
        virtual  int baz(int)
        {
                return 1;
        }
    int  pad[0x110 * 2 + 1];
};

struct D: public B2, B1
{
        virtual int foo(int)  override
        {
                return 2;
        }
};

int   main(int, const char *[])
{
        D d;
        B1 b;
        auto pvtbl = [](const char *tag,void *pAddr)
        {
                void **pvg = (void**)*(void**)pAddr;
                printf("%s addr %p\n", tag, pAddr);
                for (int i = 0; i < 3; i++)
                {
                        printf("%p\n", pvg[i]);
                }
                printf("\n");
        };
        pvtbl("b1", &b);
        pvtbl("d", &d);
        pvtbl("d.b1", (B1*)&d);
        return 0;
}
tsecer@harry: g++ c++_multi_inherit_virtual_table.cpp -g
tsecer@harry: ./a.out 
b1 addr 0x7fff37e1b548
0x40124a
0x40125c
0x403d80

d addr 0x7fff37e1b550
0x40126c
0x40127e
0xfffffffffffff770

d.b1 addr 0x7fff37e1bde0
0x401290
0x40125c
(nil)

tsecer@harry: gdb -quiet ./a.out
Reading symbols from ./a.out...
(gdb) x/10i 0x40126c
   0x40126c <_ZN2B23bazEi>:     push   %rbp
   0x40126d <_ZN2B23bazEi+1>:   mov    %rsp,%rbp
   0x401270 <_ZN2B23bazEi+4>:   mov    %rdi,-0x8(%rbp)
   0x401274 <_ZN2B23bazEi+8>:   mov    %esi,-0xc(%rbp)
   0x401277 <_ZN2B23bazEi+11>:  mov    $0x1,%eax
   0x40127c <_ZN2B23bazEi+16>:  pop    %rbp
   0x40127d <_ZN2B23bazEi+17>:  ret
   0x40127e <_ZN1D3fooEi>:      push   %rbp
   0x40127f <_ZN1D3fooEi+1>:    mov    %rsp,%rbp
   0x401282 <_ZN1D3fooEi+4>:    mov    %rdi,-0x8(%rbp)
(gdb) x/10i 0x401290
   0x401290 <_ZThn2192_N1D3fooEi>:      sub    $0x890,%rdi
   0x401297 <_ZThn2192_N1D3fooEi+7>:    jmp    0x40127e <_ZN1D3fooEi>
   0x401299:    nop
   0x40129a <_ZN2B2C2Ev>:       push   %rbp
   0x40129b <_ZN2B2C2Ev+1>:     mov    %rsp,%rbp
   0x40129e <_ZN2B2C2Ev+4>:     mov    %rdi,-0x8(%rbp)
   0x4012a2 <_ZN2B2C2Ev+8>:     mov    $0x402080,%edx
   0x4012a7 <_ZN2B2C2Ev+13>:    mov    -0x8(%rbp),%rax
   0x4012ab <_ZN2B2C2Ev+17>:    mov    %rdx,(%rax)
   0x4012ae <_ZN2B2C2Ev+20>:    nop
(gdb) shell echo _ZThn2192_N1D3fooEi | c++filt -t
non-virtual thunk to D::foo(int)
(gdb) disas main
Dump of assembler code for function main(int, char const**):
   0x00000000004011b8 <+0>:     push   %rbp
   0x00000000004011b9 <+1>:     mov    %rsp,%rbp
   0x00000000004011bc <+4>:     sub    $0x8c0,%rsp
   0x00000000004011c3 <+11>:    mov    %edi,-0x8b4(%rbp)
   0x00000000004011c9 <+17>:    mov    %rsi,-0x8c0(%rbp)
   0x00000000004011d0 <+24>:    lea    -0x8a0(%rbp),%rax
   0x00000000004011d7 <+31>:    mov    %rax,%rdi
   0x00000000004011da <+34>:    call   0x4012ca <_ZN1DC2Ev>
   0x00000000004011df <+39>:    mov    $0x402098,%eax
   0x00000000004011e4 <+44>:    mov    %rax,-0x8a8(%rbp)
   0x00000000004011eb <+51>:    lea    -0x8a8(%rbp),%rdx
   0x00000000004011f2 <+58>:    lea    -0x8a9(%rbp),%rax
   0x00000000004011f9 <+65>:    mov    $0x402020,%esi
   0x00000000004011fe <+70>:    mov    %rax,%rdi
   0x0000000000401201 <+73>:    call   0x401136 <<lambda(char const*, void*)>::operator()(const char *, void *) const>
   0x0000000000401206 <+78>:    lea    -0x8a0(%rbp),%rdx
   0x000000000040120d <+85>:    lea    -0x8a9(%rbp),%rax
   0x0000000000401214 <+92>:    mov    $0x402023,%esi
   0x0000000000401219 <+97>:    mov    %rax,%rdi
   0x000000000040121c <+100>:   call   0x401136 <<lambda(char const*, void*)>::operator()(const char *, void *) const>
   0x0000000000401221 <+105>:   lea    -0x8a0(%rbp),%rax
   0x0000000000401228 <+112>:   lea    0x890(%rax),%rdx
   0x000000000040122f <+119>:   lea    -0x8a9(%rbp),%rax
   0x0000000000401236 <+126>:   mov    $0x402025,%esi
   0x000000000040123b <+131>:   mov    %rax,%rdi
   0x000000000040123e <+134>:   call   0x401136 <<lambda(char const*, void*)>::operator()(const char *, void *) const>
   0x0000000000401243 <+139>:   mov    $0x0,%eax
   0x0000000000401248 <+144>:   leave
   0x0000000000401249 <+145>:   ret
End of assembler dump.
(gdb) 

一些现象

虚函数表

派生类重写的函数,转换之后基类的虚函数和原始类的不同:
派生类重写了foo函数,所以原始类B1虚函数表中的函数地址为0x40124a,而通过D对象转换获得的B1指针中foo函数地址为0x401290。

派生类没有重写的虚函数(bar),它们的地址相同(都是0x40125c)。

b1 addr 0x7fff37e1b548
0x40124a
0x40125c
d.b1 addr 0x7fff37e1bde0
0x401290
0x40125c

逆操作

在将派生类指针转换为基类指针时,this指针进行了偏移,从而指向基类看到的内存布局(加上0x890):

   0x0000000000401228 <+112>:   lea    0x890(%rax),%rdx

通过基类指针调用派生类重写的虚函数时,会进行一个相反的操作,从而再次从基类指针还原到派生类指针(减去0x890):

(gdb) x/10i 0x401290
   0x401290 <_ZThn2192_N1D3fooEi>:      sub    $0x890,%rdi
   0x401297 <_ZThn2192_N1D3fooEi+7>:    jmp    0x40127e <_ZN1D3fooEi>

编译器怎么称呼派生类中基类的重写虚函数

由于前面显示0x401290位置处指令时,调试器已经贴心的标出了这部分代码的名字为_ZThn2192_N1D3fooEi,所以稍微转换下就可以得到编译器对它的命名。

这个命名包含了它是哪个类实现(重写)的哪个虚函数。

tsecer@harry: echo _ZThn2192_N1D3fooEi | c++filt -t
non-virtual thunk to D::foo(int)
tsecer@harry: 

派生类构造函数如何定制基类虚函数表

由于派生类需要执行基类的构造函数,所以派生类有义务为基类提供定制的虚函数表,对于任意层的类派生关系,派生类是如何为所有基类初始化定制虚函数表的呢?

(gdb) shell echo _ZN1DC2Ev |c++filt -t
D::D()
(gdb) disas 0x4012ca
Dump of assembler code for function _ZN1DC2Ev:
   0x00000000004012ca <+0>:     push   %rbp
   0x00000000004012cb <+1>:     mov    %rsp,%rbp
   0x00000000004012ce <+4>:     sub    $0x10,%rsp
   0x00000000004012d2 <+8>:     mov    %rdi,-0x8(%rbp)
   0x00000000004012d6 <+12>:    mov    -0x8(%rbp),%rax
   0x00000000004012da <+16>:    mov    %rax,%rdi
   0x00000000004012dd <+19>:    call   0x40129a <_ZN2B2C2Ev>
   0x00000000004012e2 <+24>:    mov    -0x8(%rbp),%rax
   0x00000000004012e6 <+28>:    add    $0x890,%rax
   0x00000000004012ec <+34>:    mov    %rax,%rdi
   0x00000000004012ef <+37>:    call   0x4012b2 <_ZN2B1C2Ev>
   0x00000000004012f4 <+42>:    mov    $0x402040,%edx
   0x00000000004012f9 <+47>:    mov    -0x8(%rbp),%rax
   0x00000000004012fd <+51>:    mov    %rdx,(%rax)
   0x0000000000401300 <+54>:    mov    $0x402060,%edx
   0x0000000000401305 <+59>:    mov    -0x8(%rbp),%rax
   0x0000000000401309 <+63>:    mov    %rdx,0x890(%rax)
   0x0000000000401310 <+70>:    nop
   0x0000000000401311 <+71>:    leave
   0x0000000000401312 <+72>:    ret
End of assembler dump.
(gdb) shell echo _ZN2B2C2Ev | c++filt -t
B2::B2()
(gdb) shell echo _ZN2B1C2Ev  | c++filt -t
B1::B1()
(gdb) info symbol 0x402040
vtable for D + 16 in section .rodata
(gdb) info symbol 0x402060
vtable for D + 48 in section .rodata
(gdb) 

在D的构造函数中,首先调用基类(B1、B2)的原始构造函数,然后再使用定制的虚函数表来覆盖

   0x00000000004012f4 <+42>:    mov    $0x402040,%edx
   0x00000000004012f9 <+47>:    mov    -0x8(%rbp),%rax
   0x00000000004012fd <+51>:    mov    %rdx,(%rax)
   0x0000000000401300 <+54>:    mov    $0x402060,%edx
   0x0000000000401305 <+59>:    mov    -0x8(%rbp),%rax
   0x0000000000401309 <+63>:    mov    %rdx,0x890(%rax)

更深层的继承

前面的例子只有两层,但是如果有三层呢?有三层之后,前两层到最底层派生类的重写函数都不同,所以单单一个"non-virtual thunk to D::foo(int)"字符串并不能表示所有的non-virtual thunk,因为每个基类都有不同的"non-virtual thunk"。
另外,这些定制的虚函数表如何初始化呢?

在没有看答案之前,可以猜测"non-virtual thunk to D::foo(int)"这个字符串应该加入类名到里面,例如"B0non-virtual thunk to D::foo(int)",而虚表的初始化可以在派生类的构造函数中把所有基类(不单单是直接基类)统统进行一次初始化。

测试

#include <stdio.h>

struct B0
{
        virtual int foo(int)
        {
                return 333;
        }
    int xx[30];
};

struct B00
{
        virtual int zzz(int)
        {
                return 0;
        }
    int xx[10];
};

struct B1:  public B00, public B0
{
        virtual int foo(int)
        {
                return 0;
        }

        virtual int bar()
        {
                return 1;
        }
};

struct B2
{
        virtual  int baz(int)
        {
                return 1;
        }
    int  pad[0x110 * 2 + 1];
};

struct D: public B2, B1
{
        virtual int foo(int)  override
        {
                return 2;
        }
};

int   main(int, const char *[])
{
        D d;
        B1 b;
        auto pvtbl = [](const char *tag,void *pAddr)
        {
                void **pvg = (void**)*(void**)pAddr;
                printf("%s addr %p\n", tag, pAddr);
                for (int i = 0; i < 3; i++)
                {
                        printf("%p\n", pvg[i]);
                }
                printf("\n");
        };
        pvtbl("b1", &b);
        pvtbl("d", &d);
        pvtbl("d.b1", (B1*)&d);
        pvtbl("d.b0", (B0*)&d);
        return 0;
}

虚表初始化

三个基类都执行了虚函数表的覆盖。

(gdb) disas 0x40137e
Dump of assembler code for function _ZN1DC2Ev:
   0x000000000040137e <+0>:     push   %rbp
   0x000000000040137f <+1>:     mov    %rsp,%rbp
   0x0000000000401382 <+4>:     sub    $0x10,%rsp
   0x0000000000401386 <+8>:     mov    %rdi,-0x8(%rbp)
   0x000000000040138a <+12>:    mov    -0x8(%rbp),%rax
   0x000000000040138e <+16>:    mov    %rax,%rdi
   0x0000000000401391 <+19>:    call   0x4012f2 <_ZN2B2C2Ev>
   0x0000000000401396 <+24>:    mov    -0x8(%rbp),%rax
   0x000000000040139a <+28>:    add    $0x890,%rax
   0x00000000004013a0 <+34>:    mov    %rax,%rdi
   0x00000000004013a3 <+37>:    call   0x40133a <_ZN2B1C2Ev>
   0x00000000004013a8 <+42>:    mov    $0x402040,%edx
   0x00000000004013ad <+47>:    mov    -0x8(%rbp),%rax
   0x00000000004013b1 <+51>:    mov    %rdx,(%rax)
   0x00000000004013b4 <+54>:    mov    $0x402060,%edx
   0x00000000004013b9 <+59>:    mov    -0x8(%rbp),%rax
   0x00000000004013bd <+63>:    mov    %rdx,0x890(%rax)
   0x00000000004013c4 <+70>:    mov    $0x402088,%edx
   0x00000000004013c9 <+75>:    mov    -0x8(%rbp),%rax
   0x00000000004013cd <+79>:    mov    %rdx,0x8c0(%rax)
   0x00000000004013d4 <+86>:    nop
   0x00000000004013d5 <+87>:    leave
   0x00000000004013d6 <+88>:    ret
End of assembler dump.
(gdb) 

函数名

B1和B0的non-virtual thunk虚函数表尽管实现不同,但是名字相同,也就是这个虚函数表并不要求名字唯一(因为运行时是通过虚函数指针而不是函数名直接访问的)。

tsecer@harry: ./a.out 
b1 addr 0x7ffef1d74b30
0x401282
0x401294
0x4012ac

d addr 0x7ffef1d74be0
0x4012bc
0x4012ce
0xfffffffffffff770

d.b1 addr 0x7ffef1d75470
0x401282
0x4012e9
0x4012ac

d.b0 addr 0x7ffef1d754a0
0x4012e0
(nil)
0x402158

tsecer@harry: gdb -quiet ./a.out                                  
Reading symbols from ./a.out...
(gdb) info symbol 0x4012e0
non-virtual thunk to D::foo(int) in section .text
(gdb) info symbol 0x4012e9
non-virtual thunk to D::foo(int) in section .text
(gdb) 

outro

C++的虚函数添加了语言级别的函数指针定制,这种便利也引入了更多微妙的实现,尽管有些功能并不常用,或者常用但是很少人会思考底层的实现方法。

posted on 2024-11-23 18:08  tsecer  阅读(8)  评论(0编辑  收藏  举报

导航