本节为轩辕的编程宇宙公众号《从零开始学逆向》的“构造函数、析构函数、虚函数的汇编分析”小节的学习笔记。详细课程内容请自行 跳转 加入查看。
本文代码在 VS 2026 - x86 Debug 模式下编译测试完成,汇编指令分析等也均基于 VS 2026 调试时生成的汇编代码。
在 C++、Java 等面向对象编程语言中,构造函数(Constructor) 是一种特殊的成员函数,它的唯一任务是:初始化对象。
构造函数与普通函数有显著区别:
- 名称相同:函数名必须与类名完全一致。
- 无返回值:不写返回值类型,连
void 也不写。
- 自动触发:由编译器在创建对象时自动调用,程序员不能像普通函数那样手动调用它。
- 可以重载:一个类可以有多个构造函数(参数个数或类型不同)。
- 默认创建:若没写,编译器会提供一个默认构造函数。
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
|
#include <stdio.h>
class Child {
public:
Child(int a, char s, const char *n) {
this->age = 8;
this->sex = s;
this->name = n;
printf("enter child \n");
}
public:
int age;
char sex;
const char* name;
// 在 C++11 及更高标准中,"test" 这种字符串字面量的类型是 const char*。而不是 char*(没有 const)。
};
int main() {
// 在栈上创建对象
Child child(20, 's', "test");
// 在堆上创建对象
Child* ptrChild = new Child(20, 's', "test");
// 在堆中创建对象需要手动释放内存
delete ptrChild;
return 0;
}
|
1
|
Child child(20, 's', "test");
|
汇编代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 在栈上创建对象
Child child(20, 's', "test");
# 运行时内存初始化 (VS Debug 模式特有,填充 0xCC 以检测内存异常)
00F31AF8 push 0Ch # 传入 sizeof(Child) = 12 字节
00F31AFA lea ecx,[child] # 传入对象首地址(this 指针)
00F31AFD call __autoclassinit2
# 准备调用构造函数
00F31B02 push offset string "test" # 参数 3 入栈
00F31B07 push 73h # 参数 2 入栈 ('s')
00F31B09 push 14h # 参数 1 入栈 (20)
00F31B0B lea ecx,[child] # 核心:将 this 指针存入 ecx
00F31B0E call Child::Child # 调用构造函数,内部会通过 ecx 写入数据
00F31B13 nop
|
1
|
Child* ptrChild = new Child(20, 's', "test");
|
汇编代码:
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
|
// 在堆上创建对象
Child* ptrChild = new Child(20, 's', "test");
# 两大核心步骤
(1)分配内存(调用 operator new)。
(2)调用构造函数(在分配好的内存上初始化对象)。
# Step1: 申请堆内存
00F31B14 push 0Ch ; 压入 12 (0Ch),即 sizeof(Child)
00F31B16 call operator new ; 调用 C++ 运行时库函数 malloc 分配内存
00F31B1B add esp,4 ; 平栈(栈上指向堆的指针地址。占用4字节)
00F31B1E mov dword ptr [ebp-104h],eax ; eax 存的是新分配的内存地址 (栈上指向堆的指针地址)
# Step2: 构造保护阶段(C++ 异常处理(EH)状态机)
00F31B24 mov dword ptr [ebp-4],0 ; 设置异常状态索引为 0
00F31B2B cmp dword ptr [ebp-104h],0 ; 检查分配是否成功(是否为 NULL)
je __$EncStackInitStart+93h (失败跳转到 0F31B5Dh) ; 如果 NULL,跳过构造,跳转到“失败处理区”(00F31B5D)
# 为什么是 [ebp-4] = 0?
# 编译器在函数开头通常会设 [ebp-4] = -1(表示没有对象需要保护)。现在内存申请成功了,准备调用构造函数。如果构造函数内部发生异常(比如 new 失败或主动 throw),系统会检查这个索引。索引为 0 告诉系统:有一块刚申请的内存还没处理完,请帮我自动调用 operator delete 释放它,防止内存泄漏。
# Step3: 初始化阶段(确定内存可用后,填充与构造)
# 运行时内存初始化 (VS Debug 模式特有,填充 0xCC 以检测内存异常)
00F31B34 push 0Ch ; 再次传入大小 12
00F31B36 mov ecx,dword ptr [ebp-104h] ; 取出堆地址传给 ecx
00F31B3C call Child::__autoclassinit2 ; 用 0xCC 填充内存(Debug 模式特有,防止野值)
# 准备调用构造函数
00F31B41 push offset string "test" ; 参数 3:字符串地址
00F31B46 push 73h ; 参数 2:'s' (ASCII 115)
00F31B48 push 14h ; 参数 1:20
00F31B4A mov ecx,dword ptr [ebp-104h] ; 关键:将堆地址作为 this 指针存入 ecx
00F31B50 call Child::Child ; 调用构造函数
00F31B55 mov dword ptr [ebp-10Ch],eax ; 保存构造完成后的对象地址
# Step4: 分支合并与地址选择 (00F31B5B - 00F31B6D)
00F31B5B jmp __$EncStackInitStart+9Dh (0F31B67h) ; [1] 构造成功,跳过“失败处理区”(00F31B5D)
00F31B5D mov dword ptr [ebp-10Ch],0 ; [2] 失败处理区:将临时变量设为 0 (NULL)
00F31B67 mov eax,dword ptr [ebp-10Ch] ; [3] 汇合点:取出最终地址(成功地址或0)
00F31B6D mov dword ptr [ebp-0F8h],eax ; [4] 存入另一个中转站
#Step5: 异常保护的撤销 (00F31B73)
00F31B73 mov dword ptr [ebp-4],0FFFFFFFFh
# 将异常状态索引还原为 -1 (即 0xFFFFFFFF)。
# 在调用构造函数之前,这个值被设为了 0。如果构造函数内部 throw 了,系统会看这个标记,发现是 0,就会自动释放刚才申请的堆内存。
# 现在执行到这一行,说明构造函数已经平安返回。对象已经完全建立好了,不再需要编译器帮我们做“自动销毁”了。
# 后续,这块内存正式从“编译器自动管理”移交给“程序员手动管理”。
# Step6: 正式赋值给指针变量 (00F31B7A - 00F31B80)
00F31B7A mov ecx,dword ptr [ebp-0F8h] ; 从中转站取出最终地址
00F31B80 mov dword ptr [ptrChild],ecx ; 真正写入源码中的 Child* ptrChild 变量
|
析构函数(Destructor)的唯一任务是:在对象销毁之前,完成清理工作(如释放内存、关闭文件、断开网络连接等)。
析构函数的特征:
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
|
// 在栈上创建对象、在堆上创建对象需要分别注释和反注释去调试,看汇编代码。
#include <stdio.h>
class Child {
public:
Child(int a, char s, const char* n) {
this->age = 8;
this->sex = s;
this->name = n;
printf("enter child \n");
}
~Child() {
printf("enter ~child \n");
}
public:
int age;
char sex;
const char* name;
};
int main() {
// 在栈上创建对象
Child child(20, 's', "test");
// 在堆上创建对象
//Child* ptrChild = new Child(20, 's', "test");
// 在堆中创建对象需要手动释放内存
//delete ptrChild;
return 0;
}
|
1
2
3
4
5
6
7
8
9
|
; 在栈上创建对象
; ... asm code ...
; 在 return 前夕执行析构函数
return 0;
001753BC mov dword ptr [ebp-0E0h],0 ; 暂存返回值
001753C6 lea ecx,[child] ; 加载 this 指针地址
001753C9 call Child::~Child (017132Ah) ; 调用析构函数
001753CE mov eax,dword ptr [ebp-0E0h] ; 恢复返回值并退出
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
; 在堆中创建对象
; ... asm code ...
; 在堆中创建对象需要手动释放内存
delete ptrChild;
00401C94 mov eax,dword ptr [ptrChild]
00401C97 mov dword ptr [ebp-0F8h],eax
00401C9D cmp dword ptr [ebp-0F8h],0 ; 检查 this 指针是否为空
00401CA4 je __$EncStackInitStart+0C1h (0401CBBh) ; 如果是,直接跳过所有逻辑
; 标志1,告诉函数。执行完析构函数后,顺便把这块堆内存也释放掉(调用 free)
00401CA6 push 1 ; 压入参数 1 (关键标志位)
00401CA8 mov ecx,dword ptr [ebp-0F8h] ; 加载 this 指针地址
00401CAE call Child::`scalar deleting destructor` (040147Eh) ; 调用析构函数
00401CB3 mov dword ptr [ebp-100h],eax ; 函数调用结果
00401CB9 jmp __$EncStackInitStart+0CBh (0401CC5h)
00401CBB mov dword ptr [ebp-100h],0
return 0;
00401CC5 xor eax,eax ; 将 eax 置零,比 mov eax, 0 更快、指令更短
|
“多态”一词源于希腊语,意为“多种形态”。在编程中,它指同一个行为(函数调用)在不同的对象上具有不同的表现形式。
多态的两种类型:
- 静态多态(编译时多态): 主要通过函数重载和模板实现。编译器在编译阶段就确定了要调用哪个函数。
- 动态多态(运行时多态):它通过继承和虚函数实现。程序在运行过程中,根据对象的实际类型来决定调用哪个函数。
虚函数,是实现在派生类中重写(Override)基类函数的一种机制。在 C++ 中,我们使用 virtual 关键字来声明。
如果没有虚函数,当我们用基类指针指向派生类对象时,调用的是基类的版本。有了虚函数,程序会“向下查找”,执行派生类中重写的版本。
虚函数核心机制是:虚函数表 (V-Table)。
虚函数调用与普通的函数调用不同。普通函数调用,在编译期间就能确定要调用的函数的函数地址,通过反汇编便可看到这是在调用哪个函数;但虚函数则不同,它是通过指针在进行间接调用,直接反汇编,无法看出在调用哪个函数,必须在程序运行期间才能拿到地址,才知道在调用哪个函数,即动态绑定。
为了实现动态绑定,编译器会为每个包含虚函数的类创建一个虚函数表(V-Table)。
- V-Table: 存储该类所有虚函数地址的表格。
- V-Pointer (vptr): 每个对象实例中包含一个隐藏指针,指向其所属类的虚函数表。
- 调用过程: 当你调用虚函数时,程序先通过对象的 vptr 找到 V-Table,再从表中找到对应函数的地址并执行。这就是为什么多态会有轻微的运行开销。
在具有虚函数的类对象中,编译器会在对象最开始的位置插入一个隐藏指针,即虚表指针。
对象 (Object Instance) 虚函数表 (Virtual Method Table)
+-----------------------+ +----------------------------+
| 虚表指针 (vptr) |------------->| 虚函数1地址 (Fun1 addr) |------> [ 虚函数1指令 ]
+-----------------------+ +----------------------------+
| 成员变量1 (Data 1) | | 虚函数2地址 (Fun2 addr) |------> [ 虚函数2指令 ]
+-----------------------+ +----------------------------+
| 成员变量2 (Data 2) | | 虚函数3地址 (Fun3 addr) |------> [ 虚函数3指令 ]
+-----------------------+ +----------------------------+
| ... | | ... |
+-----------------------+ +----------------------------+
只有虚函数的地址才会进入虚函数表,普通函数不会进入这个表。
有时基类无法给出一个有意义的默认实现(无法直接定义),这时可以使用纯虚函数。
- 语法:
virtual void speak() = 0;
- 抽象类:包含纯虚函数的类称为抽象类,不能被实例化。它的存在纯粹是为了定义接口,强制派生类去实现这些功能。
虚函数使用示例:
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
|
#include <stdio.h>
class OSBase {
public:
virtual int create_process() = 0;
};
class Win32OS : public OSBase {
public:
virtual int create_process() {
printf("enter Win32OS create_process");
return 0;
}
};
class LinuxOS : public OSBase {
public:
virtual int create_process() {
printf("enter LinuxOS create_process");
return 0;
}
};
OSBase* create_os() {
#ifdef WIN32
return new Win32OS();
#else
return new LinuxOS();
#endif
}
int main() {
OSBase* os = create_os();
os->create_process();
return 0;
}
|
虚函数使用示例(完善输出测试):
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
|
#include <stdio.h>
class OSBase {
public:
OSBase() {
code = 0x11111111;
}
virtual int create_process() = 0;
private:
int code;
};
class Win32OS : public OSBase {
public:
Win32OS() {
code_win = 0x22222222;
}
virtual int create_process() {
printf("enter Win32OS create_process");
return 0;
}
private:
int code_win;
};
class LinuxOS : public OSBase {
public:
LinuxOS() {
code_linux = 0x33333333;
}
virtual int create_process() {
printf("enter LinuxOS create_process");
return 0;
}
private:
int code_linux;
};
OSBase* create_os() {
#ifdef WIN32
return new Win32OS();
#else
return new LinuxOS();
#endif
}
int main() {
OSBase* os = create_os();
os->create_process(); // 断点分析位置
return 0;
}
|
断点os->处,汇编代码分析:
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
|
OSBase* os = create_os();
00FC1C56 call create_os (0FC1163h)
00FC1C5B mov dword ptr [os],eax ; 对象 this 指针地址
os->create_process();
00FC1C5E mov eax,dword ptr [os] ; 对象 this 指针地址
# eax: 00AE5C20
# 此时 00AE5C20 = 内存 [os],其地址处的内容如下:
40 9b fc 00 11 11 11 11 22 22 22 22 fd fd fd fd 15 de
ff 53 69 ac 00 00 18 44 ae 00 d0 5d ae 00 00 10 00 00
# 40 9b fc 00,即 0x00fc9b40,也就是虚表指针(vptr)。
# 11 11 11 11,即 11111111,父类 code 的值。
# 22 22 22 22,即 22222222,Win32OS 继承类的 code_win 的值。
00FC1C61 mov edx,dword ptr [eax] ; 将 eax 指向的地址处的值(00fc9b40) 存放到 edx
# eax: 00AE5C20,[eax]: 00fc9b40
# edx: 00fc9b40
# 00fc9b40 是虚表指针,指向的是一个表。其地址处的内容如下:
ed 13 fc 00 00 00 00 00 65 6e 74 65 72 20 57 69 6e 33
# ed 13 fc 00,因为上述程序虚函数只有一个,则该表只有这一项 0x00fc13ed
# 0x00fc13ed 处内容
ed 13 fc 00 00 00 00 00 65 6e 74 65 72 20 57 69 6e 33 ?.?.....enter Win3
32 4f 53 20 63 72 65 61 74 65 5f 70 72 6f 63 65 73 73 2OS create_process
00FC1C63 mov esi,esp ; 把当前的栈顶地址存入 esi,做一个备份
00FC1C65 mov ecx,dword ptr [os] ; 把对象 this 指针地址(00AE5C20) 存入 ecx
00FC1C68 mov eax,dword ptr [edx] ; 将 edx 指向的地址处的值(0x00fc13ed) 存放到 eax
; 即从虚表中取出真正的虚函数执行地址
00FC1C6A call eax ; 正式跳转并执行虚函数
; Step Into(F11)
00FC13ED jmp Win32OS::create_process (0FC1AE0h)
00FC1C6C cmp esi,esp ; 比较函数执行后的栈顶是否和执行前的一致
00FC1C6E call __RTC_CheckEsp (0FC12C1h) ; CheckEsp,如果不一致,说明栈坏了或调用约定错了,报错中断
00FC1C73 nop ; 空操作,一般用于对齐
|