继承和多继承反汇编

继承和多继承反汇编

识别类与类之间的关系

cpp 复制代码
#include <stdio.h>
class Base
{ // 基类定义
  public:
    Base()
    {
        printf("Base\n");
    }
    ~Base()
    {
        printf("~Base\n");
    }
    void setNumber(int n)
    {
        base = n;
    }
    int getNumber()
    {
        return base;
    }

  public:
    int base;
};
class Derive : public Base
{ // 派生类定义
  public:
    void showNumber(int n)
    {
        setNumber(n);

        derive = n + 1;
        printf("%d\n", getNumber());
        printf("%d\n", derive);
    }

  public:
    int derive;
};
int main(int argc, char *argv[])
{
    Derive derive;
    derive.showNumber(argc);
    return 0;
}

下面这张图片展示了main函数,derive构造函数和析构函数的反汇编代码:

在Derive类的定义中,是没有显式的构造函数和析构函数的,但是编译器帮我们生成了默认的构造函数和析构函数。因为初始化Derive对象需要先初始化Base对象,故需要调用Base类的构造函数,这个构造函数需要在Derive的构造函数中调用,所以编译器为我们生成了默认的构造函数。

当子类对象被销毁时,其父类也同时被销毁,为了可以调用父类的析构函数,编译器为子类提供了默认的析构函数。在子类的析构函数中,析构函数的调用顺序与构造函数相反,先执行自身的析构代码,再执行父类的析构代码。

可以看到,在derive的构造函数中,调用了base的构造函数;在derive的析构函数中,调用了base的析构函数。

继承的情况下对象的内存分布

cpp 复制代码
#include <stdio.h>

class Base
{ // 基类定义
  public:
    Base()
    {
        printf("Base\n");
    }
    ~Base()
    {
        printf("~Base\n");
    }
    void setNumber(int n)
    {
        base = n;
    }
    int getNumber()
    {
        return base;
    }

  public:
    int base;
};

class Member
{
  public:
    Member()
    {
        member = 0;
    }
    int member;
};

class Derive : public Base
{
  public:
    Derive() : derive(1)
    {
        printf("使用初始化列表\n");
    }

  public:
    Member member; // 类中定义其他对象作为成员
    int derive;
};
int main(int argc, char *argv[])
{
    Derive derive;
    return 0;
}

反汇编Derive类的构造函数:

可以看到,在Derive类的构造函数中,先调用了Base类的构造函数,然后是Member类的构造函数,最后再执行Derive构造函数的代码(执行初始化列表,调用printf等)。

在内存中,Derive对象的内存布局是这样的:

虚函数表的初始化与虚函数的调用

cpp 复制代码
#include <stdio.h>

class Person
{ // 基类------"人"类
  public:
    Person()
    {
    }
    virtual ~Person()
    {
    }
    virtual void showSpeak()
    {
    }
};
class Chinese : public Person
{ // 中国人:继承自人类
  public:
    Chinese()
    {
    }
    virtual ~Chinese()
    {
    }
    virtual void showSpeak()
    { // 覆盖基类虚函数
        printf("Speak Chinese\r\n");
    }
};
class American : public Person
{ // 美国人:继承自人类
  public:
    American()
    {
    }
    virtual ~American()
    {
    }
    virtual void showSpeak()
    { // 覆盖基类虚函数
        printf("Speak American\r\n");
    }
};
class German : public Person
{ // 德国人:继承自人类
  public:
    German()
    {
    }
    virtual ~German()
    {
    }
    virtual void showSpeak()
    { // 覆盖基类虚函数
        printf("Speak German\r\n");
    }
};
void speak(Person *person)
{ // 根据虚表信息获取虚函数首地址并调用
    person->showSpeak();
}
int main(int argc, char *argv[])
{
    Chinese chinese;
    American american;
    German german;
    speak(&chinese);
    speak(&american);

    speak(&german);
    return 0;
}

我们来看speak函数的反汇编代码:

我们传递给speak函数的参数是一个对象的地址(其实就是this),speak函数内部先把这个地址保存在自身的栈帧中,然后取出该地址中保存的内容(this保存了虚表的首地址),通过这个地址来访问虚表,进而调用虚函数showSpeak。

在vc++ 64位环境下,一个地址的大小是8个字节,而showSpeak位于虚表的第二项,故而是call [rax+8]。

那么,在构造函数中是如何设置虚表以实现多态的呢?以Chinese类的构造函数为例,看反汇编代码:

Chinese类的构造函数中调用了Person类的构造函数,并传递了当前对象的this指针作为参数(虚表首地址),我们反汇编Person类的构造函数:

可以看到,由于Person类的构造函数先于Chinese类的构造函数调用,所以虚表的首地址先被初始化为了Person类的虚表首地址,待到Person类的构造函数结束返回到Chinese类的构造函数中后,虚表首地址才被初始化为了Chinese类的首地址。

为什么要这样设计?如果在父类的构造函数中虚表的首地址是子类的,那么父类的构造函数内部就可以访问子类重写过的虚函数,这可能会产生数据访问的危险(因为子类对象尚未构造完成)。

同样一Chinese类为例,我们来看看析构函数中对虚表做了什么:

以上是Chinese类的析构函数反汇编代码。可以看到,它先把虚表的首地址设置为了Chinese类的虚表首地址,然后再调用了Person类的析构函数。

我们接下来看看Person类的析构函数:

在Person类的析构函数里面,把虚表的首地址设置为了Person类的虚表首地址。

为什么要这样设计?这和构造函数中对虚表指针进行操作的原因是一样的。都是为了避免访问到不属于该类的虚函数,从而避免对数据进行不安全的操作。

多重继承

多重继承时的构造

在多重继承的情况下,派生类对象的内存分布情况又是什么样的呢?

cpp 复制代码
#include <stdio.h>
class Sofa
{
  public:
    Sofa()
    {
        color = 2;
    }
    virtual ~Sofa()
    { // 沙发类虚析构函数
        printf("virtual ~Sofa()\n");
    }
    virtual int getColor()
    { // 获取沙发颜色
        return color;
    }
    virtual int sitDown()
    { // 沙发可以坐下休息
        return printf("Sit down and rest your legs\r\n");
    }

  protected:
    int color; // 沙发类成员变量
};
// 定义床类
class Bed
{

  public:
    Bed()
    {
        length = 4;
        width = 5;
    }
    virtual ~Bed()
    { // 床类虚析构函数
        printf("virtual ~Bed()\n");
    }
    virtual int getArea()
    { // 获取床面积
        return length * width;
    }
    virtual int sleep()
    { // 床可以用来睡觉
        return printf("go to sleep\r\n");
    }

  protected:
    int length; // 床类成员变量
    int width;
};
// 子类沙发床定义,派生自Sofa类和Bed类
class SofaBed : public Sofa, public Bed
{
  public:
    SofaBed()
    {
        height = 6;
    }
    virtual ~SofaBed()
    { // 沙发床类的虚析构函数
        printf("virtual ~SofaBed()\n");
    }
    virtual int sitDown()
    { // 沙发可以坐下休息
        return printf("Sit down on the sofa bed\r\n");
    }
    virtual int sleep()
    { // 床可以用来睡觉
        return printf("go to sleep on the sofa bed\r\n");
    }
    virtual int getHeight()
    {
        return height;
    }

  protected:
    int height;
};
int main(int argc, char *argv[])
{
    SofaBed sofabed;
    return 0;
}

我们先看main的反汇编代码:

这里调用了SofaBed类的构造函数:

我们看到,在SofaBed的构造函数中,先调用了Sofa类的构造函数,将this加上Sofa对象的大小之后,再调用Bed类的构造函数。这和我们在定义SofaBed类时的继承顺序是一样的。这说明在多重继承时,派生类对象的内存分布情况是先按继承的顺序存储父类的数据,再存储自己的数据。

下面的图片展示了sofaBed对象的内存布局:

多重继承时虚表指针如何设置

在SofaBed类的构造函数中还可以发现,它设置了两次虚表指针,但是两次设置的虚表指针是不一样的(无论是虚表指针的内存地址还是指针中存储的地址)。

因为有了两个父类,所以子类在继承时也将它们的虚表指针一起继承了过来,也就有了两个虚表指针。可见,在多重继承中,子类虚表指针的个数取决于继承的父类的个数,有几个父类便会出现几个虚表指针。

我们先查看第一个虚表指针指向的内容:

可以看到,第一个虚表指针指向的虚表(虚函数地址数组)大小为4。我们反汇编这四个虚函数发现,分别是:

txt 复制代码
SofaBed::`scalar deleting destructor'
Sofa::getColor
SofaBed::sitDown
SofaBed::getHeight

这四个虚函数有第一个父类的,也有子类的。父类的虚函数是在父类中定义,但子类没有重写过的。子类的虚函数是子类重写父类的虚函数,以及子类特有的虚函数。

再看看第二个虚表指针指向的虚表:

有三个虚函数:

txt 复制代码
SofaBed::`scalar deleting destructor'
Bed::getArea
SofaBed::sleep

注意看,两个虚表指针指向的虚表中都有SofaBed类的代理析构函数,这是为什么呢?这是实现正确多态析构的关键。当delete一个 Bed*指针(它可能指向一个 Bed对象,也可能指向一个 SofaBed对象)时,系统需要能够正确调用到最终的析构函数(即 SofaBed::~SofaBed)。

通过观察以上两个虚函数表我们得出一下结论:为了减少开销,编译器通常不会为派生类创建全新的、独立的虚表,而是选择其中一个基类的虚表进行"扩展"。 Sofa是继承列表中的第一个基类。编译器倾向于将 SofaBed独有的虚函数(如 getHeight)添加到 Sofa子对象对应的虚表末尾。

总的来说,对于多重继承的内存布局,依据继承的顺序进行分布,先是第一个虚表指针指向的虚表存储着第一个父类的虚函数(没有被子类重写的),以及子类重写过的第一个父类的虚函数,子类独有的虚函数,第一个父类的成员。然后就是第二个虚表指针。。。以此类推。

多重继承时的析构

上面的一小节我们分析多重继承的情况下的构造情况,现在来看看对象的析构,下面的图片展示了SofaBed类的析构函数:

因为具有多个同级父类(多个同时继承的父类),所以在子类中产生了多个虚表指针。在对父类进行析构时,需要设置this指针,用于调用父类的析构函数。因为具有多个父类,所以在析构的过程中调用各个父类的析构函数时,传递的首地址将有所不同,编译器会根据每个父类在对象中占用的空间位置,相应地传入各个父类部分的首地址作为this指针。

析构的顺序仍然和构造的顺序相反。

子类对象转换为父类指针

cpp 复制代码
#include <stdio.h>
class Sofa
{
  public:
    Sofa()
    {
        color = 2;
    }
    virtual ~Sofa()
    { // 沙发类虚析构函数
        printf("virtual ~Sofa()\n");
    }
    virtual int getColor()
    { // 获取沙发颜色
        return color;
    }
    virtual int sitDown()
    { // 沙发可以坐下休息
        return printf("Sit down and rest your legs\r\n");
    }

  protected:
    int color; // 沙发类成员变量
};
// 定义床类
class Bed
{

  public:
    Bed()
    {
        length = 4;
        width = 5;
    }
    virtual ~Bed()
    { // 床类虚析构函数
        printf("virtual ~Bed()\n");
    }
    virtual int getArea()
    { // 获取床面积
        return length * width;
    }
    virtual int sleep()
    { // 床可以用来睡觉
        return printf("go to sleep\r\n");
    }

  protected:
    int length; // 床类成员变量
    int width;
};
// 子类沙发床定义,派生自Sofa类和Bed类
class SofaBed : public Sofa, public Bed
{
  public:
    SofaBed()
    {
        height = 6;
    }
    virtual ~SofaBed()
    { // 沙发床类的虚析构函数
        printf("virtual ~SofaBed()\n");
    }
    virtual int sitDown()
    { // 沙发可以坐下休息
        return printf("Sit down on the sofa bed\r\n");
    }
    virtual int sleep()
    { // 床可以用来睡觉
        return printf("go to sleep on the sofa bed\r\n");
    }
    virtual int getHeight()
    {
        return height;
    }

  protected:
    int height;
};

int main(int argc, char *argv[])
{

    SofaBed sofabed;
    Sofa *sofa = &sofabed;
    Bed *bed = &sofabed;
    return 0;
}

我们主要看main函数中的代码:

对象构造完成之后,编译器取了对象的地址,并将其赋值给栈中的一块内存,就是源代码中对应的sofa指针初始化。随后,编译器生成了一条add指令以跳过第一个父类(Sofa)的内存区域,并将计算得到的地址赋值给栈中的一块内存,就是源代码中bed指针的初始化,

单继承和多继承的比较

这里直接使用钱林松老师书中的内容:

相关推荐
Pure_White_Sword4 天前
bugku-reverse题目-peter的手机
网络安全·ctf·reverse·逆向工程
Jet_586 天前
神庙逃亡(Temple Run)IL2CPP 逆向实战:从 APK 到 Frida 实现角色无敌
unity·il2cpp·逆向工程·frida·android逆向·hook技术·游戏逆向
Jet_589 天前
IDA Pro 远程调试指南:gdbserver / armlinux_server 全流程实战
安卓逆向·逆向工程·远程调试·gdbserver·ida pro·android 调试
幽络源小助理14 天前
逆向工程系统学习资源图谱(2026):从 Windows 内核、安卓安全到游戏协议分析的全栈教程清单
学习·安全·游戏·逆向工程
阿昭L18 天前
结构体和类的反汇编
逆向工程
深念Y23 天前
proxypin抓包工具获得nb实验室VIP(已失效)
游戏·网络安全·抓包·逆向工程·软件逆向·nb实验室·教育软件
Jet_5824 天前
[特殊字符] AndroidReverse101:100 天系统学习 Android 逆向工程(学习路线推荐)
安卓逆向·逆向工程·frida·android逆向·安全研究·apk逆向
智_永无止境24 天前
MyBatisMyBatis的隐形炸弹:selectByExampleWithBLOBs使用不当,让性能下降80%的隐形炸弹
逆向工程·mgb
Logic1011 个月前
深入理解C语言if语句的汇编实现原理:从条件判断到底层跳转
c语言·汇编语言·逆向工程·底层原理·条件跳转·编译器原理·x86汇编