进程内存分布剖析

内存管理是操作系统的核心功能之一,这对于编程以及系统管理都至关重要。在接下来的叙述中我将着眼于实用方面但兼顾内部原理。这些概念都是通用的,例子大都来源于Linux以及Windows操作系统。首先来描述一下内存中进程的分布。

多任务操作系统中进程都运行在各自的地址空间中。在32位系统中进程的地址空间范围是0~2^32 (也即0---4G)。虚拟地址空间通过页表映射到物理内存,页表由操作系统内核维护,由处理器来对其发出请求。每一个进程都有自己的一套页表。这时问题就出现了,一旦虚拟地址投入使用,它就用于此计算机中所有运行的软件,也包括内核本身。所以,虚拟地址空间的一部分必须保留给内核:


这并不意味内核需要使用如此多的物理内存,而是因为内核可以使用这部分地址空间去映射到任何他想要的物理内存。内核空间在页表中被标记为只有特权代码(ring2或者更低。在此说明一下特权等级的概念:Intel x86架构CPU共有四个特权等级,0~3。0级最高,3级最低。硬件在执行每条指令时会检查其特权等级。对于Linux/Unix,只是使用了0级和3级,即Ring0 和 Ring3。在Linux中来看,0级也就表示内核态,3表示用户态)。当用户态程序想染指内核空间时会引发页面异常。在Linux系统中,内核空间总是映射到相同的物理地址空间。关于Linux内核虚拟地址的具体划分可以参考这篇博客Linux内存管理原理 。

内核代码和数据总是可寻址的,为的是随时准备响应中断或者系统调用。相比之下,进程地址空间的用户态部分随着进程切换而变化。


蓝色的部分代表映射到物理内存的虚拟地址空间。而白色的部分表示未映射的地址空间。进程地址空间中不同的段对应内存段(segment)中不同段,比如堆、栈等等。要注意这些所谓的分段只存在于内存地址空间中,他们表示不同的内存地址范围而已,它和Intel的段表机制没有任何关系。 下面是一个标准的Linux进程中各部分在地址空间中的分布:


如果程序运行的无误的话,上图显示的启用虚拟地址的段表分布对所有程序大致都是相同的。这成为很容易利用的远程安全漏洞。利用这种漏洞常常需要引用绝对地址,如堆栈地址、库函数地址等等。远程攻击者们希望所有的地址空间都是相同的,这样他们很容易选择地址,一旦是这样的话,人们就会被攻击者所玩弄。因此,地址空间的随机化变得必要和通用起来。Linux通过在起始段添加偏移量的方法来随机化堆栈(stack segment),映射区段(Memory Mapping Segment)和堆(heap segment)。不幸的是32位的地址空间太小,限制了了随机化的范围和效率。

地址空间分段的首段就是堆栈段。该段存储了大多数编程语言概念中的局部变量、函数的参数等。调用一个方法或者函数会向堆栈压入一个新的栈帧。函数返回的时候栈帧会被销毁。这种简单的设计大概是因为存储在其中的数据严格的按照LIFO(先进后出)的顺序,这同时也意味着不需要使用复杂的数据结构来跟踪堆栈的内容。只需要在栈顶简单的维持一个指针即可。入栈与出栈也相当的高效与方便。另外,栈区域的持续重用使得堆栈内存得以在CPU缓存中保活,这样加快了存取速度。进程中每个线程都有独占的堆栈。

当向栈中压入太多数据可能会导致堆栈映射区域内存的耗尽。在Linux中,这会引发一个由expend_Stack()函数处理的页面异常。该函数继续调用acct_stack_growth()来检查是否应该增长堆栈。如果这个栈的容量低于RLIMIT_STACK (通常为 8MB)限定的值,那么栈的容量会正常增加,程序也会继续正常运行,并且程序不会知道刚刚发生了什么。当然,这是根据实际需要来调整栈大小的一般机制,如果栈的容量达到了最大值上限,那么栈就会溢出,程序也会收到一个段出错的信息。虽然在程序需要的时候映射的栈空间会增加,但是栈使用的空间减少时,栈却不会释放多余的空间。这就像联邦政府预算,只会增加。

访问上图所示的未映射区域是我们能实现正确的动态堆栈增长的唯一情况。当访问上图所示以外的其它未映射区将会导致页面异常进而引发段出错。一些映射区域是只读的,当我们试图写该区域时同样会引发段错误。

堆栈的下面是内存映射段。该区域内核将文件内容直接映射到内存。任何应用程序都可以请求这种区域,Linux中通过mmap()系统调用,window中通过creatFileMapping()/MapViewOfFile。文件I/O时内存映射是方便并且高效的,所以,它常用来加载动态库。还可以创建一种匿名映射,并不对应于文件,而用于程序数据。在Linux中,如果你使用malloc()函数申请一块大的内存,C库函数便会创建这种内存映射段,而不是使用堆(Heap)内存.“大”字是指超过MMAP_THRESHOLD字节,默认128,可以通过mallopt()函数调整之。

接着说堆,如上图在Mapping Memory段下面。堆支持运行时内存分配,这点和栈相同,和栈不同的是,堆可分配存活于函数之外的内存。大多数语言都允许程序使用堆管理内存。满足内存需求是语言运行时和C语言核心间的联结点,而堆的内存管理接口是通过malloc()及其友元函数来实现的,在C#这样支持垃圾回收机制的语言中,其接口是new关键字。

当堆的空间能够满足程序的内存请求时,那么请求的处理过程就可以直接由语言运行时来负责,而不必有系统内核参与。但是如果堆的空间不能满足程序的内存申请,那么brk()函数会执行系统调用来增加堆得内存空间以满足程序的请求。堆管理很复杂,面对程序内存分配变化莫测的情况,堆管理需要成熟的算法去提升请求的响应速度与内存利用率。系统响应堆的内存请求花费的时间往往变化很大。实时操作系统解决这个问题的方法是采用专用内存分配器( special-purposeal locators)。堆在内存中的分布情况和其他内存管理管理机制一样充满了碎片,如下图所示:


最后,我们来说说位于内存中最低处的段:BSS(全局未初始化段)、数据段(data Segment)以及代码段(text Segment)。在C语言中,BSS段和数据段都用于保存全局变量和静态变量,不同的是BSS段中的全局变量和静态变量都是未初始化的,BSS段是匿名映射的,它并不映射到任何的文件。

另一方面,数据段中存储了已初始化的静态变量和全局变量,此段内存是非匿名的,它映射了程序二进制映像中 包含源码中变量初始化值的部分,所以,当你声明一个静态变量,如  static  int cntWorkBees = 10 ,则 变量cntWorkBees会赋初值并存储在数据段中。尽管数据段映射了文件,但这是一种私有内存映射,这意味着在内存中数据的更新并不会反映到源文件中。 也必须这样,否则为全局变量赋值将改变你磁盘上的二进制映像文件。

下图中的例子比较难懂,因为它使用了一个指针。如图gonzo指针的内容是 一个四字节的内存地址,保存在数据段。然而,它实际指向的字符串并不在数据段,而在代码段。代码段中的数据是只读的并且保存你的全部代码,包括字符串字面量。代码段同样将你的二进制文件映射到内存,但是写代码段会造成段错误。这有助于防止指针缺陷,尽管这种方式并不如在最开始避免C有效。下图显示了各个段的分配和我们的示例变量:


如果你想查看Linux中的进程中各内存区域,你可以读读源代码文件/proc/pid_of_process/maps。时刻记住 一个内存段往往由多个区域(area)组成。例如,每个正常映射到文件的内存在mmap段(Memory Mapping Segment)都有它自己的内存区域。动态库文件则拥有另外的段,这些段类似于BSS段与数据段。下面会详细说区域(area)的具体含义。

查看二进制映像可以使用nm和objdump命令以显示其符号、地址和段等信息。最后,上文描述的Linux虚拟地址空间分布是被称为“灵活的”方式,该机制作为默认实现已存在多年了,这种机制要求我们为RLIMIT_STACK变量赋值,否则,Linux退回到其传统地址空间分布方式,如下所示:


本文主要翻译自  http://duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory  ,另外加了一些知识点进去。


.

猜你喜欢

转载自blog.csdn.net/quanzhongzhao/article/details/45575611
今日推荐