C++内存中程序的剖析

转载自科罗拉多州大神的技术博客,有很清晰的图示添加链接描述
#1.C++内存中程序的剖析
内存管理是操作系统的核心。这对于编程和系统管理都至关重要,这篇文章介绍了程序在内存中的布局方式。
多任务操作系统的每个进程都在其自己的内存沙箱中运行。此沙箱是虚拟地址空间。在32位模式下,它始终是4GB的内存地址块,这些虚拟地址通过页表映射到物理内存,这些页表由操作系统内核维护并由处理器查询。每个进程都有自己的一组页表,但是有一个catch。启用虚拟地址后,它们将应用于计算机中运行的所有软件,包括内核本身。因此,虚拟地址空间的一部分必须保留给内核:
在这里插入图片描述
在这里插入图片描述
但这并不意味着内核使用了多少物理内存,只知道它拥有的可用于映射它所期望的物理内存地址空间的部分。页面表中的内核空间被标记为特权代码所独占(ring 2 or lower),
{
应用程序在Intel x86计算机中的功能有限,并且只有操作系统代码才能执行某些任务,CPU环,特权和保护这篇文章介绍了x86特权级别,这是OS和CPU共同限制用户模式程序可以执行的操作的机制。有四个特权级别,从0(最高特权)到3(最低特权)编号,并且受保护的三个主要资源是:内存、I/O端口以及执行某些机器指令的能力。在任何给定时间,x86 CPU都以特定的特权级别运行,该特权级别确定哪些代码可以执行和不能执行。这些特权级别通常被称为保护环,其中最里面的环对应于最高特权。大多数现代x86内核仅适用两个特权级别,即0和3:
x86保护环
}
,因此,如果用户模式程序尝试触摸该页面,则会触发页面错误。在Linux中,内核空间一直存在,并在所有进程中映射相同的物理内存。内核代码和数据始终是可寻址的,随时可以处理中断或系统调用。相反,没当发生进程切换时,地址空间的用户模式部分的映射都会更改:
单个CPU进程切换时内核空间维持固定映射,用户应用程序的映射相应更改
蓝色区域表示映射到物理内存的虚拟地址,而白色区域未映射。在上面的示例中,由于传奇的内存需求,Firefox使用了相当多的虚拟地址空间(has used far more of its virtual address space due to its legendary memory hunger)。地址空间中的不同带对应于内存段,例如堆、栈等。请记住,这些段只是一系列内存地址,与Intel样式的段无关。无论如何,这是Linux进程中的标准段布局:
在这里插入图片描述
当计算在过去是快乐、安全且友好时,上图所示的分段们的起始虚拟地址在一个机器中对几乎每个程序来说正好是相同的。这使得远程利用安全漏洞变得容易。漏洞利用经常需要引用绝对的内存位置:堆栈stack上的地址、库函数的地址等。远程攻击者必须依靠地址空间相同的事实,盲目选择此位置。因此,地址空间随机化已变得很流行。Linux通过对堆栈stack、内存映射段和堆的起始地址添加偏移来实现随机化。但不幸的是,32位地址空间非常紧凑,几乎没有留出空间进行随机化,并且还妨碍了其有效性。
进程地址空间中最顶层的部分是堆栈stack,在大多数编程语言中堆栈存储局部变量和函数参数。调用方法或函数会将新的堆栈帧压入堆栈。函数返回时,堆栈帧被销毁。这种简单的设计可能是因为数据遵循严格的LastInFirstOut(LIFO)顺序,这意味着不需要复杂的数据结构即可跟踪堆栈内容-只需指向堆栈顶部的简单指针即可。栈帧压栈和弹出因此变得非常快速且确定。同样,堆栈区域的不断重用倾向于将活动堆栈内存保留在cpu缓存中,从而加快访问速度。进程中的每个线程都有自己的堆栈。
通过压栈超出其适合范围的数据,可能会耗尽映射堆栈的区域。这将触发页面错误,该错误在Linux中由expand_stack()处理,该错误又调用acct_stack_growth()来检查是否适合增加堆栈。如果堆栈大小小于RLIMIT_STACK(通常为8MB),则正常情况下堆栈会增长,并且程序会愉快地继续运行,而不会意识到发生了什么。这是堆栈大小根据需求进行调整的正常机制。但是,如果已达到最大堆栈大小,则说明堆栈溢出并且程序收到分段错误。尽管映射的堆栈区域可以扩展以满足需求,但是当堆栈变小时则不会收缩。像联邦预算一样,它只会扩大。
动态堆栈增长是唯一有效的访问未映射内存区域的情况(如上白色所示)。对未映射内存的任何其他访问都会触发页面错误,从而导致分段错误。某些映射区域是只读的,因此对这些区域的写入尝试也会导致错误。
在堆栈下方,我们有内存映射段。在这里,内核将文件的内容直接映射到内存。任何应用程序都可以通过Linux的mmap()系统调用(实现)或Windows的CreateFileMapping()/MapViewOfFile()来请求此类映射。内存映射是一种执行文件I/O的便捷且高性能的方式,因此它用于加载动态库。也可以创建不对应于任何文件的匿名内存映射,而是将其用作程序数据。在Linux中,如果您通过malloc()请求大块内存,则C库将创建此类匿名映射,而不使用堆内存。“大”表示大于MMAP_THRESHOLD字节,默认为128kB,可通过mallopt()进行调整。
在内存映射段下方,是堆。堆提供了运行时内存分配(runtime memory allocation),就像堆栈stack一样,不同于堆栈,堆给那些必须在执行分配的函数域之外仍要保持存在的数据使用。大多数语言为程序提供堆管理。因此,满足内存请求是语言运行时和内核之间的共同事务。在C中,堆分配的接口是malloc()和friends,而在像C#这样的垃圾收集语言(garbage-collected language)中接口是new关键字。
如果堆中有足够的空间来满足内存请求,则语言运行时可以在不涉及内核的情况下对其进行处理。否则,将通过brk()系统调用(实现)扩大对,以为请求的块提前准备好空间。堆管理很复杂,需要复杂的算法来应对程序混乱的分配模式,以提高速度和有效地使用内存。需要服务一个堆请求的时间可能会有很大的不同。实时系统具有专用分配器来处理此问题。堆也变得碎片化,如下所示:
在这里插入图片描述
最后,我们到达内存的最底层:BSS分段、数据分段和程序文本分段。BSS分段和数据分段都在C中存储静态(全局)变量的内容。区别在于BSS分段存储未初始化的静态变量的内容,程序员未在源代码中设置其值。BSS内存区域是匿名的:它不行社任何文件。如果有static int cntActiveUsers;则cntActiveUsers的内容存储在BSS分段。
另一方面,数据分段存储在源代码中初始化了的静态变量的内容。该存储区不是匿名的。它映射了程序二进制镜像的一部分(maps the part of programs’s binary image),该部分包含源代码中给出的初始的静态值。所以如果有static int cntWorkerBees=10;则cntWorkerBees的内容存储在数据分段,并从10开始。即使数据分段映射了一个文件,它也是一个私有内存映射(private memory mapping),这意味着对内存的更新不会反映到基础文件中(updates to memory are not reflected in the underlying file)。必须是这样,否则分配给全局变量值将会改变你磁盘上的二进制镜像。不可思议!
代码有:

static char *userName; //未初始化的静态变量,指针userName存储在BSS分段,由于被初始化0所以指向0
static char *gonzo = "God's own prototype"; //初始化了的静态变量,指针gonzo存储在数据分段,字面值常量"God's own prototype"存储在代码文本区(可读可执行但不可写)
static int cntActiveUsers; //未初始化的静态变量,变量cntActiveUsers存储在BSS分段,被初始化0
static int cntWorkerBees=10; //初始化了的静态变量,变量cntWorkerBees存储在数据分段,字面值变量10存储在数据分段(可读写)

在这里插入图片描述
图中的数据示例比较棘手,因为它使用了指针。在这种情况下,初始化了的静态指针gonzo的内容(一个4字节的内存地址)位于数据段中。但是,它指向的实际字符串确没有。字符串位于文本段中,该文本段是只读的,除了存储字符串文本之类的花哨词外,还存储所有代码。文本段还将您的二进制文件映射到内存中,但是写入到该区域将会为你的程序带来分段错误(earn your program a Segmentation Fault)。这有助于防止指针错误,尽管不如首先避免使用C来得有效。
您可以通过读取文件/proc/pid_of_process/maps来检查Linux进程中的内存区域。请记住,一个分段可能包含许多区域。例如,每个内存映射文件通常在内存映射mmap分段中都有其自己的区域,而动态库具有类似于BSS分段和数据分段的额外区域。下一篇文章将阐明“区域”的真正含义。此外,有时人们会说“数据段”,意思是所有数据+BSS分段+堆分段。
你可以适用nm和objdump命令检查二进制图像(binary images),以显示符号、它们的地址、分段等。最后,上述虚拟地址布局是Linux中的“灵活”布局,这是几年来的默认布局。假设我们有一个RLIMIT_STACK的值。如果不是这种情况,Linux将恢复为如下所示的“经典”布局:
在这里插入图片描述
#2.内核如何管理您的内存
内核如何管理您的内存
在检查了进程的虚拟地址布局之后,我们转向内核及其用于管理用户内存的机制。
在这里插入图片描述
Linux进程在内核中以进程描述符task_struct实例被实现,task_struct中的mm域指向内存描述符mm_struct,这是一个程序内存的执行摘要。它存储上图所示的起始和终止内存分段、进程所使用的物理内存页的数目(rss代表驻留集大小Resident Set Size)、所使用的虚拟地址数量与其他玩意儿。在内存描述符中,我们还发现了两个主力(work horses)用来管理程序内存:虚拟内存区域集页表。Gonzo的内存区域如下所示:
在这里插入图片描述
每个虚拟内存区域(VMA)都是连续的虚拟地址范围;这些区域永远不会重叠。vm_area_struct实例完整描述了一个内存区域,包括其开始和结束地址、用于确定访问权限和行为的标志以及用于指定该区域正在映射哪个文件的vm_file字段(如果有的话)。不映射文件的虚拟内存区域VMA是匿名的(如栈分段、内存映射分段(可以映射文件(如动态库)或匿名映射)、堆分段、BSS分段)。上面的每个内存段(例如堆、堆栈)都对应单个VMA,但内存映射分段除外(内存映射分段可以映射多个动态库文件或实现多个malloc()申请大于MMAP_THRESHOLD个字节的堆内存时创建的匿名映射)。尽管这在x86机器中很常见,但这不是必须的。VMA不在乎他们属于哪个分段。
程序的VMA既作为mmap字段中的链接列表(按其实虚拟地址排序)又作为以mm_rb字段为根的红黑树存储在其内存描述符中。红黑树允许内核快速搜索覆盖给定虚拟地址的内存区域。当您读取文件/proc/pid_of_process/maps时,内核仅浏览该进程的VMA链表并打印每个(the kernel is simply going through the linked list of VMAs for the process and printing each one)。
在Windows中的EPROCESS块大致上就是任务结构体task_struct与内存映射结构体mm_struct的混合。Windows中类似于虚拟内存区域VMA的是虚拟地址描述符(Virtual Address Descriptor)或VAD;它们存储在一个AVL树中。现在你知道Windows与Linux之间有趣的事情了么?这只是一点小小的区别。
4GB的虚拟地址空间是分成页面的。32位模式下的x86处理器支持的页面大小有4KB、2MB和4MB。Linux和Windows都使用4KB的页面来映射虚拟地址空间中的用户部分(那3GB)。字节0-4095(4K-1)位于第0页,字节4096-8191位于第1页,依此类推。虚拟内存区域VMA的大小必须是页面大小的倍数。这是4GB页面中的3GB用户空间:
在这里插入图片描述
处理器查阅页表以将虚拟地址转换为物理内存地址(虚拟内存区域VMA中每个页面都有映射到物理内存)。每个进程都有其自己的页面表集。每当发生进程切换时,也会切换用户空间的页表(从而用户空间的3GB的页面会通过页表映射到其物理内存)。Linux在内存描述符的pgd字段中存储了指向进程的页表的指针。每个虚拟页面(4KB)在页表中都有一个页表项(PTE),在常规x86分页中,他是一个简单的4字节记录,如下所示:
在这里插入图片描述
Linux有函数来读和设置页表项中的每个标注。位P告诉处理器该虚拟页面是否映射到物理内存。如果清除(等于0),访问该页面将触发页面错误。请记住,当P位为0时,内核可以对其余的域执行任何需要的操作。R/W标志标识读/写,如果清除,则该页面只读。U/S标识用户/管理器,如果清除,那么该页面只能被内核访问。这些标识用于实现我们前面看到的只读内存与受保护的内核空间。
D和A位用于清除(dirty)和访问。脏页已经有写入,可访问页已经有写入或读取。这两个标志位都是粘性的:只能由处理器设置它们,并且它们也只能由处理器清除。最后,页表项PTE存储对应该页面的起始物理地址,对齐到4KB。这个看起来很幼稚的域是一些麻烦的根源,因为它限定可寻址的物理内存为4GB。其他页表项PTE的域后面介绍,如物理地址拓展。
虚拟页面是内存保护的单位,因为虚拟页面的所有字节(共4KB)都共享用户/管理器U/S和读写R/W标志。但是,同一物理内存可能由不同的页面映射,可能具有不同的保护标志。请注意,执行权限在页表项PTE中无处可见。这就是为什么经典的x86分页允许执行堆栈上的代码,从而更容易利用堆栈缓冲区溢出的原因(仍然可以使用返回libc和其他技术来利用不可执行的堆栈)。页表项PTE不执行标志的缺乏说明了一个更广泛的事实:虚拟内存区域VMA的许可标志可能会也可能不会转换为硬件保护。内核会尽其所能,但最终架构会限制一切。
虚拟内存不存储任何内容,它只是将程序的地址空间映射到基础物理内存上,处理器将其作为一个称为物理地址空间的大块进行访问。尽管涉及到总线上的内存操作,但在这里我们可以忽略它,并假设物理地址的范围从零到可用内存的顶部(以一字节为单位)。内核将该物理地址空间分解为页面帧(以4KB为一个单位)。处理器不知道也不关心帧,但是它们对于内核至关重要,因为页面帧是物理内存管理的单元。Linux和Windows都在32位模式下使用4KB页面帧。这是具有2GB RAM(random access memory)的计算机的实例:
在这里插入图片描述
在Linux中,每个页面帧都由一个描述符和几个标志跟踪。这些描述符一起跟踪计算机中的整个物理内存。每个页面帧的精神状态始终是已知的。物理内存是通过伙伴内存分配技术来管理的。因此,可以通过伙伴系统进行分配,则页面帧是自由的。分配的页面帧可能是匿名的,保存程序数据,也可能在页面缓存中,保存存储在文件或块设备中的数据。还有其他奇特的页面帧用途,但暂时不使用它们。Windows具有类似的页面帧号(PFN)数据库来跟踪物理内存。【这一段不太懂】
让我们将虚拟内存区域VMA、页表项也页面帧放在一起,以了解它们如何工作。以下是用户堆的示例:
在这里插入图片描述
蓝色矩形代表VMA范围内的页面帧,而箭头代表将内存页面帧映射到物理页面帧的页面表条目。某些虚拟页面帧没有箭头。这意味着其相应的页表项PTE清除了Present标志。这可能是因为页面从未被访问过,后者它们的内容已被换出。无论哪种情况,访问这些页面都将导致页面错误,即使它们在虚拟内存区域VMA中也是如此。对于VMA和页面表不吻合,这似乎很奇怪,但是这种情况经常发生。
虚拟内存区域VMA就像您的程序和内核之间的合同。您要求完成某件事(分配内存,映射文件等),内核说“确定”,然后它会创建或更新适当的VMA。但是它实际上并不能立即满足请求,它会等到页面错误发生时才开始实际工作。内核是一个懒惰的、欺骗性的浮渣。这是虚拟内存的基本原理。它适用于大多数情况,有些是熟悉的,有些是令人惊讶的,但规则是虚拟内存区域VMA记录已达成共识的内容,而页表项PTE则反映实际上由懒惰的内核完成的内容。这两个数据结构共同管理程序的内存。两者都在解决页面错误、释放内存、换出内存等方面发挥作用。让我们看一下内存分配的简单情况:
这图简直太直白了
当程序通过brk()系统调用请求更多内存时(申请堆内存),内核仅更新虚拟内存区域VMA并说它准备好了。此时,实际上没有分配任何页面帧,并且物理内存中不存在新页面。一旦程序尝试访问页面,处理器触发了页面错误并调用do_page_fault()。它使用find_vma()搜索VMA正在覆盖的错误虚拟地址。如果找到,也会同时检查一下VMA的权限与尝试访问(读或写)是否冲突。如果没有适合的VMA,没有合同覆盖尝试的内存访问(no contract covers the attempted memory access),则处理器会受到分段错误的惩罚。
找到虚拟内存区域VMA后,内核必须通过查看页表项PTE内容和VMA的类型来处理故障。在我们的情况下,页表项PTE显示页面不存在。实际上,我们的PTE完全为空白(全零),这在Linux中意味着虚拟页面从未被映射过。由于这是一个匿名VMA,因此我们有一个纯粹的RAM事件,必须有do_anoymous_page()处理,该事件分配一个页面帧,并使一个页表项PTE映射那个错误的虚拟页面到新分配的页面帧。
事情可能有所不同。例如,换出页面的页表项PTE在Present标志中具有0,但不为空白。相反,它存储包含页面内容的交换位置,这必须从磁盘读取,并由do_swap_page()加载到页面帧中,这称为主要故障。【这一段没看明白】
#3.页面缓存,内存和文件之间的事务
链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值