该文章转自阿里巴巴技术协会(ata)作者:霜天
VTable 虚表
虚表的内存分布
- 一个简单的包含虚函数的类的声明
class A {
public:
virtual void v_a(){}
virtual ~A(){}
int64_t _m_a;
};
/***********************/
int main(){
A* a = new A();
return 0;
}
- 如果在C++中定义一个对象 A,那么在内存中的分布大概是如下图这个样子。
- 首先在主函数的栈帧上有一个 A 类型的指针指向堆里面分配好的对象 A 实例。
- 对象 A 实例的头部是一个 vtable 指针,紧接着是 A 对象按照声明顺序排列的成员变量。
- vtable 指针指向的是代码段中的 A 类型的虚函数表中的第一个虚函数起始地址。
- 虚函数表的结构其实是有一个头部的,叫做 vtable_prefix ,紧接着是按照声明顺序排列的虚函数。
- 注意到这里有两个虚析构函数,后面会介绍为什么有两个虚析构。
- typeinfo 存储着 A 的类基础信息,包括父类与类名称,C++关键字 typeid 返回的就是这个对象。
- typeinfo 也是一个类,对于没有父类的 A 来说,当前 tinfo 是 class_type_info 类型的,从虚函数指针指向的vtable 起始位置可以看出。
虚表的前缀声明
- gcc 的 tinfo.h中定义的 vtable_prefix
// Initial part of a vtable, this structure is used with offsetof, so we don't
// have to keep alignments consistent manually.
struct vtable_prefix
{
// 存储指向完整对象的指针偏移,也就是上图中的offset部分
ptrdiff_t whole_object;
// 完整对象的tinfo类型
const __class_type_info *whole_type;
// 也就是当前对象指针,第一个虚函数开始的地方
const void *origin;
};
- whole_object 偏移量指向的对象又叫做 The Most Derived Class,意思是最完整的对象
- whole_type 这是一个tinfo指针指向的内容,指向的是C++内置type类型。
- C++的内置类型主要有如下几种:
- fundamental(基本类型)
- array(数组)
- function(函数)
- enum(枚举)
- pbase(指针) ,point , point_to_member
- class(基类) ,si class(单一继承类型) ,vmi class(多重或虚拟继承类型)
- 类对象里的 tinfo 指针只会指向 class , si_class 或者 vmi_class 中的一种,取决于这个类的继承体系。后面会详细介绍这几个类之间的关系。
- origin 永远指向第一个虚函数的入口,这个类型只是为了用在 offsetof 个宏命令里面,这里定义这个变量是为了用一个非常巧妙的宏 offsetof 实现的偏移来获取头部。C++中大量使用了这种成员之间的偏移来从一个成员获取另一个成员变量。
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *) 0)->MEMBER)
虚函数的调用方式
- C++在编译阶段就对虚函数和普通成员函数的调用做出了区分
int main(){
// 堆上构造
A* a = new A();
a->v_a();
// 栈上构造
A b;
b.v_a();
return 0;
}
- 对上述代码做反汇编,可以看出同样是调用v_a函数,调用方法区别是很大的
<main+0>: push %rbp
<main+1>: mov %rsp,%rbp
<main+4>: push %rbx
<main+5>: sub $0x78,%rsp // main函数开辟栈帧
<main+9>: mov $0x10,%edi // 要分配的内存,也就是sizeof(A) = 0x10
<main+14>: callq 0x4008a0 <_Znwm@plt> // new函数分配内存
<main+19>: mov %rax,%rbx // $rax寄存器存贮分配后的内存起始
<main+22>: mov %rbx,%rdi // 其实也就是this指针
<main+25>: callq 0x400cd6 <A> // 调用A的构造函数在,参数为this
<main+30>: mov %rbx,-0x48(%rbp) // 把this指针存贮在栈上,也就是变量a
<main+34>: mov -0x48(%rbp),%rax // 变量a放入$rax
<main+38>: mov (%rax),%rax
<main+41>: mov (%rax),%rax // 两次指针跳转,此时$rax存储的是虚表第一个函数地址
<main+44>: mov -0x48(%rbp),%rdi // this指针作为函数第一个参数
<main+48>: callq *%rax // 调用虚函数v_a()
<main+50>: lea -0x60(%rbp),%rdi // 栈上b变量的起始地址
<main+54>: callq 0x400cd6 <A> // 直接栈构造变量b
<main+59>: lea -0x60(%rbp),%rdi // 把this指针作为函数第一个参数
<main+63>: callq 0x400ade <_ZN1A3v_aEv> // 调用 A::v_a(),直接调用
- 虚函数的调用必须是指针的方式,无论对 a 做什么巧妙的转换,编译器都能识别,__a__ 是指针类型,那么对虚函数的调用就只能用多态的方式。
- 当编译器明确知道当前变量就是类型A时,就会显式的直接调用其成员函数。
- 因为对象有两种构造方式,栈构造和堆构造,所以对应的,对象会有两种析构方式,下面是两个 ~A 的反汇编
<~A+0>: push %rbp
<~A+1>: mov %rsp,%rbp
<~A+4>: sub $0x10,%rsp
<~A+8>: mov %rdi,-0x8(%rbp)
<~A+12>: mov $0x4012b0,%edx
<~A+17>: mov -0x8(%rbp),%rax
<~A+21>: mov %rdx,(%rax)
<~A+24>: mov $0x0,%eax
<~A+29>: test %al,%al // 与操作,al为空,ZF为1
<~A+31>: je 0x400eea <~A+42> // ZF 为 1, 跳转,不执行delete
<~A+33>: mov -0x8(%rbp),%rdi
<~A+37>: callq 0x4008b8 <_ZdlPv@plt>
<~A+42>: leaveq
<~A+43>: retq
<~A+0>: push %rbp
<~A+1>: mov %rsp,%rbp
<~A+4>: sub $0x10,%rsp
<~A+8>: mov %rdi,-0x8(%rbp)
<~A+12>: mov $0x4012b0,%edx
<~A+17>: mov -0x8(%rbp),%rax
<~A+21>: mov %rdx,(%rax)
<~A+24>: mov $0x1,%eax
<~A+29>: test %al,%al // 与操作,al不为空,ZF为0
<~A+31>: je 0x400eea <~A+42> // ZF 为 0,不跳转 执行delete
<~A+33>: mov -0x8(%rbp),%rdi
<~A+37>: callq 0x4008b8 <_ZdlPv@plt>
<~A+42>: leaveq
<~A+43>: retq
- 对比发现,两个最的区别就是 <~A+24> 行处的一个判断操作,其中,上面会执行 delete ,下面不会执行,也就对应到堆上对象的析构,和栈上对象的析构。栈内存的析构不需要执行 delete 函数,会自动被回收。
Inheritance 继承
单一继承(si)
- 单一继承是C++中用的最多的,也是结构最清晰的继承结构,这种继承下的内存结构也非常直观。
class A {
public:
virtual void v_a() {}
virtual ~A(){}
int64_t _m_a;
};
class B : public A{
public:
virtual void v_a() {}
int64_t _m_b;
};
class C : public B{
public:
virtual void v_a() {}
int64_t _m_c;
};
/***********************/
int main(){
A* a = new A();
B* b = new B();
C* c = new C();
return 0;
}
- 内存结构
- A,B,C 三个类内存中成员变量,按照其各声明顺序排列,对于单一继承来说,低位总是父类成员,高位总是子类成员。这样做的好处是无论当前对象指针是什么类型,编译器在读取成员时所计算的偏移量时固定的。
- 三者的虚函数表(VTable),也是各自分开,但位于同一块连续内存空间上的。
- vptr 总是指向 虚函数表的第一个函数入口,其虚函数的顺序和各自类内部的声明顺序一致。
- 如果子类有对应的实现,则在虚函数表内会替换成其实现的函数入口地址,否则用父类的实现。比如图中的 v_a() 函数,每个类有自己的实现;而 ~A() 全部使用的时父类中的实现。这样在运形态,编译器只负责把函数指定到对应的偏移上,不同的类指针会根据自己的虚表找到对应的实现,从而实现了多态。
- 虚表头部的 tinfo 指针各自指向了自己的 typeinfo 信息。
- typeinfo 类型维护着类之间的原始信息,比如 类名(typeid.name) ,直接父类(derect base) ,以及当前类型相关的一系列虚函数调用 VTable for si_class_type_info ,后面会介绍
单一虚继承(vsi)
- 在单一继承下面,几乎不会用到虚拟继承,虚继承和重复继承的引入使得对象模型瞬间复杂许多。这里讨论一个最简单的虚继承来了解内存分布,和后面介绍钻石内存布局对比方便理解。
class A {
public:
virtual void v_a() {}
virtual ~A() {}
int64_t _m_a;
};
class B : public virtual A{
public:
virtual void v_a() {}
int64_t _m_b;
};
/***********************/
int main(){
A* a = new A();
B* b = new B();
A* c = static_cast<A*>(b); // 指针静态转换
c->v_a(); // A类型的指针调用虚函数
return 0;
}
- 这个例子中,只有一层虚拟继承关系,虚拟继承一个最大的特点是,其虚继承的父类存放在内存最高位,同时只有一份。
- 注意到 B 对象有两个 vptr 指针,有一个比较大的误区是很多人认为b和c相同,但是实际上==赋值是相等的,但是实际地址并不相同,==操作符被重载了。 对象指针在做 强制转换(__static_cast__) 赋值的时候并不是不会修改指针内容的,例如虚拟继承,还有下面要说的多重继承,都会被编译器修改,也就是说这里 c指针,和b指针所存储的地址已经不一样了。后面会详细介绍 static_cast 的实现。
- 为什么会有两个虚表入口呢?为了实现虚拟基类的实例在继承体系内只有一份,必须要单独把虚基类单独的放在别的地方,这样当一个子类对象 B ,被强制转换位 A 时,又要保证在运行态这个指针可以通过统一的偏移量操作成员,那么只能单独独立一套虚表出来,虚基类的虚表紧挨着B对象的虚表。
- 注意到 c 对象所指向的虚表的 tinfo 信息也是指向的 B 类型的 typeinfo 对象。
- 注意到虚基类 offset 已经不是0了,这个偏移就是c和b地址的差,这个偏移是用来计算c指针指向的最完整的对象的位置,也就是b指的位置。这个对象叫做 most derived class
- 新生成的虚表内部的函数其实和 B 类型的虚表时一致的,只是做了一个简单跳转,这种虚基类的虚表内的函数叫做virtual thunk
virtual thunk
- 对 vtnk v_a的反汇编
Dump of assembler code for function _ZTv0_n24_N1B3v_aEv:
<_ZTv0_n24_N1B3v_aEv+0>: mov (%rdi),%r10 // this所指向的虚表入口函数地址指针存储到$r10
<_ZTv0_n24_N1B3v_aEv+3>: add -0x18(%r10),%rdi // $10-0x18 所指向的内容累加到 this指针
<_ZTv0_n24_N1B3v_aEv+7>: jmpq 0x400bf4 <_ZN1B3v_aEv> // 跳转到B::v_a()的入口继续执行
- virtual thunk 函数一共就三条指令,修改this指针偏移到 the most derived class ,也就是b指针所指向的内容,方式就是通过 c指针所指向的虚表的头部 offset 来实现跳转。
- 上图存储了两份 offset ,其实含义是一样的,但是使用方不一样,这里的转换用到的是第一个,后面要介绍的static_cast 用的也是这个 offset ,也就是在静态期间可确定的;而第二个 offset 属于定义在 vtable_prefix 内部的成员,可以通过 offsetof 宏命令获取,这个主要由 dynamic_cast 使用,主要用在运行态。
- 当出现虚拟继承链的时候,也就是说如果由类 C 虚继承 B ,那么对于类 B , offset 还会多存储一份,这里后面会介绍。
多重继承(mi)
- 当一个子类需要同时拥有两个父类的属性时,会引入多重继承,这种继承方式在没有方法名和函数名冲突的时候,还是挺好用的。
class A {
public:
virtual void v_a() {}
virtual ~A() {}
int64_t _m_a;
};
class B {
public:
virtual void v_a() {}
virtual ~B() {}
int64_t _m_b;
};
class C : public A, public B{
public:
virtual void v_a() {}
int64_t _m_c;
};
/***********************/
int main(){
A* a = new A();
B* b = new B();
C* c = new C();
B* d = static_cast<B*>(c); // static强制转换
d->v_a(); // B类型的指针调用虚函数
return 0;
}
- 多重的内存布局和虚拟继承差距很大,这里通过仔细比较可以发现,基类并没有放在高位,而是和单一继承类似。
- 多重继承仍然会有两张虚表的设计,在做 static_cast 转换时依然会偏移指针,不过这里转换的实现和虚继承转换的实现有区别,这里后面会介绍。这么做的原因主要和虚继承一样,是为了 A 类型, B 类型的指针在寻址自己的成员变量时,无论指向的实例是什么类型,保持偏移是一致的。
- 我们注意到类 C 必须要实现自己的和 A , B 重名的虚函数 v_a() ,不然编译器会报错,提示父类的 v_a() 实现有两份,无法确定具体用哪一个。析构函数编译器会帮助实现一个默认的。
- 类型 A , B 和上文一样,都是无父类的,属于 class_type_info ,类型 C 属于 vmi_class_info ,同时类型 C 的base_count 为2,代表有两个父类, offse_info 指针也和虚继承有很大的变化,这里我们后面会介绍。
- 紧接着就是每个对象的虚表了,和虚继承的虚表有很大的区别,指针d所指向的 vptr ,其虚函数叫做 non-virtual thunk ,其实现也和 virtual thunk 有区别,见下面反汇编
non-virtual thunk
- 对tnk v_a()的反汇编
Dump of assembler code for function _ZThn24_N1C3v_aEv:
<_ZThn24_N1C3v_aEv+0>: add $0xfffffffffffffff0,%rdi //做this指针偏移,和c地址一致
<_ZThn24_N1C3v_aEv+4>: jmpq 0x400c24 <_ZN1C3v_aEv> //直接调用C::v_a()
- non-virtual thunk 的实现比 virtual thunk 要简单许多,因为多重继承的对象不需要具有多态属性,所以偏移可以在编译的时候确定。所以对于这种 thunk 的转换函数,编译器只要实现一份就可以了。
- 我们在此猜测,为什么,多重继承可以直接计算偏移,而虚拟继承需要读取 offset 来计算偏移,虚拟继承生成的元类信息不也是固定的吗?针对 virtual thunk 固定在代码段的地址是 -0x18 这个偏移,这个存储的是其父类指针的偏移,但是由于父类属于虚继承,当自己被另一个对象继承时,父类有可能也被虚继承过去,为了满足虚继承的父类位于内存顶端依次排列的需要,当前的自己在别的对象内部,其父类偏移可能就发生变化了。所以,这个 offset 必须要间接存储一次。
多重重复继承(rmi)
- 多重重复继承其实只是多重继承的一种比较特殊的内存展示形式,主要特点就是被重复继承的父类其由继承了同样的类 A ,那么这个类 A ,会在当前类保存两份实例。这个我们只讨论成员内存分布,其他信息同上面的多重继承。
class A {
public:
virtual void v_a() {}
virtual ~A() {}
int64_t _m_a;
};
class B : public A {
public:
virtual void v_a() {}
int64_t _m_b;
};
class C : public A{
public:
virtual void v_a() {}
int64_t _m_c;
};
class D : public B, public C{
public:
virtual void v_a() {}
int64_t _m_d;
};
/***********************/
int main(){
A* a = new A();
B* b = new B();
C* c = new C();
D* d = new D();
return 0;
}
- 内存结构
- 图中, B 和 C 同时继承了 A ,那么对于类 D 来说,会有两份A的实例,其他结构层次和多重继承一致。
- 可以观察到,继承只处理单层关系, D 在继承 B 和 C 时,只会把 B ,__C__ 的内存分布拷贝一份到自己的区域,而不会关心 B , C 的父类是什么, B , C 中是否有相同的父类,相同的成员; typeinfo 也只维护一层直接父类即可。
多重虚拟继承(vmi)
- 一般,为了避免多重重复继承中有两份类A的成员,特别是当A内有单例或静态变量的场景时,就会对A引入虚拟继承,这样可以保证A在子类中只出现一份,这种继承结构就是 钻石型继承(Diamond Shaped)
class A {
public:
virtual void v_a() {}
int64_t _m_a;
};
class B : public virtual A {
public:
virtual void v_a() {}
int64_t _m_b;
};
class C : public virtual A{
public:
virtual void v_a() {}
int64_t _m_c;
};
class D : public B, public C{
public:
virtual void v_a() {}
int64_t _m_d;
};
/***********************/
int main(){
A* a = new A();
B* b = new B();
C* c = new C();
D* d = new D();
return 0;
}
- 内存结构
- 把多重重复继承做一点调整,对 A 使用虚继承,就得到了钻石型继承结构,这里省去了析构函数。
- B 和 C 的结构和上述单一虚拟继承式样的,可以参考上面。
- 多重继承的 D 内存结构发生了变化,公有虚基类被放如内存顶部,同时额外生成了两张虚表。
- 在 D 的 VTable 内部,同时存在这 non-virtual thunk 和 virtual thunk ,这里原理和多重继承以及虚拟继承一致,都是为了在隐式转换的时候的时候能做到和基类型指针处理方式一致。
most derived class
- 含义为 最完整的类,我们看多重继承,虚拟继承,以及钻石继承结构里面的虚表结构,当进行 up_cast 转换时,static_cast 会修改指针偏移,此时指针所指向的实例还是原始实例,但指针类型发生了变化,我们称为原始实例所属的类为 most derived class 。
- 当前例子中,这里D的虚表包括了很多 offset ,其中最紧挨着 typeinfo 的那个 offset 属于上文中 vtable_prefix 中定义的变量 whole_object , 其内部存储的时当前指针倒 most derived class 的偏移量。
- 其余的 offset 按照内存从高位到低位的顺序,依次指向的其直接继承的虚基类。
- 图中每一个 most derived class 头部仍有虚基类偏移,这里没有画出。
Rtti 多态与内置转换关键字的实现浅析
- C++支持运行时多态,也为此实现了一套内置关键字,在编译期或运形态期过预先存储好的元信息来获取变量真实信息。
- 无基类的类都是 class_info , 单一继承的子类类都属于 si_class_info , 多重继承和虚拟继承的子类都属于vmi_class_info 。
- typeinfo 提供了几种方法,C++关键字 typeid 返回的对象,就是这个类型,可以获得name,判断先后,类型相等比较操作。
- vmi_class_info 中的 base_class_info 存储着继承的每一个父类的关系,注意到 offset_info 这个变量,其构成是低2位为 flag offset,高位为基类到当前 most derived class 在虚表中的偏移。
// Helper class for __vmi_class_type.
class __base_class_type_info
{
public:
const __class_type_info* __base_type; // Base class type.
long __offset_flags; // Offset and info.
enum __offset_flags_masks
{
__virtual_mask = 0x1, //虚拟继承
__public_mask = 0x2, //公有继承
__hwm_bit = 2,
__offset_shift = 8 // Bits to shift offset.
};
}
};
typeid
- typeid 的实现就是获取每一个类对应的 most derived class 的typeinfo指针,然后调用相关的成员函数。
- name() 方法的实现就是获取对应的 name ptr指针。
- operator()== 方法的实现依赖于一个 GXX_MERGED_TYPEINFO_NAMES 宏的实现,如果编译器做了typeinfo names的merge,就只做指针比较,否做就是对name指针的字符串比较。
- before() 函数的实现很让人费解,其就是简单的字符串比较,也许编译器在对类起唯一标示的时候,已经把类的继承关系考虑进去了。用开头的数字来区分,比如 1A 2B 3C 这种,这里不做深究了。
bool before(const type_info& __arg) const
{ return __name < __arg.__name; }
reinterpret_cast
- 非常直观的按位拷贝,不包括任何隐式的转换,可以用于任何指针的转换,包括指针到整形的转换。
<main+392>: mov -0xa8(%rbp),%rax
<main+399>: mov %rax,-0x78(%rbp)
const_cast
- const_cast 其实可以理解为解除或赋予编译器对 const 约束的限制,在实现上和 reinterpret_cast 是一样的。当编译器识别到所转换的两个类型不匹配,比如 属于虚拟继承或者多重继承,并不会做任何隐式转换,而是直接编译报错。所以这个转换的场合只用在相同类型之间解除(赋予)constness 约束。
static_cast
单一继承 (si) 的实现和 reinterpret_cast 是一样的,按位拷贝,因为其内存成员分布和只有一张虚表,指针在做转换的时候,并不需要区别对待,但是需要注意,单一继承下的 up_cast 虽然是安全的,但 down_cast 是不安全的,虽然编译器不会报error,但有可能会带来runtime时的异常。
多重继承 (mi): 偏移方法和 non-virtual thunk 的实现相呼应,这种类体系的 static_cast 的 down_cast 会报编译错误。下面是多重继承例子中的 static_cast 转换反编译。
<main+139>: cmpq $0x0,-0x38(%rbp) // 如果栈上this指针为空则跳转
<main+144>: je 0x400a94 <main+160>
<main+146>: mov -0x38(%rbp),%rax // this指针放入 $rax
<main+150>: add $0x10,%rax // this指针偏移 0x10,指向non-virtual虚函数入口
<main+154>: mov %rax,-0x58(%rbp)
<main+158>: jmp 0x400a9c <main+168>
<main+160>: movq $0x0,-0x58(%rbp) // 因为this指针为空,不需要做转换,直接赋值为0
- 虚拟继承 (vsi) : 偏移方法和 virtual thunk 的实现相呼应,这种类体系的 static_cast 的 down_cast 也会报编译错误。下面是单一虚拟继承例子中的 static_cast 转换的反编译
<main+151>: cmpq $0x0,-0x28(%rbp) // 如果栈上this指针为空则跳转
<main+156>: je 0x400a5d <main+185>
<main+158>: mov -0x28(%rbp),%rdx
<main+162>: mov -0x28(%rbp),%rax // 寄存器保存两份this指针
<main+166>: mov (%rax),%rax // $rax指向 虚表第一个函数的地址
<main+169>: sub $0x18,%rax // $rax减去0x18,指向虚基类的偏移offset存储处
<main+173>: mov (%rax),%rax // 获取偏移offset
<main+176>: add %rax,%rdx // 累加到this指针上,当前指针指向 virtual虚函数入口
<main+179>: mov %rdx,-0x40(%rbp)
<main+183>: jmp 0x400a65 <main+193>
<main+185>: movq $0x0,-0x40(%rbp) // 因为this指针为空,不需要做转换,直接赋值为0
dynamic_cast
- 最后是 dynamic_cast 的实现,其利用 C++ rtti 信息来保证 down_cast 转换有效,具体的实现可谓相当复杂,我们用一个简单而且比较典型的 dynamic_cast 例子来介绍下实现原理。采用的继承结构是单一的多重继承,参考第二节的 多重继承(MI) 示例。
int main(){
C* c = new C();
B* b = static_cast<B*>(c); // static静态转换,up_cast
C* cc = dynamic_cast<C*>(b); // dynamic强制转换,down_cast
return 0;
}
- 转换流程
- 首先在栈上我们获得了b指针和c指针,b指针获取对应的虚表头。
- b指针进一步获取到对应的虚表的第一个函数入口。
- b指针的类型为 B ,但是并不知道所指向的 most derived class 是什么。那么就进一步的获取,根据 vtable_prefix的定义,从当前指针的 -0x18 处 取得 最完整类型的偏移,为 -0x10。
- 当前指针b减去 0x10 就是对应的 最全类型 C 的起始地址了,到此,第一步结束,我们获得了 whole obj ptr 指针。
- 接着需要判断 B 是否是 C 的基类。如果是基类,那么需要做反向验证。首先获取 C 类型指向的 tinfo ,做第一次判断 C 是否是当前 whole obj ptr 所指向的类型。
- 循环遍历基类,寻找 B 对象的 tinfo 。
- 找到 B 后确认 B ,确实属于 C 的基类,执行
offset_info >> 8
操作,获取 B 到 C 在实例中的偏移,为0x10。 - 最后一步验证就是用 whole obj ptr 指针偏移 0x10,计算结果是否就是指针b。如果相等,返回 whole obj ptr 的值,不相等返回 NULL。
adjust_pointer <void> (obj_ptr, src2dst) == src_ptr
dynamic_cast<T>(V)
的具体实现是用虚函数做的,在不同的类型,SI,MI,VMI ,其实现方式大不相同,不过实现上的思想都是一样的:判断V所指向的真实类型是否和T有继承关系,继承关系是否满足安全转换。