一个类只有一张虚表吗
intro
毫无疑问,每个对象只有(最多)一个虚函数表指针,但是每个类是否只有一个虚函数表呢?
从两个比较常用的C++功能可以引申到这个疑问:
- 虚函数调用
一个派生类对象转换为基类指针后,通过该基类指针来调用虚函数,调用到的是派生类的虚函数。这个通过虚函数表很容易实现,但是调用虚函数的时候不仅仅是找到虚函数地址的问题,更重要的是要将调用时编译器维护的this指针转换为派生类的地址,因为派生类虚函数中,访问内存是基于派生类的内存布局访问的。
- 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++的虚函数添加了语言级别的函数指针定制,这种便利也引入了更多微妙的实现,尽管有些功能并不常用,或者常用但是很少人会思考底层的实现方法。