程序在内存中的分布

来源:互联网 发布:淘宝app怎么看自己信用 编辑:程序博客网 时间:2024/05/16 14:40

(一般我们说到内存就会讲到虚拟内存和物理内存,虚拟内存是指cpu内部的寻址空间,物理空间是总线上的可寻址空间。我们一般说的程序在内存中的分布是指的程序在虚拟内存中的分布)

    内存管理是操作系统的核心,对于程序员和系统管理员来说,理解内存管理十分重要。在接下来的几篇文章里面,我会关注于内存实用方面的研究,但也不会回避它的本质。这些内容是通用的,主要是研究linux和Windows on 32-bit x86操作系统内核的内存管理。第一篇文章主要是介绍程序在内存中是怎样存在的。

    在多任务的操作系统(在同一时间内允许多个程序运行的os 参见http://en.wikipedia.org/wiki/Computer_multitasking/http://stackoverflow.com/questions/79248/what-is-a-multitasking-operating-system)中,每一个进程都运行在它自己的内存沙盒中,也就是说每一个进程都有它自己的内存。这个沙盒就是虚拟地分址空间,在32-bit mode中总是4GB的内存空间(但是其物理内存并不一定是这么大,只是通过虚拟内存将物理主存扩大到磁盘上)。这些虚拟地址通过页表映射到物理内存上,整个过程由os(操作系统的内存管理单元mmu)管理,通过处理器执行。每一个进程都有它自己的一组页表,但是这里有一个问题。一旦虚拟地址启用,它适用于计算机上所有的应用程序,包括操作系统内核本身。因此虚拟地址的一部分空间是必须留给内核本身的。


这并不意味着内核本身要使用多少物理内存,这只是说它知道的那部分虚拟地址空间可以映射到它想叫它映射的任何物理空间。内核空间的被标记为优先执行的页表,因此,如果用户程序试图接触内核空间就会引发页面错误。在linux中,内核空间是连续的,并映射到相同物理空间的所有进程。内核的代码总是可寻址的,并随时准备着处理中断和接受系统调用。相反,用户的虚拟内存映射的那部分物理内存地址空间会随着进程的切换随时改变。


蓝色部分代表映射到物理内存的虚拟地址,白色代表没有映射的部分。下面的例子中,firefox这个应用程序进程由于传说中的内存饥渴用了过多的虚拟地址空间。不同频段的地址空间对应不同的内存段(对应于物理内存中),如堆,栈等。请记住这些段仅仅是内存地址范围,和 intel-style segment  没有任何关系。下面是一个linux下标准的段布局。


当计算机高兴,安全,看起来可爱的时候,上面显示的段的起始的虚拟地址在机器的每一个进程里面都几乎是一样的,这样就会容易出现远程的漏洞缺陷。如果开发远程漏洞就要参考内存绝对地址:栈地址,库函数地址...远程攻击者必须要猜出你程序所在的内存地址,计算地址空间。当这些都做到以后,那你的电脑就没攻击了。因此地址空间的随机化变得非常普遍。linux通过在地址的起始部位增加地址偏移量随机分配堆栈.内存映射段.堆。非常不幸的是,32-bit的地址空间给随机分配留下了很小的空间影响了其效率。

如图所示,一个程序地址空间的最上面的部分是栈,存储了程序语言中常用的本地变量和函数参数,调用一个函数的方法就会把栈帧压到栈上面。当函数返回的时候堆栈帧就会消失。这种设计的主要原因是数据严格遵循后进先出的顺序,不需要复杂的数据结构去跟踪栈的内容--只需要一个简单的栈指针。进栈和出栈是一个非常快和严格的过程。此外,如果不断重复使用堆栈区域会在cpu的缓存中保持不断活跃的栈内存,加速访问速度。进程中的每一个线程都有它自己的栈。

如果把多于它所能承受的数据放进去很可能会耗尽区域映射堆栈。这就会引发一个页错误,有linux的expand_stack()函数发出,然后它就会调用acct_stack_growth()去检查是否需要增加栈。如果栈的大小小于RLIMIT_STACK(通常是8MB),通常这个栈会继续增长,程序继续运行就像什么都没有发生一样。栈的大小根据需求自动调节,这是嘴常见的机制。但是如果栈的最大上限已经达到了,那么就会有栈溢出,程序就会收到一个分割错误。虽然栈的映射空间会随着需求的增加而增加,但是当栈缩小时它并不会随着缩小。就像联邦政府的预算-只会增加。

动态的栈增长是实现存取上图中白色的未映射物理内存部分的唯一办法。其他所有的试图存取未映射的内存部分的操作都会引发一个页错误最终倒是段错误。还有一些映射的区域是只读的,因此任何试图对这些区域进行写的操作都会引发段错误。

在栈的下面是内存映射段。在这里内核将文件的内容直接映射到内存。应用程序可以通过函数请求访问映射里面的数据,在linux中通过mmap()函数系统调用,windows中是CreatFileMapping()和MapViewOfFile()。内存映射是非常方便高效的文件i/o方式(可以很高效的减少i/o文件的移动,数据不必复制到进程数据缓冲区),所以它被用于加载动态库。也可以创建一个不匹配任何文件的匿名内存映射,用于替代程序数据。在linux中,如果你malloc()函数请求一个大的内存空间,c运行库就会创建一个匿名内存映射而不使用堆内存。这里的“很大”是说请求的内存空间大于MMAP_THRESHOLD 字节。默认是128kb,也可以通过mallopt()函数调整。

说到堆,就引入了我们接下来要说的下一个内存空间。堆和栈一样用于提供运行时候的内存分配,但是不同于栈,.....?许多语言支持对程序的堆管理。满足内存请求就是语言运行库和内核共同要完成的任务。在c运行库中malloc()及其系列函数是堆分配的接口函数,而在具有垃圾回收功能的语言中(如c#)接口就是new关键字。

如果在堆中有足够的空间满足内存请求,那么这些操作就不需要内核的参与直接由语言运行库就可以完成。否则,就要通过brk()的系统调用去为这个请求申请更多的空间来扩大堆。堆管理很复杂,需要复杂的算法来达到快速和高效的内存使用,同时还要面对我们程序复杂混乱的分配模式。处理一个堆请求所用的时间显著不同。实时操作系统有特殊用途的分配模式来处理这些问题。堆也可以变成帧,如下图所示:


下面我们来看内存中最下面的段:BSS,数据段,代码段。bss段和数据段都包含c运行库中的静态变量(全局变量)。不同是bbs中存储的是未初始化的静态全局变量,它的值不是在程序的源码中赋值的。bbs段内存区域是匿名的,它不映射任何文件。如果你写static int cntActiveUsers.那么cntActiveUsers就会出现在bbs中。

从另一方面讲。数据段保存了初始化源代码中的内容。这个内存区域不是匿名的,其映射了包含在源码中给定的初始化值的二进制映像。如果你写staic int cntWorkerBees=10,那么其内容就会存在于数据段,初始值为10。尽管数据段映射文件,但是那是一个私有的内存映射,就是说更改此处的内存不会改变内存映像。情况就应该是这样,给全局变量复制就会更改你磁盘上的二进制映像,结果难以预料。

下图中的数据段的例子更为复杂,因为其用到了指针。因此指针的内容是一个在数据段的4字节的内存地址,但是它实际指的字符串并不在数据段,这个字符串在代码段,代码段的数据是只读的并且存储着全部的代码外加其他像字符串常量这些零碎的东西。代码段也将你的二进制文件映射到内存中,当你尝试去写这个区域的文件的时候你的程序就会提示一个段错误,通过这种机制阻止你的程序出现指针错误,尽管这不会像你在写c语言程序时候就注意避免那么高效。下面的表格展示了这些段和我们例子中的变量:



你可以通过读/proc/pid_of_process/maps里面的文件来测试linux进程中的内存区域。记住一个段会包含很多区域,比方说,每一个内存映像文件在mmap段中都有它自己的区域,动态库和bbs和数据段一样也有它额外的区域。许多时候人们会说“数据段”就是数据+bbs+堆。(???)

你可以通过nm和objdump命令来测试二进制镜像来显示其中的符号,地址,段等等。最后说明,我们上面所描述的虚拟地址布局是linux中灵活的布局,已经作为默认的布局好多年了。它假设我们有一个默认的值RLIMIT_STACK.如果不是这样的话,linux会返回一个经典的布局:


以上就是虚拟空间布局。接下来的文章里面我会讲解内核是怎样跟踪这些内存区域的。接下来我我们会看以下内存的映射,文件是的读写操作是怎样与之相关联的以及内存使用的含义。

译:http://duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory

参考:http://my.oschina.net/solu/blog/2537