解析内存中的程序(翻译https://manybutfinite.com/post/anatomy-of-a-program-in-memory/)
内存管理是操作系统的核心;它对于编程和系统管理都是至关重要的。在接下来的几篇文章中,我将关注内存的实际方面,但不会回避内存的内部特性。虽然这些概念是通用的,但是示例大多来自32位x86上的Linux和Windows。
第一篇文章描述了程序是如何在内存中布局的。多任务操作系统中的每个进程都在自己的内存沙箱中运行。这个沙箱是虚拟地址空间,在32位模式下总是4GB内存地址块。这些虚拟地址由页表映射到物理内存,页表由操作系统内核维护并由处理器查询。每个进程都有自己的一组页表,但是有一个问题(Each process has its own set of page tables, but there is a catch)。一旦启用了虚拟地址,它们将应用于计算机中运行的所有软件,包括内核本身。因此,虚拟地址空间的一部分必须保留给内核:
这并不意味着内核使用了那么多物理内存,只是它有那部分地址空间可用来映射它希望映射的任何物理内存。在页表中,内核空间被标记为独占特权代码(第2层或更低),因此,如果用户模式程序试图访问它,就会触发缺页异常。在Linux中,内核空间一直存在,并在所有进程中映射相同的物理内存。内核代码和数据总是可寻址的,随时可以处理中断或系统调用。相反,每当进程切换发生时,地址空间的用户模式部分的映射就会改变:
蓝色区域表示映射到物理内存的虚拟地址,而白色区域未映射。在上面的例子中,Firefox使用了更多的虚拟地址空间,这是由于它对内存的巨大需求。地址空间中不同的频带对应于堆、堆栈等内存段。请记住,这些段只是内存地址的范围,与Intel类型的段无关。无论如何,下面是Linux进程中的标准段布局:
当计算正常、安全的执行时,上面所示的段的起始虚拟地址对于计算机中的几乎每个进程都是完全相同的。这使得远程利用安全漏洞变得很容易。漏洞常常需要引用绝对内存位置:堆栈上的地址、库函数的地址等等。远程攻击者必须盲目地选择这个位置,因为地址空间都是相同的。正因为如此时,程序容易被黑客攻破。因此,地址空间随机化已经变得很流行。Linux通过向它们的起始地址添加偏移量来随机化堆栈、内存映射段和堆。不幸的是,32位的地址空间非常紧张,几乎没有随机化的空间,这阻碍了它的有效性。
进程地址空间中最上面的部分是堆栈,它在大多数编程语言中存储本地变量和函数参数。调用方法或函数会将新的堆栈帧推入堆栈。当函数返回时,堆栈帧被销毁。这种简单的设计可能的原因是因为数据遵循严格的后进先出(LIFO)顺序,这意味着不需要复杂的数据结构来跟踪堆栈内容——一个指向堆栈顶部的简单指针就可以做到。往栈里push和pop数据因而是非常快速和确定的。此外,堆栈区域的不断重用往往会在cpu缓存中保持活动的堆栈内存,从而加快访问速度。进程中的每个线程都有自己的堆栈。
这有可能通过写(push)入比它所能容纳的更多的数据来耗尽映射堆栈的区域。这将触发一个页面错误(page fault),该错误在Linux中由expand_stack()处理,而expand_stack()又调用acct_stack_growth()来检查是否适合扩展堆栈。如果堆栈大小低于RLIMIT_STACK(通常为8MB),那么堆栈通常会增长,程序也会愉快地继续运行,不知道刚刚发生了什么。这是根据需要调整堆栈大小的正常机制。然而,如果达到最大堆栈大小,我们有一个堆栈溢出,并且程序收到段错误(Segmentation Fault)。当映射的堆栈区域扩展以满足需求时,它不会在堆栈变小时收缩。就像联邦政府预算一样,它只会扩大。
动态堆栈增长是访问未映射的内存区域(如上图中白色部分所示)可能有效的惟一情况。对未映射内存的任何其他访问都将触发一个导致段错误的页面错误。一些映射区域是只读的,因此对这些区域的写尝试也会导致段错误。
在堆栈下面,我们有内存映射段。这里,内核将文件的内容直接映射到内存。任何应用程序都可以通过Linux mmap()系统调用(实现)或Windows中的CreateFileMapping() / MapViewOfFile()请求这样的映射。内存映射是执行文件I/O的一种方便且高性能的方法,因此它用于加载动态库。还可以创建不与任何文件对应的匿名内存映射,将其用于程序数据。在Linux中,如果通过malloc()请求大内存块,C库将创建这样的匿名映射,而不是使用堆内存。“大”表示大于MMAP_THRESHOLD字节,默认为128 kB,可以通过mallopt()进行调整。
说到堆,接下来是地址空间。与堆栈不同,堆提供运行时内存分配,这意味着数据必须比执行分配的函数寿命长。大多数语言都为程序提供堆管理。因此,满足内存请求是语言运行时和内核之间的共同事务。在C语言中,堆分配的接口是malloc(),而在其它语言例如c#这样的垃圾回收语言中,接口是new关键字。
如果堆中有足够的空间来满足内存请求,那么语言运行时可以在不涉及内核的情况下处理它。否则,通过brk()系统调用(实现)来扩大堆,为请求的块腾出空间。在面对我们程序的复杂分配模式时,堆管理是复杂的,需要复杂的算法,以争取速度和有效的内存使用。为堆请求提供服务所需的时间可能有很大差异。实时系统有特殊用途的分配器来处理这个问题。堆也变得产生很多碎片,如下图所示:
最后,我们讨论内存的最低段:BSS、数据和程序文本。在c语言中,BSS和数据都存储静态(全局)变量的内容。不同的是,BSS存储未初始化的静态变量的内容,这些变量的值在源代码中没有被程序员设置。BSS内存区域是匿名的:它不映射任何文件。如果您说的是static int cntActiveUsers,那么cntActiveUsers的内容就位于BSS中。
另一方面,数据段保存源代码中初始化的静态变量的内容。这个内存区域不是匿名的。它映射程序二进制图像中包含源代码中给定的初始静态值的部分。因此,如果您说static int cntWorkerBees = 10,那么cntWorkerBees的内容就在数据段中,并从10开始。即使数据段映射一个文件,它也是一个私有内存映射,这意味着对内存的更新不会反映在底层文件中。必须如此,否则对全局变量的赋值将改变磁盘上的二进制映像。不可思议!
图中的数据示例比较复杂,因为它使用了一个指针。在这种情况下,指针的内容——一个4字节的内存地址——在数据段中。然而,它所指向的实际字符串却不是。字符串位于文本段中,该文本段是只读的,除了字符串文本之类的花絮外,还存储所有代码。文本段也映射你的二进制文件在内存中,但写入到这个区域使你的程序产生一个段错误。这有助于防止指针错误,尽管没有首先避免C那么有效。这是一个图表显示这些部分和我们的例子变量:
您可以通过读取/proc/pid_of_process/maps文件来检查Linux进程中的内存区域。请记住,一个片段可能包含许多区域。例如,每个内存映射文件通常在mmap段中有自己的区域,而动态库有类似于BSS和数据的额外区域。下一篇文章将阐明“区域”的真正含义。另外,有时人们说“数据段”,意思是所有的数据+ bss +堆。
您可以使用nm和objdump命令检查二进制图像,以显示符号、它们的地址、段等。最后,上面描述的虚拟地址布局是Linux中的“灵活”布局,这是几年来的默认布局。它假设我们有一个RLIMIT_STACK的值。如果不是这样,Linux就会恢复到如下所示的“经典”布局:
这就是虚拟地址空间布局。下一篇文章将讨论内核如何跟踪这些内存区域。接下来,我们将研究内存映射,文件读写如何与所有这些联系起来,以及内存使用数据意味着什么。