C++逆向之结构体和类

在C++中,结构体和类都具有构造函数、析构函数和成员函数,两者只有一个区别:结构体的访问控制默认为public,而类的默认访问控制是private。对于C++中的结构体而言,public、private、protected的访问控制都是在编译期进行检查,当越权访问时,编译过程中会检查出此类错误并给予提示。编译成功后,程序在执行的过程中不会在访问控制方面做任何检查和限制。因此,在反汇编中,C++中的结构体与类没有分别,两者的原理相同,只是类型名称不同。

对象的内存布局

结构体和类都是抽象的,在真实世界中它们只可以表示某个群体,无法确定这个群体中的某个独立个体,而对象则是群体中独立存在的个体。例如,地球上最智慧的群体生物是人,人便是抽象事物,可以看做是一个类。“人”只能描述这个类型的事物具有哪些特征,而无法得知具体是哪一个人。而在“人”这个类中,如马云等都是独立存在的实体,可被看做是“人”这个类中的实体对象。

人 → 类、结构,抽象概念
马云 → 实例对象,实际存在的事物

由于类是抽象概念,当两个类的特征相同时,它们之间应该是相等的关系。而对象是实际存在的,即使它们之间所包含的数据相同,也不能视为同一个对象,这就如同人类中的两个实体对象,即使他们是一对双胞胎,也不能因为他们的相貌等各方面的特征都相同就将他们描述成一个人。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class CNumber
{
public:
CNumber()
{
m_nOne = 1;
m_nTwo = 2;
}
int GetNumberOne()
{
return m_nOne;
}
int GetNumberTwo()
{
return m_nTwo;
}
private:
int m_nOne;
int m_nTwo;
};
int main(int argc, char* argv[])
{
CNumber Number;
return 0;
}

定义了自定义类型CNumber类,以及该类的实例对象Numer。CNumber类型与C++中提供的int都属于数据类型。在32位下,整形变量大小为4字节。

通过反汇编得知,对象Number在内存中的地址为0x0019FF38,该地址处定义了对象Number的各个数据成员,它们分别存放在地址0x0019FF38与0x0019FF3C处。对象Number中先定义的数据成员在低地址处,后定义的数据成员在高地址处,一次排列。对象对的大小只包含数据成员,类成员函数属于执行代码,不属于类对象的数据。
凡是属于CNumber类型的变量,在内存中都会占据8字节空间。这8字节由类中的两个数据成员组成,它们都是int类型,各自数据长度为4字节。从内布局上看,类与数组非常相似,都是由多个数据元素构成,但类的能力要远远大于数组。类成员的数据类型定义非常广,除本身的对象外,任何已知数据类型都可以在类中定义。
根据以上已知知识,可以总结出如下对象长度计算公式:
对象长度=sizeof(数据成员1) + sizeof(数据成员2) + sizeof(数据成员n)
从表面上看,这个公式没有问题,但对象的大小计算远远没有这么简单。即使类中没有继承和虚函数的定义,仍有三种特殊情况能推翻此公式:空类、内存对齐、静态数据成员。当出现这三种情况时,使用此公式得到的对象长度与实际情况不符。

  • 空类。空类中没有任何数据成员,按照该公式计算得出的对象长度为0字节。类型长度为0,则此类的对象不占据内存空间。而实际情况是,空类的长度为1字节。如果对象完全不占用内存空间,那么空类就无法取得实例对象的地址,this指针失效,因此不能被实例化。而类的定义是由成员数据和成员函数组成。在没有成员数据的情况下,还可以有成员函数,因此仍然需要实例化,分配了1字节的空间用于类的实例化,这1字节的数据并没有被使用。
  • 内存对齐。类和结构体中的数据成员是根据它们在类或结构体中出现的顺序来依次申请内存空间的,由于内存对齐的原因,它们并不一定会像数组那样连续地排列。由于数据类型的不同,因此占用的内存空间大小也会不同。在申请内存时,会遵守一点的规则。

在为结构体和类中数据成员分配内存时,结构体中的当前数据成员类型长度为M,指定的对齐值为N,那么实际对齐值为q = min(M, N),其成员函数的地址安排在q的倍数上。如以下代码:

1
2
3
4
5
struct tag
{
short sShort; //应占2字节内存空间,假设所在地址为0x0012FF74
int nInt; //应占4字节内存空间
}

数据成员sShort的地址为0x0012FF74,类型为short,占2字节内存空间。编译器指定对齐默认值为8,short的长度为2,于是实际的对齐值取较小者2。所以,short被分配在地址0x0012FF74处,此地址是2的倍数,可分配。此时,;轮到为第二个数据成员分配内存,如果分配在sShort后,应在地址0x0012FF76处,但第二个数据成员为int类型,占4字节内存空间,与指定的对齐值比较后,实际对齐值取int类型的长度4,而地址0x0012FF76不是4的倍数,需要插入两个字节填充,以满足对齐条件,因此第二个数据成员被定义在地址0x0012FF78处。

  • 静态数据成员。当类中的数据成员被修饰为静态时,对象的长度计算又会发生变化。虽然静态数据成员在类中被定义,但它与静态局部变量类似,存放的位置和全局变量一致。只是编译器增加了作用域的检查,在作用域之外不可见,同类对象将共同享有静态数据成员的空间。

this指针

在学习C++的过程中,大家都会接触到this指针。在类中没有对this指针的定义,但是在成员函数中却可以使用。许多C++
程序员只知道在编码的过程中有this指针,但不知它从何而来和为何存在。
根据字面含义,this指针应属于指针类型,在232位环境下占4字节大小,保存的数据为地址信息。“this”可翻译为“这个”,因此经过字面的分析可认为this指针中保存了所属对象的首地址。假设type为某个正确定义的结构体或类,member是type中可以访问的成员:

1
2
3
type *p;
//此处省略p的赋值
p->member的地址 = =指针p的地址值 + member在type中的偏移量

举个栗子,如果有以下定义:

1
2
3
4
5
6
7
8
struct A
{
int m_int;
float m_float;
};
struct A a;
struct A *pA = &a;
printf("%p", &pA->m_float);

我们知道pA中保存的地址为0x0012ff00,m_float在结构体内的偏移量为4,于是可以得到:pA->m_float的地址 = 0x0012ff00 + 4 = 0x0012ff04。

接上代码,以下结果是什么?程序会奔溃吗?
printf(“%p”, &((struct A*)NULL)->m_float);
点击跳转至答案

明白结构体和类成员变量的寻址方法后,来看一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CTest
{
public:
void SetNumber(int nNumber) //公有成员函数
{
m_nInt = nNumber;
}
int m_nInt; //公有数据成员
};
int main(int argc, char* argv[])
{
CTest Test;
Test.SetNumber(5); //调用成员函数
printf("CTest : %d", Test.m_nInt); //获取数据成员
return 0;
}

main函数

1
2
3
4
5
6
7
8
0040D4B8  |.  6A 05         push 0x5                                 ;  压入参数5
0040D4BA |. 8D4D FC lea ecx,[local.1] ; 取出对象Test首地址存储ecx中
0040D4BD |. E8 4D3BFFFF call test.0040100F ; 调用成员函数
0040D4C2 |. 8B45 FC mov eax,[local.1] ; 取出对象首地址处4字节数据m_nInt存入eax中
0040D4C5 |. 50 push eax ; 传参
0040D4C6 |. 68 6C2F4200 push test.00422F6C ; ASCII "CTest : %d"
0040D4CB |. E8 40020000 call test.printfgvdbgind_blockeressges ; printf
0040D4D0 |. 83C4 08 add esp,0x8 ; 堆栈平衡

SetNumber函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
00401060 >/> \55            push ebp
00401061 |. 8BEC mov ebp,esp
00401063 |. 83EC 44 sub esp,0x44
00401066 |. 53 push ebx
00401067 |. 56 push esi ; test.<ModuleEntryPoint>
00401068 |. 57 push edi
00401069 |. 51 push ecx ; 注意,ecx保存了对象Test的首地址
0040106A |. 8D7D BC lea edi,[local.17]
0040106D |. B9 11000000 mov ecx,0x11
00401072 |. B8 CCCCCCCC mov eax,0xCCCCCCCC
00401077 |. F3:AB rep stos dword ptr es:[edi]
00401079 |. 59 pop ecx ; 还原ecx
;将ecx中的数据存入local.1地址处,该地址处保存着调用对象的首地址,即this指针
0040107A |. 894D FC mov [local.1],ecx
;取出对象的地址放入eax中
0040107D |. 8B45 FC mov eax,[local.1]
;取出参数中的数据并放入arg.1中
00401080 |. 8B4D 08 mov ecx,[arg.1]
;这里是给成员m_nInt赋值。由于eax是对象的首地址,成员m_nInt的偏移量为0,
00401083 |. 8908 mov dword ptr ds:[eax],ecx
00401085 |. 5F pop edi
00401086 |. 5E pop esi
00401087 |. 5B pop ebx
00401088 |. 8BE5 mov esp,ebp
0040108A |. 5D pop ebp
0040108B \. C2 0400 retn 0x4

代码演示了对象调用成员的方法,以及取出数据成员的过程。在使用默认的调用约定时,在调用成员函数的过程中,编译器做了个“小动作”:利用寄存器ecx保存了对象的首地址,并以寄存器传参的方式传递到成员函数中,这便是this指针的由来。由此可见,所有成员函数(非静态成员函数)都有一个隐藏的参数,即自身类型的指针,这便是this指针,将这样的默认调用约定称为thiscall。
识别this指针的关键点是在函数的调用过程中使用了ecx作为第一个参数,并且在ecx中保存的数据为对象的首地址,但并非所有的this指针的传递都是如此。在以上代码中。成员函数SetNumber的调用方式为thiscall。thiscall的栈平衡方式与_stdcall相同,都是由被调用方负责平衡。但是,两者在传参的过程中却不一样,声明为thiscall的函数,第一个参数使用寄存器ecx传递,而非通过栈顶传递。而且thiscall并不属于关键字,它是C++成员函数特有的调用方式,在C语言中是没有这种调用方式的。由于在C++环境下thiscall不属于关键字,因此函数无法显示声明为thiscall调用方式,而类的成员函数默认是thiscall调用方式。所以,在分析过程中,如果看到某函数使用ecx传参,且ecx中保留了对象的this指针,以及在函数实现代码内,存在this指针参与的寄存器相对间接访问方式,如[reg+8],即可怀疑此函数为成员函数。
当使用其他调用方式(如__stdcall)时,this指针将不再使用ecx传递,而是改用栈传递。

静态数据成员

当类中定义了静态数据成员时,由于静态数据成员和静态变量原理相同(是一个含有作用域的特殊全局变量),因此该静态数据成员的初值会被写入编译链接后的执行文件中。当程序被加载时,操作系统将执行文件中的数据读到对应的内存单元里,静态数据成员便已经存在,而这时类并没有实例对象。所以静态数据成员和对象之间的生命周期不同,并且静态数据成员也不属于某一对象,与对象之间是一对多的关系。静态数据成员仅仅和类相关,和对象无关,多个对象可以共同拥有同一个静态数据成员。
在成员函数中,由于静态数据成员属于全局变量,并且不属于任何对象,因此访问时无需this指针。而普通的数据成员属于对象所有,访问时需要使用this指针。

对象作为函数参数

对象作为函数的参数时,其传递过程较为复杂,传递方式比较独特。其传参过程与数组不同:数组变量的名称代表数组的首地址,而对象的变量名称却不能代表对象的首地址,传参时不会像数组那样以首地址作为参数传递,而是先将对象中的所有数据进行备份(复制)。将复制的数据作为形参传递到调用函数中使用。
在基本的数据类型中,除双精度浮点类型外,其他所有数据类型在3位下都不超过4字节大小,使用一个栈元素即可完成数据的复制和传递。而类对象是自定义数据类型,是除自身外的所有数据类型的集合,各个对象的长度不定。那么,对象是如何被复制和传递的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CTest
{
public:
int m_nOne;
int m_nTwo;
};
void showTest(CTest Test)
{
printf("%d %d",Test.m_nOne, Test.m_nTwo);
}
int main(int argc, char* argv[])
{
CTest Test;
Test.m_nOne = 1;
Test.m_nTwo = 2;
showTest(Test);
return 0;
}

main函数

1
2
3
4
5
6
7
8
00401078  |.  C745 F8 01000>mov [local.2],0x1                        ;  Test.m_nOne = 1
0040107F |. C745 FC 02000>mov [local.1],0x2 ; Test.m_nTwo = 2
00401086 |. 8B45 FC mov eax,[local.1]
00401089 |. 50 push eax ; 传入数据成员m_nOne
0040108A |. 8B4D F8 mov ecx,[local.2]
0040108D |. 51 push ecx ; 传入数据成员m_nTwo
0040108E |. E8 81FFFFFF call test.00401014 ; showTest
00401093 |. 83C4 08 add esp,0x8

showTest函数

1
2
3
4
5
6
7
0040D4B8  |.  8B45 0C       mov eax,[arg.2]                          ;  取出成员函数m_nTwo作为printf函数的第三个参数
0040D4BB |. 50 push eax
0040D4BC |. 8B4D 08 mov ecx,[arg.1] ; 取出成员函数m_nOne作为printf函数的第二个参数
0040D4BF |. 51 push ecx
0040D4C0 |. 68 6C2F4200 push test.00422F6C ; ASCII "%d %d"
0040D4C5 |. E8 46020000 call test.printfgvdbgind_blockeressges
0040D4CA |. 83C4 0C add esp,0xC

类CTest的体积不大,只有两个数据成员,编译器在调用函数传参的过程中分别将对象的两个成员依次压栈,也就是直接将两个数据成员当成两个int类型数据,并将他们当做printf函数的参数。同理,它们也是一份复制数据,除数据相同外,与对象中的两个数据成员没有关系。
类对象中的数据成员的传参顺序为:最先定义的数据成员最后压栈,最后定义的数据成员最先压栈。当类的体积过大,或者其中定义有数组类型的数据成员时,会将数组的首地址作为参数压栈吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CFunTest
{
public:
int m_nOne;
int m_nTwo;
char m_szName[32];
};

void showFunTest(CFunTest FunTest)
{
printf("%d %d %s\n", FunTest.m_nOne, FunTest.m_nTwo, FunTest.m_szName);
}

int main(int argc, char* argv[])
{
CFunTest FunTest;
FunTest.m_nOne = 1;
FunTest.m_nTwo = 2;
strcpy(FunTest.m_szName, "Name");
showFunTest(FunTest);
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0040D7A8  |.  C745 D8 01000>mov [local.10],0x1                       ;  数据成员m_nOne赋值
0040D7AF |. C745 DC 02000>mov [local.9],0x2 ; 数据成员m_nTwo
0040D7B6 |. 68 A82F4200 push test.00422FA8 ; ASCII "Name"
0040D7BB |. 8D45 E0 lea eax,[local.8]
0040D7BE |. 50 push eax
0040D7BF |. E8 EC5EFFFF call test.strcpysgzeHeaderListle_pages ; strcpy
0040D7C4 |. 83C4 E0 add esp,-0x20 ; 调整栈顶,抬高32字节
0040D7C7 |. B9 0A000000 mov ecx,0xA ; 设置循环次数10次
0040D7CC |. 8D75 D8 lea esi,[local.10] ; 获取对象首地址并保存到esi中
0040D7CF |. 8BFC mov edi,esp ; 设置edi为当前栈顶
;执行10次4字节内存复制,将esi所指向的数据复制到edi中。
0040D7D1 |. F3:A5 rep movs dword ptr es:[edi],dword ptr ds>
0040D7D3 |. E8 4138FFFF call test.00401019 ; showFunTest
0040D7D8 |. 83C4 28 add esp,0x28

在传递对象的过程中使用了“add esp,-0x20”来调整栈顶指针esp,参数变量在传递时,需要向低地址调整栈顶指针esp,此处申请的32字节栈空间,加上strcpy未平衡的8字节参数空间,都用于存放参数对象FunTest的数据。将对象FunTest中的数据一次复制到申请的栈空间中,对象FunTest的内存布局如下:

图中,0x0019FEA4为对象FunTest的首地址,第一个4字节的数据为数据成员由此向后,第二个4字节的数据为数据成员m_nTwo,以0x19FEAC为起始地址,后面的第一个32字节数据为数组成员m_szName。对象FunTest占用的内存大小为40字节,而代码却只为栈空间申请了32字节大小。以对象的首地址为起始点,使用指令“rep movs”复制了40字节的数据,比栈空间申请的大小多出了8字节。为什么申请栈空间时少了8字节呢?
先看一下之前所调用的函数strcpy,该函数的调用方式为__cdecl方式,当函数调用结束后,并没有平衡参数使用的栈顶。函数strcpy有两个参数,正好使用了8字节的栈空间。在函数showFunTest的调用过程中,重新利用这8字节栈空间,完成了对对象FunTest中的数据的复制。当函数showFunTest调用结束后,调用指令“add esp,0x28”平衡了该函数参数所使用的40字节的栈空间。
之前示例代码定义的类都没有定义构造函数和析构函数。由于对象作为参数在传递过程中会制作一份对象的复制数据,当向对象分配内存时,如果有构造函数,编译器会再调用一次构造函数,并做一些初始化工作。当代码执行到作用域结束时,局部对象将被销毁,而对象中可能会涉及资源释放的问题,同样,编译器也会再调用一次局部变量的析构函数,从而完成资源数据的释放。
当对象作为函数的参数时,由于重新定义了对象,等同于又定义了一个对象,在某些情况下会调用特殊的构造函数-拷贝构造函数。当函数退出时,复制的对象作为函数内的局部变量,将会被销毁。当析构函数存在时,则会调用析构函数,这时问题便出现了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class CMyString
{
public:
CMyString()
{
m_pString = new char[10];
if(m_pString == NULL)
{
return;
}
strcpy(m_pString, "Hello");
}
~CMyString()
{
if(m_pString != NULL)
{
delete m_pString;
m_pString = NULL;
}
}
char *GetString()
{
return m_pString;
}
private:
char * m_pString;
};
void showMyString(CMyString MyStringCpy)
{
printf(MyStringCpy.GetString());
}
int main(int argc, char* argv[])
{
CMyString MyString;
showMyString(MyString);
return 0;
}

main()

1
2
3
4
5
6
7
8
9
10
11
12
13
004011AD   .  8D4D F0       lea ecx,dword ptr ss:[ebp-0x10]          ;  获取对象的首地址,放入ecx中作为this指针
004011B0 . E8 5AFEFFFF call test.0040100F ; 构造函数
004011B5 . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0 ; 记录同一作用域内该类的对象个数
;MyString对象长度为4,一个寄存器单元刚好能存放
;于是eax获取对象首地址处4字节的数据,即数据成员m_pString
004011BC . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
004011BF . 50 push eax
004011C0 . E8 45FEFFFF call test.0040100A
004011C5 . 83C4 04 add esp,0x4
;main函数结束
004011CF . C745 FC FFFFF>mov dword ptr ss:[ebp-0x4],-0x1 ; 由于对象被释放,修改对象个数
004011D6 . 8D4D F0 lea ecx,dword ptr ss:[ebp-0x10] ; 获取对象首地址,传入ecx作为this指针
004011D9 . E8 3BFEFFFF call test.00401019 ; 调用析构函数

void showMyString(CMyString MyStringCpy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
00401040 > > \55            push ebp
00401041 . 8BEC mov ebp,esp
=================================异常链处理===============================
00401043 . 6A FF push -0x1
00401045 . 68 59344100 push test.00413459 ; SE 处理程序安装
0040104A . 64:A1 0000000>mov eax,dword ptr fs:[0]
00401050 . 50 push eax
00401051 . 64:8925 00000>mov dword ptr fs:[0],esp
=================================异常链处理================================
00401058 . 83EC 40 sub esp,0x40
0040105B . 53 push ebx
0040105C . 56 push esi ; test.<ModuleEntryPoint>
0040105D . 57 push edi
0040105E . 8D7D B4 lea edi,dword ptr ss:[ebp-0x4C]
00401061 . B9 10000000 mov ecx,0x10
00401066 . B8 CCCCCCCC mov eax,0xCCCCCCCC
0040106B . F3:AB rep stos dword ptr es:[edi]
0040106D . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0 ; 作用域内的对象个数
;取参数1的数据成员m_pString的地址并保存到ecx中作为this指针
;注意,此m_pString地址非main函数中的对象MyString的首地址
00401074 . 8D4D 08 lea ecx,dword ptr ss:[ebp+0x8]
00401077 . E8 89FFFFFF call test.00401005 ; 调用成员函数GetString
0040107C . 50 push eax ; 将返回eax中保存的字符串的首地址作为参数压栈
0040107D . E8 1E020000 call test.printfgvdbgind_blockeressges
00401082 . 83C4 04 add esp,0x4
00401085 . C745 FC FFFFF>mov dword ptr ss:[ebp-0x4],-0x1 ; 由于对象被释放,修改对象个数
0040108C . 8D4D 08 lea ecx,dword ptr ss:[ebp+0x8] ; 取参数1的地址,作为this指针调用析构函数
0040108F . E8 85FFFFFF call test.00401019

当对象作为参数被传递时,参数MyStringCpy复制了对象MyString中的数据成员m_pString,产生了两个CMyString类的对象。由于没有编写拷贝构造函数,因此在传参的时候就没有被调用,这时候编译器以浅拷贝处理,它们的数据成员m_pString都指向了同一个堆地址。
两个对象中的数据成员m_pString指向了相同的地址,当函数showMyString调用结束后,便会释放对象MyStringCpy,以对象MyStringCpy的首地址作为this指针调用析构函数。在析构函数中,调用delete函数来释放掉对象MyStringCpy的数据成员m_pString所保存的堆空间的首地址。但对象MyStringCpy是MyString的复制品,真正的MyString还存在,而数据成员m_pString所保存的堆空间的首地址却被释放,如果出现以下代码便会产生错误:

1
2
3
4
5
CMyString MyString;
//当该函数调用结束后,对象MyString中的数据成员m_pString所保存的堆空间已经
//被释放掉,再次使用此对象中的数据成员m_pString便无法得到堆空间的数据
showMyString(MyString);
showMyString(MyString); //显示地址中为错误数据

这个错误在debug选项中会被触发,因为使用delete后,堆空间被置为某个标记值:而在O2选项中,并不会对释放堆中的数据进行检查。如果没有再次申请堆空间,则此地址中的数据任然存在,会导致错误被隐藏,为程序埋下隐患。
有两种解决方案可以修正这个错误:深拷贝数据和设置引用计数,这两种解决方案都需要拷贝构造函数的配合。

  • 深拷贝数据:在复制对象时,编译器会调用一次该类的拷贝构造函数,给编码者一次机会。深拷贝利用这次机会将原对象的数据成员所保存的资源信息也制作一份副本。这样,当销毁复制对象时,销毁的资源是复制对象在拷贝构造函数中制作的副本,而非原对象中保存的资源信息。
  • 设置引用计数器:在进入拷贝构造函数时,记录类对象被复制引用的次数。当对象被销毁时,检查这个引用计数中保存的引用复制次数是否为0.如果是,则释放掉申请的资源,否则引用计数减1.

当参数为对象的指针类型时,则不存在这种错误。传递的数据是指针类型,在函数内的操作都是针对原对象的,不存在对象被复制的问题。由于没有副本,因此在函数进入和退出时不会调用构造函数和析构函数,也就不存在资源释放的错误隐患。在使用类对象作为参数时,如无特殊需求,应尽量使用指针或引用。这样做不但可以避免资源释放的错误隐患,还可以在函数调用过程中避免复制对象的过程,提升程序运行的效率。

对象作为返回值

对象作为函数的返回值时,与基本的数据类型不同。基本数据类型(双精度浮点类型以及非标准的__int64类型除外)作为返回值时,通过寄存器eax来保存返回的数据,而对象属于自定义类型,寄存器eax无法保存对象中的所有数据,所以在函数返回时,寄存器eax不能满足需求。
对象作为返回值与对象作为参数的处理方式非常类似。对象作为参数时,进入函数前预先将对象使用的栈空间保留出来,并将实参对象中的数据复制到栈空间中。该栈空间作为函数参数,用于函数内部使用。同理,对象作为返回值时,进入函数后将申请返回对象使用的栈空间,在退出函数时,将返回对象中的数据复制到临时的栈空间中,以这个临时的栈空间的首地址作为返回值。
先来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CReturn
{
public:
int m_nNumber;
int m_nArray[10]; //定义两个数据成员,该类大小为44字节
};
CReturn GetCReturn()
{
CReturn RetObj;
RetObj.m_nNumber = 0;
for(int i=0;i<10;i++)
{
RetObj.m_nArray[i] = i + 1;
}
return RetObj;
}
int main(int argc, char* argv[])
{
CReturn objA;
objA = GetCReturn();
printf("%d %d %d", objA.m_nNumber, objA.m_nArray[0], objA.m_nArray[9]);
return 0;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
004010A0 >  55              push ebp
004010A1 8BEC mov ebp,esp
004010A3 81EC C4000000 sub esp,0xC4 ; 预留返回对象的栈空间
004010A9 53 push ebx
004010AA 56 push esi ; test.<ModuleEntryPoint>
004010AB 57 push edi
004010AC 8DBD 3CFFFFFF lea edi,dword ptr ss:[ebp-0xC4]
004010B2 B9 31000000 mov ecx,0x31
004010B7 B8 CCCCCCCC mov eax,0xCCCCCCCC
004010BC F3:AB rep stos dword ptr es:[edi]
004010BE 8D85 7CFFFFFF lea eax,dword ptr ss:[ebp-0x84] ; 获取返回对象栈空间的首地址
;将返回对象的首地址压入栈中,用于保存返回对象对的数据
004010C4 50 push eax
004010C5 E8 54FFFFFF call test.0040101E ; GetCReturn()
004010CA 83C4 04 add esp,0x4
;函数调用结束后,eax中保存着地址ebp-84h,即返回对象的首地址
004010CD 8BF0 mov esi,eax ; 将返回对象的首地址存入esi中
004010CF B9 0B000000 mov ecx,0xB ; 设置循环次数
004010D4 8D7D A8 lea edi,dword ptr ss:[ebp-0x58] ; 获取临时对象的首地址
;每次从返回对象中复制4字节数据到临时对象的地址中,共复制11次。
004010D7 F3:A5 rep movs dword ptr es:[edi],dword ptr ds:[esi]
004010D9 B9 0B000000 mov ecx,0xB ; 重新设置循环次数
004010DE 8D75 A8 lea esi,dword ptr ss:[ebp-0x58] ; 获取临时对象的首地址
004010E1 8D7D D4 lea edi,dword ptr ss:[ebp-0x2C] ; 获取对象objA的首地址
;将数据复制到对象objA中
004010E4 F3:A5 rep movs dword ptr es:[edi],dword ptr ds:[esi]
004010E6 8B4D FC mov ecx,dword ptr ss:[ebp-0x4]
004010E9 51 push ecx
004010EA 8B55 D8 mov edx,dword ptr ss:[ebp-0x28]
004010ED 52 push edx
004010EE 8B45 D4 mov eax,dword ptr ss:[ebp-0x2C]
004010F1 50 push eax
004010F2 68 30604200 push test.00426030 ; ASCII "%d %d %d"
004010F7 E8 A4010000 call test.printfgvdbgind_blockeressges
004010FC 83C4 10 add esp,0x10

CReturn GetCReturn()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
00401040 >  55              push ebp
00401041 8BEC mov ebp,esp
00401043 83EC 70 sub esp,0x70 ; 调整栈空间,预留临时返回对象与局部对象的内存空间
00401046 53 push ebx
00401047 56 push esi ; test.<ModuleEntryPoint>
00401048 57 push edi
00401049 8D7D 90 lea edi,dword ptr ss:[ebp-0x70]
0040104C B9 1C000000 mov ecx,0x1C
00401051 B8 CCCCCCCC mov eax,0xCCCCCCCC
00401056 F3:AB rep stos dword ptr es:[edi]
;为数据成员m_nNumber赋值0,地址ebp-2C便是对象RetObj的首地址
00401058 C745 D4 0000000>mov dword ptr ss:[ebp-0x2C],0x0
0040105F C745 D0 0000000>mov dword ptr ss:[ebp-0x30],0x0
===============================for循环===============================
00401066 EB 09 jmp short test.00401071
00401068 8B45 D0 mov eax,dword ptr ss:[ebp-0x30]
0040106B 83C0 01 add eax,0x1
0040106E 8945 D0 mov dword ptr ss:[ebp-0x30],eax
00401071 837D D0 0A cmp dword ptr ss:[ebp-0x30],0xA
00401075 7D 0F jge short test.00401086
00401077 8B4D D0 mov ecx,dword ptr ss:[ebp-0x30]
0040107A 83C1 01 add ecx,0x1
0040107D 8B55 D0 mov edx,dword ptr ss:[ebp-0x30]
00401080 894C95 D8 mov dword ptr ss:[ebp+edx*4-0x28],ecx
00401084 ^ EB E2 jmp short test.00401068
===============================for循环===============================
00401086 B9 0B000000 mov ecx,0xB ; 设置循环次数11次
0040108B 8D75 D4 lea esi,dword ptr ss:[ebp-0x2C] ; 获取局部对象的首地址
0040108E 8B7D 08 mov edi,dword ptr ss:[ebp+0x8] ; 获取返回对象的首地址
00401091 F3:A5 rep movs dword ptr es:[edi],dword ptr ds:[esi]
00401093 8B45 08 mov eax,dword ptr ss:[ebp+0x8] ; 获取返回对象的首地址并保存到eax中,作为返回值
00401096 5F pop edi ; test.<ModuleEntryPoint>
00401097 5E pop esi ; test.<ModuleEntryPoint>
00401098 5B pop ebx ; test.<ModuleEntryPoint>
00401099 8BE5 mov esp,ebp
0040109B 5D pop ebp ; test.<ModuleEntryPoint>
0040109C C3 retn

在调用GetCReturn前,编译器将在main函数中申请的返回对象的首地址作为参数压栈,在函数GetReturn调用结束后进行了数据复制,将GetReturn函数中定义的局部变量RetObj的数据复制到这个返回对象的空间中,再将这个返回的对象复制给目标对象objA,从而达到返回对象的目的。因为在这个示例中不存在函数返回后为的对象的引用赋值,所以这里的返回对象是临时存在的,也就是C++中的临时对象,作用域仅限于单条语句。
为什么会产生这个临时对象呢?因为调用返回对象的函数时,C++程序员可能采用这类写法,如GetReturn().m_nNumber,这只是针对返回对象的操作,而此时函数已经退出,其栈帧也被关闭。函数退出后去操作局部对象显然不合适,因此只能由函数的调用方准备空间,建立临时对象,然后将函数中的局部对象复制给临时对象,再把这个临时对象交给调用方去操作。本例中的objA=GetReturn();是个赋值运算,由于赋值时GetReturn函数已经退出,其栈空间已经关闭,同理objA不能直接和函数内局部对象做赋值运算,因此需要临时对象记录返回值以后再来参与赋值。
虽然使用临时对象进行了数据复制,但是同样存在出错的风险。这与对象作为参数时遇到的情况一样,由于使用了临时对象进行数据复制,当临时对象被销毁时,会执行析构函数。如果析构函数中有对资源释放的处理,就有可能造成同一个资源多次释放的错误发生。
这个错误与对象作为函数参数时的错误在原理上是一样的,也是临时对象被析构造成的,因此两者的解决方案也相同。
当对象作为函数的参数时,可以传递指针;当对象作为返回值时,如果对象在函数内被定义为局部变量,则不可返回此对象的首地址或引用,以避免返回已经被释放的局部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CTest
{
public:
int m_nOne;
int m_nTwo;
};
CTest* GetTest()
{
CTest Test;
return &Test;
}
CTest& GetTest()
{
CTest Test;
return Test;
}

由于函数退出后栈内对象的空间将被释放,因此无法保证返回值所指向地址的数据的正确性。

小结

当对象结构简单、体积小时,函数间的对象传递直接使用eax个edx保存对象中的内容。当对象体积过大,结构复杂时,寄存器就明显不够用了,于是编译器在开发人员不知情的情况下,偷偷地给函数加上一个参数,将其作为返回值。传递参数对象时,存在一次复制过程,简单的对象直接按成员顺序push传参,复制对象则使用重复前缀的串指令rep moves,其edi被设置为栈顶。
访问对象成员时,其寻址方式颇为特别,使用的是寄存器相对间接访问方式。这种访问方式可作为识别对象的必要条件。

&((struct A*)NULL)->m_float不会崩溃,这是求m_float的地址,根据前面提出的结构体寻址公式:
p->member的地址=指针p的地址值 + member在type中的偏移量
代入得:
&((struct A*)NULL)->m_float = 0 + 4 = 4,这个表达式实际上是求结构体内成员的偏移量。
可以定义如下宏,用于在不产生对象的情况下取得成员偏移量:

#deffine offsetof(s,m) (size_t)&(((s *)0)->m)
这个在VC的stddef.h中有官方的定义。

文章目录
  1. 1. 对象的内存布局
  2. 2. this指针
  3. 3. 静态数据成员
  4. 4. 对象作为函数参数
  5. 5. 对象作为返回值
  6. 6. 小结