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;调用成员函数

;在函数调用内,将第一个函数参数作为指针变量,以寄存器相对间接寻址方式访问


对于这种形式的代码,应重点分析压入的第一个参数是否为对象的首地址。如果是,则可通过分析得知,该函数等价为此对象中的成员函数。根据第一个参数的使用,以及它所指向的地址,可还原出该结构中的各数据成员。

本节中只简单地讲解了与类和对象相关的内容,并没有涉及复杂的案例。稍后将会逐步接触较为复杂的对象结构。万丈高楼平地起,有一个良好的基础,才能够掌握更多的知识。