介绍

前面的文章讨论了C++面向对象基础。现在我们知道,C++结构体与类只有默认访问控制的区别:publicprivate。并且是在编译期检查,当越权访问时,编译过程会检查此类错误并给予提示。那么对于C++的面向对象来讲,编译器是如何对其进行处理并生成二进制程序的?

(了解一件事物的原理,对它的了解就会很透彻)

对象的内存布局

创建结构体或类的实例也就是对象后,会为对象分配相应的内存空间。其实也就是一种变量,局部对象会在栈上分配,new或者malloc出来的会在堆上分配。对象的首地址即为对象的指针,从低地址开始存放对象的成员。从内存低地址到高地址按照定义的顺序存放。对象的大小只包含数据成员,成员函数属于执行代码,并不在对象的布局中。那么一个对象会占多大的空间呢?

空类

空类没有任何数据成员(包括虚表指针,后面会讲),理论上对象内存大小会为零。但是这样无法获取实例的地址,this指针也会失效。因此不能被实例化。因此没有数据成员会分配1字节的空间用于实例化,可以用来代表对象的指针,但是它不会被使用。

内存对齐

由于对齐的原因,对象成员不会像数组那样连续排列。数据类型不同占用空间不同,申请内存时会遵守一定的规则。C++通常对齐值默认为8,具体的对齐规则此处不作展开,请自行查找资料。

this指针

在类中没有对this指针的定义,但是成员函数却可以使用this指针。首先要明白this指针也不属于对象的数据成员,并且它被编译器隐藏起来了,对于程序员来讲是透明的。

举个例子,有如下定义:

1
2
3
4
5
6
7
8
struct A {
int n;
float f;
}

struct A a;
struct A *p = &a;
printf("%p", &p->f);

我们知道p指针中存放的是对象a的地址,程序通过f在结构体A中为4的偏移访问成员数据:p+4。

现在,我们看下面的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

class Person {
public:
void setAge(int age) { //公有成员函数
this->age = age;
}
public:
int age; //公有数据成员
};

int main(int argc, char* argv[]) {
Person person;
person.setAge(5); //调用成员函数
printf("Person : %d\n", person.age); //获取数据成员
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
.text:0000000140001530 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:0000000140001530 public main
.text:0000000140001530 main proc near ; CODE XREF: __tmainCRTStartup+210↑p
.text:0000000140001530 ; DATA XREF: .pdata:000000014000A06C↓o
.text:0000000140001530
.text:0000000140001530 var_4 = dword ptr -4
.text:0000000140001530 arg_0 = dword ptr 10h
.text:0000000140001530 arg_8 = qword ptr 18h
.text:0000000140001530
.text:0000000140001530 push rbp
.text:0000000140001531 mov rbp, rsp
.text:0000000140001534 sub rsp, 30h
.text:0000000140001538 mov [rbp+arg_0], ecx
.text:000000014000153B mov [rbp+arg_8], rdx
.text:000000014000153F call __main
.text:0000000140001544 lea rax, [rbp+var_4]
.text:0000000140001548 mov edx, 5 ; int
.text:000000014000154D mov rcx, rax ; this
.text:0000000140001550 call _ZN6Person6setAgeEi ; Person::setAge(int)
.text:0000000140001555 mov eax, [rbp+var_4]
.text:0000000140001558 mov edx, eax
.text:000000014000155A lea rax, aPersonD ; "Person : %d\n"
.text:0000000140001561 mov rcx, rax ; char *
.text:0000000140001564 call _Z6printfPKcz ; printf(char const*,...)
.text:0000000140001569 mov eax, 0
.text:000000014000156E add rsp, 30h
.text:0000000140001572 pop rbp
.text:0000000140001573 retn
.text:0000000140001573 main endp


.text:0000000140007890 ; __int64 __fastcall Person::setAge(Person *__hidden this, int)
.text:0000000140007890 public _ZN6Person6setAgeEi
.text:0000000140007890 _ZN6Person6setAgeEi proc near ; CODE XREF: main+20↑p
.text:0000000140007890 ; DATA XREF: .pdata:000000014000A474↓o
.text:0000000140007890
.text:0000000140007890 arg_0 = qword ptr 10h
.text:0000000140007890 arg_8 = dword ptr 18h
.text:0000000140007890
.text:0000000140007890 push rbp
.text:0000000140007891 mov rbp, rsp
.text:0000000140007894 mov [rbp+arg_0], rcx
.text:0000000140007898 mov [rbp+arg_8], edx
.text:000000014000789B mov rax, [rbp+arg_0]
.text:000000014000789F mov edx, [rbp+arg_8]
.text:00000001400078A2 mov [rax], edx
.text:00000001400078A4 nop
.text:00000001400078A5 pop rbp
.text:00000001400078A6 retn
.text:00000001400078A6 _ZN6Person6setAgeEi endp

代码中演示了对象调用成员的方法以及取出数据成员的过程。使用默认调用约定时调用成员函数,编译器做了个小动作:利用寄存器rcx保存了对象首地址,并使用寄存器传参的方式传递到成员函数中,这便是this指针。由此可见,所有成员函数(非静态成员函数)都有一个隐藏参数——即自身类型的指针,这样的默认调用约定成为thiscall。

因此,成员函数访问对象成员也是通过this指针间接访问的。并且在成员函数调用过程中使用rcx作为第一个参数,并且保存了对象的首地址。

thiscall与__stdcall一样被调方负责栈平衡。当使用其他调用方式时(例如__stdcall),this指针不再使用ecx传递参数,而是改用栈传递参数(第一个压入)。

静态数据成员

程序加载时,静态数据成员已经存在,此时类没有实例对象。生命周期不同,静态数据成员是所有同类对象公用的数据,展示形态与全局变量相同,因此很难被识别为静态数据成员。

1
2
3
.bss:000000014000C030 ; Person::name
.bss:000000014000C030 _ZN6Person4nameE dd ? ; DATA XREF: main+14↑r
.bss:000000014000C034 align 20h

对象作为函数参数

对象作为函数参数时,其传递过程较为复杂和独特。和数组不同,对象变量名不代表其指针,因此传参时会先将其所有数据复制作为形参传递给调用的函数中使用。

传参顺序:最先定义的成员最后压栈(符合内存地址顺序)。数据成员比较简单时编译器会直接按成员顺序尽心压栈传参。当对象比较大或者复杂时,会使用rep movs这样的指令复制数据。篇幅有限,其具体过程读者可以自行反汇编观察。

由于对象被复制,相当于创建了一个新的对象,在某些情况下会调用复制构造函数。函数退出时,作为局部变量对象被销毁,可能也会调用析构函数。这里便有个问题:如果程序员不注意编写复制构造函数,当对象作为参数传递,函数结束后调用析构函数释放对象内可能存在的动态分配的内存,此时会造成原对象指向的动态内存已经被标记为释放了(因为对象复制默认为浅拷贝),为程序错误埋下隐患。

对象作为返回值

与作为对象参数的处理方式非常类似,调用时会预留返回对象的栈空间,被调用函数将返回对象复制到临时栈空间,将其首地址作为返回值返回。

同样,此时也可能会产生上面所说的析构函数所造成的问题。

总结

本篇简单讨论了结构体和类的反汇编知识,内容主要来自钱林松的《C++反汇编与逆向分析技术揭秘》,推荐读者可以直接观看原书。除此之外,结构体与类的相关内容还有很多,例如构造函数和析构函数、虚函数、继承和多重继承等。后面有机会可能会再次总结几篇文章,当然直接看钱老师的书是更好。

⬆︎TOP