Still Shines.

Anatomy of a Program in Memory

Word count: 2.6k / Reading time: 9 min
2020/04/14 Share

翻译自:https://manybutfinite.com/post/anatomy-of-a-program-in-memory/

内存管理是系统的核心,它对于编程和系统管理都至关重要。在接下来的几篇文章中,我将关注内存的实际方面,但不会回避内存的内部特性。虽然这些概念是通用的,但示例大多来自32位x86上的Linux和Windows。第一篇文章描述了程序在内存中的布局方式。

每个进程都在多任务系统中拥有自己的内存沙箱。这个沙箱是虚拟地址空间,在32位模式下总是一个4GB大小的内存地址块。这些虚拟地址由页表映射到物理内存,页表由操作系统内核维护并由处理器查询。每个进程都有自己的一组页表,但这里有个玄机。一旦启用了虚拟地址,它们将应用于计算机中运行的所有软件,包括内核本身。因此,虚拟地址空间的一部分必须保留给内核:

Kernel/User Memory Split

这并不意味着内核使用了更多的物理内存,只是它有那部分地址空间可用来映射它希望映射的任何物理内存。在页表中,内核空间被标记为独占特权代码(第2环或更低),因此,如果用户模式程序试图触及它,就会触发页错误。在Linux中,内核空间一直存在,并在所有进程中映射相同的物理内存。内核代码和数据总是可寻址的,随时可以处理中断或系统调用。相反,每当进程切换发生时,地址空间的用户模式部分的映射就会改变:

Process Switch Effects on Virtual Memory

蓝色区域表示映射到物理内存的虚拟地址,而白色区域未映射。在上面的例子中,Firefox程序使用了更多的虚拟地址空间,这是由于它对内存的巨大需求。地址空间中不同的频带对应于内存段,如堆、堆栈等。请记住,这些段只是内存地址的一个范围,与intel风格的段无关。总之,Linux进程中的标准段布局如下:

Flexible Process Address Space Layout In Linux

当计算机是准确无误的运行程序时,那么上图显示的段的起始虚拟地址对于机器上的每一个进程都是完全相同的。这会使得远程代码执行漏洞利用变得很容易。漏洞常常需要利用绝对内存位置:如堆栈上的地址、库函数的地址等等。远程攻击者只要尝试选择这些固定地址,因为如果地址空间都是相同的(没有地址随机化),就能pwned了。因此,地址空间随机化这种保护机制现在已经变得很流行。Linux将stackmemory mapping segmentheap随机化,简单的讲就是向它们的起始地址添加偏移量。不幸的是,32位地址空间非常紧张,几乎没有随机化的空间,而且妨碍了它的有效性。(32位的地址可被轻易爆破😁)

进程地址空间中最上面的部分是用户栈,它在大多数编程语言中存储本地变量和函数参数。调用一个方法或函数将一个新的堆栈帧推入堆栈。当函数返回时,堆栈帧被销毁。这种简单的设计是可能的,因为数据遵循严格的LIFO(后进先出)顺序,这意味着不需要复杂的数据结构来跟踪堆栈内容——一个指向堆栈顶部的简单指针就可以做到。压栈(Pushing)和出栈(Popping)两个操作是非常方便和准备的。此外,堆栈区域的不断重复使用往往会在cpu缓存中保持,从而加快访问速度。进程中的每个线程都有自己的堆栈。

如果向栈中压入超过其自身所能容纳的数据时,就有可能耗尽映射的栈区内存。这将触发一个由 Linux 函数 expand_stack( )处理的页错误,该函数接着调用acct_stack_growth()来检查是否适合增大堆栈内存。如果被填满的栈大小低于“RLIMIT_STACK ”(通常为8MB),那么通常栈会增大且程序继续运行,不会察觉到刚才发生了什么。这个是根据需要调整堆栈大小的一般机制。但如果达到了最大栈大小,就会有产生栈溢出,并且程序接收到一个段错误信息( Segmentation Fault)。栈可以通过扩展大小来满足需求,但不会根据实际需求减少栈大小,以释放多余空间。就想联邦政府预算,只会扩大~

如上面白色所示,在程序访问到白色的未映射内存区域时,动态栈增长是唯一能使访问合法的情况,任何其它访问未映射内存的方式都会触发页错误,继而导致段错误。某些映射区域是只读的,因此尝试向这些区域写入数据也将导致段错误。

在栈下方是共享内存映射区。内核将文件的内容直接映射到这里。任何应用程序都可以通过Linux的mmap ( )系统调用(执行)或着windows的CreateFileMapping ( ) . aspx) / MapViewOfFile ( ) . aspx)来申请。内存映射是执行文件I/O的一种方便且高性能的方法,因此它被用于加载动态链接库。也可以创建一个不对应于任何文件的匿名内存映射,用于程序数据。在Linux中,如果您通过malloc( )请求大内存块,C库将创建这样的匿名映射,而不是使用堆内存。“一大块”意味着大于“MMAP_THRESHOLD”字节,默认为128 kB,可通过mallopt( )进行调整。

接下来是地址空间的堆。与栈类似,堆也提供运行时的动态内存分配,但与栈不同的是,对于分配的数据,可以比进行分配的函数的生命周期存活的时间更长(即函数返回,分配的内存并不是自动销毁)。大多数编程语言都为程序提供堆管理功能。因此,满足内存请求是编程语言运行时和内核之间的共同事务。在C语言中,堆分配的接口是malloc()及其友元函数,而在c#这样的有垃圾回收机制的编程语言中,接口是‘new’关键字。

如果堆中有足够的空间来满足内存请求,那么语言运行时可以在不涉及内核的情况下处理它。否则,通过brk()系统调用(实现来扩大堆,以便为请求的块腾出空间。堆管理是复杂的,在面对我们的程序的混乱分配模式时,需要复杂的算法来争取速度和有效的内存使用。为堆请求提供服务所需的时间可能有很大差异。实时系统有专用分配器来处理这个问题。堆也变得“支离破碎”,如下图所示:

Fragmented Heap

最后我们讨论内存的最低段:BSS、data和text段。在C语言中,BSS和data段都存储了静态(全局)变量的内容。不同的是,BSS存储了未初始化的静态变量的内容,这些变量的值在源代码中没有被程序员设置(在链接时,链接器会为bss节分配固定的大小,并且用字节0x00填充;加载时创建对应内容的段)。BSS内存区域是匿名的:它不映射任何文件。就如你定义一个如下的变量:

1
static int cntActiveUsers;

则该变量的内容位于BSS中。

另一方面,data段保存源代码中已经初始化的静态变量的内容。这个内存区域不是匿名的。它映射程序的二进制映像部分,包含了源代码中给定的初始静态值的部分。因此,如果你定义

1
static int cntWorkerBees = 10

变量的内容就在data段中,初始值为10。即使data段映射一个文件,它也是一个私有内存映射,这意味着对内存的更新不会反映在底层文件中。这是理所当然的,否则对全局变量的赋值将改变磁盘上的二进制映像,这将无法想象!

下图中的数据示例比较复杂,因为它使用了一个指针。此时,指针’ gonzo ‘的内容是在数据段中的一个4字节的内存地址。然而,它所指向的实际字符串却不是。该字符串位于text段中,该段是只读的,除了字符串常量,它还存储所有代码。text段也映射你的二进制文件在内存中,但如果在这个区域写入会导致程序段错误。这有助于防止指针错误,尽管没有让C程序员有安全编码意识那么有效。这是一个图表显示这些部分和我们的例子变量:

ELF Binary Image Mapped Into Memory

在 Linux 中,你可以通过读取文件 /proc/pid_of_process/maps 来查看特定进程的虚拟地址空间布局。需要注意的是,一个虚拟段可能包含多个区域,例如,每个映射进 mmap 共享内存段中的。文件内容通常各划分了各自的区域,并且动态链接库在该段中拥有类似BSS和data段的额外布局。下一篇文章将会阐明“区域”的确切含义。以及谈谈有时人们说的“数据段”= data段+bss段+堆段,这种说法的正确性。

您可以使用nmobjdump命令查看二进制文件来显示符号及其地址,预定映射到的内存段等等信息。最后,在 Linux 中,前文描述的虚拟地址空间布局很灵活,作为默认已经沿用数年之久。这种机制假设 RLIMIT_STACK 的值是确定的,否则,Linux 将恢复“经典的”布局方案,如下图所示:

Classic Process Address Space Layout In Linux

最后这个图就是经典的虚拟地址空间布局。下一篇文章将探讨内核是怎样跟踪这些内存区域的;内存映射的原理,如何与文件读写联系起来;以及内存使用图表的含义。

CATALOG