9.2 this指针
在学习C++的过程中,大家都会接触到this指针。在类中没有对this指针的定义,但是在成员函数中却可以使用。许多C++程序员只知道在编码的过程中有this指针,但不知它从何而来和为何存在。
由于this指针的使用过程被编译器隐藏起来了,因此它变得格外神秘,我们通过本节的学习来揭开它的庐山真面目。
根据字面含义,this指针应属于指针类型,在32位环境下占4字节大小,保存的数据为地址信息。“this”可翻译为“这个”,因此经过字面的分析可认为this指针中保存了所属对象的首地址。9.1节介绍了对象的组成和它们在内存中的布局,但并没有分析如何访问对象中的数据成员。接下来,我们将从访问对象的数据成员和成员函数入手来分析this指针的使用过程。先来了解一下使用指针访问结构体或类成员的公式,假设type为某个正确定义的结构体或者类,member是type中可以访问的成员:
type*p;
//此处略去p的赋值
//以下是整型加法
p->member的地址=指针p的地址值+member在type中的偏移量
举个例子,如果有以下定义:
struct A{
int m_int;//在结构体内的偏移量为0
float m_float;//在结构体内的偏移量为4
};
struct A a;//假设这个结构体变量a的地址为0x0012ff00
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);
明白结构体和类成员变量的寻址方法后,我们来看一个示例,如代码清单9-2所示。
代码清单9-2 访问类对象的数据成员—Debug版
//C++源码说明:类定义以及数据成员的访问
class CTest{
public:
void SetNumber(int nNumber){//公有成员函数
m_nInt=nNumber;
}
public:
int m_nInt;//公有数据成员
};
void main(){
CTest Test;
Test.SetNumber(5);//调用成员函数
printf("CTest:%d\r\n",Test.m_nInt);//获取数据成员
}
//C++源码与对应汇编代码讲解
//main函数分析
void main(){
CTest Test;//类对象定义
Test.SetNumber(5);//调用CTest类中的成员函数SetNumber
0040B768 push 5;压入参数5
0040B76A lea ecx,[ebp-4];取出对象Test的首地址存入ecx中
;调用成员函数
0040B76D call@ILT+10(CTest:SetNumber)(0040100f)
printf("CTest:%d\r\n",Test.m_nInt);
;取出对象首地址处4字节的数据m_nInt存入eax中
0040B772 mov eax, dword ptr[ebp-4]
0040B775 push eax;将eax中保存的数据成员m_nInt向成员函数传参
0040B776 push offset string"CTest:%d\r\n"(0042001c)
0040B77B call printf(00401060)
0040B780 add esp,8
}
//SetNumber函数讲解
void SetNumber(int nNumber){//SetNumber成员函数实现
0040B7B0 push ebp
0040B7B1 mov ebp, esp
0040B7B3 sub esp,44h
0040B7B6 push ebx
0040B7B7 push esi
0040B7B8 push edi
0040B7B9 push ecx;注意,ecx中保存了对象Test的首地址
0040B7BA lea edi,[ebp-44h]
0040B7BD mov ecx,11h
0040B7C2 mov eax,0CCCCCCCCh
0040B7C7 rep stos dword ptr[edi]
0040B7C9 pop ecx;还原ecx
;将ecx中的数据存入ebp-4地址处,该地址处保存着调用对象的首地址,即this指针
0040B7CA mov dword ptr[ebp-4],ecx
m_nInt=nNumber;
;取出对象的首地址并存入eax
0040B7CD mov eax, dword ptr[ebp-4]
;取出参数中的数据并保存到ecx中
0040B7D0 mov ecx, dword ptr[ebp+8]
;这里是给成员m_nInt赋值。由于eax是对象的首地址,成员m_nInt的偏移量为0,如果写成这样可能
;更容易理解:mov dword ptr[eax+0],ecx
0040B7D3 mov dword ptr[eax],ecx
}
代码清单9-2中演示了对象调用成员的方法,以及取出数据成员的过程。在使用默认的调用约定时,在调用成员函数的过程中,编译器做了一个“小动作”:利用寄存器ecx保存了对象的首地址,并以寄存器传参的方式传递到成员函数中,这便是this指针的由来。由此可见,所有成员函数都有一个隐藏参数,即自身类型的指针,这便是this指针,将这样的默认调用约定称为thiscall。
在成员函数中访问数据成员也是通过this指针间接访问的,这便是为什么在成员函数内可以直接使用数据成员的原因。在类中使用数据成员以及成员函数时,编译器隐藏了如下操作:
class CTest{
public:
void Show(){
//隐藏传递了this指针,这里实际为this->GetNumber()
printf("%d\r\n",GetNumber());
}
int GetNumber(){
//隐藏传递了this指针,这里实际为retrun this->m_nInt;
return m_nInt;
}
private:
int m_nInt;
};
在VC++的环境下,识别this指针的关键点是在函数的调用过程中使用了ecx作为第一个参数,并且在ecx中保存的数据为对象的首地址,但并非所有的this指针的传递都是如此。在代码清单9-2中,成员函数SetNumber的调用方式为thiscall。thiscall的栈平衡方式与__stdcall相同,都是由被调用方负责平衡。但是,两者在传参的过程中却不一样,声明为thiscall的函数,第一个参数使用寄存器ecx传递,而非通过栈顶传递。而且thiscall并不属于关键字,它是C++中成员函数特有的调用方式,在C语言中是没有这种调用方式的。由于在VC++环境下thiscall不属于关键字,因此函数无法显式声明为thiscall调用方式,而类的成员函数默认是thiscall调用方式。所以,在分析过程中,如果看到某函数使用ecx传参,且ecx中保留了对象的this指针,以及在函数实现代码内,存在this指针参与的寄存器相对间接访问方式,如[reg+8],即可怀疑此函数为成员函数。
当使用其他调用方式(如__stdcall)时,this指针将不再使用ecx传递,而是改用栈传递。将代码清单9-2中的成员函数SetNumber修改为__stdcall调用方式,查看this指针的传递与使用过程,如代码清单9-3所示。
代码清单9-3 使用__stdcall调用方式的成员函数—Debug版
//C++源码说明:数组和局部变量的定义以及初始化
class CTest{
public:
void__stdcall SetNumber(int nNumber){//修改其调用方式
m_nInt=nNumber;
}
public:
int m_nInt;//公有数据成员
};
void main(){
CTest Test;
Test.SetNumber(5);//调用__stdcall成员函数
printf("CTest:%d\r\n",Test.m_nInt);//获取成员数据
}
//C++源码与对应汇编代码讲解
//成员函数调用过程,其他略
Test.SetNumber(5);
0040B808 push 5
0040B80A lea eax,[ebp-8];获取对象首地址并存入eax中
0040B80D push eax;将eax作为参数压栈
0040B80E call@ILT+15(CTest:SetNumber)(00401014)
//成员函数SetNumber的实现过程
void__stdcall SetNumber(int nNumber){
;Debug初始化过程略
m_nInt=nNumber;
0040B7C8 mov eax, dword ptr[ebp+8];取出this指针并存入eax中
0040B7CB mov ecx, dword ptr[ebp+0Ch];取出参数nNumber并存入ecx中
0040B7CE mov dword ptr[eax],ecx;使用eax取出成员并赋值
}
在代码清单9-3中,成员函数SetNumber在调用过程中没有通过ecx传递this指针,取而代之的是以栈方式传递参数。__cdecl调用方式和__stdcall调用方式只是在参数平衡时有所区别,这里就不详细讲解了。使用__cdecl和__stdcall声明的成员函数,this指针并不像thiscall那样容易识别。使用栈方式传递参数,并且第一个参数为对象首地址的函数很多,很难区分。
虽然难以区分,但如果能确定函数的第一个参数为this指针,并且在函数体内将this指针存入某寄存器,然后出现寄存器相对间接访问方式,那么将其还原为成员函数也是等价的。
在O2选项中,代码清单9-2和代码清单9-3经过优化后,类对象将不复存在,只是使用printf函数,输出数字5。SetNumber函数完成的功能是将数据成员m_nInt赋值为常量5。其他代码没有再对此变量做任何修改,而类对象Test只有一个数据成员m_nInt,该对象除了为数据成员赋值外,并无其他操作,因此编译器作了减少变量的优化处理。转换后代码如下所示:
int nInt=5;//可使用常量传播
printf("CTest:%d\r\n",nInt);
//减少变量后
printf("CTest:%d\r\n",5);
经过优化后,此程序中只有一句代码。使用O2选项编译代码清单9-3,可通过图9-3验证分析结果。
图 9-3 代码清单9-3优化后的反汇编代码
使用thiscall调用方式的成员函数的要点分析:
lea ecx,[mem];取对象首地址并存入ecx中,要注意观察内存
call FUN_ADDRESS;调用成员函数
;在函数调用内,ecx尚未重新赋值之前
mov XXX, ecx;发现函数内使用ecx中的数据,说明函数调用前对ecx的赋值
;实际上是在传递参数
;其后ecx中的内容会传递给其他寄存器
mov[reg+i],XXX;发现了寄存器相对间接寻址方式,如果能排除数组访问,那就能说明reg中保存的是结构体或者类对象的首地址
符合以上特点,基本可判定这是调用类的成员函数。通过分析函数代码中访问ecx的方式,并结合内存窗口,以ecx中的值为地址去观察其数据,可以进一步分析并还原出对象中的各数据成员。
__stdcall与__cdecl调用方式的成员函数分析:
lea reg,[mem];取出对象首地址并存入寄存器变量中
push reg;将保存对象首地址的寄存器作为参数压栈
call FUN_ADDRESS;调用成员函数
;在函数调用内,将第一个函数参数作为指针变量,以寄存器相对间接寻址方式访问
对于这种形式的代码,应重点分析压入的第一个参数是否为对象的首地址。如果是,则可通过分析得知,该函数等价为此对象中的成员函数。根据第一个参数的使用,以及它所指向的地址,可还原出该结构中的各数据成员。
本节中只简单地讲解了与类和对象相关的内容,并没有涉及复杂的案例。稍后将会逐步接触较为复杂的对象结构。万丈高楼平地起,有一个良好的基础,才能够掌握更多的知识。