结构体和类的反汇编

结构体和类

c++中结构体和类的唯一区别是成员的默认访问权限不同,其它的方面都是一样的。

对象的内存布局

实例化一个类之后,对象的地址就是类中第一个声明的成员的地址,紧接着的是第二个成员,以此类推。

按照上述说法,对象的大小应该是类中各成员大小的总和,然而事实并非总是如此。

空类

空类中没有任何数据成员,按照该公式计算得出的对象长度为0字节。类型长度为0,则此类的对象不占内存空间。而实际情况是,空类的长度为1字节。如果对象完全不占用内存空间,空类就无法取得实例对象的地址,this指针失效,因此不能被实例化。

而类的定义是由成员数据和成员函数组成的,在没有成员数据的情况下,还可以有成员函数,因此仍然需要做实例化。分配1字节的空间用于类的实例化,这1字节的数据并没有被使用。

内存对齐

当发生了内存对齐的时候,对象的大小也不会等于各成员大小之和。下面说一下内存对齐的规则。

在为结构体和类中的数据成员分配内存时,结构体中当前数据成员类型的长度为M,指定的对齐值为N,那么实际对齐值为q =min(M, N),成员的地址安排在q的倍数上。最后计算对象的大小的时候,还需要让对象的大小是对齐粒度的整数倍。

接下来看一个例子:假设c++要求的对齐值为8字节。

cpp 复制代码
struct Test
{
    short s;
    int n;
};

int main()
{
    Test test;

    return 0;
}

对于test对象,假设其地址为0x08,这是2的整数倍地址,因此s的地址也是0x08,因为int的大小为4字节,而0x0a并不是4的整数倍地址,所以n的地址应该是0x0c。此时计算得test的大小为4+4=8字节。注意这还不是最终的大小。

test的象的大小和c++默认的对齐值相等,是8的整数倍,所以最终大小为8字节。

假设有一个对象大小为10字节,其中最大的成员大小为4字节,此时取4字节作为对齐值,结构体的大小必须是4的整数倍,所以为12字节。

既然有默认的对齐值,就可以在定义结构体时进行调整,在C++中可使用预编译指令#pragma pack(N)调整对齐大小。

当结构体中以数组作为成员时,计算对齐值是根据数组元素的长度,而不是数组的整体大小。

当结构体中出现结构体类型的数据成员时,不会将嵌套的结构体类型的整体长度加入对齐值计算中,而是以嵌套定义的结构体使用的对齐值进行对齐。

this

在调用对象的成员函数的时候,传递给rcx寄存器的参数(x64环境下真正的第一个参数)就是this,它是一个指针,保存了对象的首地址,同时也是对象中第一个成员的地址。

静态数据成员

类的静态数据成员和局部静态变量一样,都是含有特殊作用域的全局变量。它们的初始值会被编写进链接后的可执行文件中,当操作系统把程序加载至内存中时就已经存在了,当程序运行结束之后它们才从内存中消失,所以它们的生命周期和对象的生命周期是不一样的。

静态数据成员和对象是一对多的关系,因此它不参与对象大小的计算中。

对象作为函数参数

对象作为参数传递的时候,根据对象内存布局的不同,编译器会生成不同的传参方式。下面所说的都是按值传递的情况。

在语法上,把对象当作参数传递的时候,会调用拷贝构造函数。如果没有定义拷贝构造函数,编译器会自动帮我们生成一个拷贝构造函数,把传递的对象的内容浅拷贝到形参对象中。

小型对象直接传递

小型对象是只有几个基本数据类型变量作为成员变量的对象。对于这样的对象,在传递给函数的时候是直接传递对象成员变量给函数,也就是说将对象的每个成员像普通参数一样传递给函数。

cpp 复制代码
class MyClass1
{
	public:
		int num1;
		int num2;
};

static void fun1(MyClass1 obj)
{
	obj.num1 = 1;
	obj.num2 = 2;

	printf_s("%d %d\r\n", obj.num1, obj.num2);
}

int main()
{
		MyClass1 obj1;
		obj1.num1 = 1;
		obj1.num2 = 2;
		fun1(obj1);
		
		return 0;
}

对于上面那样的对象,结构简单,它作为参数的传递方式是这样的:

这里直接使用了RCX寄存器保存num1和num2这两个成员变量,传递obj1的时候直接把使用rcx传递。这实际上是将内存中的obj1拷贝到了rcx中,创建了一个obj1的副本作为参数传递给fun1。

同时可以看到,成员变量num1先于num2定义,在传递参数的时候num2比num1先进栈。类对象中数据成员的传参顺序为最先定义的数据成员最后压栈,最后定义的数据成员最先压栈。

对象的体积变大时

如果对象有多个成员变量,该对象在作为参数传递的时候,编译器生成的汇编代码会把这个对象的内存空间,复制到栈上去,然后把栈上对象的起始地址作为参数传递给函数(通过rcx传递)。

被调函数操作参数对象的时候,就是在操作复制得到的那块内存区域。

cpp 复制代码
class MyClass2
{
	public:
		int num1;
		int num2;
		int num3;
		char ch;
};

static void func2(MyClass2 obj)
{
	obj.num1 = 1;
	obj.num2 = 2;
	obj.num3 = 3;
	obj.ch = 'A';

	printf_s("%d %d %d %c\r\n", obj.num1, obj.num2, obj.num3, obj.ch);
}

int main()
{
		MyClass2 obj2;
		obj2.num1 = 1;
		obj2.num2 = 2;
		obj2.num3 = 3;
		obj2.ch = 'a';
		func2(obj2);
		return 0;
}

main函数中的关键代码反汇编如下:

可以查看rsi指向的内存空间:

被调函数内部操作参数的汇编代码如下:

对象中含有数组的情况

这和上面那种情况是一样的处理方式。

对象作为返回值

对象作为返回值的情况和对象作为参数的情况差不多,但是其中又有些不同。

开启优化的情况下

假设接收返回值的变量为a。主调函数会在自己的栈帧中开辟一块空间供a使用,并把a的首地址作为参数传递给被调函数(即使被调函数没有任何参数),被调函数直接在a的内存空间中构造需要返回的对象,不涉及拷贝操作。

未开启优化的情况下

假设接收返回值的变量为a。主调函数会在自己的栈帧中开辟一块空间供a使用,并把a的首地址作为参数传递给被调函数(即使被调函数没有任何参数),被调函数在自己的栈帧上开辟一块内存空间构造需要返回的对象b,并返回b的首地址。主调函数拿到b的首地址之后,会先把b的内存空间复制到一个临时对象c中,然后把c的内存空间复制给a。

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

class MyClass
{
    public:
        int num;
        int arr[10];
};

MyClass getMyClass()
{
    MyClass obj;
    obj.num = 0x42;
    for (size_t i = 0; i < 10; i++)
    {
        obj.arr[i] = i;
    }
    
    return obj;
}

int main(int argc, char const *argv[])
{
    MyClass obj = getMyClass();
    printf_s("%d %d %d\r\n", obj.num, obj.arr[0], obj.arr[9]);

    return 0;
}

先来看看主调函数把接收返回值的对象的首地址传递给被调函数:

接下来看看被调函数在自己的栈帧中构造需要返回的对象:

仔细观察getMyClass的反汇编代码,可以发现它其实就是在main函数中的obj的地址空间上构造需要返回的对象的。构造完成之后,返回了构造完成的对象的首地址(其实就是main传递给getMyClass的参数)。

也就是说,getMyClass函数其实已经在main的obj中构造了对象了。

下面是main函数的反汇编代码,注意观察其中处理getMyClass返回值(RAX)的汇编代码:

可以看到,main函数再次从getMyClass返回的首地址开始复制对象到一个临时对象上,并把这个临时对象拷贝给obj。

没错,这里有做了很多多余的步骤:本来getMyClass返回之后,main中的obj对象就已经初始化完成了,但是main函数依然复制了obj的内存空间构建了一个临时对象,并将该临时对象的内存空间复制到了obj的内存空间中。

为什么会多一次拷贝?原因如下:

  • 调试模式:编译器为了更容易调试,即使传入了目标地址,也可能会在函数内部创建局部对象
  • 确保语义正确:严格按照C++标准语义,先构造局部对象,再拷贝返回
  • 调试器友好:在函数栈帧上有完整的局部对象,方便查看

安全地传递和返回对象

假设我们有一个类:

cpp 复制代码
#include <stdio.h>
class Person
{
    public:
        Person()
        {
            name = new char[40];
        }

        ~Person()
        {
            if (name)
            {
                delete[] name;
                name = nullptr;
            }
        }

        char* getName()
        {
            return name;
        }
    private:
        char* name;
};

void Show(Person person)
{
    printf_s(person.getName());
}

Person* GetPerson()
{
    Person p;
    return &p;
}

int main(int argc, char const *argv[])
{
    Person p;
    Show(p);
    p = GetPerson();

    return 0;
}

在调用Show函数时,我们传递给Show函数的p是以复制的方式传递的,但是p对象内部有name这一个字符指针,复制的时候只是复制了地址。Show函数内部使用的person和main函数的p内部的name的值是一样的。Show函数调用结束后,person会执行析构函数,这会让main中的p的name也被delete掉。

同样在调用GetPerson的时候,GetPerson中的p对象在函数返回之后会执行析构函数,这会让main中的p内部的name的指针被delete掉。

我们可以通过定义拷贝构造函数来解决这样的问题。

相关推荐
深念Y6 天前
proxypin抓包工具获得nb实验室VIP(已失效)
游戏·网络安全·抓包·逆向工程·软件逆向·nb实验室·教育软件
Jet_588 天前
[特殊字符] AndroidReverse101:100 天系统学习 Android 逆向工程(学习路线推荐)
安卓逆向·逆向工程·frida·android逆向·安全研究·apk逆向
智_永无止境8 天前
MyBatisMyBatis的隐形炸弹:selectByExampleWithBLOBs使用不当,让性能下降80%的隐形炸弹
逆向工程·mgb
Logic10123 天前
深入理解C语言if语句的汇编实现原理:从条件判断到底层跳转
c语言·汇编语言·逆向工程·底层原理·条件跳转·编译器原理·x86汇编
阿昭L1 个月前
c++中if语句的反汇编及优化
逆向工程
Eloudy2 个月前
一个逆向工具 Ghidra 在 Linux 上的安装和基本使用
逆向工程
程序猿编码5 个月前
二进制签名查找器(Aho-Corasick 自动机):设计思路与实现原理(C/C++代码实现)
c语言·c++·网络安全·二进制·逆向工程·ac自动机
0xCC说逆向8 个月前
Windows逆向工程提升之IMAGE_EXPORT_DIRECTORY
开发语言·数据结构·windows·安全·网络安全·pe结构·逆向工程
0xCC说逆向8 个月前
Windows逆向工程提升之IMAGE_SECTION_HEADER
汇编·windows·单片机·嵌入式硬件·安全·逆向工程·pe结果