mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
4988 字
13 分钟
cpp(十一)——虚函数
2026-11-01

CPP(十一)——继承,重写,虚函数,多态#

这一篇也有点硬,慢慢食用。

一、继承#

#include <iostream>
class Animal {
public:
int age;
void eat() {
std::cout << "Animal eat\n";
}
};
class Dog : public Animal {
public:
int weight;
void bark() {
std::cout << "Dog bark\n";
}
};

这里:

  • Animal 是父类(基类)。
  • Dog 是子类(派生类)。
  • Dog 继承了 Animal 的成员。

因此,Dog 对象可以访问 age,也可以访问自己的 weight。

来看看子类的内存布局。

Dog对象总共8字节,4字节的age,4字节的weight。

这里最重要的一点:

继承不会让子类对象额外保存一份父类对象指针。

父类的非静态成员通常直接成为子类对象布局的一部分。

所以就会出现

Dog dog;
Animal* p = &dog;

p 指向的是 dog 对象中作为基类子对象的那一部分。在这个简单的单继承例子中,基类子对象通常位于偏移 0。

二、虚函数,重写与多态#

先来看没有虚函数的情况

#include <iostream>
class Animal {
public:
void speak() {
std::cout << "Animal\n";
}
};
class Dog : public Animal {
public:
void speak() {
std::cout << "Dog\n";
}
};
int main() {
Dog dog;
Animal* p = &dog;
p->speak();
}

你可能会认为:

p 指向 Dog,所以调用 Dog 的 speak()。

但实际输出是:Animal。

为什么?

因为 speak() 不是虚函数。

编译器根据指针的静态类型 Animal* 决定调用哪个函数,而不是根据对象的实际类型决定。

使用 virtual 实现多态#

现在给父类函数加上 virtual:

class Animal {
public:
virtual void speak() {
std::cout << "Animal\n";
}
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "Dog\n";
}
};
int main() {
Dog dog;
Animal* p = &dog;
p->speak();
}
//输出Dog

这就是运行时多态。

它的核心是:

  • 编译时:p 的静态类型是 Animal*。
  • 运行时:p 实际指向一个 Dog 对象。
  • 最终:调用 Dog::speak()。

override 是 C++11 提供的关键字,用于明确告诉编译器:

这个函数应该重写父类的虚函数。

注意:

  • virtual:声明虚函数。
  • override:检查是否成功重写。
  • final:禁止进一步重写(也可用于禁止类被继承)。

三、vptr和vtable:虚函数背后的实现#

先记住两个概念:

名称含义
vtable虚函数表,保存虚函数相关的函数入口
vptr虚表指针,通常保存在对象内部,指向对应的虚表

一个典型的实现思路是:

  1. 含虚函数的类会有一张虚函数表。
  2. 对象内部通常会有一个 vptr。
  3. vptr 指向该对象当前动态类型对应的虚函数表。
  4. 调用虚函数时,通过 vptr 找到表,再从表中找到对应函数入口。
class Animal {
public:
int age;
virtual void speak() {}
virtual void run() {}
};
class Dog : public Animal {
public:
int weight;
void speak() override {}
};

这个时候Dog对象的内存布局可以变成下面的样子

offset
0 vptr
8 age
12 weight

Dog的虚函数表(可能不是这么简单的排列,gpt说和架构也有点关系)

表项0: Dog::speak
表项1: Animal::run

这里发生了一个重要变化:

原本 Animal 对象中只有 age,现在因为存在虚函数,典型实现中对象还多了一个 vptr。

Dog 继承 Animal 后,通常沿用这套虚函数机制,并在自己的虚表中将 speak 对应的入口替换为 Dog::speak。

虚函数调用时发生了什么#

Animal* p = new Dog();
p->speak();

可以理解成以下过程

p保存Dog对象地址->拿到vptr指针->查表->调用函数。

// 仅用于理解的伪代码,不是标准 C++ 实现
vptr = *(void***)p;
func = vptr[0];
func(p);

四、基类子对象#

class Animal {
public:
int age;
virtual void speak() {}
};
class Dog : public Animal {
public:
int weight;
void speak() override {}
};
创建对象:
Dog dog;

从 C++ 语言的角度看,Dog 对象内部包含一个 Animal 基类子对象,以及 Dog 自己声明的成员。

这里的「基类子对象」非常重要。

它不是一个单独通过 new 申请的对象,也不是一个指向父类的指针,而是完整 Dog 对象的一部分。

基类指针为什么可以指向子类#

Dog dog;
Animal* p = &dog;

这是一种隐式向上转型(upcast)。

因为 Dog 是 Animal,所以 Dog 对象中包含 Animal 基类子对象。

在简单的单继承中,基类子对象通常位于完整对象的起始位置。

在这个例子中,p 保存的地址与 &dog 相同。

但是,记住:

基类指针不等于完整子类对象的全部信息。

通过 Animal*,你只能直接访问 Animal 提供的接口,不能直接访问 Dog 独有的 weight。

p->age; // 正确
p->speak(); // 正确
// p->weight; // 编译错误

五、多重继承#

单继承只有一个直接基类。

多重继承则允许一个类同时继承多个类。

class A {
public:
int a;
virtual void funcA() {}
};
class B {
public:
int b;
virtual void funcB() {}
};
class C : public A, public B {
public:
int c;
void funcA() override {}
void funcB() override {}
};
C obj;

obj包含:

  • 一个 A 基类子对象;
  • 一个 B 基类子对象;
  • C 自己的成员 c。

此时的obj的内存布局如下

A 基类子对象
A 的 vptr · offset 0
A::a · offset 8
B 基类子对象
B 的 vptr · offset 16
B::b · offset 24
C::c · offset 28

你会发现一个重要现象:

一个完整对象中可能存在多个 vptr。

单继承的简单例子中,我们只看到一个 vptr。

但多重继承中,A 和 B 的基类子对象都可能有各自的 vptr。

这也是 Native 逆向中很重要的识别线索。

多重继承的this调整#

C obj;
B* pb = &obj;
pb->funcB();

pb 指向的是 B 子对象,而 C::funcB() 是 C 的成员函数。

如果函数内部需要访问 C::c,就必须知道完整 C 对象的位置。

因此,编译器可能生成一个 this 调整 thunk:

B* pb
|
v
B 子对象地址
|
v
this 调整(减去 B 子对象偏移)
|
v
完整 C 对象地址
|
v
C::funcB()

六、RTTI:运行时类型识别#

python有个叫自省的东西,不知道和这个有没有啥渊源。

RTTI 全称是:

Run-Time Type Information,运行时类型信息。

它解决的问题是:

当我手里只有一个父类指针时,能不能在运行时知道它实际指向哪个子类?

class Animal {
public:
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void bark() {}
};
class Cat : public Animal {
public:
void meow() {}
};
Animal* p = new Dog();

编译时,你只知道它是 Animal*。

但运行时,它实际指向的是 Dog。

RTTI 就可以帮助程序识别这个实际类型。

typeid:获取运行时类型信息#

#include <typeinfo>
#include <iostream>
Animal* p = new Dog();
std::cout << typeid(*p).name() << std::endl;

这里有一个细节:

typeid(p)

得到的是指针的静态类型 Animal*。

而:

typeid(*p)

在 Animal 是多态类型(至少有一个虚函数)的情况下,会获取对象的动态类型,也就是 Dog。

如果类没有虚函数,typeid(*p) 通常只能得到表达式的静态类型

RTTI 和 vtable 的关系#

在常见的 Itanium C++ ABI 实现中,虚表附近通常会关联 RTTI 类型信息。

概念上:

Dog 对象
|
vptr
|
vtable
|
+---- offset-to-top
|
+---- typeinfo 指针
|
+---- Dog::speak()
|
+---- Dog::run()

不过要注意:

  • RTTI 不是虚函数表本身。
  • RTTI 是运行时类型信息机制。
  • 虚表是常见 ABI 实现虚函数动态派发的一种方式。
  • 两者在常见 ABI 中有关联,但不是同一个概念。

七、dynamic_cast:运行时安全类型转换#

这是 RTTI 最常见的应用之一。

假设:

Animal* p = new Dog();

现在想把 Animal* 转换成 Dog*。

可以使用:

Dog* dog = dynamic_cast<Dog*>(p);

比如

Animal* p = new Cat();
Dog* dog = dynamic_cast<Dog*>(p);
if (dog == nullptr) {
std::cout << "不是 Dog\n";
}

dynamic_cast 的几个特点#

转换类型失败时表现
指针转换 dynamic_cast<Dog*>(p)返回 nullptr
引用转换 dynamic_cast<Dog&>(*p)抛出 std::bad_cast

还有一个非常重要的条件:

对于通常的向下转型(基类指针转派生类指针),基类必须是多态类型,也就是至少有一个虚函数。

例如:

class Animal {
public:
virtual ~Animal() {}
};

这个虚析构函数使 Animal 成为多态类型。

dynamic_cast 在多重继承中尤其有用#

看下面的代码:

class A {
public:
virtual ~A() {}
};
class B {
public:
virtual ~B() {}
};
class C : public A, public B {
public:
int value;
};
C obj;
B* pb = &obj;
C* pc = dynamic_cast<C*>(pb);

转换成功。

这里比较有意思:

pb 指向 C 对象中的 B 基类子对象,并不一定等于 C 对象的起始地址。

但 dynamic_cast 能够利用运行时类型信息,识别完整对象,并完成必要的地址调整。

其他几种类型转换#

这四种类型转换需要区分清楚。

转换主要用途是否运行时检查
static_cast常规类型转换、继承体系中的向上转型和某些向下转型否
dynamic_cast多态类型的安全运行时转换是
reinterpret_cast按另一种类型解释底层值或地址否
const_cast增加或移除 const/volatile 限定否

1. static_cast#

Dog dog;
Animal* p = static_cast<Animal*>(&dog);

这是向上转型,安全且常见。

也可以:

Animal* p = new Dog();
Dog* d = static_cast<Dog*>(p);

但是这里要小心:

static_cast 不会在运行时验证 p 是否真的指向 Dog。

如果 p 实际指向 Cat,却强行转换成 Dog*,再通过这个指针访问对象,会产生未定义行为。

2. dynamic_cast#

Animal* p = new Cat();
Dog* d = dynamic_cast<Dog*>(p);

会检查运行时类型,失败返回 nullptr。

3. reinterpret_cast#

int value = 100;
int* p = &value;
uintptr_t address = reinterpret_cast<uintptr_t>(p);

常用于底层地址和数据表示的处理。

但不要误认为 reinterpret_cast 可以把一个对象真的变成另一种对象。

Animal* p = new Cat();
Dog* d = reinterpret_cast<Dog*>(p);

这不会让 Cat 变成 Dog,也不会做 RTTI 检查。

4. const_cast#

const int value = 10;
const int* p = &value;
int* q = const_cast<int*>(p);

它只改变指针类型上的 const 限定。

如果原对象本身就是 const,之后通过去掉 const 的指针修改它,会产生未定义行为。

八、纯虚函数与抽象类#

class Animal {
public:
virtual void speak() = 0;
};

上面这样带了个=0表示纯虚函数。

它表达的是:

Animal 规定所有派生类都需要提供 speak 接口,但 Animal 本身不要求提供一个可直接使用的具体实现。

抽象类#

至少包含一个纯虚函数的类就是抽象类

// Animal a; // 编译错误

但可以创建派生类对象:

class Dog : public Animal {
public:
void speak() override {
std::cout << "Dog\n";
}
};
Dog dog; // 正确

如果子类没有实现父类的纯虚函数,那么子类也仍然是抽象类。

纯虚函数与虚表#

class Animal {
public:
virtual void speak() = 0;
virtual void run() = 0;
};
class Dog : public Animal {
public:
void speak() override {}
void run() override {}
};

Animal 是抽象类。

在常见 ABI 实现中,Animal 仍然可能有自己的虚表相关数据。

Dog 则有自己的虚表,其中对应的表项指向:

Dog::speak
Dog::run

而不是简单地认为抽象类没有虚表。

如果纯虚函数没有实现,某些 ABI 的虚表表项可能指向纯虚函数处理入口,例如 Itanium ABI 中可能出现:

__cxa_pure_virtual

这是一个很有用的逆向识别线索。

但注意:纯虚函数也允许在类外提供函数定义,只是它仍然保持纯虚属性,派生类仍然必须重写它才能成为具体类。

纯虚函数的析构函数则是例外

class Animal {
public:
virtual ~Animal() = 0;
};
Animal::~Animal() {}

纯虚析构函数必须提供定义,因为销毁派生类对象时,最终仍然需要执行基类析构函数。

九、反编译实战#

源代码

#include <iostream>
class Animal
{
public:
int age;
Animal(int a) : age(a) {}
virtual void speak() = 0;
virtual int getAge()
{
return age;
}
virtual ~Animal() {}
};
class Flyable
{
public:
int speed;
Flyable(int s) : speed(s) {}
virtual void fly() = 0;
virtual ~Flyable() {}
};
class Dog : public Animal, public Flyable
{
public:
int weight;
Dog(int a, int s, int w)
: Animal(a), Flyable(s), weight(w) {}
void speak() override
{
age += 1;
}
int getAge() override
{
return age;
}
void fly() override
{
speed += 1;
}
~Dog() override {}
};
int test_cast(Animal *p)
{
Dog *d = dynamic_cast<Dog *>(p);
if (d == nullptr)
{
return -1;
}
return d->weight;
}
int test_virtual(Animal *p)
{
p->speak();
return p->getAge();
}
int test_multi()
{
Dog dog(3, 100, 20);
Animal *a = &dog;
Flyable *f = &dog;
test_virtual(a);
f->fly();
return test_cast(a);
}
int main(){
Dog d(1,2,3);
Animal* p = &d;
test_cast(p);
test_virtual(p);
test_multi();
}

RTTI元数据定义#

注意!这里不是在定义类的实现,而是在定义类的typeinfo等信息对象!

我们来看看RTTI的具体实现,非常的有意思!

image-20261101191059790

这里定义了两个基类的typeinfo,可以看到Animal是一个一个指向 __class_type_info 虚表的指针,一个指向类型名字符串 "Animal" 的指针。Flyable同理。

它是全局唯一的、编译期生成的类型描述符,每个类只有一份,放在 .data.rel.ro 里。它描述的是”Animal 这个类型长什么样”,供 typeid、dynamic_cast、异常匹配使用。

而 Dog 对象里的基类子对象是运行时在栈/堆上构造出来的实例,布局是 vptr + 成员数据。子对象里只有一个 vptr 指向 vtable,vtable 里再指向 typeinfo。typeinfo 本身不在 Dog 对象里,是独立的全局数据。

_ZTVN10__cxxabiv117__class_type_infoE+0x10 这个值表示:

  • _ZTVN10__cxxabiv117__class_type_infoE 是 __class_type_info 类的 vtable 符号
  • +0x10 跳过 vtable 头部的 offset-to-top 和 typeinfo 两个槽,指向第一个虚函数槽
  • 把 typeinfo 对象的 vptr 设成这个值,就说明”我是一个 __class_type_info 实例”

所以 _ZTI6Animal 这个对象本质是:

// 编译器生成的等价结构
struct __class_type_info_Animal {
void* vptr = &__class_type_info_vtable[2]; // +0x10 后的地址
const char* name = "Animal";
};

接着来看看Dog

.data.rel.ro:0000000000003308 ; public Dog :
.data.rel.ro:0000000000003308 ; public /* offset 0x0 */ Animal,
.data.rel.ro:0000000000003308 ; public /* offset 0x10 */ Flyable
可以看到在Dog类里,两个基类所处的偏移地址
.data.rel.ro:0000000000003308 WEAK _ZTI3Dog
.data.rel.ro:0000000000003308 ; `typeinfo for'Dog
.data.rel.ro:0000000000003308 _ZTI3Dog DCQ _ZTVN10__cxxabiv121__vmi_class_type_infoE+0x10
.data.rel.ro:0000000000003308 ; DATA XREF: LOAD:0000000000000450↑o
.data.rel.ro:0000000000003308 ; test_cast(Animal *)+1C↑o ...
.data.rel.ro:0000000000003308 ; reference to RTTI's type class
.data.rel.ro:0000000000003310 DCQ _ZTS3Dog ; reference to type's name
.data.rel.ro:0000000000003318 DCD 0 ; attribute
.data.rel.ro:000000000000331C DCD 2 ; count of base classes
.data.rel.ro:0000000000003320 DCQ _ZTI6Animal ; base class' type descriptor
.data.rel.ro:0000000000003328 DCQ 2 ; base class' attributes
.data.rel.ro:0000000000003330 DCQ _ZTI7Flyable ; base class' type descriptor
.data.rel.ro:0000000000003338 DCQ 0x1002 ; base class' attributes
.data.rel.ro:0000000000003340 WEAK _ZTV3Dog
.data.rel.ro:0000000000003340 ; `vtable for'Dog
.data.rel.ro:0000000000003340 _ZTV3Dog DCQ 0 ; DATA XREF: LOAD:00000000000005A0↑o
.data.rel.ro:0000000000003340 ; test_multi(void)+24↑o ...
.data.rel.ro:0000000000003340 ; offset to this
.data.rel.ro:0000000000003348 DCQ _ZTI3Dog ; `typeinfo for'Dog
.data.rel.ro:0000000000003350 off_3350 DCQ _ZN3Dog5speakEv ; Dog::speak(void)
.data.rel.ro:0000000000003358 DCQ _ZN3Dog6getAgeEv ; Dog::getAge(void)
.data.rel.ro:0000000000003360 DCQ _ZN3DogD2Ev ; Dog::~Dog()
.data.rel.ro:0000000000003368 DCQ _ZN3DogD0Ev ; Dog::~Dog()
.data.rel.ro:0000000000003370 DCQ _ZN3Dog3flyEv ; Dog::fly(void)
.data.rel.ro:0000000000003378 DCQ -16 ; offset to this
.data.rel.ro:0000000000003380 DCQ _ZTI3Dog ; `typeinfo for'Dog
.data.rel.ro:0000000000003388 off_3388 DCQ _ZThn16_N3Dog3flyEv ; `non-virtual thunk to'Dog::fly(void)
.data.rel.ro:0000000000003390 DCQ _ZThn16_N3DogD1Ev ; `non-virtual thunk to'Dog::~Dog()
.data.rel.ro:0000000000003398 DCQ _ZThn16_N3DogD0Ev ; `non-virtual thunk to'Dog::~Dog()
.data.rel.ro:00000000000033A0 WEAK _ZTV6Animal
.data.rel.ro:00000000000033A0 ; `vtable for'Animal
.data.rel.ro:00000000000033A0 _ZTV6Animal DCQ 0 ; DATA XREF: LOAD:00000000000003D8↑o
.data.rel.ro:00000000000033A0 ; offset to this
.data.rel.ro:00000000000033A8 DCQ _ZTI6Animal ; `typeinfo for'Animal
.data.rel.ro:00000000000033B0 DCQ __cxa_pure_virtual
.data.rel.ro:00000000000033B8 DCQ _ZN6Animal6getAgeEv ; Animal::getAge(void)
.data.rel.ro:00000000000033C0 DCQ _ZN6AnimalD2Ev ; Animal::~Animal()
.data.rel.ro:00000000000033C8 DCQ _ZN6AnimalD0Ev ; Animal::~Animal()
.data.rel.ro:00000000000033D0 WEAK _ZTV7Flyable
.data.rel.ro:00000000000033D0 ; `vtable for'Flyable
.data.rel.ro:00000000000033D0 _ZTV7Flyable DCQ 0 ; DATA XREF: LOAD:00000000000003F0↑o
.data.rel.ro:00000000000033D0 ; offset to this
.data.rel.ro:00000000000033D8 DCQ _ZTI7Flyable ; `typeinfo for'Flyable
.data.rel.ro:00000000000033E0 DCQ __cxa_pure_virtual
.data.rel.ro:00000000000033E8 DCQ _ZN7FlyableD2Ev ; Flyable::~Flyable()
.data.rel.ro:00000000000033F0 DCQ _ZN7FlyableD0Ev ; Flyable::~Flyable()
.data.rel.ro:00000000000033F0 ; .data.rel.ro ends
.data.rel.ro:00000000000033F0

我们将上表简化一下

_ZTI3Dog
DCQ _ZTVN10__cxxabiv121__vmi_class_type_infoE+0x10
DCQ _ZTS3Dog
DCD 0
DCD 2
DCQ _ZTI6Animal
DCQ 2
DCQ _ZTI7Flyable
DCQ 0x1002

先回顾一下符号:

符号含义
_ZTI6AnimalAnimal 的 typeinfo
_ZTI7FlyableFlyable 的 typeinfo
_ZTI3DogDog 的 typeinfo
_ZTS3DogDog 的类型名称字符串
_ZTV3DogDog 的虚表

这里:

_ZTI3Dog
DCQ _ZTVN10__cxxabiv121__vmi_class_type_infoE+0x10

表示 Dog 使用的是:

__vmi_class_type_info

为什么不是普通的 __class_type_info?

因为 Dog 有多个直接基类。

在 Itanium C++ ABI 中,RTTI 类型描述大致分为:

  • __class_type_info:普通类。
  • __si_class_type_info:符合条件的单继承类。
  • __vmi_class_type_info:多重继承或虚继承相关的类。

所以你看到 __vmi_class_type_info,就应该想到:这个类的 RTTI 中需要记录基类关系。

再来根据上面简化的代码拆一下_ZTI3Dog 的定义

字段值含义
flags0类继承属性标志
base_count2有两个直接基类
base[0].type_ZTI6Animal第一个基类是 Animal
base[0].offset_flags2Animal 子对象偏移 0,public 继承
base[1].type_ZTI7Flyable第二个基类是 Flyable
base[1].offset_flags0x1002Flyable 子对象偏移 0x10,public 继承

这里最重要的是 offset_flags。

它不是简单的偏移,而是一个打包字段:

高位:基类子对象偏移
低 8 位:继承属性标志
2 = 0x00000002

低 8 位是 0x02,表示 public 继承;偏移为 0。

再看:

0x1002

拆开:

0x1002>>8得到0x10

所以:

  • 偏移:0x10
  • 属性:public 继承

这就直接验证了类定义:

class Dog : public Animal, public Flyable

Animal 位于偏移 0,Flyable 位于偏移 0x10。

后面就是Dog的两端虚表

_ZTV3Dog
3340 DCQ 0
3348 DCQ _ZTI3Dog
3350 DCQ Dog::speak
3358 DCQ Dog::getAge
3360 DCQ Dog::~Dog()
3368 DCQ Dog::~Dog()
3370 DCQ Dog::fly
3378 DCQ -16
3380 DCQ _ZTI3Dog
3388 DCQ thunk to Dog::fly
3390 DCQ thunk to Dog::~Dog()
3398 DCQ thunk to Dog::~Dog()

先理解一个重要概念:

_ZTV3Dog 是一个虚表组(vtable group),里面可以包含主虚表和次要虚表。

1. 主虚表#

3340 DCQ 0
3348 DCQ _ZTI3Dog
3350 DCQ Dog::speak
3358 DCQ Dog::getAge
3360 DCQ Dog::~Dog()
3368 DCQ Dog::~Dog()
3370 DCQ Dog::fly

这里有两个容易混淆的概念:

  • _ZTV3Dog 的起始地址是 0x3340。
  • 对象的 vptr 指向的是虚表的 address point(地址点),这里是 0x3350。

也就是说:

vptr = 0x3350;

而不是:

vptr = 0x3340;

为什么?

因为在 address point 之前还有 ABI 定义的元数据:

0x3340:offset-to-top
0x3348:typeinfo
0x3350:虚函数入口开始

你可以记住:

vptr 通常指向虚表地址点,而不一定指向虚表符号的开头。

所以特喵的虚表里都藏了本类的元数据,牛皮!???

2. 次要虚表#

再看:

3378 DCQ -16
3380 DCQ _ZTI3Dog
3388 DCQ thunk to Dog::fly
3390 DCQ thunk to Dog::~Dog()
3398 DCQ thunk to Dog::~Dog()

这就是 Flyable 对应的次要虚表。

为什么需要它?

因为:

class Dog : public Animal, public Flyable

Dog 对象中存在两个基类子对象:

Dog
├── Animal
└── Flyable

当通过:

Flyable* f = &dog;
f->fly();

调用虚函数时,f 指向的是 Flyable 子对象。

因此,Flyable 子对象需要有自己的 vptr,指向与 Flyable 接口对应的次要虚表。

在main代码中:

v6 = &off_3388;

这就对应了第二个 vptr。

3. 为什么次要虚表的 offset-to-top 是 -16?#

这是一个非常重要的细节。

次要虚表开头:

3378 DCQ -16

这个字段叫:

offset-to-top(this)

意思是:

从当前虚表所对应的基类子对象位置,回到完整对象起始位置,需要移动多少字节。

Flyable 子对象在 Dog 中的偏移是:

+0x10

所以反过来回到 Dog 起始地址:

-0x10

也就是-16

内存布局
Dog起始地址
|
| 偏移0x10
Flyable 子对象地址

4、thunk:为什么 Flyable 的虚表不直接指向 Dog::fly?#

次要虚表中有:

_ZThn16_N3Dog3flyEv

IDA 注释:

non-virtual thunk to Dog::fly(void)

这个符号可以拆成:

_ZThn16_...

表示一个需要对 this 做负 16 字节调整的非虚 thunk。

为什么要调整?

先看:

Dog dog(3, 100, 20);
Flyable* f = &dog;
f->fly();

假设 Dog 对象起始地址为:

0x1000

那么:

Dog* = 0x1000
Flyable* = 0x1010

调用:

f->fly();

传入函数的 this 是:

0x1010

但是 Dog::fly() 要访问:

speed += 1;

speed 位于完整 Dog 对象偏移 0x18。

如果直接将 0x1010 当作 Dog 起始地址,那么访问 this + 0x18 就会访问错误位置。

所以需要先调整:

0x1010 - 0x10 = 0x1000

然后才能执行真正的:

Dog::fly();

典型 thunk 可能长这样:

SUB X0, X0, #0x10
B Dog_fly

这两条指令的含义:

  1. 将 this 从 Flyable 子对象地址调整为完整 Dog 对象地址。
  2. 跳转到真正的 Dog::fly()。

这就是 thunk。

在 IDA 中,如果你看到一个短函数只做 this 加减,然后跳转到另一个函数,要优先考虑它是不是 C++ ABI 生成的 thunk。

同理,你看到的:

_ZThn16_N3DogD1Ev
_ZThn16_N3DogD0Ev

也是用于从 Flyable 子对象视角调用 Dog 析构函数时进行 this 调整的 thunk。

补充一下析构函数的 ABI 名称:

  • D0:Deleting Destructor,删除析构函数。
  • D1:Complete Object Destructor,完整对象析构函数。
  • D2:Base Object Destructor,基类子对象析构函数。

主虚表中显示了 D2 和 D0,而次要虚表显示了 D1 和 D0 的 thunk。这里可能涉及编译器对析构函数实现的合并或符号别名,不能只根据 IDA 的注释就断定它们是完全独立的函数实现。

所以Dog的内存布局如下

偏移 内容 说明
0x00 vptr (主表) 指向 _ZTV3Dog @0x3340,Animal 子对象的 vptr
0x08 Animal::age int,Animal 的数据成员
0x0C padding
0x10 vptr (次表) 指向 _ZTV3Dog 第二块 @0x3378,Flyable 子对象的 vptr
0x18 Flyable::speed int,Flyable 的数据成员
0x1C padding
0x20 Dog::weight int,Dog 自己的数据成员
0x24 padding

main函数里的类型转换#

v5 = 1; 对应dog.age = 1;
v4 = off_3350; 第一个 vptr
v6 = &off_3388; 第二个 vptr
v7 = 0x300000002LL; //转成小端序 02 00 00 00 03 00 00 00 也就是对应的2和3参数
所以上面这段代码实际上是在栈上构造一个 Dog 对象的内存布局
__dynamic_cast(
&v4,
&typeinfo for Animal,
&typeinfo for Dog,
0
);

参数含义:

参数你的值含义
sub&v4当前 Animal 子对象地址
srcAnimal typeinfo源类型
dstDog typeinfo目标类型
src2dst_offset0编译器提供的源基类在目标类中的偏移提示

最后一个参数特别容易误解。

这里的 0 表示:

Animal 基类子对象在 Dog 对象中的偏移为 0。

它不是说转换后指针需要加 0,也不是动态转换失败的标志。

在 Itanium ABI 中,这个参数还可能使用负值表示不同的提示状态,例如没有可用偏移提示等。

转换成功后,返回的是完整 Dog 对象的地址。

最后则是纯虚函数的实现,这里不多说了。

_ZTV6Animal
DCQ 0
DCQ _ZTI6Animal
DCQ __cxa_pure_virtual
DCQ Animal::getAge
DCQ Animal::~Animal()
DCQ Animal::~Animal()

最后一个小问题:为什么Dog里只有两张表?#

先给结论:

Dog 有两个多态基类:Animal 和 Flyable,因此在这个典型 ABI 布局下,Dog 对象中有两个 vptr,分别指向 Dog 的主虚表和次级虚表。

三个概念

  1. 类的虚表(vtable)。
  2. 对象里的虚表指针(vptr)。
  3. 基类自己的虚表与派生类的虚表。

它们不是一回事。

1. Animal、Flyable、Dog 都有自己的虚表吗?#

是的,作为独立的多态类,它们都可以有自己的虚表。

例如:

class Animal {
public:
virtual void speak() = 0;
virtual int getAge() { return 0; }
virtual ~Animal() {}
};
class Flyable {
public:
virtual void fly() = 0;
virtual ~Flyable() {}
};
class Dog : public Animal, public Flyable {
public:
void speak() override {}
int getAge() override { return 1; }
void fly() override {}
~Dog() override {}
};

可以把它们理解成:

Animal 的虚表

Animal 类型对象使用

Animal vtable
├── offset-to-top
├── typeinfo: Animal
├── speak → __cxa_pure_virtual
├── getAge → Animal::getAge
└── destructor

Flyable 的虚表

Flyable 类型对象使用

Flyable vtable
├── offset-to-top
├── typeinfo: Flyable
├── fly → __cxa_pure_virtual
└── destructor

Dog 的虚表组(vtable group)

Dog 类型对象使用,包含主表和次表

Dog vtable group
├── 主虚表(Animal 子对象对应)
│ ├── Dog::speak
│ ├── Dog::getAge
│ └── Dog destructor
│
└── 次级虚表(Flyable 子对象对应)
├── thunk → Dog::fly
└── thunk → Dog destructor

在 .data.rel.ro 里看到 Animal、Flyable、Dog 各自的虚表数据是正常的。

但是,Dog 对象本身不需要同时保存 Animal、Flyable 和 Dog 三套独立的 vptr。

Dog 对象只需要保存两个 vptr:

  • 第一个 vptr:指向 Dog 的主虚表。
  • 第二个 vptr:指向 Dog 的次级虚表。

因为 Dog 的主虚表已经覆盖了 Animal 子对象的虚函数调用,次级虚表则负责 Flyable 子对象的虚函数调用。

vtable 是按子对象分配的,不是按类。一个子对象只要”有虚函数且是某个继承链里最派生视图的起点”,就带一个 vptr。

只有 Animal 和 Flyable 这两个基类子对象带 vptr。Dog 自己没有独立的子对象——Dog 的成员(weight)直接挂在对象末尾,不单独引入 vptr。

是什么谁有数量
vtable函数指针数组,全局静态数据每个有虚函数的类一张Animal 1 张、Flyable 1 张、Dog 2 张(按子对象)
vptr对象里的一个指针,指向某张 vtable每个含 vptr 的子对象一个Animal 对象 1 个、Flyable 对象 1 个、Dog 对象 2 个

注意看,是类和对象的区别,类里有表,对象有指针。只要是有虚函数的类就会有虚函数表。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

cpp(十一)——虚函数
https://fatdog.20060113.xyz/posts/cpp-again11/
作者
神秘大胖狗
发布于
2026-11-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00