反汇编(Disassembly) 即把目标二进制机器码转为汇编代码的过程,该技术常用于软件破解、外挂技术、病毒分析、逆向工程、软件汉化等领域,学习和理解反汇编对软件调试、系统漏洞挖掘、内核原理及理解高级语言代码都有相当大的帮助,软件一切神秘的运行机制全在反汇编代码里面。下面将分析VS 2013 编译器产生C代码的格式与实现方法,研究一下编译器的编译特性。
C++ 基本输入输出
c语言使用printf函数输出,printf函数的输出方式很好理解,反汇编后会发现代码不过就那么几句,而C++则不同,C++输出数据时,使用了一种流的输出方式,通常是调用 ostream 类里面的cin或者是cout,你可以把输入输出理解为小河流水,如下反汇编代码,先来观察一下输出格式的变化。
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[])
{
int number =100 ;
int age = 33;
std::cin >> number >> age;
std::cout << "number: " << number << "age: "<< age << std::endl;
system("pause");
return 0;
}
cin接收参数看起来是这样
00415ED4 | 8D4D F8 | lea ecx,dword ptr ss:[ebp-0x8] |
00415ED7 | 51 | push ecx |
00415ED8 | 8B0D A8004200 | mov ecx,dword ptr ds:[<&?cin@std@@3V?$basic_istream@DU?$char_traits@D@std@@@1@A>] | 获取cin地址
00415EDE | FF15 A4004200 | call dword ptr ds:[<&??5?$basic_istream@DU?$char_traits@D@std@@@std@@QAEAAV01@AAH@Z>] | 第一次cin接收参数
00415EE4 | 3BFC | cmp edi,esp |
00415EE6 | E8 53B4FFFF | call 0x41133E |
00415EEB | 8BC8 | mov ecx,eax |
00415EED | FF15 A4004200 | call dword ptr ds:[<&??5?$basic_istream@DU?$char_traits@D@std@@@std@@QAEAAV01@AAH@Z>] | 第二次cin接收参数
00415EF3 | 3BF4 | cmp esi,esp |
00415EF5 | E8 44B4FFFF | call 0x41133E |
cout打印参数也是如此。。
00415F12 | 68 84CC4100 | push consoleapplication2.41CC84 | 41CC84:"number: "
00415F17 | 8B15 AC004200 | mov edx,dword ptr ds:[<&?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A>] | 获取cout地址
00415F1D | 52 | push edx |
00415F1E | E8 94B3FFFF | call 0x4112B7 |
00415F23 | 83C4 08 | add esp,0x8 |
00415F26 | 8BC8 | mov ecx,eax |
00415F28 | FF15 98004200 | call dword ptr ds:[<&??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@H@Z>] | 输出第一个参数
00415F2E | 3BDC | cmp ebx,esp |
00415F30 | E8 09B4FFFF | call 0x41133E |
00415F35 | 50 | push eax |
00415F36 | E8 7CB3FFFF | call 0x4112B7 |
00415F3B | 83C4 08 | add esp,0x8 |
00415F3E | 8BC8 | mov ecx,eax |
00415F40 | FF15 98004200 | call dword ptr ds:[<&??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@H@Z>] | 输出第二个参数
00415F46 | 3BFC | cmp edi,esp |
00415F48 | E8 F1B3FFFF | call 0x41133E |
00415F4D | 8BC8 | mov ecx,eax |
00415F4F | FF15 94004200 | call dword ptr ds:[<&??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV0 | 结束cout
ida 也同样可识别出来。
分析类的实现原理
在C语言中我们学习过结构体类型,其实C++中的类就是在结构体这个数据类型的基础上衍生出来的,两者之间的反汇编代码几乎一致,结构体和类都具有构造函数,析构函数和成员函数,但两者之间还是会有细微的不同,首先结构体的访问控制默认是Public,而类的访问控制是Private,这些属性是由编译器在编译的时候进行检查和确认的,一旦编译成功,程序在执行过程中就不会在访问控制方面做任何检查和限制了.
简单地定义一个类: 首先我们来定义一个Student
学生类,然后赋予不同的属性,最后调用内部的成员函数,观察其反汇编代码.
#include <iostream>
using namespace std;
class Student
{
public:
int number;
char *name;
private: void p_display()
{
std::cout << "name:" << name << std::endl;
}
public:void display()
{
p_display();
std::cout << "num: " << number << std::endl;
}
};
int main(int argc, char* argv[])
{
Student student;
student.number = 22;
student.name = "lyshark";
student.display();
system("pause");
return 0;
}
通过反汇编这段代码,你会发现其实类这个东西并不存在于汇编层面,因为所谓的面向对象其实都是编译器来帮你映射成相应的C语言代码,换句话说,其实并不存在面向对象,你所写的面向对象代码最终都会被编译器降级为C语言代码,然后C代码又会被降级为机器指令,而中间的转换过程都是由编译器来完成的,只不过面向对象更易于使用,但是其本质上还是C代码.
008E12A6 | 68 E0198E00 | push <class std::basic_ostream<char,struct std::char_traits<char> > & __cdecl std::en | main.cpp:27
008E12AB | 51 | push ecx | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12AC | 8B0D 54308E00 | mov ecx,dword ptr ds:[<&?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A>] | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12B2 | BA BC318E00 | mov edx,consoleapplication2.8E31BC | 8E31BC:"name:"
008E12B7 | E8 E4040000 | call <class std::basic_ostream<char,struct std::char_traits<char> > & __cdecl std::op |
008E12BC | BA CC318E00 | mov edx,consoleapplication2.8E31CC | 8E31CC:"lyshark"
008E12C1 | 8BC8 | mov ecx,eax | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12C3 | E8 D8040000 | call <class std::basic_ostream<char,struct std::char_traits<char> > & __cdecl std::op |
008E12C8 | 83C4 04 | add esp,0x4 |
008E12CB | 8BC8 | mov ecx,eax | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12CD | FF15 5C308E00 | call dword ptr ds:[<&??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV0 |
008E12D3 | 8B0D 54308E00 | mov ecx,dword ptr ds:[<&?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A>] | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12D9 | BA C4318E00 | mov edx,consoleapplication2.8E31C4 | 8E31C4:"num: "
008E12DE | 68 E0198E00 | push <class std::basic_ostream<char,struct std::char_traits<char> > & __cdecl std::en |
008E12E3 | 6A 16 | push 0x16 |
008E12E5 | E8 B6040000 | call <class std::basic_ostream<char,struct std::char_traits<char> > & __cdecl std::op |
008E12EA | 8BC8 | mov ecx,eax | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12EC | FF15 34308E00 | call dword ptr ds:[<&??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@H@Z>] |
008E12F2 | 8BC8 | mov ecx,eax | ecx:&"ALLUSERSPROFILE=C:\\ProgramData"
008E12F4 | FF15 5C308E00 | call dword ptr ds:[<&??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV0 |
008E12FA | 68 D4318E00 | push consoleapplication2.8E31D4 | main.cpp:29, 8E31D4:"pause"==L"慰獵e"
008E12FF | FF15 E0308E00 | call dword ptr ds:[<&system>] |
008E1305 | 83C4 04 | add esp,0x4
IDA 的识别更能说明这一点。
this指针的使用: this指针在本类中可以调用自身的数据成员与成员函数,this指针属于指针类型,默认在32位环境中占用4字节的存储空间,指针中保存了所属对象的首地址,当访问数据成员时则通过加偏移的方式移动指针,我们先来看一个结构体的定义.
struct this{
int m_int; // 在结构体内偏移是0
float m_float; // 在结构体内偏移是4
}
int main(int argc, char* argv[])
{
struct this test; // 假设结构体变量地址是0x401000
struct test *p_test = &test; // 定义结构体指针
printf("%p",*p_test->m_float); // 输出指针地址
}
如上结构体定义所示,其中p_test
中保存的地址就是0x401000
,而m_float
在结构体中的偏移量为4,于是乎可以得出p_test->m_float
的实际地址应该是0x401000(结构首地址) + 4(元素偏移) = 0x401004
这就是结构体成员变量的寻址方式,接着我们来研究this指针.
#include <iostream>
using namespace std;
class Student
{
public:
int m_nInt;
int m_nInt_01;
int m_nInt_02;
public: void SetNumber(int nNum){
m_nInt = nNum;
m_nInt_01 = nNum;
m_nInt_02 = nNum;
printf("this 指针数据: %d \n", this->m_nInt);
printf("this1 指针数据: %d \n", this->m_nInt_01);
printf("this2 指针数据: %d \n", this->m_nInt_02);
}
};
int main(int argc, char* argv[])
{
Student stu;
stu.SetNumber(5);
printf("打印参数: %d\n", stu.m_nInt);
system("pause");
return 0;
}
首先编译上方代码,并从主函数开始分析,来观察其汇编代码是如何使用this指针进行参数传递的.
0041532E | 6A 05 | push 0x5 | 压如参数 5
00415330 | 8D4D F0 | lea ecx,dword ptr ss:[ebp-0x10] | 取出对象stu的首地址(thiscall)
00415333 | E8 16BFFFFF | call 0x41124E | 调用 std::setnumber()
00415338 | 8BF4 | mov esi,esp | main.cpp:25
0041533A | 8B45 F0 | mov eax,dword ptr ss:[ebp-0x10] | 取对象首地址处4字节的数据m_nInt
0041533D | 50 | push eax |
0041533E | 68 BCCC4100 | push consoleapplication2.41CCBC | 41CCBC:"打印参数: %d\n"
00415343 | FF15 80014200 | call dword ptr ds:[<&printf>] | 打印参数
00415349 | 83C4 08 | add esp,0x8 |
进入call 0x41124E
,分析this指针传递,代码中可看出,在使用thiscall
调用约定时,默认利用ECX寄存器保存对象首地址,并以寄存器传递的方式将this指针传递到成员函数中,所有成员函数默认都有隐藏的this参数,即指向自身成员类型的指针.
0041375C | 51 | push ecx | ecx中保存的就是对象this指针首地址
0041375D | 8DBD 34FFFFFF | lea edi,dword ptr ss:[ebp-0xCC] |
00413763 | B9 33000000 | mov ecx,0x33 |
00413768 | B8 CCCCCCCC | mov eax,0xCCCCCCCC |
0041376D | F3:AB | rep stosd |
0041376F | 59 | pop ecx | 还原ecx到源寄存器中
00413770 | 894D F8 | mov dword ptr ss:[ebp-0x8],ecx | 该地址保存着调用对象首地址,this指针
00413773 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | 取出this指针
00413776 | 8B4D 08 | mov ecx,dword ptr ss:[ebp+0x8] | 从堆栈中取出参数,并赋值到ecx
00413779 | 8908 | mov dword ptr ds:[eax],ecx | 赋值 this->m_nInt
0041377B | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | main.cpp:13
0041377E | 8B4D 08 | mov ecx,dword ptr ss:[ebp+0x8] | 从堆栈中取出参数,并赋值到ecx
00413781 | 8948 04 | mov dword ptr ds:[eax+0x4],ecx | 赋值 this->m_nInt_01
00413784 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | main.cpp:14
00413787 | 8B4D 08 | mov ecx,dword ptr ss:[ebp+0x8] | 从堆栈中取出参数,并赋值到ecx
0041378A | 8948 08 | mov dword ptr ds:[eax+0x8],ecx | 赋值 this->m_nInt_02
0041378D | 8BF4 | mov esi,esp | main.cpp:15, esi:__enc$textbss$end+27B
this指针通常情况下是由编译器维护的,在成员函数中访问数据成员也是通过this指针间接访问的,这便是为什么在成员函数内可以直接使用数据成员的原因,在类中使用数据成员或成员函数,编译器会在编译时自动为我们添加上了this指针.
class Student{
private:
int m_nInt;
public:void GetNumber(){
// 此处的m_nInt 其实默认隐藏了this指针,完整写法应该是 this->m_nInt;
return m_nInt;
}
public:void Display(){
// 此处的GetNumber 其实等于 this->GetNumber();
printf("%d\n",GetNumber());
}
}
如下代码: 在VS环境下,识别this指针的关键就在于函数调用的第一个参数是使用ECX寄存器传递,而并非通过栈顶传递,并且在ECX寄存器中保存的数据就是该对象的首地址,成员函数SetNumber
的调用方式为thiscall
该方式的平栈是由调用者维护的,该调用方式并不属于预定义关键字,它是C++中成员函数特有的调用方式,在C语言中并不存在这种调用约定.
0041532E | 6A 05 | push 0x5 | 压如参数 5
00415330 | 8D4D F0 | lea ecx,dword ptr ss:[ebp-0x10] | 取出对象stu的首地址(thiscall)
00415333 | E8 16BFFFFF | call 0x41124E | 调用 std::setnumber()
由于C++环境下thiscall不属于关键字,因此函数无法被显示声明为thiscall调用方式,而类的成员函数默认是thiscall调用方式,所以在分析过程中,如果发现call 0x41124E
的上方第一个参数是lea ecx,dword ptr ss:[ebp-0x10]
那么我们可以猜测,该函数很有可能就是某个类中的成员函数.
静态数据成员: 静态数据成员与静态变量原理相同,因此静态数据成员的初始值也会被编译到文件中,当程序加载时,系统会将可执行文件读入内存,此时顺带着静态数据成员也已经装入到了内存,就算你还没有实例化对象,其依然会被初始化.
静态数据成员是全局性的,与类的关系不大,因为静态数据成员有此特性,所以当我们计算类和对象的长度时,静态数据成员并不会被计算在其中,如下代码我们定义了两个数据成员其长度是8字节,那么当我们输出类的长度时只会显示出8字节,至于静态数据成员m_static_Int
则被忽略计算了,因为他在全局数据区,并不属于类中的变量.
#include <iostream>
using namespace std;
class CStatic
{
public:
int m_Int; // 占用4字节
int x_Int; // 占用4字节
static int m_static_Int; // 不会占用类空间
};
int CStatic::m_static_Int = 1;
int main(int argc, char* argv[])
{
CStatic cs;
cs.m_Int = 2;
cs.x_Int = 3;
int nSize = sizeof(cs); // 显示出类的长度
printf("类中成员大小: %d\n", nSize);
printf("静态数据成员内存地址: %x\n", &cs.m_static_Int);
printf("普通数据成员内存地址: %x %x \n", &cs.m_Int,&cs.x_Int);
system("pause");
return 0;
}
编译并调试汇编代码,可发现静态数据成员是一个常量值可以任意访问,而普通数据成员则是通过栈空间传递的,所以在成员函数中使用这两种数据成员时,静态数据成员属于全局变量,并且不属于任何对象,因此访问也无需使用this指针,而普通的数据成员属于对象所有,访问时默认会隐藏使用this指针,来看如下代码清单:
#include <iostream>
using namespace std;
class CStatic
{
public:
int m_Int; // 普通数据成员:占用4字节
static int m_static_Int; // 静态数据成员:不会占用类空间
public: void ShowNumber()
{
printf("普通数据成员: %d --> 静态数据成员: %d \n", m_Int, m_static_Int);
}
};
int CStatic::m_static_Int = 1;
int main(int argc, char* argv[])
{
CStatic cs;
cs.m_Int = 2;
cs.ShowNumber();
system("pause");
return 0;
}
我们将上面的代码反汇编一下,主要来看void ShowNumber()
函数内部是如何调用数据成员的,我们可以看到静态数据成员在反汇编代码中其展示形态与全局变量完全相同打印方式也与全局变量一致,而普通数据成员则需要使用mov ecx,dword ptr ss:[ebp-0x8]
获取到this指针才可以输出.
002137B3 | 8BF4 | mov esi,esp | main.cpp:11
002137B5 | A1 00F02100 | mov eax,dword ptr ds:[<public: static int CStatic::m_static_Int>] | 直接访问静态数据成员
002137BA | 50 | push eax | 第一个参数压栈
002137BB | 8B4D F8 | mov ecx,dword ptr ss:[ebp-0x8] | 获取this指针
002137BE | 8B11 | mov edx,dword ptr ds:[ecx] | 通过this指针访问数据成员
002137C0 | 52 | push edx | edx:"樾K"
002137C1 | 68 6CCC2100 | push consoleapplication2.21CC6C | 输出两个参数
002137C6 | FF15 80012200 | call dword ptr ds:[<&printf>] |
002137CC | 83C4 0C | add esp,0xC |
对象作为函数参数传递: 对象作为函数参数时,其传递过程与数组不同,数组变量的名称就代表了数组的首地址,而对象变量名称却无法代表对象的首地址,传参时不会像数组那样以首地址作为参数传递,而是先将对象中的所有数据进行复制,然后将复制的数据作为形参传递到调用函数中使用.
默认情况下在32位系统中所有的数据类型都不会超过4字节大小,使用一个栈元素即可完成数据的复制和传递,而类对象是自定义数据类型,是除了自身以外的所有数据类型的集合,各个对象的长度不定,对象在传递的过程中是如何被复制和传递的呢,我们来分析如下代码:
#include <iostream>
using namespace std;
class CFunction
{
public:
int m_nOne;
int m_nTwo;
};
void ShowFunction(CFunction fun)
{
printf("%d -->%d\n", fun.m_nOne, fun.m_nTwo);
}
int main(int argc, char* argv[])
{
CFunction fun;
fun.m_nOne = 1;
fun.m_nTwo = 2;
ShowFunction(fun);
system("pause");
return 0;
}
编译这段代码并反汇编,然后来到main函数,可看出编译器在调用函数传参的过程中分别将对象的两个成员依次压栈,也就是直接将两个数据成员当成两个int类型的数据,并将它们复制一份压入堆栈存储,这里压栈的两个参数虽然数值相等,但是因为是变量复制,所以它与对象中的两个数据成员是没有任何关系的,然后直接调用call 0xC31276
过程完成参数传递.
int main(int argc, char* argv[])
00C3530E | C745 F4 01000000 | mov dword ptr ss:[ebp-0xC],0x1 | 第一个参数
00C35315 | C745 F8 02000000 | mov dword ptr ss:[ebp-0x8],0x2 | 第二个参数
00C3531C | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | main.cpp:22
00C3531F | 50 | push eax | 压栈存储第一个参数 [ebp-0x8]
00C35320 | 8B4D F4 | mov ecx,dword ptr ss:[ebp-0xC] | [ebp-C]:__enc$textbss$end+FF
00C35323 | 51 | push ecx | 压栈存储第二个参数 [ebp-0c]
00C35324 | E8 4DBFFFFF | call 0xC31276 | 调用ShowFunction
00C35329 | 83C4 08 | add esp,0x8 | 堆栈平衡
void ShowFunction(CFunction fun)
00C337B0 | 8B45 0C | mov eax,dword ptr ss:[ebp+0xC] | 从堆栈中去除第一个参数
00C337B3 | 50 | push eax |
00C337B4 | 8B4D 08 | mov ecx,dword ptr ss:[ebp+0x8] | 从堆栈中去除第二个参数
00C337B7 | 51 | push ecx |
00C337B8 | 68 6CCCC300 | push consoleapplication2.C3CC6C | C3CC6C:"%d -->%d\n"
00C337BD | FF15 8001C400 | call dword ptr ds:[<&printf>] |
00C337C3 | 83C4 0C | add esp,0xC |
类对象中的数据成员的传参顺序为: 最先定义的数据成员最后压栈,最后定的数据成员最先压栈,当类的体积过大,或者其中定义有数组类型的成员时,那么参数传递就会发生一些变化,此时将变为含有数组数据成员的对象传参
,如下代码:
#include <iostream>
using namespace std;
class CFunction
{
public:
int m_nOne;
int m_nTwo;
char m_nName[32]; // 增加一个数组类型的变量,来观察其发生的寻址变化
};
void ShowFunction(CFunction fun)
{
printf("%d -->%d --> %s \n", fun.m_nOne, fun.m_nTwo,fun.m_nName);
}
int main(int argc, char* argv[])
{
CFunction fun;
fun.m_nOne = 1;
fun.m_nTwo = 2;
strcpy(fun.m_nName, "lyshark");
ShowFunction(fun);
system("pause");
return 0;
}
通过反汇编观察发现,其数组的传递方式依然使用的是首地址指针的传递,这一点与数组传参道理是相同的.
int main(int argc, char* argv[])
00085325 | 8945 FC | mov dword ptr ss:[ebp-0x4],eax | 第三个参数: 就是 m_nName[32];
00085328 | C745 D0 01000000 | mov dword ptr ss:[ebp-0x30],0x1 | 第二个参数
0008532F | C745 D4 02000000 | mov dword ptr ss:[ebp-0x2C],0x2 | 第一个参数
00085336 | 68 84CC0800 | push consoleapplication2.8CC84 | push 字符串 "lyshark"
0008533B | 8D45 D8 | lea eax,dword ptr ss:[ebp-0x28] |
0008533E | 50 | push eax | 指向 字符串 "lyshark"
0008533F | E8 29BEFFFF | call 0x8116D | 调用 strcpy
00085344 | 83C4 08 | add esp,0x8 | 调整栈顶
00085347 | 83EC 28 | sub esp,0x28 | main.cpp:24
0008534A | B9 0A000000 | mov ecx,0xA | 设置填充大小,循环十次
0008534F | 8D75 D0 | lea esi,dword ptr ss:[ebp-0x30] | 获取对象的首地址并保存到esi中
00085352 | 8BFC | mov edi,esp | 设置edi为当前栈顶
00085354 | F3:A5 | rep movsd |
00085356 | E8 20BFFFFF | call 0x8127B |
0008535B | 83C4 28 | add esp,0x28 |
void ShowFunction(CFunction fun)
000837C0 | 8D45 10 | lea eax,dword ptr ss:[ebp+0x10] | 取 m_nName[32];
000837C3 | 50 | push eax | eax:"lyshark"
000837C4 | 8B4D 0C | mov ecx,dword ptr ss:[ebp+0xC] | 取 m_nTwo;
000837C7 | 51 | push ecx |
000837C8 | 8B55 08 | mov edx,dword ptr ss:[ebp+0x8] | 取 m_nOne;
000837CB | 52 | push edx |
000837CC | 68 6CCC0800 | push consoleapplication2.8CC6C | 8CC6C:"%d -->%d --> %s \n"
000837D1 | FF15 84010900 | call dword ptr ds:[<&printf>] |
000837D7 | 83C4 10 | add esp,0x10 |
对象作为函数参数传递(新): 对象作为函数参数时,其传递过程与数组不同,数组变量的名称就代表了数组的首地址,而对象变量名称却无法代表对象的首地址,传参时不会像数组那样以首地址作为参数传递,而是先将对象中的所有数据进行复制,然后将复制的数据作为形参传递到调用函数中使用.
#include <iostream>
using namespace std;
class CFunction
{
public:
int m_nOne;
int m_nTwo;
char m_nName[32];
};
void ShowFunction(CFunction fun) // 此处就是对象作为函数参数
{
printf("%d -->%d --> %s \n", fun.m_nOne, fun.m_nTwo,fun.m_nName);
}
int main(int argc, char* argv[])
{
CFunction fun;
fun.m_nOne = 1;
fun.m_nTwo = 2;
strcpy(fun.m_nName, "lyshark");
ShowFunction(fun);
system("pause");
return 0;
}
编译代码并反汇编,然后来到Main函数,首先我们对类中的数据成员依次赋值,前两个是整数类型则直接赋值即可,最后一个char m_nName[32];
因为是数组类型,则需要通过lea取出其首地址然后将该地址压入堆栈,当数据被初始化完成以后,则开始调用ShowFunction(fun);
函数,调用之前先来sub esp,0x28
开辟局部空间,之所以需要开辟0x28的空间是因为类的大小是int + int + char[32] = 0x28
,通过调用rep movsd
指令将参数拷贝到sub esp,0x28
的堆栈中.
004152CE | C745 D4 01000000 | mov dword ptr ss:[ebp-0x2C],0x1 | 第一个参数:int m_nOne;
004152D5 | C745 D8 02000000 | mov dword ptr ss:[ebp-0x28],0x2 | 第二个参数:int m_nTwo;
004152DC | 68 84CC4100 | push consoleapplication2.41CC84 | push 字符串 "lyshark"
004152E1 | 8D45 DC | lea eax,dword ptr ss:[ebp-0x24] | 第三个参数: char m_nName[32];
004152E4 | 50 | push eax | 将拷贝地址压栈
004152E5 | E8 83BEFFFF | call 0x41116D | 调用 strcpy(dst,src)
004152EA | 83C4 08 | add esp,0x8 | 拷贝结束后,调整堆栈
004152ED | 83EC 28 | sub esp,0x28 | 开辟局部空间,与类CFunction数据成员一致
004152F0 | B9 0A000000 | mov ecx,0xA | 设置填充大小
004152F5 | 8D75 D4 | lea esi,dword ptr ss:[ebp-0x2C] | 取出类CFunction数据成员首地址
004152F8 | 8BFC | mov edi,esp | 当前堆栈栈帧给EDI
004152FA | F3:A5 | rep movsd | 拷贝到临时空间里
004152FC | E8 7ABFFFFF | call 0x41127B | 调用CFunction函数
00415301 | 83C4 28 | add esp,0x28 | 堆栈平衡
接着我们继续跟进到call 0x41127B
过程中,由于在Main函数中我们已经将类对象的数据成员全部压入堆栈保存了,所以在内部过程中只需要通过ebp+0x
的方式即可找到传递过来的参数.
00413780 | 8D45 10 | lea eax,dword ptr ss:[ebp+0x10] | 取出三个参数 char m_nName[32];
00413783 | 50 | push eax |
00413784 | 8B4D 0C | mov ecx,dword ptr ss:[ebp+0xC] | 取出第二个参数 int m_nTwo;
00413787 | 51 | push ecx |
00413788 | 8B55 08 | mov edx,dword ptr ss:[ebp+0x8] | 取出第一个参数 int m_nOne;
0041378B | 52 | push edx |
0041378C | 68 6CCC4100 | push consoleapplication2.41CC6C | 41CC6C:"%d -->%d --> %s \n"
00413791 | FF15 84014200 | call dword ptr ds:[<&printf>] |
00413797 | 83C4 10 | add esp,0x10 |
对象作为返回值传递: 当对象作为函数参数返回时,我们并不能通过EAX寄存器返回,因为对象是一个复杂的数据结构,显然寄存器EAX无法保存对象中的所有数据,所以在函数返回时,寄存器EAX不能满足需求.
对象作为返回值与对象作为参数的处理方式类似,对象作为参数时,进入函数前预先将对象使用的栈空间保留出来,并将实参对象中的数据复制到栈空间中,该栈空间作为函数参数,用于函数内部的使用.
对象作为返回值时,进入函数后将申请返回对象使用的栈空间,在退出函数时,将返回对象中的数据复制到临时的栈空间中,以这个临时栈空间的首地址作为返回值返回给上层函数使用,首先编译如下代码,我来给大家解释这段话的意思:
#include <iostream>
using namespace std;
class CReturn
{
public:
int m_nNumber; // 占用4字节
int m_nArray[10]; // 占用 4*10 = 40 字节
}; // 该类总大小为44字节
CReturn GetCReturn(){
CReturn RetObj;
RetObj.m_nNumber = 0;
for (int x = 0; x < 10; x++)
{
RetObj.m_nArray[x] = x + 1;
}
return RetObj; // 此处返回一个对象
}
int main(int argc, char* argv[])
{
CReturn obj;
obj = GetCReturn();
printf("类的首地址: 0x%x\n", &obj);
for (int x = 0; x < 10; x++)
{
printf("输出元素: %d \n", obj.m_nArray[x]);
}
system("pause");
return 0;
}
将上方代码反汇编观察,此处我们只关心GetCReturn()
函数调用后的返回部分,首先GetCReturn函数的内部定义了一个CReturn RetObj;
类,只要有这样的定义其默认都会在编译时自动分配 sub esp,0x104
一段堆栈空间,其次当内层GetCReturn
函数执行完毕以后,返回到上层Main
函数之前会将内层类的堆栈数据自动填充到外层堆栈中,然后将外层堆栈的首地址作为指针传递到EAX寄存器中,以此来实现类数据成员的传递,此处可能不太好理解,其实就是内部类的数据运算完毕以后会直接拷贝到外部类的堆栈空间中,外部类则直接遍历自己的堆栈空间就可以知道内部类的执行结果,从而实现结构的传递.
int main(int argc, char* argv[])
00415350 | 55 | push ebp | main.cpp:22
00415351 | 8BEC | mov ebp,esp |
00415353 | 81EC 6C010000 | sub esp,0x16C | 提前分配的栈空间
..........
0041537F | E8 28C0FFFF | call 0x4113AC | 调用GetCReturn函数
00415384 | 83C4 04 | add esp,0x4 |
00415387 | B9 0B000000 | mov ecx,0xB | 外部设置填充大小
0041538C | 8BF0 | mov esi,eax | 设置ESI源指针
0041538E | 8DBD 98FEFFFF | lea edi,dword ptr ss:[ebp-0x168] | 拷贝到EDI里面
00415394 | F3:A5 | rep movsd | 开始填充临时空间 GetCReturn()
00415396 | B9 0B000000 | mov ecx,0xB | B:'\v'
0041539B | 8DB5 98FEFFFF | lea esi,dword ptr ss:[ebp-0x168] | 最后将临时空间里的内容取出
004153A1 | 8D7D CC | lea edi,dword ptr ss:[ebp-0x34] | 拷贝到EDI里面
004153A4 | F3:A5 | rep movsd | 相当于: obj = GetCReturn();
004153A6 | 8BF4 | mov esi,esp | main.cpp:25
004153A8 | 8D45 CC | lea eax,dword ptr ss:[ebp-0x34] | 获取到类返回值
004153AB | 50 | push eax |
004153AC | 68 6CCC4100 | push consoleapplication2.41CC6C | 41CC6C:"类的首地址: 0x%x\n"
004153B1 | FF15 80014200 | call dword ptr ds:[<&printf>] |
004153B7 | 83C4 08 | add esp,0x8 |
CReturn GetCReturn()
00413790 | 55 | push ebp | main.cpp:11
00413791 | 8BEC | mov ebp,esp |
00413793 | 81EC 04010000 | sub esp,0x104 | 提前分配的栈空间
..........
004137E6 | B9 0B000000 | mov ecx,0xB | 设置填充大小
004137EB | 8D75 CC | lea esi,dword ptr ss:[ebp-0x34] | 取出计算出来的结果的首地址
004137EE | 8B7D 08 | mov edi,dword ptr ss:[ebp+0x8] | 取出上层堆栈类的首地址
004137F1 | F3:A5 | rep movsd | 开始覆盖
004137F3 | 8B45 08 | mov eax,dword ptr ss:[ebp+0x8] | 获取到分配后的首地址
004137F6 | 52 | push edx | main.cpp:19
004137F7 | 8BCD | mov ecx,ebp |
004137F9 | 50 | push eax | 保存外层类首地址
在调用GetCReturn()
函数之前,编译器将在Main函数中提前申请了一块用于存储返回对象的空间,接着我们在GetCReturn()
函数内部定义了CReturn RetObj;
对象,当GetCReturn
函数调用结束后会进行数据复制,将GetCReturn
函数中定义的局部对象RetObj
中的数据复制到外部的CReturn obj
对象的空间中,然后将外层堆栈的首地址作为指针传递到EAX寄存器中,外层的Main
函数接收到这个EAX寄存器指针,则可以拿着该指针遍历到类中的所有数据成员.
嗯。。。表述的不太清晰,再来表述一遍。。。。。。。。。。。
如下是Main函数代码,在调用GetCReturn()
函数之前,编译器将在Main函数中提前申请sub esp,0x16C
一段堆栈空间,用于存储返回对象的成员,接着主函数开始调用call 0x4113AC
并根据调用约定将返回对象中数据成员的指针放到EAX并将源指针ESI也指向EAX,然后设置目标EDI指针,最后执行rep movsd
将GetCReturn
函数堆栈中的数据拷贝到Main函数的堆栈中,之所以需要复制一份是因为,我们的成员函数在执行完毕后就返回了堆栈会被释放,无法保证返回值所指向地址的数据的正确性,所以需要在外部保存一份.
00415350 | 55 | push ebp | main.cpp:22
00415351 | 8BEC | mov ebp,esp |
00415353 | 81EC 6C010000 | sub esp,0x16C | 提前分配的栈空间
.........| ............. | .............. |
0041537F | E8 28C0FFFF | call 0x4113AC | 调用GetCReturn函数
00415384 | 83C4 04 | add esp,0x4 | 平衡参数
00415387 | B9 0B000000 | mov ecx,0xB | 设置填充大小
0041538C | 8BF0 | mov esi,eax | 设置ESI源指针
0041538E | 8DBD 98FEFFFF | lea edi,dword ptr ss:[ebp-0x168] | 拷贝到EDI里面
00415394 | F3:A5 | rep movsd | 开始填充临时空间 GetCReturn()
00415396 | B9 0B000000 | mov ecx,0xB |
0041539B | 8DB5 98FEFFFF | lea esi,dword ptr ss:[ebp-0x168] | 最后将临时空间里的内容取出
004153A1 | 8D7D CC | lea edi,dword ptr ss:[ebp-0x34] | 拷贝到EDI指针里面
004153A4 | F3:A5 | rep movsd | 相当于调用: obj = GetCReturn();
004153A6 | 8BF4 | mov esi,esp | main.cpp:25
004153A8 | 8D45 CC | lea eax,dword ptr ss:[ebp-0x34] | 获取到obj的首地址指针
004153AB | 50 | push eax |
004153AC | 68 6CCC4100 | push consoleapplication2.41CC6C | 41CC6C:"类的首地址: 0x%x\n"
004153B1 | FF15 80014200 | call dword ptr ds:[<&printf>] |
004153B7 | 83C4 08 | add esp,0x8 |
接着我们再来看一下GetCReturn()
函数的内部做了什么,首先我们在代码中同样定义了CReturn RetObj;
对象,所以其也会通过sub esp,0x104
分配临时栈空间,接着就是运算并填充这段空间,最后将计算后的结果拷贝到上层Main函数提前申请的sub esp,0x16C
堆栈中,最后内层返回EAX,该寄存器里面存放的就是外层数据成员的首地址.
00413790 | 55 | push ebp | main.cpp:11
00413791 | 8BEC | mov ebp,esp |
00413793 | 81EC 04010000 | sub esp,0x104 | 提前分配的栈空间
.........| ............. | .............. |
004137E6 | B9 0B000000 | mov ecx,0xB | 设置填充大小
004137EB | 8D75 CC | lea esi,dword ptr ss:[ebp-0x34] | 取出计算出来的结果的首地址
004137EE | 8B7D 08 | mov edi,dword ptr ss:[ebp+0x8] | 取出main函数中堆栈类的首地址
004137F1 | F3:A5 | rep movsd | 开始覆盖
004137F3 | 8B45 08 | mov eax,dword ptr ss:[ebp+0x8] | 获取到分配后的首地址
004137F6 | 52 | push edx | main.cpp:19
004137F7 | 8BCD | mov ecx,ebp |
004137F9 | 50 | push eax | 保存外层类数据成员的首地址,最后返回
在GetCReturn()
函数内部定义了CReturn RetObj;
对象,当GetCReturn
函数调用结束后会进行数据复制,将GetCReturn
函数中定义的局部对象RetObj
中的数据复制到外部的CReturn obj
对象的空间中,然后将外层堆栈的首地址作为指针传递到EAX寄存器中,外层的Main
函数接收到这个EAX寄存器指针,则可以拿着该指针遍历到类中的所有数据成员.
此处我们只关心GetCReturn()
函数调用后的返回部分,首先GetCReturn函数的内部定义了一个CReturn RetObj;
类,只要有这样的定义其默认都会在编译时自动分配 sub esp,0x104
一段堆栈空间,其次当内层GetCReturn
函数执行完毕以后,返回到上层Main
函数之前会将内层类的堆栈数据自动填充到外层堆栈中,然后将外层堆栈的首地址作为指针传递到EAX寄存器中,以此来实现类数据成员的传递,此处可能不太好理解,其实就是内部类的数据运算完毕以后会直接拷贝到外部类的堆栈空间中,外部类则直接遍历自己的堆栈空间就可以知道内部类的执行结果,从而实现结构的传递.
分析构造/析构函数
构造函数与析构函数是类的重要组成部分,其中构造函数主要用于在对象创建时对数据成员的初始化工作,析构函数则主要负责在对象销毁后释放对象中所申请的各种资源,构造函数与析构函数都是类中特殊的成员函数,构造函数支持传参且返回值是对象首地址,析构函数则不能传递任何参数且不能定义返回值.
局部对象(构造函数): 局部对象下的构造函数的出现时机很好识别,当对象产生时,就会自动的触发构造函数,编译器隐藏了这一过程,我们可以编写一个简单地案例,来逆向分析其中的奥秘.
#include <iostream>
using namespace std;
class CFunction
{
public:
int x_pos;
int y_pos;
public: CFunction(){
x_pos = 10;
y_pos = 20;
}
};
int main(int argc, char* argv[])
{
CFunction num;
printf("X坐标: %d Y坐标: %d", num.x_pos,num.y_pos);
system("pause");
return 0;
}
反汇编后观察以下核心代码,可以看到当进入Main函数时,首先执行的就是取出对象的首地址,并调用了call 0x4111FE
构造函数,然后进入到函数内部,因为构造函数也是成员函数,所以会通过pop ecx
取出this指针,构造函数调用结束后,会将this指针作为返回值mov eax,dword ptr ss:[ebp-0x8]
返回到上层Main函数中,所以说返回this指针就是构造函数的特征.
int main(int argc, char* argv[])
0041529E | 8D4D F4 | lea ecx,dword ptr ss:[ebp-0xC] | 取对象首地址
004152A1 | E8 58BFFFFF | call 0x4111FE | 调用构造函数
004152A6 | 8BF4 | mov esi,esp | main.cpp:19, esi:__enc$textbss$end+27B
004152A8 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | 输出X坐标
004152AB | 50 | push eax |
004152AC | 8B4D F4 | mov ecx,dword ptr ss:[ebp-0xC] | 输出Y坐标
004152AF | 51 | push ecx |
004152B0 | 68 6CCC4100 | push consoleapplication2.41CC6C | 41CC6C:"X坐标: %d Y坐标: %d"
004152B5 | FF15 80014200 | call dword ptr ds:[<&printf>] |
004152BB | 83C4 0C | add esp,0xC |
public: CFunction()
0041301F | 59 | pop ecx | 恢复对象首地址
00413020 | 894D F8 | mov dword ptr ss:[ebp-0x8],ecx |
00413023 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | main.cpp:11
00413026 | C700 0A000000 | mov dword ptr ds:[eax],0xA | 将x_pos设置为10
0041302C | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | main.cpp:12
0041302F | C740 04 14000000 | mov dword ptr ds:[eax+0x4],0x14 | 将y_pos设置为20
00413036 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | 将this指针存入eax返回
00413039 | 5F | pop edi |
0041303A | 5E | pop esi | esi:__enc$textbss$end+27B
0041303B | 5B | pop ebx |
0041303C | 8BE5 | mov esp,ebp |
0041303E | 5D | pop ebp |
0041303F | C3 | ret |
接着我们在上面代码基础上给构造函数传递三个参数,将C代码稍微修改以下,编译并观察其发生的变化.
#include <iostream>
using namespace std;
class CFunction
{
public:
int x_pos;
int y_pos;
char *string;
public: CFunction(int x,int y,char *str){
x_pos = x;
y_pos = y;
strcpy(string, str);
}
};
int main(int argc, char* argv[])
{
CFunction num1(10,20,"admin");
printf("X坐标: %d Y坐标: %d 字符串: %s \n", num1.x_pos, num1.y_pos,num1.string);
CFunction num2(100, 200,"lyshark");
printf("X坐标: %d Y坐标: %d 字符串: %s \n", num2.x_pos, num2.y_pos,num2.string);
system("pause");
return 0;
}
堆对象(构造函数): 堆对象空间的申请需要使用malloc,new
等函数,例如我们可以CNumber *pNumber = new CNumber;
来申请类型为Cnumber
类的一个对象,使用指针PNumber
保存对象首地址,我们来看以下代码做具体的分析.
#include <iostream>
using namespace std;
class CNumber
{
public:
int x_pos;
int y_pos;
public:CNumber(){ // 定义无参构造函数
this->x_pos = 10; // 对函数赋值
this->y_pos = 20;
printf("X_pos: %d Y_pos: %d \n", this->x_pos,this->y_pos);
}
};
int main(int argc, char* argv[])
{
CNumber *pNumber = NULL; // 定义一个指向CNumber的指针
pNumber = new CNumber; // 初始化堆变量
pNumber->x_pos = 100;
printf("PNumber X: %d PNumber Y: %d \n", pNumber->x_pos, pNumber->y_pos);
system("pause");
return 0;
}
观察反汇编代码,首先我们通过call 0x41137F
函数申请一段堆空间,该new函数有一个参数push 0x8
这里的8字节是CNumber
类的数据成员总大小,分配完成以后会执行je 0x41532D
判断是否分配成功,成功则执行call 0x4111B8
构造函数并返回对象的首地址,失败则不会执行构造函数并将指针填充为0,我们可以通过是否存在双分支来推测构造函数的具体位置.
004152F3 | C745 EC 00000000 | mov dword ptr ss:[ebp-0x14],0x0 | 此处就是指针 CNumber *pNumber 初始化为0
004152FA | 6A 08 | push 0x8 | 压入类的大小,用于堆内存申请
004152FC | E8 7EC0FFFF | call 0x41137F | 调用 new 分配临时空间
00415301 | 83C4 04 | add esp,0x4 |
00415304 | 8985 20FFFFFF | mov dword ptr ss:[ebp-0xE0],eax | 使用临时变量保存new返回值
0041530A | C745 FC 00000000 | mov dword ptr ss:[ebp-0x4],0x0 | 保存申请堆空间的次数
00415311 | 83BD 20FFFFFF 00 | cmp dword ptr ss:[ebp-0xE0],0x0 | 检测堆是否分配成功
00415318 | 74 13 | je 0x41532D | 失败则跳过构造函数的执行
0041531A | 8B8D 20FFFFFF | mov ecx,dword ptr ss:[ebp-0xE0] | 成功,则将对象首地址传入ECX中
00415320 | E8 93BEFFFF | call 0x4111B8 | 调用构造函数 CNumber
00415325 | 8985 0CFFFFFF | mov dword ptr ss:[ebp-0xF4],eax | 构造函数返回的this指针
0041532B | EB 0A | jmp 0x415337 |
0041532D | C785 0CFFFFFF 00000000 | mov dword ptr ss:[ebp-0xF4],0x0 | 申请失败,将指针设置为0
00415337 | 8B85 0CFFFFFF | mov eax,dword ptr ss:[ebp-0xF4] | 取出构造函数返回的this指针
局部对象(析构函数): 析构函数的作用就是在对象执行完毕以后完成一定的清理任务,通常析构函数只出现在C++语言中,且析构函数不能接收参数也不能返回数据,接下来我们来探索一下析构函数的汇编形态.
#include <iostream>
using namespace std;
class CFunction
{
public:
int x_pos;
int y_pos;
CFunction(int x,int y){
x_pos = x;
y_pos = y;
printf("构造函数执行: %d \n", x_pos);
}
~CFunction(){
printf("调用析构函数.");
}
};
int main(int argc, char* argv[])
{
CFunction num(10,20);
return 0;
}
相比于构造函数来说析构函数的执行就更加简单了,析构函数不存在任何参数传递更不会返回任何值,并且会在类的最后面被执行,寻找析构函数只需要照着这两点找一般都会准确地被定位到,需要注意析构函数虽然没参数传递,但是this指针还是会存在的.
0041534E | 6A 14 | push 0x14 | main.cpp:22
00415350 | 6A 0A | push 0xA |
00415352 | 8D4D F4 | lea ecx,dword ptr ss:[ebp-0xC] | ecx:__enc$textbss$end+27B
00415355 | E8 0EBEFFFF | call 0x411168 | 本类中的构造函数
0041535A | C785 28FFFFFF 00000000 | mov dword ptr ss:[ebp-0xD8],0x0 | main.cpp:23
00415364 | 8D4D F4 | lea ecx,dword ptr ss:[ebp-0xC] | ecx:__enc$textbss$end+27B
00415367 | E8 A8BCFFFF | call 0x411014 | 本类中的析构函数
堆对象(析构函数): 上方构造函数中我们说过,可以使用new
申请堆空间,如果我们需要释放这段堆空间则需要使用delete
关键字来完成,我们先来看看释放堆空间前调用析构函数的过程.
#include <iostream>
using namespace std;
class CFunction
{
public:
int x_pos;
int y_pos;
};
int main(int argc, char* argv[])
{
CFunction *pNumber = NULL;
pNumber = new CFunction();
pNumber->x_pos = 10;
pNumber->y_pos = 20;
printf("传递参数: %d \n", pNumber->x_pos);
if (pNumber != NULL){
delete pNumber;
pNumber = NULL;
}
return 0;
}
当堆对象使用完以后需要手动的释放这段空间,通常是获取到堆对象的首地址,然后通过调用delete
函数完成对堆对象的释放,如下是反汇编代码.
004152D6 | 837D F8 00 | cmp dword ptr ss:[ebp-0x8],0x0 | 判断堆是否分配成功
004152DA | 74 1F | je 0x4152FB |
004152DC | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | 取堆空间首地址
004152DF | 8985 2CFFFFFF | mov dword ptr ss:[ebp-0xD4],eax |
004152E5 | 8B8D 2CFFFFFF | mov ecx,dword ptr ss:[ebp-0xD4] | 将堆空间首地址放入ecx
004152EB | 51 | push ecx | ecx:__enc$textbss$end+276
004152EC | E8 36BEFFFF | call 0x411127 | 执行代理函数,释放堆
004152F1 | 83C4 04 | add esp,0x4 |
004152F4 | C745 F8 00000000 | mov dword ptr ss:[ebp-0x8],0x0 | pNumber = NULL;
多态与虚函数
多态性是面向对象的重要组成部分,利用多态可以设计和实现易于扩展的程序,所谓多态顾名思义就是一个类函数有多重形态,在C++中多态的意思是,具有不同功能的函数可以用同一个函数名,实现使用一个函数名调用不同内容的函数,从而返回不同的结果,这就是多态性,从系统实现的角度来分析,多态性可分为两类,静态多态与动态多态:
静态多态: 通常是通过函数或运算法重载实现的,静态多态性又称作编译时的多态性.
动态多态: 动态多态性不在编译时确定调用函数的功能,而是通过虚函数实现,它又被叫做运行时的多态性.
多数情况下静态多态使用不多,我们主要关注动态多态性,在C++中使用关键字virtual
声明函数为虚函数,当类中定义有虚函数时,编译器会将该类中所有虚函数的首地址保存在一张地址表中,这张地址表被称为虚函数表
,同时编译器还会在类中添加一个隐藏数据成员,称为虚表指针
,该指针中保存着虚表的首地址,用于记录和查找虚函数.
#include <iostream>
using namespace std;
class CVirtual
{
private: int m_Number; // 整数占用4字节
public: virtual int GetNumber(){
return m_Number;
}
public: virtual void SetNumber(int num){
m_Number = num;
}
};
int main(int argc, char* argv[])
{
CVirtual cv;
return 0;
}
如上代码片段中,我们只定义了一个m_number
的数据成员,如果此时该类中没有定义虚函数的情况下,则类的大小为4字节,但如果我们定义了虚函数,那么编译器会自动为我们增加一个隐藏的数据成员来用作虚表指针,因此该类的大小将变为8字节,虚表指针所指向的就是虚函数的指针数组,里面放着所有虚函数的首地址.,而这一切对程序员来说都是透明的,他们被蒙蔽了双眼。
int main(int argc, char* argv[])
0041532E | 8D4D F4 | lea ecx,dword ptr ss:[ebp-0xC] | 取对象首地址
00415331 | E8 B2C0FFFF | call 0x4113E8 | 调用默认构造函数
00415336 | 33C0 | xor eax,eax | main.cpp:22
class CVirtual --> call 0x4113E8
0041301F | 59 | pop ecx | 还原对象指针
00413020 | 894D F8 | mov dword ptr ss:[ebp-0x8],ecx | 存储this指针
00413023 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | 取出this指针,并作为虚表首地址
00413026 | C700 70CC4100 | mov dword ptr ds:[eax],<consoleapplic | 取出虚函数指针,放入变量中存储
0041302C | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | [ebp-8]:"p藺"
0041302F | 5F | pop edi |
观察上面的反汇编代码,你会发现我们并没有在类中定义构造函数,但是编译器还是为我们加上了一个默认构造函数,该构造函数是必须要存在的,因为虚函数指针的获取需要在类被创建时赋值到堆栈里,所以此处的默认构造函数就是用来初始化虚函数指针的,另外值得注意的是虚函数地址是编译时固定到文件里的,一般虚函数地址是不会发生变化的.
继续跟进看看,我们定义的两个虚函数地址,可以看到了。
类继承与派生
面向对象中非常重要的特性之一包括类之间的继承,至于为什么出现继承,主要是为了提高代码的可用性降低冗余代码,说白了就是让你更好的偷懒,开发效率更快,让你的头发少掉一些,在继承体系中,通常分为父类与子类,父类就是基类子类就是派生类.
定义简单派生类: 编译以下代码,我们主要观察父类与子类之间是如何被关联到一起的.
#include <iostream>
using namespace std;
class CBase // 定义父类
{
public:
int m_nBase;
public:
CBase(){ printf("CBase 父类构造函数 \n"); }
~CBase(){ printf("CBase 父类析构函数 \n"); }
int GetBaseNumber(){ return m_nBase; }
};
class CDervie :public CBase // 定义派生类
{
public:
int pos_x;
int pos_y;
public:
CDervie(){ printf("CDervie 子类构造函数 \n"); }
~CDervie(){ printf("CDervic 子类析构函数 \n"); }
int GetCDervie(){ return pos_x; };
};
int main(int argc, char* argv[])
{
CDervie cd;
return 0;
}
观察反汇编代码,原来的CBase
父类成为了CDervie
的一个成员对象,当我们创建CDervie
类的对象时,将会在派生类中产生成员对象int m_nBase;
接着就会自动调用CBase
类中的构造函数,当CDervie类没有构造函数时,编译器同样会提供默认构造函数,以实现继承.当子类被销毁时其父类也会被销毁,同样按照顺序执行析构函数.
int main(int argc, char* argv[])
0041540E | 8D4D F0 | lea ecx,dword ptr ss:[ebp-0x10] | 传递 this指针
00415411 | E8 3FBCFFFF | call 0x411055 | 子类 CDervie 构造函数
00415416 | C785 24FFFFFF 00000000 | mov dword ptr ss:[ebp-0xDC],0x0 | main.cpp:28, [ebp-DC]:&"Y]A"
00415420 | 8D4D F0 | lea ecx,dword ptr ss:[ebp-0x10] |
00415423 | E8 28BCFFFF | call 0x411050 | 子类 CDervie 析构函数
call 0x411055 --> CDervie
0041308F | 59 | pop ecx | 获得this指针
00413090 | 894D F8 | mov dword ptr ss:[ebp-0x8],ecx |
00413093 | 8B4D F8 | mov ecx,dword ptr ss:[ebp-0x8] | 传递this指针
00413096 | E8 71E2FFFF | call 0x41130C | 调用父类 CBase 构造函数
main函数里面执行的操作。
进入 call 0x411055
进入父类构造函数
子类调用父类函数: 两个类之间同为父子关系,定义子类并调用父类,观察反汇编代码的展现方式.
#include <iostream>
using namespace std;
class CBase // 定义父类
{
public:
int m_nBase;
public:
CBase(){ printf("CBase 父类构造函数 \n"); }
~CBase(){ printf("CBase 父类析构函数 \n"); }
int GetBaseNumber(){ return m_nBase; }
};
class CDervie :public CBase // 定义派生类
{
public:
int pos_x;
int pos_y;
public:
CDervie(){ printf("CDervie 子类构造函数 \n"); }
~CDervie(){ printf("CDervic 子类析构函数 \n"); }
int GetCDervie(){ return pos_x; };
};
int main(int argc, char* argv[])
{
CDervie cd;
cd.m_nBase = 5;
cd.pos_x = 10;
cd.pos_y = 20;
int x = cd.GetBaseNumber();
printf("调用返回值: %d \n", x);
return 0;
}
观察代码,发现当我们在子类中调用父类方法GetBaseNumber()
时,编译器直接取到了父类中函数的地址并直接call 0x4111DB
,传递的this指针则是子类的指针.
00415473 | 8D4D E4 | lea ecx,dword ptr ss:[ebp-0x1C] | 取出this指针
00415476 | E8 DABBFFFF | call 0x411055 | 定义 CDervie
0041547B | C745 FC 00000000 | mov dword ptr ss:[ebp-0x4],0x0 |
00415482 | C745 E4 05000000 | mov dword ptr ss:[ebp-0x1C],0x5 | cd.m_nBase
00415489 | C745 E8 0A000000 | mov dword ptr ss:[ebp-0x18],0xA | cd.pos_x = 10;
00415490 | C745 EC 14000000 | mov dword ptr ss:[ebp-0x14],0x14 | cd.pos_y = 20;
00415497 | 8D4D E4 | lea ecx,dword ptr ss:[ebp-0x1C] | 再次取this指针
0041549A | E8 3CBDFFFF | call 0x4111DB | 调用 CBase.GetBaseNumber()
0041549F | 8945 D8 | mov dword ptr ss:[ebp-0x28],eax |
类的多级继承: 上面的案例仅仅只是继承自一个类,接着我们来编写以下代码,首先定义派生类CDervie
并继承自基类CBase,MBase
.
#include <iostream>
using namespace std;
class CBase // 定义父类1
{
public:
virtual void display(){ printf("我是基类CBase中的display"); }
virtual void Run(){ printf("我是基类CBase中的Run"); }
};
class MBase // 定义父类2
{
public:
virtual void display(){ printf("我是基类MBase中的display"); }
virtual void Run(){ printf("我是基类MBase中的Run"); }
};
class CDervie :public CBase,public MBase // 定义派生类,多继承
{
public:
void display(){ printf("我是子类中的display"); }
void Run(){ printf("我是子类中的 Run"); }
};
int main(int argc, char* argv[])
{
CDervie cd;
cd.display();
return 0;
}
通过反汇编观察我们可以看出,其实继承多个类就是将CBase,MBase
父类全部合并到CDervie
这个派生类中.
004130AF | 59 | pop ecx | ecx:"橥N"
004130B0 | 894D F8 | mov dword ptr ss:[ebp-0x8],ecx |
004130B3 | 8B4D F8 | mov ecx,dword ptr ss:[ebp-0x8] |
004130B6 | E8 6FE2FFFF | call 0x41132A | 执行CBase的构造函数
004130BB | 8B4D F8 | mov ecx,dword ptr ss:[ebp-0x8] |
004130BE | 83C1 04 | add ecx,0x4 | ecx:"橥N"
004130C1 | E8 A7E0FFFF | call 0x41116D | 执行MBase的构造函数
004130C6 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] |
我们以第一个call 0x41132A
构造函数为例,进入函数内部继续观察,其他地方都与单继承相同,唯一不同点在于,此处子类自身构造中会复写两次虚表.
004130AF | 59 | pop ecx | 获取this指针
004130B0 | 894D F8 | mov dword ptr ss:[ebp-0x8],ecx | [ebp-8]:&"橥N"
004130B3 | 8B4D F8 | mov ecx,dword ptr ss:[ebp-0x8] | [ebp-8]:&"橥N"
004130B6 | E8 6FE2FFFF | call 0x41132A | 执行CBase构造函数
004130BB | 8B4D F8 | mov ecx,dword ptr ss:[ebp-0x8] | [ebp-8]:&"橥N"
004130BE | 83C1 04 | add ecx,0x4 |
004130C1 | E8 A7E0FFFF | call 0x41116D | 执行MBase构造函数
004130C6 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | [ebp-8]:&"橥N"
004130C9 | C700 08CD4100 | mov dword ptr ds:[eax],0x41CD08 for CBase | 执行初始化CBase虚表
004130CF | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | [ebp-8]:&"橥N"
004130D2 | C740 04 18CD4100 | mov dword ptr ds:[eax+0x4],0x41CD18 MBase | 执行初始化MBase虚表
004130D9 | 8B45 F8 | mov eax,dword ptr ss:[ebp-0x8] | [ebp-8]:&"橥N"
虚函数实现多态: 首先我们编译下面一段代码,我们想通过改变类指针的方式分别让基类与子类中的同名函数打印出来.
#include <iostream>
using namespace std;
class CBase // 定义父类
{
public:
void display(){ printf("我是基类中的display\n"); }
};
class CDervie :public CBase // 定义派生类
{
public:
void display(){ printf("我是子类中的display\n"); }
};
int main(int argc, char* argv[])
{
CBase cb;
CDervie cd;
CBase *ptr = &cb;
ptr->display();
ptr = &cd; // 改变类指针
ptr->display();
return 0;
}
分析如下代码,我们在主函数中定义了指向基类对象的指针ptr,并将其指向CBase类然后使用ptr指针调用基类CBase对象函数,接着我们将ptr指针指向CDervie对象,想要调用CDervie对象中的display函数,发现无论如何都只能调用到CBase里面的display函数.
004152AE | 8D45 FB | lea eax,dword ptr ss:[ebp-0x5] | 获取this指针
004152B1 | 8945 E0 | mov dword ptr ss:[ebp-0x20],eax |
004152B4 | 8B4D E0 | mov ecx,dword ptr ss:[ebp-0x20] | 传递this指针
004152B7 | E8 8CC0FFFF | call 0x411348 | 调用CBase下面的Display
004152BC | 8D45 EF | lea eax,dword ptr ss:[ebp-0x11] | main.cpp:23
004152BF | 8945 E0 | mov dword ptr ss:[ebp-0x20],eax |
004152C2 | 8B4D E0 | mov ecx,dword ptr ss:[ebp-0x20] | main.cpp:24
004152C5 | E8 7EC0FFFF | call 0x411348 | 调用CBase下面的Display
004152CA | 33C0 | xor eax,eax | main.cpp:25
如果想要调用子类中的同名函数,只需要将CBase对象中的void display()
声明为虚函数virtual void display()
则就会允许在其派生类中对该函数重新定义,赋予它新的功能,并可以通过指向基类的指针调用到子类的同名函数.
#include <iostream>
using namespace std;
class CBase // 定义父类
{
public:
virtual void display(){ printf("我是基类中的display"); }
virtual void Run(){ printf("我是基类中的 Run"); }
};
class CDervie :public CBase // 定义派生类
{
public:
void display(){ printf("我是子类中的display"); }
void Run(){ printf("我是子类中的 Run"); }
};
int main(int argc, char* argv[])
{
CBase cb;
CDervie cd;
CBase *ptr = &cb;
ptr->display();
ptr->Run();
ptr = &cd;
ptr->display();
return 0;
}
观察反汇编代码,你或许会有些头绪,这里以CBase类中的两个虚函数为例,虚函数表中存储了这两个函数的首地址,我们只需要通过mov eax,dword ptr ds:[edx+0x4]
递增指针即可调用到不同的虚函数.
004154CE | 8D4D F8 | lea ecx,dword ptr ss:[ebp-0x8] |
004154D1 | E8 40BEFFFF | call 0x411316 | 调用CBase构造函数,初始化父虚表
004154D6 | 8D4D EC | lea ecx,dword ptr ss:[ebp-0x14] |
004154D9 | E8 72BBFFFF | call 0x411050 | 调用Dervie构造函数,初始化子虚表
004154DE | 8D45 F8 | lea eax,dword ptr ss:[ebp-0x8] | 获取虚表首地址
004154E1 | 8945 E0 | mov dword ptr ss:[ebp-0x20],eax |
004154E4 | 8B45 E0 | mov eax,dword ptr ss:[ebp-0x20] | main.cpp:24, [ebp-20]:"p藺"
004154E7 | 8B10 | mov edx,dword ptr ds:[eax] | 取出虚函数首地址
004154E9 | 8BF4 | mov esi,esp |
004154EB | 8B4D E0 | mov ecx,dword ptr ss:[ebp-0x20] |
004154EE | 8B02 | mov eax,dword ptr ds:[edx] | 取出第一个虚函数地址 display()
004154F0 | FFD0 | call eax | 调用虚函数
004154F2 | 3BF4 | cmp esi,esp |
004154F4 | E8 F5BDFFFF | call 0x4112EE |
004154F9 | 8B45 E0 | mov eax,dword ptr ss:[ebp-0x20] | 再次获取虚表首地址
004154FC | 8B10 | mov edx,dword ptr ds:[eax] |
004154FE | 8BF4 | mov esi,esp |
00415500 | 8B4D E0 | mov ecx,dword ptr ss:[ebp-0x20] | 取出虚函数首地址
00415503 | 8B42 04 | mov eax,dword ptr ds:[edx+0x4] | 在第一个虚函数基础上+4字节指针
00415506 | FFD0 | call eax | 调用第二个虚函数 Run()
00415508 | 3BF4 | cmp esi,esp |
纯虚函数的使用: 在虚函数的结尾加上=0
这种虚函数被称为纯虚函数,纯虚函数没有实现只有声明,它的存在就是为了让类具有虚基类的功能,让继承自虚基类的子类都具有虚表以及虚表指针,利用虚基类指针可以更好地完成多态的工作.
#include <iostream>
using namespace std;
class CVirtualBase
{
public: virtual void Show() = 0; // 定义纯虚函数
};
class CVirtualChild : public CVirtualBase{
public: virtual void Show(){ // 实现纯虚函数
printf("虚基类分析 \n");
}
};
int main(int argc, char* argv[])
{
CVirtualChild cvcd;
return 0;
}
其实纯虚函数就像一个占位符,在基类中霸占一段空间,在子类中实现其方法,但纯虚函数也是存在虚函数表,只不过该虚表默认是空表,因为该代码反汇编和前面所说的类相同,这里就不在分析了.