C++逆向之从内存角度看继承和多重继承

C++中,类之间的关系与现实社会非常相似。类的继承与派生是一个从抽象到具体的过程。抽象类没有实例。具体类可以存在实例。
指向父类对象的指针除了可以操作父类对象外,还能操作子类对象,指向子类对象的指针不能操作父类对象。
如果强制将父类对象的指针转化为子类对象的指针

1
CDerive *pDerive = (CDerive *)&base; //base为父类对象,CDerive继承自base

这条语句虽然可以编译通过,但是存在潜在的危险。

识别类和类之间的关系

在C++的继承关系中,子类具备父类所有的成员数据和成员函数。子类对象可以直接使用父类中声明为公有和保护的数据成员与成员函数。在父类中声明为私有的成员,虽然子类对象无法直接访问,但是在子类对象的内存结构中,父类私有的成员数据依然存在。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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include"stdafx.h"
class CBase
{
public:
CBase()
{
printf("CBase\n");
}
~CBase()
{
printf("~CBase\n");
}
void SetNumber(int nNumber)
{
m_nBase=nNumber;
}
int GetNumber()
{
return m_nBase;
}
private:
int m_nBase;
};
class CDervie : public CBase
{
public:
void ShowNumber(int nNumber)
{
SetNumber(nNumber);
m_nDerive = nNumber+1;
printf("%d\n",GetNumber());
printf("%d\n",m_nDerive);
}
private:
int m_nDerive;
};
int main(int argc,char *argv[])
{
CDervie Derive;
Derive.ShowNumber(argc);
return 0;
}

以上定义了两个具有继承关系的类。父类CBase中定义了数据成员m_nBase、构造函数、析构函数和两个成员函数。子类中只有一个成员函数ShowNumber和一个数据成员m_nDervie。根据C++的语法规则,子类CDervie将继承父类中的成员数据和成员函数。那么,当申请了子类对象Dervie时,它在内存中如何存储,又是如何使用父类成员函数?

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

1
2
3
4
5
6
7
8
9
10
11
12
0040108B   .  F3:AB         rep stos dword ptr es:[edi]
0040108D . 8D4D EC lea ecx,dword ptr ss:[ebp-0x14] ; 获取对象首地址作为this指针
00401090 . E8 7FFFFFFF call test.00401014 ; 调用类CDervie的构造函数,编译器为CDervie提供了默认的构造函数
00401095 . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0
0040109C . 8B45 08 mov eax,dword ptr ss:[ebp+0x8]
0040109F . 50 push eax
004010A0 . 8D4D EC lea ecx,dword ptr ss:[ebp-0x14]
004010A3 . E8 76FFFFFF call test.0040101E ; 调用.ShowNumber成员函数
004010A8 . C745 E8 00000>mov dword ptr ss:[ebp-0x18],0x0
004010AF . C745 FC FFFFF>mov dword ptr ss:[ebp-0x4],-0x1
004010B6 . 8D4D EC lea ecx,dword ptr ss:[ebp-0x14]
004010B9 . E8 51FFFFFF call test.0040100F ; 调用CDerive的析构函数,编译器为CDervie提供了默认的析构函数

子类默认析构函数

1
2
3
4
5
6
;函数入口略
004012C9 |. 59 pop ecx ; test.<ModuleEntryPoint>
004012CA |. 894D FC mov [local.1],ecx
004012CD |. 8B4D FC mov ecx,[local.1]
;调用父类析构函数
004012D0 |. E8 35FDFFFF call test.0040100A

对以上代码进行分析后发现,编译器提供了默认构造函数与析构函数。当子类中没有构造函数或析构函数,而其父类却需要构造函数与析构函数时,编译器会为该父类的子类提供默认的构造函数与析构函数。
由于子类继承了父类,因此子类中需要拥有父类的各成员,类似于在子类中定义了父类的对象作为数据成员使用。类关系如果装换成以下代码,则它们的内存结构等价。

1
2
3
4
5
6
7
class CBase{...};
class CDervie
{
public:
CBase m_Base; //原来的父类CBase成为成员对象
int m_nDervie; //原来的子类派生数据
}

原来的父类CBase成为了CDervie的一个成员对象,当产生CDervie类的对象时,将会先产生成员对象m_Base,这需要调用其构造函数。当CDervie类没有构造函数时,为了能够在CDervie类对象产生时调用成员对象的构造函数,编译器同样会提供默认构造函数,以实现成员构造函数的调用。
但是,如果子类含有构造函数,而父类不存在构造函数,则编译器不会为父类提供默认的构造函数。在构造子类时,由于父类中没有虚表指针,也不存在构造祖先类的问题,因此添加默认构造函数对父类没有任何意义。父类中含有虚函数的情况则不同,此时的父类需要初始化虚表工作,因此编译器会为其提供默认的构造函数,以初始化虚表指针。
当子类对象被销毁时,其父类也同时被销毁,为了可以调用父类的析构函数,编译器为子类提供了默认的析构函数。在子类的析构函数中,析构函数的调用顺序与构造函数相反,先执行自身的析构代码,再执行其父类的析构代码。
依照构造函数与析构函数的调用顺序,不仅可以顺藤摸瓜找出各类之间的关系,还可以根据调用顺序区别出构造函数与析构含糊是。
子类对象在内存中的数据排列为:先安排父类的数据,后安排子类新定义的数据。当类中定义了其他对象作为成员,并在初始化列表中指定了某个成员的初始值时,构造顺序又是怎样?

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 CInit
{
public:
CInit()
{
m_nNumber=0;
}
int m_nNumber;
};
class CDervie : public CBase
{
public:
CDervie():m_nDervie(1)
{
printf("使用初始化列表\n");
}
private:
CInit m_Init; //类中定义其他对作为成员
int m_nDervie;
};
int main(int argc,char *argv[])
{
CDervie Derive;
return 0;
}

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

1
2
00401078    8D4D F4         lea ecx,dword ptr ss:[ebp-0xC]           ; 传递this指针,调用CDervie的构造函数
0040107B E8 94FFFFFF call test.00401014

CDervie():m_nDervie(1)

1
2
3
4
5
6
7
8
9
10
11
12
13
0040112F    894D F0         mov dword ptr ss:[ebp-0x10],ecx          ; [ebp-10h]保存了this指针
00401132 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10] ; 传递this指针并调用父类构造函数
00401135 E8 EEFEFFFF call test.00401028
0040113A C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0 ; 调试版产生的对象计数代码,不必理会
00401141 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401144 83C1 04 add ecx,0x4 ; 根据this指针调整到类中定义的的对象m_nInit的首地址处,并调用其构造函数
00401147 E8 E1FEFFFF call test.0040102D
0040114C 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
0040114F C740 08 0100000>mov dword ptr ds:[eax+0x8],0x1
;最后才是执行CDervie的构造函数代码
00401156 68 3C604200 push test.0042603C ; ASCII "使用初始化列表\n"
0040115B E8 00070000 call test.printfgvdbgind_blockeressges
00401160 83C4 04 add esp,0x4

根据以上分析,在有初始化列表的情况下,将会优先执行初始化列表中的操作,其次才是自身的构造函数。构造的顺序为:先构造父类,然后按照声明顺序构造成员对象和初始化列表中指定的成员,最后才是自身的构造函数。

- Dervie对象内存结构 -
this+0 父类CBase部分 m_nBase
this+4 子类CDervie部分 m_nDervie

在使用父类成员函数时,传递的this指针也可以是子类对象首地址。因此,在父类中,可以根据以上内存结构将子类对象的首地址视为父类对象的首地址来对数据进行操作,而且不会出错。由于父类对象的长度不超过子类对象,而子类对象只要派生新的数据,其长度即可超过父类,因此子类指针的寻址范围不小于父类指针。在使用子类指针访问父类对象时,如果访问的成员数据是父类对象所定义的,那么不会出错;如果访问的是子类派生的成员数据,则会造成访问越界。

void ShowNumber(int nNumber)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0040111A    894D FC         mov dword ptr ss:[ebp-0x4],ecx                  ; [ebp-0x4]中保留了this指针
0040111D 8B45 08 mov eax,dword ptr ss:[ebp+0x8] ; 访问参数nNumber并保存到eax中
00401120 50 push eax
;由于this指针同时也是对象中父类部分的首地址,因此在调用父类成员函数时,this指针的值和子类对象等同
00401121 8B4D FC mov ecx,dword ptr ss:[ebp-0x4]
00401124 E8 09FFFFFF call test.00401032
00401129 8B4D 08 mov ecx,dword ptr ss:[ebp+0x8]
0040112C 83C1 01 add ecx,0x1 ; m_nDervie = nNumber+1
0040112F 8B55 FC mov edx,dword ptr ss:[ebp-0x4] ; edx获得this指针
00401132 894A 08 mov dword ptr ds:[edx+0x8],ecx
00401135 8B4D FC mov ecx,dword ptr ss:[ebp-0x4]
00401138 E8 FAFEFFFF call test.00401037 ; GetNumber();
0040113D 50 push eax
0040113E 68 3C604200 push test.0042603C ; ASCII "%d\n"
00401143 E8 18070000 call test.printfgvdbgumberlockeressges
00401148 83C4 08 add esp,0x8
0040114B 8B45 FC mov eax,dword ptr ss:[ebp-0x4]
0040114E 8B48 08 mov ecx,dword ptr ds:[eax+0x8]
00401151 51 push ecx
00401152 68 3C604200 push test.0042603C ; ASCII "%d\n"
00401157 E8 04070000 call test.printfgvdbgumberlockeressges
0040115C 83C4 08 add esp,0x8

void SetNumber(int nNumber)

1
2
3
4
5
004011A9    59              pop ecx                                         ; 还原this指针
004011AA 894D FC mov dword ptr ss:[ebp-0x4],ecx
004011AD 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; eax得到this指针
004011B0 8B4D 08 mov ecx,dword ptr ss:[ebp+0x8] ; ecx得到参数
004011B3 8908 mov dword ptr ds:[eax],ecx ; 这里的[eax]相当于[this+0],即父类成员this指针

父类中成员函数SetNumber在子类中并没有定义,但根据派生关系,子类中可以使用父类的公有函数。
在调用父类成员函数时,虽然其this指针传递的是子类对象的首地址,但是在父类成员函数中可以成功寻址到父类中的数据。
在学习虚函数时,我们分析了类中的隐藏数据成员—虚表指针。正因为有这个虚表指针,调用虚函数的方式改为查表并间接调用,在虚表中得到函数首地址并跳转到此地址执行代码。利用此特性即可通过父类指针访问不同的派生类。在调用父类中定义的虚函数时,根据指针所指向的对象中的虚表指针,可得到虚表信息,间接调用虚函数,即构成了多态。
以“人”为基类,可以派生出不同国家的人:中国人、美国人、德国人等。这些人有着一个共同的功能—说话,但是它们实现这个功能的过程不同,例如,中国人说汉语、美国人说英语、德国人说德语等。每个国家的人都有不同的说话方法,为了让“说话”这方法有一个通用接口,可以设立一个“人”类将其抽象化。使用“人”类的指针或引用调用具体对象的“说话”方法,这样就形成了多态。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include"stdafx.h"
class CPerson
{
public:
CPerson(){}
virtual ~CPerson(){}
virtual void ShowSpeak(){}
};
class CChinese : public CPerson
{
public:
CChinese(){}
virtual ~CChinese(){}
virtual void ShowSpeak()
{
printf("Speak Chinese\n");
}
};
class CAmerican : public CPerson
{
public:
CAmerican(){}
virtual ~CAmerican(){}
virtual void ShowSpeak()
{
printf("Speak American\n");
}
};
class CGerman : public CPerson
{
public:
CGerman(){}
virtual ~CGerman(){}
virtual void ShowSpeak()
{
printf("Speak CGerman\n");
}
};
void speak(CPerson * pPerson)
{
pPerson->ShowSpeak();
}
int main(int argc,char *argv[])
{
CChinese Chinese;
CAmerican American;
CGerman German;
speak(&Chinese);
speak(&American);
speak(&German);
return 0;
}

利用父类指针可以指向子类的特性,可以间接调用各子类中的虚函数。虽然指针类型为父类,但由于虚表的排列顺序是按虚函数在类继承层次中首次声明的顺序依次排列的,因此,只要继承了父类,其派生类的虚表中的父类部分的排列就与父类一致,子类新定义的虚函数将会按照声明顺序紧跟其后。所以,在调用过程中,我们给speak函数传递任何一个基于CPerson的派生对象地址都能够正确调用虚函数ShowSpeak。

void speak(CPerson * pPerson)

1
2
3
4
5
6
7
8
9
10
004011E8    8B45 08         mov eax,dword ptr ss:[ebp+0x8]                  ; eax获取参数pPerson的值
004011EB 8B10 mov edx,dword ptr ds:[eax] ; 取虚表首地址传递给edx
004011ED 8BF4 mov esi,esp
004011EF 8B4D 08 mov ecx,dword ptr ss:[ebp+0x8] ; 设置this指针
;利用虚表指针edx,间接调用函数。回顾父类CPerson的类型声明,其中第一个声明的虚函数是析构函数,
;第二个声明的虚函数是ShowSpeak,所以ShowSpeak在虚表中的位置排第二,[edx+4]即ShowSpeak
;的函数地址
004011F2 FF52 04 call dword ptr ds:[edx+0x4]
004011F5 3BF4 cmp esi,esp
004011F7 E8 34050000 call test._chkespetUnhandledExceptionFilterter'>

虚函数的调用过程使用了间接寻址方式,而非直接调用一个函数地址。由于虚表采用间接调用机制,因此在使用父类指针pPerson调用虚函数时,没有依照其作用域调用CPerson类中定义的成员函数ShowSpeak。
当没有使用对象指针或对象引用时,调用虚函数指针的寻址方式为直接调用方式,从而无法构成多态。
当父类中定义有虚函数时,将会产生虚表。当父类的派生类产生对象时,将会在调用子类构造函数前优先调用父类构造函数,并以子类对象的首地址作为this指针传递给父类构造函数。在父类构造函数中,会先初始化子类虚表指针为父类的虚表首指针。此时,如果在父类构造函数中调用虚函数,虽然虚表指针属于子类对象,但指向的地址却是父类的虚表首地址,这是可判断出虚表所属作用域与当前作用域相同,于是会转换成直接调用方式,从而造成构造函数内的虚函数失效。

1
2
3
4
5
6
7
8
9
class CPerson
{
public:
CPerson(){
ShowSpeak(); //调用虚函数,将失效
}
virtual ~CPerson(){}
virtual void ShowSpeak(){}
};

按C++规定的构造顺序,父构造函数会在子类构造函数之前运行,在执行父类构造函数时将虚表指针修改为当前类的虚表指针,也就是父类的虚表指针,因此导致虚函数的特性失效。如果父类构造函数内部存在虚函数调用,这样的顺序能防止在子类中构造父类时,父类会根据虚表错误地调用子类的成员函数。
在析构函数中,同样需要处理虚函数的调用,因此也需要处理虚函数。按C++中定义的析构顺序,首先调用自身的析构函数,然后调用成员对象的析构函数,最后调用父类的析构函数。在对象析构时,首先设置虚表指针为自身虚表,再调用自身的析构函数。如果有成员对象,则按照声明的顺序以倒序方式依次调用成员对象的析构函数。最后,调用父类析构函数。在调用父类的析构函数时,会设置虚表指针为父类自身的虚表。
构造和析构过程如下:

  • 构造:基类->基类的派生类-> …… ->当前类
  • 析构: 当前类->基类的派生类-> …… ->基类

多重继承

前一节所接触的派生类都只有一个父类。当子类拥有多个父类(如类C继承自类A同时也继承自类B)时,便构成了多次继承额额关系。在多重继承的情况下,子类所继承的父类变为多个,但其结构与单一继承相似。
分析多重继承的第一步是了解派生类中各数据成员在内存中的布局情况。在之前中子类继承自一个父类,其内存中首先存放的是父类的数据成员。当子类产生多重继承时,其父类数据成员如下。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include"stdafx.h"
//定义沙发类
class CSofa
{
public:
CSofa()
{
m_nColor=2;
}
virtual ~CSofa() // 沙发类虚析构函数
{
printf("virtual ~CSofa\n");
}
virtual int GetColor() // 获取沙发颜色
{
return m_nColor;
}
virtual int SitDown() // 沙发可以坐下休息
{
return printf("Sit down and rest your legs\n");
}
protected:
int m_nColor; // 沙发类成员变量
};
// 定义床类
class CBed
{
public:
CBed()
{
m_nLength = 4;
m_nWidth = 5;
}
virtual ~CBed() // 床类虚析构函数
{
printf("virtual ~CBed\n");
}
virtual int GetArea() // 获取床面积
{
return m_nLength*m_nWidth;
}
virtual int Sleep() // 床可以用来睡觉
{
return printf("got to sleep\n");
}

protected:
int m_nLength; // 床类成员变量
int m_nWidth;
};
// 子类沙发床定义,派生自CSofa类和CBed类
class CSofaBed : public CSofa, public CBed
{
public:
CSofaBed()
{
m_nHeight = 6;
}
virtual ~CSofaBed() // 沙发床类的虚析构函数
{
printf("virtual ~CSofaBed\n");
}
virtual int SitDown() // 沙发可以坐下休息
{
return printf("Sit down on the sofa bed\n");
}
virtual int Sleep() // 床可以用来睡觉
{
return printf("go to sleep on the sofa bed\n");
}
virtual int GetHeight()
{
return m_nHeight;
}
protected:
int m_nHeight; // 沙发床类的成员变量
};
int main(int argc,char *argv[])
{
CSofaBed SofaBed;
return 0;
}

定义了两个父类:沙发类和床类,通过多重继承,以它们为父类派生出沙发床类,它们都拥有各自的属性以及方法。main函数中定义了子类SofaBed对象,其中包含两个父类的数据成员,此时SofaBed在内存中占0x18字节。如图所示:

地址 成员变量 所属类
0x0019FF28 CSofaBed_vt CSofa
0x0019FF2C m_nColor = 2; CSofa
0x0019FF30 CSofaBed_vt CBed
0x0019FF34 m_nLength = 4; CBed
0x0019FF38 m_nWidth = 5; CBed
0x0019FF3C m_nHeight = 6; CSofaBed

对象SofaBed的首地址在0x0019FF28处,在图中可看到子类的数据成员和两个父类中的数据成员。数据成员的排列顺序由继承父类的先后顺序决定,从左向右依次排列。除此之外,还剩余两个地址值,分别为0x0042502C与0x0042501C,这两个地址的数据如下所示:

由此可见,编译器将子类CSofaBed的虚函数制作了两份。下面看一下SofaBed的构造过程。

CSofaBed SofaBed;

1
2
004010F8    8D4D E8         lea ecx,dword ptr ss:[ebp-0x18]                     ; 传递this指针
004010FB E8 0AFFFFFF call test.0040100A ; 调用构造函数

CSofaBed()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0040116E    59              pop ecx                                       ; 还原this指针
0040116F 894D F0 mov dword ptr ss:[ebp-0x10],ecx
00401172 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10] ; 以对象首地址作为this指针
00401175 E8 D1FEFFFF call test.0040104B ; 调用沙发父类的构造函数
0040117A C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0
00401181 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401184 83C1 08 add ecx,0x8 ; 将this指针调整到第二个虚表指针地址处
00401187 E8 D8FEFFFF call test.00401064 ; 调用床父类构造函数
0040118C 8B45 F0 mov eax,dword ptr ss:[ebp-0x10] ; 获取第一个虚表指针地址
0040118F C700 2C504200 mov dword ptr ds:[eax],offset test.CSofaBed::>; 设置虚表指针
00401195 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10] ; 获取对象的首地址
00401198 C741 08 1C50420>mov dword ptr ds:[ecx+0x8],offset test.CSofaB>; 设置虚表指针
0040119F 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
004011A2 C742 14 0600000>mov dword ptr ds:[edx+0x14],0x6 ; m_nHeight = 6;

在子类的构造中,根据继承关系的顺序,首先调用了父类CSofa的构造函数。在调用另一个父类CBed时,并不是直接将对象的首地址作为this指针传递,而是向后调整了父类CSofa的长度,以调整后的地址值作为this指针,最后再调用父类CBed的构造函数。
由于有了两个父类,因此子类在继承时也将它们的虚表指针一起继承了过来,也就有了两个虚表指针。可见,在多重继承中,子类虚表指针的个数取决于所继承的父类的个数,有几个父类便会出现对应个数的虚表指针(虚基类除外)。
这些虚表指针在将子类转换成父类指针时使用,每个虚表指针对应着一个父亲。

CSofa *pSofa = &SofaBed;

1
2
0040F290    8D45 E8         lea eax,dword ptr ss:[ebp-0x18]
0040F293 8945 E4 mov dword ptr ss:[ebp-0x1C],eax ; 直接将首地址装换为父类地址

CBed *pBed = &SofaBed;

1
2
3
4
5
6
7
8
9
0040F296    8D4D E8         lea ecx,dword ptr ss:[ebp-0x18]
0040F299 85C9 test ecx,ecx ; 检测对象首地址
0040F29B 74 08 je short test.0040F2A5
0040F29D 8D55 F0 lea edx,dword ptr ss:[ebp-0x10] ; 即 lea edx,[ebp-0x18+0x8],调整为CBed的指针
0040F2A0 8955 D8 mov dword ptr ss:[ebp-0x28],edx
0040F2A3 EB 07 jmp short test.0040F2AC
0040F2A5 C745 D8 0000000>mov dword ptr ss:[ebp-0x28],0x0
0040F2AC 8B45 D8 mov eax,dword ptr ss:[ebp-0x28]
0040F2AF 8945 E0 mov dword ptr ss:[ebp-0x20],eax ; 保存调整后的this指针

在转换CBed指针时,会调整首地址并跳过第一个父类所占用的空间。这样一来,当使用父类CBed的指针访问CBed中实现的虚函数时,就不会错误地寻址到继承自CSofa类的成员变量。
下面来看一下析构过程。

virtual ~CSofaBed()

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
0040159E    59              pop ecx                                       ; 还原this指针
0040159F 894D F0 mov dword ptr ss:[ebp-0x10],ecx
004015A2 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
;将两个虚表指针设置为各个父类的虚表首地址
004015A5 C700 2C504200 mov dword ptr ds:[eax],offset test.CSofaBed::>
004015AB 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
004015AE C741 08 1C50420>mov dword ptr ds:[ecx+0x8],offset test.CSofaB>
004015B5 C745 FC 0000000>mov dword ptr ss:[ebp-0x4],0x0
;执行子类虚函数内的代码
004015BC 68 BC504200 push test.004250BC ; ASCII "virtual ~CSofaBed\n"
004015C1 E8 EA060000 call test.printfgvdbgCSofaBedking destructor'>
004015C6 83C4 04 add esp,0x4
;比较对象地址,与子类对象转为父类指针相似
004015C9 837D F0 00 cmp dword ptr ss:[ebp-0x10],0x0 ; 当this==NULL时不需要调整
004015CD 74 0B je short test.004015DA
004015CF 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
004015D2 83C2 08 add edx,0x8
004015D5 8955 EC mov dword ptr ss:[ebp-0x14],edx ; 将调整后的this指针保存到[ebp-0x14]
004015D8 EB 07 jmp short test.004015E1
004015DA C745 EC 0000000>mov dword ptr ss:[ebp-0x14],0x0
004015E1 8B4D EC mov ecx,dword ptr ss:[ebp-0x14]
;调用父类CBed的析构函数
004015E4 E8 49FAFFFF call test.00401032
004015E9 C745 FC FFFFFFF>mov dword ptr ss:[ebp-0x4],-0x1
004015F0 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
;无需转换this指针,直接调用父类CSofa的析构函数
004015F3 E8 67FAFFFF call test.0040105F
004015F8 8B4D F4 mov ecx,dword ptr ss:[ebp-0xC]

由于具有多个同级父类,因此在子类中产生了多个虚表指针。在对父类进行析构时,需要设置this指针,用于调用父类的析构函数。由于具有多个父类,当在析构的过程中调用各个父类的析构函数时,传递的首地址将有所不同,编译器会根据每个父类在对象中占用的空间位置,对应地传入各个父类部分的首地址作为this指针。
对比单继承类,两者特征总结如下:

  • 单继承类
    • 在类对象占用的内存空间中,只保存一份虚表指针。
    • 由于只有一个虚表指针,对应的也只有一个虚表。
    • 虚表中各项保存了类中各虚函数的首地址。
    • 构造时先构造父类,再构造自身,并且只调用一次父类构造函数。
    • 析构时先析构自身,再析构父类,并且只调用一次父类析构函数。
  • 多重继承类
    • 在类对象所占用的内存空间中,根据继承父类的个数保存对应的虚表指针。
    • 根据所保存的虚表指针个个数,对应产生相应个数的虚表。
    • 转换父类指针时,需要调整到对象的首地址。
    • 构造时需要调用多个父类构造函数。
    • 构造时先构造继承列表中第一个父类,然后依次调用到最后一个继承的父类构造函数。
    • 析构时先析构自身,然后以与构造函数想法的顺序调用所有父类的析构函数。
    • 当对象作为成员时,整个类对象的内存结构和多重继承很相似。当类中无虚函数时,整个类对象内存结构和多重继承完全一样;当父类或成员对象存在虚函数时,通过观察虚表指针的位置和构造函数、析构函数中填写虚表指针的数目及目标地址,来还原继承或成员关系。

抽象类

既然是抽象事物,就不存在实体。在编码过程中,抽象类的定义需要配合虚函数使用。在虚函数的声明结尾处添加“=0”,这种虚函数被称为纯虚函数。纯虚函数时一个没有实现只有声明的函数,它的存在就是为了让类具有抽象类的功能,让继承自抽象类的子类具有虚表以及虚表指针。在使用过程中,利用抽象类指针可以更好地完成多态的工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include"stdafx.h"
class CAbstractBase
{
public:
virtual void show() = 0;
};
class CVirtualChild : public CAbstractBase
{
public:
virtual void show()
{
printf("抽象类分析\n");
}
};
int main(int argc,char *argv[])
{
CVirtualChild VirtualChild;
VirtualChild.show();
return 0;
}

抽象类构造函数

1
2
3
4
5
6
00401149    59              pop ecx                                           ; 0019FF3C
0040114A 894D FC mov dword ptr ss:[ebp-0x4],ecx
0040114D 8B45 FC mov eax,dword ptr ss:[ebp-0x4]
;设置抽象类虚表指针,虚表地址在0x00422030处。
00401150 C700 30204200 mov dword ptr ds:[eax],offset test.CAbstractBase:>
00401156 8B45 FC mov eax,dword ptr ss:[ebp-0x4]

抽象类CAbstractBase中虚表信息的第一项所指向的虚函数首地址

1
2
3
4
5
6
7
00401230 >  55              push ebp
00401231 8BEC mov ebp,esp
00401233 6A 19 push 0x19 ; 压入错误编码
00401235 E8 36010000 call test._amsg_exitbh_thresholdessges ; 结束程序
0040123A 83C4 04 add esp,0x4
0040123D 5D pop ebp
0040123E C3 retn

在虚基类CAbstractBase的虚表信息中,由于纯虚函数没有实现代码,因此没有首地址。编译器为了防止误调用纯虚函数。将虚表中保存的纯虚函数的首地址项替换成函数_purecall,用于结束程序,并发出错误编码信息0x19。
根据这一特性,在分析过程中,一旦在虚表中发现函数地址为_purecall函数的地址时,就可以高度怀疑此虚表对应的类是一个虚基类。当虚基类中定义了多个纯虚函数时,虚表中将保存相同的函数指针。

菱形继承

菱形继承是最复杂的对象结构,菱形结构会将单一继承与多重继承进行组合。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include"stdafx.h"
//定义家具类
class CFurniture
{
public:
CFurniture()
{
m_nPrice = 0;
}
virtual ~CFurniture()
{
printf("virtual ~CFurniture\n");
}
virtual int GetPrice()
{
return m_nPrice;
}
protected:
int m_nPrice;
};
// 定义沙发类,继承自类CFurniture
class CSofa : virtual public CFurniture
{
public:
CSofa()
{
m_nPrice = 1;
m_nColor = 2;
}
virtual ~CSofa()
{
printf("virtual ~CSofa\n");
}
virtual int GetColor()
{
return m_nColor;
}
virtual int SitDown()
{
return printf("Sit down and rest your legs\n");
}
protected:
int m_nColor;
};
// 定义床类,继承自类CFurniture
class CBed : virtual public CFurniture
{
public:
CBed()
{
m_nPrice = 3;
m_nLength = 4;
m_nWidth = 5;
}
virtual ~CBed()
{
printf("virtual ~CBed\n");
}
virtual int GetArea()
{
return m_nLength*m_nWidth;
}
virtual int Sleep()
{
return printf("go to sleep\n");
}
protected:
int m_nLength;
int m_nWidth;
};
// 子类沙发床的定义,派生自类CSofa和类CBed
class CSofaBed : public CSofa, public CBed
{
public:
CSofaBed()
{
m_nHeight = 6;
}
virtual ~CSofaBed()
{
printf("virtual ~CSofaBed\n");
}
virtual int SitDown()
{
return printf("Sit down on the sofa bed\n");
}
virtual int Sleep()
{
return printf("got to sleep on the sofa bed\n");
}
virtual int GetHeight()
{
return m_nHeight;
}
protected:
int m_nHeight;
};
int main(int argc,char *argv[])
{
CSofaBed SofaBed;
return 0;
}

一共定义了4个类,分别为CFurniture、CSofa、CBed和CSofaBed。CFurniture为祖父类,从CFurniture类中派生了两个子类:CSofa与CBed,它们在继承时使用virtual的方式,即虚继承。
使用虚继承可以避免共同派生出的子类产生多义性的错误。接下来结束菱形结构中子类CSofaBed的对象在内存中的存放。

地址 成员变量 注解 所属类
0x0019FF18 CSofaBed_vt(new) 基类未定义的虚函数 CSofa
0x0019FF1C vt_offset 基类定义的虚函数偏移 CSofa
0x0019FF20 m_nColor = 2 - CSofa
0x0019FF24 CSofaBed_vt(new) 基类未定义的虚函数 CBed
0x0019FF28 vt_offset 基类定义的虚函数偏移 CBed
0x0019FF2C m_nLength = 4 - CBed
0x0019FF30 m_nWidth = 5 - CBed
0x0019FF34 m_nHeight = 6 - CSofaBed
0x0019FF38 CSofaBed_vt CFurniture定义 CFurniture
0x0019FF3C m_nPrice = 3 - CFurniture

菱形结构的虚表指针转换过程

1
2
3
4
5
6
7
8
int main(int argc,char *argv[])
{
CSofaBed SofaBed;
CSofaBed SofaBed; //转换成祖父类指针
CSofa *pSofa = &SofaBed; //转换成父类指针
CBed *pBed = &SofaBed; //转换成父类指针
return 0;
}

CSofaBed SofaBed;

1
2
3
0040F6CB    6A 01           push 0x1                                          ; 是否构造祖父类的标志,TRUE表示构造,FALSE表示不构造
0040F6CD 8D4D D8 lea ecx,dword ptr ss:[ebp-0x28] ; 传入对象的首地址作为this指针
0040F6D0 E8 3519FFFF call test.0040100A ; 调用构造函数

CSofaBed SofaBed;

1
2
3
4
5
6
7
8
9
10
11
0040F6D5    8D45 D8         lea eax,dword ptr ss:[ebp-0x28]                   ; 获取对象首地址
0040F6D8 85C0 test eax,eax ; 检查代码
0040F6DA 75 09 jnz short test.0040F6E5
0040F6DC C745 C4 0000000>mov dword ptr ss:[ebp-0x3C],0x0
0040F6E3 EB 0D jmp short test.0040F6F2
0040F6E5 8B4D DC mov ecx,dword ptr ss:[ebp-0x24] ; 取出对象的第二项数据vt_offset
0040F6E8 8B51 04 mov edx,dword ptr ds:[ecx+0x4] ; 取出偏移值后存入edx中
0040F6EB 8D4415 DC lea eax,dword ptr ss:[ebp+edx-0x24] ; 得到祖父类数据所在地址
0040F6EF 8945 C4 mov dword ptr ss:[ebp-0x3C],eax ; 利用中间变量保存祖父类的首地址
0040F6F2 8B4D C4 mov ecx,dword ptr ss:[ebp-0x3C]
0040F6F5 894D D4 mov dword ptr ss:[ebp-0x2C],ecx ; 赋值pFurniture

CSofa *pSofa = &SofaBed;

1
2
0040F6F8    8D55 D8         lea edx,dword ptr ss:[ebp-0x28]                   ; 直接转换SofaBed对象的首地址为父类CSofa的指针
0040F6FB 8955 D0 mov dword ptr ss:[ebp-0x30],edx

CBed *pBed = &SofaBed;

1
2
3
4
5
6
7
8
9
0040F6FE    8D45 D8         lea eax,dword ptr ss:[ebp-0x28]                   ; 获取对象SofaBed的首地址
0040F701 85C0 test eax,eax ; 地址检查
0040F703 74 08 je short test.0040F70D
0040F705 8D4D E4 lea ecx,dword ptr ss:[ebp-0x1C] ; 获取第二个CSofaBed_vt(new)指针
0040F708 894D C0 mov dword ptr ss:[ebp-0x40],ecx
0040F70B EB 07 jmp short test.0040F714
0040F70D C745 C0 0000000>mov dword ptr ss:[ebp-0x40],0x0
0040F714 8B55 C0 mov edx,dword ptr ss:[ebp-0x40]
0040F717 8955 CC mov dword ptr ss:[ebp-0x34],edx ; 保存转换后的SofaBed地址到pBed中

从指针转换过程可以看出,vt_offset指向的内存地址中保存的数据为偏移数据,如下图每个vt_offset对应的数据有两项;第一项为-4,即vt_offset所属类对应的虚表指针相对于vt_offset的偏移值;第二项保存的是父类虚表指针相对于vt_offset的偏移值。

对代码分析可知,3个虚表指针分别为0x00425034、0x00425028、0x0042501C,它们所指向的数据如图:

这三个虚表指针所指向的虚表包含了子类CSofaBed含有的虚函数。有了这些记录就可以随心所以地将虚表指针转换成任意的父类指针。在利用父类指针访问虚函数时,只能调用子类与父类共有的虚函数,子类继承自其它父类的虚函数是无法调用的,虚表中也没有相关的记录。当子类的父类也存在多个父类时,会依次记录它们的偏移。
下面来分析菱形继承子类的构造函数。

CSofaBed SofaBed;

1
2
3
0040F6CB    6A 01           push 0x1                                          ; 是否构造祖父类的标志,TRUE表示构造,FALSE表示不构造
0040F6CD 8D4D D8 lea ecx,dword ptr ss:[ebp-0x28] ; 传入对象的首地址作为this指针
0040F6D0 E8 3519FFFF call test.0040100A ; 调用构造函数

CSofaBed()

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
004011AE   .  59            pop ecx                                           ;  还原this指针
004011AF . 894D F0 mov dword ptr ss:[ebp-0x10],ecx
004011B2 . C745 EC 00000>mov dword ptr ss:[ebp-0x14],0x0 ; 传入构造标记
004011B9 . 837D 08 00 cmp dword ptr ss:[ebp+0x8],0x0 ; 比较参数是否为0,为0则只需je跳转,防止重复构造
004011BD . 74 2F je short test.004011EE
004011BF . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
004011C2 . C740 04 50504>mov dword ptr ds:[eax+0x4],offset test.CSofaBed::>; 设置父类CSofa中的vt_offset域
004011C9 . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
004011CC . C741 10 44504>mov dword ptr ds:[ecx+0x10],offset test.CSofaBed:>; 设置父类CBed中的vt_offset域
004011D3 . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
004011D6 . 83C1 20 add ecx,0x20 ; 调整this指针
;调用祖父类构造函数,祖父类为最上级,它的构造函数和无继承关系的构造函数相同。
004011D9 . E8 4FFEFFFF call test.0040102D
004011DE . 8B55 EC mov edx,dword ptr ss:[ebp-0x14] ; 获取构造标记
004011E1 . 83CA 01 or edx,0x1 ; 将构造标记置为1
004011E4 . 8955 EC mov dword ptr ss:[ebp-0x14],edx ; 修改构造标记
004011E7 . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0
004011EE > 6A 00 push 0x0 ; 压入0作为构造标记
004011F0 . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10] ; 获取对象首地址作为this指针
004011F3 . E8 76FEFFFF call test.0040106E ; 调用CSofa父类构造函数
004011F8 . C745 FC 01000>mov dword ptr ss:[ebp-0x4],0x1
004011FF . 6A 00 push 0x0 ; 压入0作为构造标记
00401201 . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401204 . 83C1 0C add ecx,0xC ; 调整this指针
00401207 . E8 76FEFFFF call test.00401082 ; 调用CBed父类构造函数
0040120C . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
0040120F . C700 34504200 mov dword ptr ds:[eax],offset test.CSofaBed::`vft>; CSofaBed对应CSofa的虚表指针
00401215 . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401218 . C741 0C 28504>mov dword ptr ds:[ecx+0xC],offset test.CSofaBed::>; CSofaBed对应CBed的虚表指针
0040121F . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10] ; 通过this指针和vt_offset定位到祖父类的虚表指针
00401222 . 8B42 04 mov eax,dword ptr ds:[edx+0x4] ; vt_offset存入eax中
00401225 . 8B48 04 mov ecx,dword ptr ds:[eax+0x4] ; 父类虚表指针相对于vt_offset的偏移存入ecx中
00401228 . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
0040122B . C7440A 04 1C5>mov dword ptr ds:[edx+ecx+0x4],offset test.CSofaB>; CSofaBed对应CFurniture的虚表指针
00401233 . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
00401236 . C740 1C 06000>mov dword ptr ds:[eax+0x1C],0x6 ; m_nHeight = 6;

子类CSofaBed的构造过程,它的特别之处是在调用时要传入一个参数。这个参数是一个标志信息。构造过程中要先构造父类,然后构造自己。CSofaBed的两个父类有一个共同的父类,如果没有构造标记,它们共同的父类将会被构造两次,因此需要使用构造标记来防止重复构造的问题,构造顺序如下:

  • CFurniture
  • CSofa(根据标记跳过CFurniture构造)
  • CBed(根据标记跳过CFurniture构造)
  • CSofaBed自身

CSofaBed也是用来构造标记,当CSofaBed也是父类时,这个标记将产生作用,跳过所有父类的构造,只构造自身。当标记为1时,则构造父类;当标记为0时,则跳过构造函数。构造时可以使用标记来防止重复构造,同样也不能出现重复析构的错误。

CSofaBed调用析构代理函数,由编译器自动添加,无源码对照

1
2
3
4
5
6
7
8
00401A99  |.  59            pop ecx
00401A9A |. 894D FC mov [local.1],ecx
00401A9D |. 8B4D FC mov ecx,[local.1]
00401AA0 |. 83C1 20 add ecx,0x20
00401AA3 |. E8 B2F5FFFF call test.0040105A ; 调用CSofaBed的析构函数
00401AA8 |. 8B4D FC mov ecx,[local.1]
00401AAB |. 83C1 20 add ecx,0x20
00401AAE |. E8 89F5FFFF call test.0040103C ; 调用祖父类的析构函数

virtual ~CSofaBed()

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
00401B0E   .  59            pop ecx                                           ;  还原this指针
00401B0F . 894D F0 mov dword ptr ss:[ebp-0x10],ecx ; 调整this指针
00401B12 . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
00401B15 . C740 E0 34504>mov dword ptr ds:[eax-0x20],offset test.CSofaBed:>; 设置自身虚表
00401B1C . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401B1F . C741 EC 28504>mov dword ptr ds:[ecx-0x14],offset test.CSofaBed:>; 设置自身虚表
00401B26 . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
00401B29 . 8B42 E4 mov eax,dword ptr ds:[edx-0x1C]
00401B2C . 8B48 04 mov ecx,dword ptr ds:[eax+0x4]
00401B2F . 8B55 F0 mov edx,dword ptr ss:[ebp-0x10]
00401B32 . C7440A E4 1C5>mov dword ptr ds:[edx+ecx-0x1C],offset test.CSofa>; 设置自身虚表。至此,3个虚表指针设置完毕,执行自身析构函数内的代码
00401B3A . C745 FC 00000>mov dword ptr ss:[ebp-0x4],0x0
00401B41 . 68 68514200 push test.00425168 ; ASCII "virtual ~CSofaBed\n"
00401B46 . E8 A5050000 call test.printfgvdbgCSofaBedktor'destructor'
00401B4B . 83C4 04 add esp,0x4
00401B4E . 8B45 F0 mov eax,dword ptr ss:[ebp-0x10]
00401B51 . 83E8 20 sub eax,0x20 ; 获取this指针
00401B54 . 85C0 test eax,eax ; 检查this指针
00401B56 . 74 0B je short test.00401B63
00401B58 . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401B5B . 83E9 14 sub ecx,0x14
00401B5E . 894D EC mov dword ptr ss:[ebp-0x14],ecx
00401B61 . EB 07 jmp short test.00401B6A
00401B63 > C745 EC 00000>mov dword ptr ss:[ebp-0x14],0x0
00401B6A > 8B4D EC mov ecx,dword ptr ss:[ebp-0x14]
00401B6D . 83C1 10 add ecx,0x10 ; 调整this指针
00401B70 . E8 D6F4FFFF call test.0040104B ; 调用父类析构函数
00401B75 . C745 FC FFFFF>mov dword ptr ss:[ebp-0x4],-0x1
00401B7C . 8B4D F0 mov ecx,dword ptr ss:[ebp-0x10]
00401B7F . 83E9 14 sub ecx,0x14 ; 调整this指针
00401B82 . E8 F6F4FFFF call test.0040107D ; 调用父类析构函数

菱形结构中子类的析构函数执行流程并咩有像构造函数那样使用标记来防止重复析构,而是将祖父类放在最后调用。先依次执行两个父类CBed和CSofa的析构函数,然后执行祖父类的析构函数。

文章目录
  1. 1. 识别类和类之间的关系
  2. 2. 多重继承
  3. 3. 抽象类
  4. 4. 菱形继承