Itanium C++ ABI 标准
https://www.zhihu.com/question/389546003/answer/1194780618
什么时候需要虚表?
虚表是为了解决“多态”问题而设立的。一般的情况静态绑定就足够了,如果编译器能够直接确定这个类最终调用的是什么函数,那么只需要直接调用即可。
void f(my_class* self){
...
}
假如我们想要达到这种类似“接口”的效果,那么就需要一个能够实现“延迟绑定”类似的结构。假设 p 是指向含有虚函数的对象指针
(*(p->vptr)[n])(p)
“覆盖”操作是怎么实现的?
先看 虚表结构
对于单继承,因为虚表就放最前面,所以等到所有基类都搞定之后,自己再最后在同样的位置覆盖全部就行了。如果有没有 override 的情况
为了确保正确性,如果基类没有 default 的构造函数,父类必须显式调用子类的构造函数
对于多个单继承,最后的构造内容完全按照用户编写的形式完成,不过在子类都搞定之后就写上自己的虚表
怎么找到被覆盖过的?这个情况其实就能够回到最开始的“静态调用”了,用不着虚表直接调用就行
你说一直单继承也容易,我一直在虚表后面加就行了。反正虚表指针占用大小是确定的,基类调用的函数一定能够在指针指向的表的前面某个位置找到,而这个位置也是固定的(基类的类型能够确定在虚表的哪个位置有虚类知道的函数)
但是如果是多重继承呢?这个时候就需要虚表的前面两个结构出场了!
多重继承的虚函数调用
https://www.zhihu.com/question/29251261/answer/1297439131
在多重继承中,“顺序”就变得重要了起来。基类调用的时候,需要知道自己被继承在子类到了哪个位置(而不是无脑在第一个位置)
int main() {
son s(10);
base_a *b_a = &s;
base_b *b_b = &s;
b_a->f_base(); // Calls de_a::f_base()
b_b->g_base(); // Calls de_b::g_base()
return 0;
}
没错!指针在这里没有“忠实”的完成转换,而是偷偷产生了偏移。这个过程用到的技术是 thunk,信息从虚表结构中可以获得,也可以在编译阶段获取足够的信息得到
这里的偏移就是 offset_to_top 中的 -8
新的成员变量放在哪里?
void __fastcall son::son(son *this, int n10)
{
de_a::de_a(this);
de_b::de_b((son *)((char *)this + 16));
*(_QWORD *)this = &off_100004018;
*((_QWORD *)this + 2) = &off_100004030;
*((_DWORD *)this + 7) = n10;
}
总结一下:每个 base class 都按顺序放好,在对应的偏移位置调用各自的构造函数,然后覆盖各自上面的虚表指针到新的位置上,最后在后面放上新的自己的成员
多态调用的时候,编译器会根据类型转换,自动找到那个 base class 在类中的偏移,然后去调用那个位置上的虚表找到对应函数,最后调用
新的虚函数加在第一个表上
虚继承
https://shaharmike.com/cpp/vtable-part3/
为了解决“菱形继承”中确保基类只能存在一份,构造和析构都只调用一次。简单的按顺序放在一起是不行了,必须引入更多的信息
#include <iostream>
using namespace std;
class Grandparent {
public:
virtual void grandparent_foo1() {}
virtual void grandparent_foo2() {}
int grandparent_data;
};
class Parent1 : virtual public Grandparent {
public:
virtual void parent1_foo1() {}
virtual void parent1_foo2() {}
int parent1_data;
};
class Parent2 : virtual public Grandparent {
public:
virtual void parent2_foo1() {}
virtual void parent2_foo2() {}
int parent2_data;
};
class Child : public Parent1, public Parent2 {
public:
virtual void child_foo1() {}
virtual void child_foo2() {}
int child_data = 3;
};
int main() { Child child; }
最后达到的效果希望能够和前面差不多:
void __fastcall Child::Child(Child *this)
{
Grandparent::Grandparent((Child *)((char *)this + 32));
Parent1::Parent1(this);
Parent2::Parent2((Child *)((char *)this + 16));
*(_QWORD *)this = child_base_vtb;
*((_QWORD *)this + 4) = child_grandparent_vtb;
*((_QWORD *)this + 2) = child_parent2_vtb;
*((_DWORD *)this + 7) = 3;
}
只调用一次各自的构造函数,grandparent 最先(内存中放在两个 parent 的后面),然后是后两个,最后再更新各自的 vtable 指针
虚继承一种解决方案可以是构造函数直接调用虚继承类的构造函数(而非递归调用,产生多次构造),所以这里首先 Grandparent 构造函数调用是合理的。但是随之而来的问题是 parent1 和 2 都不知道他们所继承的 Grandparent 类的内存在哪里。原来可以直接定位到最低偏移位置的,在这里变成放在后面了。
而且如果我单独调用 parent1 的构造函数,产生的内存结构又是不一样的
注意到这里有三个 vtb。一个是从 parent1 那里拿过来的,并且在这个表后面加上自己的,第二个是 parent2 的,第三个是 grandparent 的
进入 construction table for Parent1-in-Child
。该表专门用于告知 Parent1
在哪里可以访问其数据片段。
- 首先是一个
virtual-base offset
指示在哪里可以找到 grandparent 的数据,往后跳 0x20 个内存就能找到 grandparent 的内存 - 然后是
top-offset
- 然后是
type info
- 最后跟着虚函数
VTT 里面就是将上面很多个虚函数表放在一起的总表
- vtable for child +24
- construction vtable for Parent1-in-child+24
- construction vtable for Parent1-in-child+56
- construction vtable for Parent2-in-child+24
- construction vtable for Parent2-in-child+56
- vtable for child +96
- vtable for child +64
这个转换表能够识别 Parent1
的构造函数是被独立对象调用、 Parent1-in-Child
对象调用还是 Patrent1-in-SomeOtherObject
对象调用
所以在构造 child 的时候:
- 直接负责调用虚基类
Grandparent
的构造函数,并且确定了其在最终对象中的位置 - 构造第一个非虚的子类的时候,放在 this 位置,即 parent1 子对象在 child 对象的起始位置开始。然后加载 VTT,+8 偏移找到
construction vtable for Parent1-in-child+24
,然后作为参数传进去 - 传进去之后,直接将这个位置作为虚表设置了。这个构造虚表中的虚函数指针和到虚基类的偏移量都是针对当前正在构造的
Child
对象的布局的。- 首先 this 赋值为构造虚表
(_QWORD *)((char *)this + *(_QWORD *)(*(_QWORD *)this - 0x18LL)
然后这一大串找到了基类的 this。- 对基类的 this 的虚表进行覆盖(这里进行重写)
- 对基类的 this 后面偏移的成员进行访问
- 同时,对于 parent2 也是一样的做法
- 最后再统一重写各自的虚表指针
可以看到,上面的所有偏移都是写死的,说明在编译阶段其实就能够确定很多东西了
所以在逆向的时候要注意到,虚表是分散的,并且最后取决于最后一次的赋值。各个虚表的位置往往可以表明各自子类所在的位置
在调用的时候,会先在类中找到子类的偏移,然后在那个位置的虚表上调用继承的方法(也许被重写了)
所以想要静态的找到虚表,唯一的方法是找到构造函数或者析构函数。如果是动态来找,可以通过识别 this+ 偏移这个第一次找子类的过程定位到虚表(之后还有偏移就是在虚表里面找函数指针了) ,并且这个虚表只是这个子类的虚表,并不全