- UID
- 2198
注册时间2005-6-29
阅读权限255
最后登录1970-1-1
副坛主
该用户从未签到
|
I 类的出现及其编译原则
1.1 模块化程序设计的内存模型
类是对数据及其对相关操作的一个封装,其思想就是将我们所写的结构体及其操作结构体的方法封装到一起,因为我们所写的程序基本上就是在做一个数据处理的操作。将数据及其方法封装之后,会使我们的程序更具逻辑性、结构更清晰,同时封装也更能促进程序的分工合作。
而类中的成员方法如何调用是让大家困惑的一个问题,这里我们站在编译的角度来思考可能就更能理解类的内部实现。
测试代码:
class Test
{
private:
int m_a;
int m_b;
public:
Test(int a,int b):m_a(a),m_b(b){}
virtual ~Test(){}
};
int main(int argc, char* argv[])
{
Test tt(10,20);
return 0;
}
在结构体时代,其结构体数据操作的方法(函数)是与结构体数据在逻辑上分离的,即各个函数都是独立编译的,编译器在进行编译时,将每个函数都进行模块化编译。
这里我们先用一个实例代码,说明一下普通函数的编译思想:
编译器一定要将我们的函数进行模块化编译,模块化编译需要解决的就是解决参数及局部对象的处理,限于寄存器数量的有限性,于是CPU设计时引出了栈结构。
实现上就是我们调用函数前,将参数从右向左依次压入堆栈,如上图中的PUSH 参数2,PUSH 参数1。当调用函数时顺便把call函数下一行代码的地址压入堆栈,就跟我们现在正在看书,然后电话响了,我们接完电话继续看书时得知道之前读到哪了,这里的压入下一行代码地址就是这个目的。
进到函数之后,为了方便对参数的寻址,我们便使用了EBP这个寄存器来进行对参数的操作,在使用该寄存器之前,要先将EBP的数据进行保存,使用完了再对其恢复。所以有了一个PUSH EBP 的操作,然后MOV EBP,ESP之后,EBP就指向了当前的栈顶,此时[EBP+8]、[EBP+0C] …… 指向的就是我们压入的参数了。
函数要想模块化,首先解决对了参数的处理,然后就是要解决局部变量的空间问题。这里的解决方案就是在编译时候,先统计好所有的局部变量所需的空间,然后再EBP上方的堆栈中申请空间(也就是我在C中所说的定义变量就是申请空间)。所以用[EBP-4]、[EBP-8] …… 就可以寻址到函数内定义的局部变量了。这样就解决了我们的模块化编译的问题。
类思想引入之后,首先也必须要实现模块化编译,否则类就变成累赘了哈~~
类的对象空间的大小为类内成员的大小(有虚方法存在时,前四个字节为指向虚表的指针),那就说明类对象根本就跟类方法在编译上是独立的,也就是说,我们在理解上就可以将类成员和类方法独立开,类方法就是一普通的函数,而在设计上将其封装聚合。而类方法的参数中,大家都知道默认带了一个隐藏的this指针,下面我们来揭开this指针的真面目。
我们以构造函数的调用为例:
20: Test tt(10,20);
00401038 push 14h
0040103A push 0Ah
0040103C lea ecx,[ebp-0Ch] // 这里[EBP-C]就是main函数为局部变量所申请的空间(就是对象tt空间)
0040103F call @ILT+0(Test::Test) (00401005) // 这里的this指针的这个参数没有压栈而是通过寄存器ECX传参
11: Test(int a,int b:m_b(b),m_a(a){}
00401070 push ebp
00401071 mov ebp,esp
00401073 sub esp,44h
…… ……
0040108A mov dword ptr [ebp-4],ecx // [EBP-4]作为一个局部临时变量 保存this指针
0040108D mov eax,dword ptr [ebp-4] // this指针保存到eax寄存器
00401090 mov ecx,dword ptr [ebp+8] // 参数 a
00401093 mov dword ptr [eax+4],ecx // 将参数 a 保存到 对象指针的第四个字节位置(前四个字节为虚表)
00401096 mov edx,dword ptr [ebp-4]
00401099 mov eax,dword ptr [ebp+0Ch]
0040109C mov dword ptr [edx+8],eax
// 设置虚表指针
0040109F mov ecx,dword ptr [ebp-4]
004010A2 mov dword ptr [ecx],offset Test::`vftable' (0042210c)
004010A8 mov eax,dword ptr [ebp-4]
…… ……
004010AE mov esp,ebp
004010B0 pop ebp
004010B1 ret 8
通过上边的代码,我们可以得出,类成员函数默认带一个this指针的参数,该参数通过ECX寄存器传参(以后再看到那个函数调用有ECX传参并在函数内为ECX寻址就可以得出该函数为类方法),用ECX这个类对象的基地址的相对偏移来对对象的成员进行操作。这样就可以实现模块化编译,无论哪个对象调用该函数,只需要将对象的地址送到ECX寄存器即可。编译器用这样的编译方案就实现了函数的模块化编译,所以说类思想是在逻辑上将成员及其方法进行封装,而在实现上仍然是模块化编译来实现。这也就印证了为何类对象的空间仅为该对象数据和的大小,而没有添加类似方法的指针等内容。
通过上方的代码,我们还可以得出,在类的构造函数中,先对参数列表进行复制,然后填充虚表指针,最后才执行构造函数的指令。
构造函数()
{
执行初始化类表
填充虚表指针(若存在虚函数)
执行函数代码
}
我们只要弄清楚了函数如何实现模块化编译,如何进行传参及变量的赋值,就可以看到C源码而联想到其汇编代码,看到汇编反推出其C代码,这些是走向逆向的基础。
1.2 类在逻辑上封装,编译上独立
测试代码:
//-----------------------------------------------
struct Test1
{
public:
int m_a;
int m_b;
};
void SetData(Test1 * pObj,int a,int b)
{
pObj->m_a = a;
pObj->m_b = b;
}
//----------------------------------------------
class Test2
{
public:
int m_a;
int m_b;
void SetData(int a,int b)
{
m_a = a;
m_b = b;
}
void Show(){}
};
//-----------------------------------------------
int main()
{
struct Test1 tt1;
class Test2 tt2;
printf("%d %d",sizeof(tt1),sizeof(tt2));
reutrn 0;
}
运行结构都是8个字节,类机制并未因为包含了成员函数而使其体积变得臃肿,那当我们调用其成员函数时,编译器如何定位该成员函数的位置呢?又如何对成员数据进行操作,将所谓的 this 指针传入到参数中呢?那我们调用一下成员方法看一下其编译情况:
42: tt2.SetData(10,20);
00401089 push 14h
0040108B push 0Ah
0040108D lea ecx,[ebp-10h] // 将对象指针(地址)通过 ECX寄存器 传入函数
00401090 call @ILT+15(Test2::Show) (00401014) // 成员函数的调用是一个确定的地址,并非通过对象来获取
27: void SetData(int a,int b)
28: {
0040D740 push ebp
0040D741 mov ebp,esp
0040D743 sub esp,44h
……
0040D75A mov dword ptr [ebp-4],ecx // this 指针,对成员数据的操作都通过(this指针 + 偏移)来操作
29: m_a = a;
0040D75D mov eax,dword ptr [ebp-4]
0040D760 mov ecx,dword ptr [ebp+8]
0040D763 mov dword ptr [eax],ecx
30: m_b = b;
0040D765 mov edx,dword ptr [ebp-4]
0040D768 mov eax,dword ptr [ebp+0Ch]
0040D76B mov dword ptr [edx+4],eax
31: }
……
0040D771 mov esp,ebp
0040D773 pop ebp
0040D774 ret 8
43: SetData(&tt1,10,20);
00401085 push 14h
00401087 push 0Ah
00401089 lea eax,[ebp-8]
0040108C push eax // 这里直接将结构体变量地址压入堆栈
0040108D call @ILT+0(SetData) (00401005) // 函数的调用是一个确定的地址
通过查看编译后的代码,我们可以得出:类方法的调用不需要通过类对象来寻址(这里不考虑虚表)。类方法的寻址跟普通函数一样,就是CALL一个固定的地址。其解决对类对象数据操作的方案就是将对象的指针通过ECX寄存器来传参,非成员函数是将参数压栈而已。
这也印证了类对象的大小等于其类数据大小的总和。由此可以得,类思想是在逻辑上进行封装,在编译上独立,我们清楚了类方法如何处理类对象数据后,可以认为类方法跟普通函数没有什么两样。
1.3 new 和 delete 的运算符重载
若类中没有定义构造函数,创建对象时编译器并不会为我们添加一个默认的构造函数,若类中未定义析构对象退出作用域时候也不会为我们添加一个析构函数来调用。因为此时的对象就相当于C语言中结构体所定义的变量,没必要去构造和析构。
下面我们看一下当我们使用 new 和 delete 来处理类对象时,new 和 delete 在编译中均被做了运算符重载,其中调用了C语言的 malloc 和 free 函数,下面将其情况简单描述:
1. 若我们的类中存在构造函数,new 指令编译时,先调用 operator new 来申请空间,将返回值作为 this 指针来调用构造函数。
2. 若没写构造函数,将省去调用构造函数这一步。
3. 若类中未定义析构函数,执行 delete 语句仅是调用 operator delete 函数来调用 free 函数释放其空间。
4. 当 new class[] 创建对象时,将调用 eh vector constructor iterator 方法来生成 Vector 迭代器保存对象数组。
5. 若析构函数为虚函数,vector deleting destructor (向量删除函数)将作为虚表中的一项(析构函数并非添加进虚表)。此时我们无论调用delete 还是 delete[] 都将直接调用续表第一项进行处理,该函数内部有一个分析,若参数为1(参数为对象的个数),则直接调用析构,然后operator delete释放空间,若大于1,将调用 eh vector destructor iterator(向量删除迭代器),从后往前依次析构向量中所有的对象。在对空间进行释放。
new class() 和 delete 及 new class[] 和 delete[] 都被该类进行运算符重载,前一种情况简单,我们重点分析一下第二种情况:
测试代码(析构函数非虚函数):
测试代码:
int main(int argc, char* argv[])
{
Test * pObj = new Test();
Test * pObj1 = new Test[3];
delete pObj;
delete[] pObj1;
return 0;
}
new class[] 运算符的重载
004010BD push 1Ch // 注意这里申请的空间为 sizeof(dword) + n*sizeof(class)
004010BF call operator new (004014c0) // 先调用 operator new 申请空间
004010C7 . 8945 D4 MOV DWORD PTR SS:[EBP-2C],EAX ; 003907e0 多申请了四个字节的空间
004010CA . C645 FC 02 MOV BYTE PTR SS:[EBP-4],2
004010CE . 837D D4 00 CMP DWORD PTR SS:[EBP-2C],0
004010D2 . 74 2E JE SHORT testnewd.00401102
004010D4 . 68 0A104000 PUSH testnewd.0040100A ; 析构
004010D9 . 68 19104000 PUSH testnewd.00401019 ; 构造
004010DE . 8B55 D4 MOV EDX,DWORD PTR SS:[EBP-2C]
004010E1 . C702 03000000 MOV DWORD PTR DS:[EDX],3 ; 这里把数量放在前四个字节
004010E7 . 6A 03 PUSH 3
004010E9 . 6A 08 PUSH 8
004010EB . 8B45 D4 MOV EAX,DWORD PTR SS:[EBP-2C] ; 所申请空间地址
004010EE . 83C0 04 ADD EAX,4
004010F1 . 50 PUSH EAX ; push 申请空间+4的字节
004010F2 . E8 29030000 CALL testnewd.??_L@YGXPAXIHP6EX0@Z1@Z
申请到的空间 前四个字节存放申请对象的个数
调用 XXXX 函数
0040146B mov ecx,dword ptr [ebp+8]
0040146E call dword ptr [ebp+14h]
再调用为对象循环调用构造的函数:
0040145A mov eax,dword ptr [ebp-1Ch]
0040145D add eax,1
00401460 mov dword ptr [ebp-1Ch],eax
00401463 mov ecx,dword ptr [ebp-1Ch]
00401466 cmp ecx,dword ptr [ebp+10h]
00401469 jge `eh vector constructor iterator'+5Ch (0040147c)
0040146B mov ecx,dword ptr [ebp+8]
0040146E call dword ptr [ebp+14h]
00401471 mov edx,dword ptr [ebp+8] // 这里调用构造的顺序是从前向后
00401474 add edx,dword ptr [ebp+0Ch]
00401477 mov dword ptr [ebp+8],edx
0040147A jmp `eh vector constructor iterator'+3Ah (0040145a)
delete[] 运算符的重载: Test::`vector deleting destructor'
析构 delete[]
0040121A mov dword ptr [ebp-4],ecx
0040121D mov eax,dword ptr [ebp+8] // 申请对象的个数
00401220 and eax,2
00401223 test eax,eax
00401225 je Test::`vector deleting destructor'+5Fh (0040125f)
00401227 push offset @ILT+5(Test::~Test) (0040100a)
0040122C mov ecx,dword ptr [ebp-4] // 空间的起始地址
0040122F mov edx,dword ptr [ecx-4] // 申请对象的个数
00401232 push edx
00401233 push 8
00401235 mov eax,dword ptr [ebp-4]
00401238 push eax
00401239 call `eh vector destructor iterator' (004019f0) // 从后向前循环调用所有对象的析构函数
0040123E mov ecx,dword ptr [ebp+8]
00401241 and ecx,1
00401244 test ecx,ecx
00401246 je Test::`vector deleting destructor'+57h (00401257)
00401248 mov edx,dword ptr [ebp-4]
0040124B sub edx,4
0040124E push edx
0040124F call operator delete (00401390) // 然后再去调用 operetor delete 去释放空间
`eh vector destructor iterator' (004019f0) 函数内部:
00401A30 mov edx,dword ptr [ebp+10h] // 取一共申请了多少个
00401A33 sub edx,1
00401A36 mov dword ptr [ebp+10h],edx
00401A39 cmp dword ptr [ebp+10h],0
00401A3D jl `eh vector destructor iterator'+60h (00401a50)
00401A3F mov eax,dword ptr [ebp+8] // 这里是从后往前析构
00401A42 sub eax,dword ptr [ebp+0Ch] // 一个对象的长度
00401A45 mov dword ptr [ebp+8],eax
00401A48 mov ecx,dword ptr [ebp+8]
00401A4B call dword ptr [ebp+14h] // 开始调用析构
00401A4E jmp `eh vector destructor iterator'+40h (00401a30)
当析构函数为虚函数时,调用的方法将调用虚表中对应的函数指针,调用类似如下:
0040112F mov eax,dword ptr [ebp-34h] // this 指针
00401132 mov edx,dword ptr [eax] // 虚表给 EDX
00401134 mov ecx,dword ptr [ebp-34h] // this 指针赋值给 ECX
00401137 call dword ptr [edx] // 调用虚表第一项
总结: 假设类中存在构造和析构函数,当new单个对象时,将先申请空间再调用构造函数;delete时先调用析构再调用释放空间;
当new多个对象时,先申请n*sizeof(class)+sizeof(dword)个空间,然后从第一个对象开始依次调用构造函数,delete[]时,从最后一个对象依次向前调用析构函数,最后再释放空间。
再分析一下两个关键函数:
eh vector constructor iterator 传参分析:
004012B4 push offset @ILT+5(Test::~Test) (0040100a)
004012B9 push offset @ILT+30(Test::Test) (00401023)
004012BE mov edx,dword ptr [ebp-2Ch] // 将申请空间地址送EDX
004012C1 mov dword ptr [edx],3 // 将申请对象个数保存到前四个字节
004012C7 push 3
004012C9 push 8
004012CB mov eax,dword ptr [ebp-2Ch]
004012CE add eax,4
004012D1 push eax
004012D2 call `eh vector constructor iterator' (00401700)
堆栈数据:
0012FED0 0039089C // 所申请空间地址 + 4
0012FED4 00000008 // 一个对象的大小
0012FED8 00000003 // 对象个数
0012FEDC 00401023 // 对象构造函数
0012FEE0 0040100A // 对象析构函数
eh vector destructor iterator 函数传参分析:
00401407 push offset @ILT+5(Test::~Test) (0040100a)
0040140C mov ecx,dword ptr [ebp-4]
0040140F mov edx,dword ptr [ecx-4]
00401412 push edx
00401413 push 8
00401415 mov eax,dword ptr [ebp-4]
00401418 push eax
00401419 call `eh vector destructor iterator' (00401c90)
函数参数(堆空间地址,对象大小,对象个数,对象析构函数地址)
1.4 当参数为对象和返回值为对象时的分析及编译器对其的优化操作
测试代码:
void SetData(Test obj)
{
}
Test GetData()
{
static Test obj;
return obj;
}
int main(int argc, char* argv[])
{
Test tt;
SetData(tt);
GetData();
return 0;
}
情形一、当参数为对象时的构造和析构
21: Test tt;
004010BD lea ecx,[ebp-18h] // tt对象的地址在 [EBP-18H]
004010C0 call @ILT+30(Test::Test) (00401023)
004010C5 mov dword ptr [ebp-4],0
22: SetData(tt);
004010CC sub esp,0Ch // 在堆栈中申请新空间
004010CF mov ecx,esp // 将新地址作为系统生成的构造函数的this指针
004010D1 mov dword ptr [ebp-1Ch],esp
004010D4 lea eax,[ebp-18h] // 将源数据以参数压入堆栈
004010D7 push eax
004010D8 call @ILT+0(Test::Test) (00401005)
004010DD mov dword ptr [ebp-24h],eax // 把临时对象的this指针保存到[EBP-24]中
// 注意:此时栈顶地址就是临时对象的地址 即this指针
004010E0 call @ILT+20(SetData) (00401019)
004010E5 add esp,0Ch
系统生成的构造函数:@ILT+0(Test::Test) (00401005)
00401159 pop ecx
0040115A mov dword ptr [ebp-4],ecx // 先将this指针放到局部变量[EBP-4]中
0040115D mov eax,dword ptr [ebp-4] // 用eax寻址
00401160 mov ecx,dword ptr [ebp+8] // 取出参数
00401163 mov edx,dword ptr [ecx+4] // 跳过虚表
00401166 mov dword ptr [eax+4],edx // 跳过虚表向目标对象赋值,为this指针的对象赋值
00401169 mov eax,dword ptr [ebp-4]
0040116C mov ecx,dword ptr [ebp+8]
0040116F mov edx,dword ptr [ecx+8]
00401172 mov dword ptr [eax+8],edx
00401175 mov eax,dword ptr [ebp-4]
00401178 mov dword ptr [eax],offset Test::`vftable' (0042501c) // 为虚表赋值
0040117E mov eax,dword ptr [ebp-4] // 将this指针保存到eax中
SetData中析构该对象:@ILT+20(SetData) (00401019)
00401068 lea ecx,[ebp+8] // 上文中讲到为什么这里是临时对象的this指针
0040106B call @ILT+5(Test::~Test) (0040100a)
思考:这里显然是调用了一个拷贝构造函数,那为何编译器在我们没有定义构造和析构的情况下都懒的这两个函数,却好心在我们没有定义拷贝构造时为我们生成一个按位赋值的拷贝构造呢?并且当我们定义了拷贝构造后编译器将不再为我们生成该函数。
假设我们类成员中有一指针变量,拷贝构造时也按位进行了Copy,而出函数时临时对象进行了析构,释放了该指针指向的数据,而当这个类对象处作用域时在自动调用析构将会怎样呢?释放指针指向的空间时程序必定崩溃。
我们将所有的变量都思考为对象,即万物都为对象,我们看下方代码:
int i = 1;
int j = i;
第二句指令同样也可以写成 int j(i); // 该赋值就相当于就执行了一个拷贝构造
当普通变量做参数时,执行函数时需要压入堆栈,参数为对象时也不例外,只是该情况时编译器使用了一个小技巧来实现压栈操作,如上例中的:
004010CC sub esp,0Ch // 在堆栈中为参数申请新空间
情况二:当函数返回值为类类型时的情况
29: GetData();
0040128C lea eax,[ebp-24h] // 用于接收函数返回值的空间 尽管我们并未接受返回值 编译器还是为其预留空间
0040128F push eax // 将返回空间作为参数压入堆栈
00401290 call @ILT+35(GetData) (00401028)
00401295 add esp,4 // 自动平衡堆栈
00401298 lea ecx,[ebp-24h] // 用完返回值后 析构该临时对象
0040129B call @ILT+5(Test::~Test) (0040100a)
16: return obj;
004010F3 push offset __cfltcvt_tab+0AB8h (0042ae70) // static对象地址
004010F8 mov ecx,dword ptr [ebp+8] // 压入的返回值指针
004010FB call @ILT+0(Test::Test) (00401005) // 又调用了Copy构造
情况三: 编译器优化情况
class Student
{
char * m_pName;
public:
Student(char * str){ cout<<this<<"这里是构造函数!"<<endl; }
Student(const Student& obj){ cout<<this<<"这里是拷贝构造!"<<endl; }
~Student(){ cout<<this<<"这里是析构函数!"<<endl; }
};
void Fn(Student& obj)
{
}
int main(int argc, char* argv[])
{
Student& obj = Student("HaNi");
Student st = Student("Jeney");
Fn(Student("Hi"));
return 0;
}
0x0012FF6C这里是构造函数!
0x0012FF68这里是构造函数!
0x0012FF64这里是构造函数!
0x0012FF64这里是析构函数!
0x0012FF68这里是析构函数!
0x0012FF6C这里是析构函数!
Press any key to continue
45: Student& obj = Student("HaNi");
004012AD push offset string "HaNi" (00428028)
004012B2 lea ecx,[ebp-14h]
004012B5 call @ILT+45(Student::Student) (00401032)
004012BA mov dword ptr [ebp-4],0
004012C1 lea eax,[ebp-14h]
004012C4 mov dword ptr [ebp-10h],eax
46: Student st = Student("Jeney"); // 这里不再生成无名对象再进行拷贝构造,而是直接优化掉
004012C7 push offset string "Jeney" (00428020)
004012CC lea ecx,[ebp-18h]
004012CF call @ILT+45(Student::Student) (00401032)
004012D4 mov byte ptr [ebp-4],1
47: Fn(Student("Hi"));
004012D8 push offset string "Hi" (00428020)
004012DD lea ecx,[ebp-1Ch]
004012E0 call @ILT+45(Student::Student) (00401032) // 自己先创建一个临时对象
004012E5 mov dword ptr [ebp-24h],eax
004012E8 mov ecx,dword ptr [ebp-24h]
004012EB mov dword ptr [ebp-28h],ecx
004012EE mov byte ptr [ebp-4],2
004012F2 mov edx,dword ptr [ebp-28h]
004012F5 push edx
004012F6 call @ILT+70(Fn) (0040104b)
004012FB add esp,4
004012FE mov byte ptr [ebp-4],1
00401302 lea ecx,[ebp-1Ch]
00401305 call @ILT+40(Student::~Student) (0040102d) // 出函数后自己再释放掉 函数对&作指针处理
1.5 const 相关总结
1. const 引发的成员函数重载
class Test
{
public:
char * m_p;
public:
void SetData(char * pstr);
void SetData(char * pstr) const;
};
注:const 可修饰的函数只能是成员函数,即不可能出现静态方法用const修饰 static void Fun() const
2. const 常成员函数与 成员函数的区别
const 修饰的成员函数内的this指针类型为 const class * const this 指针,即this指针及对象数据均不能修改;
非const 的成员函数内this指针类型为 class * const this指针,即this指针无法修改。
3. const 修饰的变量
int i;
const int * p; <==> int const (* p) 即 指针 p 指向的数据不能通过 *p 来做修改;
int * const p <==> 即 指针 p 为常量,使用时不可修改。
4. 类成员函数的参数及返回值 何时使用const做修饰
int SetData(int a){}
int main()
{
int i = 1;
SetData(i);
}
上例中函数参数为非指针类型,即便我们不希望函数内部修改参数值,但有必要将参数类型修改为 const int a 吗?在调用函数时,压入的参数已开辟了新的栈空间,即便我们修改参数数据,对源数据没有丝毫影响。但当参数为指针(或引用)时,我们就很有必要使用 const 对参数进行修饰来声明其禁止修改其数据了,函数的返回值亦如此。
5. const 于非 const 数据间的转换
class Test
{
public:
char * m_pName;
const char * GetData() const {}
// 返回值为const 类型的函数多为 const 常成员函数
};
int main()
{
Test tt;
tt.GetData(); // 对象直接调用常成员函数是OK的
char * pstr = tt.GetData(); // 这个是不OK的
}
在 调用 和 赋值中(传参或接受返回值)中:
非const类型 ==> 转化为const类型是OK的
但const类型 ==> 转为非const类型是不合理的
II 类的构造及析构顺序
III 类的拷贝构造
一切皆为构造
通过两种情况来写出拷贝构造为必须
IV 类间的对象相互包含情况 |
|