【C++】对类及类对象的内存分析


在学习和使用c++的过程中有了一个感悟: 编写程序实质就是 操作内存,如果能对程序运行过程中涉及到 内存的变化了如指掌,势必对开发工作大有裨益。

为了分析内存的变化,首先要清楚一段程序文本是从如何从一行行c++代码变为运行时CPU能够直接读懂的二进制指令的。

一段程序的生前身后事

程序的加工过程:

在编辑器中的程序代码本身是.cpp文件中的字符,而运行程序的实质是CPU执行代码翻译成的二进制指令的过程,因此在编辑器中按下control + F5执行程序后,首先要做的是将代码文本转换为CPU可直接执行的二进制文本。
一串程序代码转变为一串可执行文件的过程为:预处理->编译->汇编->链接
在这里插入图片描述

参考:
C/C++:编译全过程——预处理、编译、汇编、链接(包含预处理指令:宏定义,文件包括、条件编译)

预处理

1.预处理:
详细过程 见下文:
C/C++预处理过程详细梳理(预处理步骤+宏定义#define/#include+inline函数+宏展开顺序+条件预处理+其它预处理定义)
大致内容:
头文件展开:将#include头文件中包含的内容附加到当前文件,从而可以使用其他文件中的函数、类等;
宏定义替换:将代码中使用宏定义#define的内容进行展开,注意只是进行简单的文本替换,需要与编译期inline函数的内联(将函数内容直接拷贝在inline函数名处)区别开来
代码文本处理:注释替换为空格、去掉多余空格、将用\分割开的物理行合并到一个逻辑行、等字符相关处理
最终将.cpp文件经过处理生成.i文件。

编译

2.编译:
将高级语言编译为汇编语言,由.i文件生成.s文件。
编译阶段完成的工作主要包括生成符号表生成汇编代码

关于汇编代码:
在线转汇编网站中将c++代码转为汇编代码。
现代 c++ 四:查看汇编代码、看懂汇编代码

重点关注一下符号表:

符号表是用来干什么的:
CPU只能通过内存地址+操作数的形式工作,因此一串代码必须被转化为内存地址+二进制数值的形式,对于代码中的函数名、全局变量、静态变量、常量、引用等字符串符号,必须将其通过一种数据结构(哈希表等)映射到其存放在内存中的实际地址(虚拟地址),这样将代码翻译为汇编代码后,就不会出现变量名或函数名,而是直接用地址值替代,符号表就是用来存放这种变量名->内存映射关系。对于局部变量,运行时将其存放在栈空间中。

符号表的生成过程涉及到如何给代码/变量分配到内存,首先要清楚虚拟内存的概念。

虚拟内存

虚拟内存空间:
一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射
一段程序在运行之前只是存放在磁盘中的文件,运行后操作系统会为该程序创建一个进程,进程是操作系统进行分配内存资源最小单位,分配好内存资源后,一个进程又作为一个线程在运行,一个进程可创建多个线程,每个线程共享该进程的内存资源(代码、变量值等),因此线程是操作系统执行最小单位,在线程调度器的计算下,CPU会以看似“并行”的方式运行着很多线程,实际上根据CPU的核心数量,同一时刻CPU是以串行的方式执行单个线程的程序。
在这里插入图片描述
每个进程都占用了了物理内存的一部分空间,CPU在不同进程之间切换时,访问的物理内存地址相差很大且毫无规律,为了统一CPU的寻址操作,人们为每一个进程都分配了一个虚拟完整的内存空间,通过内存管理单元为虚拟内存和实际物理内存映射关系创建了一个页表,这样每个进程不用关心实际的物理内存地址,都在统一的虚拟内存空间上进行内存分配,因此之后涉及到内存地址的部分都是指虚拟内存地址。

对于一个进程的虚拟内存空间,会分为内核区、栈区、内存映射区、堆区、数据区,分别存放不同类型的数据。
在这里插入图片描述
可以看到在编译阶段生成的符号表并没有被加载到进程的虚拟内存中,这是因为符号表只是一个中间辅助工具,在链接阶段分配好函数及变量的内存地址(虚拟内存地址),就没有符号表什么事了,此时二进制代码中只有地址和操作数,CPU已经可以正常执行这段程序了,而对于编辑器中调试模式,我们可以看到每个变量的地址,这就要靠符号表起作用了。

符号表

符号表到底是什么:
深入理解计算机系统(电子书)
3.6 编译过程(3):符号表
符号表结构体如下图所示,包含了一个符号代表的基本信息,比如地址值,所在段(.text、.data)。
符号表的信息在经过编译、汇编和链接之后才能确定一个符号在虚拟内存中的位置,在链接之前,符号用相对地址偏移量表示函数、变量之间的位置关系。
在这里插入图片描述
以一段具体的代码为例:

int sum(int *a, int n);

int array[2] = {1, 2};

int main()
{
    int val = sum(array, 2);
    return val;
}

生成的符号表:其中前八个是链接器内部使用的符号。
在这个例子中,我们看到全局符号 main 定义的条目,它是一个位于 .text 节中偏移量为 0(即 value 值)处的 24 字节函数。其后跟随着的是全局符号 array 的定义,它是一个位于 .data 节中偏移量为 0 处的 8 字节目标。最后一个条目来自对外部符号 sum 的引用。READELF 用一个整数索引来标识每个节。Ndx=1 表示 .text 节,而 Ndx=3 表示 .data 节。

在这里插入图片描述

汇编

3.汇编:
将汇编语言翻译为二进制机器码,生成.o文件,但是不能直接运行,因为原始cpp文件中用到的一些外部函数的二进制机器码还没有被链接进来,对于这些函数名来说,CPU是无法找到函数体来执行对应程序的。

链接

4.链接:
将cpp文件中使用到的外部库函数本体,通过两种方式链接进.o文件中。最终生成可执行.exe文件
静态链接:直接把函数体内容替换到函数名处
动态链接:库函数本身已经编译汇编为了dll文件,为了节省代码占用的空间,我们将外部函数名替换为dll文件中对应函数的地址偏移量,这样执行到此函数后,从dll文件中找到函数体的二进制机器码去执行(当然也要将此文件加载到内存中)。

链接之后生成的可执行文件在linux系统下一般为ELF文件,在windows系统下为exe文件,此时该文件仍然只是磁盘中的数据,当真正开始执行这个文件,会将其中关键信息加载到内存。
在这里插入图片描述

内存中的各种数据

根据测试代码在调试中的实际输出,整理如下内存布局图,具体内存地址分配与前面所总结虚拟内存分配有所不同,应该是不同编译器差异造成的,暂时忽略这个问题,将重点放在各种类型的数据如何存入内存中。
在这里插入图片描述
全局性质的数据:
全局变量、静态变量: 存放在全局/静态区,包括已初始化和未初始化两区域。

  • 未初始化的,会统一放在未初始化区域
  • 已初始化的,放在统一的已初始化区域
    编译器地址分配完毕后,运行时不会再发生改变:在运行中未初始化的变量初始化后,仍然在原内存地址,并不改变。

全局的const常量: 单独存放在一个区域。

字面值常量: 如“hello”,1这种,也是单独的一个区域;

虚函数表: 经过测试,虚函数表应该是放在数据段中,而不是代码段。虚函数表是一个数组结构,里面存放的是虚函数代码所在位置的地址,因此可以理解为虚函数表存放的是虚函数指针

代码:
程序中的所有函数只会在内存中存在一份,对于类而言,类的成员函数在编译时统一存放在代码区,类的实例在调用函数时,是通过函数名索引到函数代码的地址,从而执行函数的。
全局函数代码: 在代码区一个专属区域

类内普通函数代码: 应该是统一的一个区域,按类内声明顺序存放

类内虚函数代码: 存放并不连续、也不按顺序来,应该是根据占用大小动态分配。

引用:
对于引用这种类型,之前认为是在符号表中直接对于变量名的替换,不占用实际内存,经过实验发现,类内引用在实例化出来后,有8字节的占用空间,类似指针,但是在编辑器中直接取引用的地址,输出还是引用对象的地址,这一点不同于指针,所以应该是编译器将引用这个符号直接替换成了引用对象,而在底层,引用是个* const也就是指针常量(指向的地址不能变)

  • 如果引用右值,如 int&& rref1 = 10,那么rref1 本身是个左值,占用8字节,在编译时会为10在常量区安置一块内存,存放10,而rref1 中存放的地址就是这块内存的地址,因此可以把rref1 当成一个正常的int类型来用。
  • 如果引用std::move后的左值,如 int&& rref2 =std::move(var); 那么rref2 中存放的就是var的地址,类似指针的用法,只是不能更改指向。

关于左值、右值、右值引用、完美转发等等概念,之后再议,放这里就太冗长了。

栈区:
执行函数的时候,就会进入到函数栈中,将实参入栈,这时就只能只用当前函数栈中的局部变量和全局/静态变量了,但是
对于实例化的对象而言:在调用函数时,会有一个隐式的参数this指针,其指向该对象的首地址,这样就能在类成员函数中使用类的成员变量了(使用时是通过this指针调用)。

下面整理一下类的内存模型以及类调用函数和变量的机理。

类的内存模型

c++虚函数表、地址详解
C++中如何获取虚表和虚函数的地址
一直很好奇,对于测试类refer,实例化出的r的内存布局是什么,r是如何调用其成员变量和函数的,经过实验和一些博客总结如下:

class refer {
public:
	refer() {};
	int var = 0;
	int& lref = var;
	int var2 = 1;
	int&& rref1 = 10;
	int&& rref2 = std::move(var);
	static int stc_init;
	static int stc_uninit;
	const int con = 1;
	void func1() { cout << this << endl; int a = 0; int b = 0; int c = 0; a = a + b + c; a = a + b; a = a + c; a = a + a; return; };
	void func2() { };
	void func3() { int a = 0; int b = 0; int c = 0; a = a + b + c; return; };
	void func4() {  };
	virtual void vfunc1() { cout << "虚函数中this指针指向的地址"<<this << endl; int a = 0; int b = 0; int c = 0; a = a + b + c; a = a + b; a = a + c; a = a + a; return; };
	virtual void vfunc2() { int a = 0; int b = 0; int c = 0; a = a + b + c; return; };
	virtual void vfunc3() { int a = 0; int b = 0; int c = 0; a = a + b + c; return; };
	virtual void vfunc4() { int a = 0; int b = 0; int c = 0; a = a + b + c; return; };
};

该类对象的内存模型
在这里插入图片描述
实例化的类对象:通过构造函数在内存中分配该类大小的空间。

类大小的计算:

  1. 有虚函数的类在首地址加入一个虚函数表指针,大小为8字节
  2. 根据类成员变量所有类型中占据字节数最大值进行内存对齐,这里是8字节。
  3. 根据变量声明顺序进行分配内存,第一变量为int ,占四字节,第二个为int&&,类似指针,占8字节,这时就需要在第一个变量之后插入四字节的空余,用来进行内存对齐,依次计算后的值该类大小为56字节。

类对象变量的调用

r.var是如何取得实际内存中该对象中var的值的?
通过偏移量取得类成员变量:变量名实际上存储了相对于首地址的偏移量
refer.var实际地址 = refer头地址 + refer::var代表的偏移量

	printf("类refer refer::var的偏移地址:%p\n", &refer::var); //结果为8
	printf("类refer refer::var2的偏移地址:%p\n", &refer::var2);//结果为24
	printf("类refer refer::con的偏移地址:%p\n", &refer::con);//结果为48

类内普通函数的调用

r.func1()是如何调用到普通函数代码的?

通过静态绑定:在编译器即将函数名绑定到了函数代码地址:
函数名指向了函数代码存放的地址:&refer::func1 = 实际代码地址

	refer r;
	//定义一个函数指针funcPtr1
	void (refer:: * funcPtr1)() = &refer::func1;
	printf("类refer r.funcPtr1的函数代码地址:%p\n", funcPtr1);
	//函数代码所在地址为0x00007FF6488C478F
	//通过函数地址调用函数,并且能够正确初始化this指针
	//下面相当于func1(&r),也相当于r.func1();
	(r.*funcPtr1)();
	

类内虚函数的调用

r.vfunc1()是如何调用到虚函数代码的?
通过动态绑定,只有在运行期才能确定函数名代表的函数代码地址

显然,对于虚函数 &refer::vfunc1,在编译器它并没有被分配到实际的函数代码地址,只有在运行时,通过函数名确定了在虚函数表中的偏移量,这才能从虚函数表中找到真正存放虚函数代码的地址值。

r.vfunc1()实际代码地址 = r首地址 -> 虚函数表 + 偏移量 -> 真正的虚函数代码所在地址
在这里插入图片描述
通过虚函数表指针虚函数表的机制,才能实现动态多态的效果。
以下为例:
基类refer,有普通函数func1(),虚函数vfunc1()
继承自refer的派生类referchild ,重载了普通函数func1(),重写了虚函数vfunc1()
使用基类指针指向派生类,指针pbase 指向的头地址是派生类的内存头地址

  • 调用虚函数会通过虚函数表指针+虚函数名代表的偏移量找到派生类的虚函数表,从而调用到派生类自己重写的虚函数。
  • 调用普通函数时,因为指针类型是基类,而基类的func1已经被绑定到实际的代码地址了,因此基类指针调用普通函数是会调用到基类的普通函数的,this指针会被初始化为派生类的地址,但是类型是基类,只能调用基类的成员变量和普通成员函数。
	referchild rc;
	refer* pbase = &rc; 
	pbase->vfunc1();
	pbase->func1();

地址说明:
对象首地址:&r
虚函数表指针:(long long *)(&r),指向首地址8个字节空间的指针
虚函数表首地址: *(long long *)(&r),是一个十进制数。
转化为十六进制地址(long long * ) * (long long *)(&r)
虚函数表中首个虚函数指针:(long long * ) * (long long *)(&r)
首个虚函数地址:(long long * )*(long long * ) * (long long *)(&r)
其实*(long long * ) * (long long *)(&r)解引用后就已经是地址了,只不过是十进制的数,再转换为(long long * )就可看到十六进制的地址了。

找虚表:

	// 由首地址获取虚函数表指针vptr 
	intptr_t* vptr = reinterpret_cast<intptr_t*>(&r);
	// 解引用,获取虚表的地址vtable_addr (是long long 的整数格式)
	intptr_t vtable_addr = *vptr;
	// 获取虚表的十六进制地址:下面两种都可行
	cout << "类refer 虚函数表的地址" << (long long *)vtable_addr << endl;
	cout << "类refer 虚函数表的地址" << (long long*)*reinterpret_cast<long long*>(&r) << endl;

找虚函数实际内存:
有三种方法:

  1. 方法一:利用虚函数指针偏移
	// 获取虚表中指向的实际虚函数代码所在的内存地址
	// 先定义一个函数指针
	typedef void (*func)();
	// 方法一:利用虚函数指针
	// 将long long类型的8字节整数转换为long long指针,则指针的值就是该内存指向的地址值
	intptr_t* virtual_func_ptr = reinterpret_cast<intptr_t*>(vtable_addr);
	func f1 = (func)*virtual_func_ptr;
	// 可以正常调用虚函数,但是不能正确初始化this指针,因为没有传入类对象
	f1();
	// 通过对象调用,才能正确初始化this指针
	r.vfunc1();
	//f1 本身是个指针,存放在栈区,f1指向的地址是vfunc1代码的地址
	cout << "函数指针f1 本身的地址:" << &f1 << endl;
	cout << "类refer 通过实例对象首地址 计算的vfunc1 的地址 " << f1 << endl;
	func f3 = (func) * (virtual_func_ptr + 2);
	f3();
	cout << "类refer 通过实例对象首地址 计算的vfunc3 的地址 " << f3 << endl;
  1. 方法二:通过实际地址偏移量
    除了用函数指针偏移外,还可直接在表头地址+实际偏移的字节数来索引到虚函数的实际地址。
	// 方法二
	func f2 = (func)*(long long*) (vtable_addr + 8);
	f2();
	cout << "类refer 通过实例对象首地址 计算的vfunc2 的地址 " << f2 << endl;
  1. 方法三:换个写法
	func f4 = (func) * (reinterpret_cast<long long*>(*(reinterpret_cast<long long*>(&r))) + 3);
	f4();
	cout << "类refer 通过实例对象首地址 计算的vfunc4 的地址 " << f4 << endl;

如何查看类的内存模型

两种方法:均使用visual studio
1.直接在编辑器的输出框中查看,编译后即输出类的结构信息
其中,命令行代码:/d1 reportSingleClassLayoutxxx “xxx.cpp” 需要填写要查看的类名和源文件名,似乎只能设定一个类
示例:要查看名为 vbase 的类,该类的定义在test2.cpp中
/d1 reportSingleClassLayoutvbase “test2.cpp”
在这里插入图片描述
2.在终端中查看(感觉这个方便一点)
使用以下工具,第一个是基于x86的,查看的是32位系统下类的内存分布,此时一个指针占用的内存是4字节。
第二个工具默认是基于x64的,查看的是64位系统下的内存分布,此时一个指针占用的内存是8字节。
在这里插入图片描述
使用步骤:
①配置环境变量
使用everything查看cl.exe所在路径,选择一个x64环境
在这里插入图片描述添加路径
在这里插入图片描述
①进入程序所在文件目录
cd E:\ProgramData\StudyDemo\test\debug\debug
②查看指定类
cl /d1 reportSingleClassLayoutvbase “test2.cpp”
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值