首页 > 代码库 > 对现代操作系统进程地址空间的想法

对现代操作系统进程地址空间的想法

什么是堆,什么是栈,什么是数据段,什么是代码段...这些都是历史遗留问题。如今编程真的没有必要在意这些了!不要被/proc/xx/{maps,smaps}里面的内容所迷惑和萦绕。自己管理好自己的内存分配就好。假设程序不是自己写的,那么就找写它的人。
本文将从一个链接动态库的可运行文件怎样载入进程地址空间開始,谈一下我对进程地址空间布局的看法。我没有採用精确的方式描写叙述ELF或PE文件怎样载入的,而仅仅表述一种思想过程,目的是为了不让看到本文的人重新的陷入无穷尽的代码分析的深渊,因此我仅仅讲过程而不谈细节。

1.可运行文件的结构

可运行文件包含以下的内容:
自描写叙述头:描写叙述该文件的类型。大小,运行平台,兼容信息,入口地址等元数据。
符号表:所谓的符号表就是函数或者变量的标识。这些标识代表的实体终于映射到地址空间的某个地址。
代码:存在自身的可运行二进制代码。可运行程序的要旨在此。
已经初始化数据:程序中定义的已经初始化的数据。除了保存数据符号本身之外。还要保存数据值。
未初始化数据:程序中声明的数据。可是还未经初始化。

仅仅保存数据符号,不保存值。
链接库信息:本运行文件链接到的动态库信息。
无论是Windows的PE文件还是UNIX的ELF文件,存放的无非就是上面这些东西,复杂性在于,它们的格式不同,细节太多。

2.可运行文件的载入

所谓的可运行文件载入,说的是将一个文件系统中的可运行文件映射到一个虚拟地址空间中。内容即包含上述的可运行文件本身。通过自描写叙述头这类元数据能够找到程序的入口地址。因此此步骤的最后就是跳转到该地址运行。

注意。此步骤并不载入动态库,载入动态库的职责由用户态负责。通常是解释器的事情。

假设你运行一下以下的序列。就知道动态库是什么时候由谁载入的了:
readelf -a /bin/ls
ldd /bin/ls
strace -e trace=open /bin/ls

我看过非常多文章,都没有谈到这个细节。对于Linux而言,都说是在load_elf_library中完毕的动态库载入。甚至毛德操也是这么说的。他们根本没有注意到C库的行为。难道搞内核的人就如此高深吗?非常多人都认为仅仅要事情在内核完毕的,那就高深无比。其实根本不是这回事!


3.动态库的载入

因为上述第2步,进程地址空间里面往往已经有了动态库的信息,包含库文件名称字。那么依据一些环境变量指示的库路径。则有希望在这些路径下找到这些库,然后便逐一将其载入进程地址空间了。

在上面的第2步最后,程序已经返回用户态运行了,一般而言此时运行的是一个叫做解释器的程序,它会解析须要载入的动态库信息,然后逐一载入动态库的。随后会解决一些符号重定向的问题。
       其实。在直接运行main函数之前,有非常多工作要做。载入动态库仅仅是当中最复杂的之中的一个。

须要明白的是,仅仅要有动态库,进入main之前的工作就会无比复杂,这是必定的代价。收益就是在load_elf_library中的工作量大大降低了,换句话说,你的可运行文件的负担降低了,假设想为main前行为减负,就用静态链接吧。

4.地址空间布局

至此。一个进程的地址空间就被填充好了,接下来就要跳转到我们定义的main函数了。空间布局什么样子呢?假设问起来,一般人都会回答:
代码段|数据段|BSS|堆|空暇|栈|内核
是这样吗?某种意义上是的,可是请细致看第1步和第2步。我并没有刻意强调那些可运行文件或者动态库的数据是怎样被塞到这些段的,其实,根本没有必要用这些段,这些都是历史遗留,真正的地址空间布局应该是:
动态库1|空暇|动态库2|动态库3|可运行程序|空暇|栈|内核
看到了吗?没有堆,是的。没有堆。

以现有的Linux为例,实际上除了保留了一个brk来表示堆之外,代码段,数据段等古老的段已经全然弱化了,甚至退化成了例行公事或者纯粹为了唤起人的记忆。在我看来。堆也是应该摒弃的。仅仅有栈,因为和处理器极度相关。因此须要保留。


       如今的内存管理方式是保护模式下虚拟内存管理。对于一个进程而言。32位的虚拟地址拥有4G的空间,对于64位的虚拟地址而言。则更大,还有必要将内存依照属性划分区段吗?其实,在实模式的时代,有专门的硬件寄存器来表示这些区段的起始地址,因为处理器就是这么设计的。因此必定要划分内存为一系列内部连续的区段,可是到了32位保护模式以后,非常多这中划分就没有必要了,段寄存器在平坦模式下依旧例行公事但不再起作用,无论是代码段还是数据段,都是占领整个地址空间。


       堆是怎么回事呢?堆就是heap,也是一个古老的概念,它在地址空间是一段连续的空间,一般位于栈的以下。和栈向下增长不同,堆是向上增长的。

在史前时期,大家是共享内存地址空间的,每个进程都在一个连续的空间范围内。而且有确定的大小。和数据段,代码段空间连续的意义一样,堆也是一段连续的空间,因为和栈的增长方向相反。就保证了程序使用的内存在越界之前就会出错。

然而如今都是独享虚拟内存了,怎样管理物理内存已经被剥离出去了,因此就没有必要採用这些原始的方式了。
       注意,以上并没有涉及内碎片和外碎片的问题,因为在保护模式时代,大得多的独享地址空间能够利用充分且设计良好的内存管理算法来高效避免两种碎片。而在史前。极小的共享的内存不足以容纳复杂的内存管理程序本身,也就仅仅能设计一个简单一致的分段约定来依照数据的属性将空间分为不同区段来避免内存碎片。

5.再说一点关于堆的

本质上讲,在独享的进程虚拟地址空间内,一切都由mmap来确定才是简单的方式。究竟是数据,还是代码全都体如今mmap区段的权限上。对于Linux而言,一个区段就是一个vm_area_struct。它的权限体现了它里面映射的是代码还是数据。那么堆有存在的必要么?诚然。它在虚拟地址空间是连续的,可是连续并非它的最重要的意义,它很多其它的是体现了用户怎样管理内存这一点上。问题归结为怎样为用户提供一块用户指定大小的虚拟内存上,假设Linux不採用堆,而是将全部的空暇的没有映射给vm_area_struct的空间依照伙伴系统组织起来,岂不更好?伙伴系统本身就有避免碎片的功能。

6.运行期分配内存

原则上。我倾向于全部的内存分配都是用mmap来进行,即便内核眼下还没有实现虚拟内存伙伴系统的情况下也是如此。

其实,标准的做法是,在malloc申请少于128K的内存时。採用堆上分配(眼下Linux还在使用堆),大于128K的时候使用mmap来分配。


       假设你非常精通Linux内存的使用。就会认为以上我对堆的抨击简直就是一派胡言。因为我忽略了堆内存和mmap的申请和释放机制全然不同。某种意义上确实是,但不幸的是,我对这样的不同相同也是深刻理解的。假设man一下mallopt,就会发现M_MMAP_THRESHOLD以下有一句话:
mmap()-allocated  memory can be immediately returned to the OS when it is freed, but this is not true for all mem‐
ory allocated with sbrk(); however, memory allocated by mmap() and later freed is neither joined  nor  reused,  so
 the overhead is greater.  Default: 128*1024.

这不正是在否定mmap,这不正是和我的提议相反吗?是的!
       可是。brk的高效性和用户程序怎样使用内存有极大的关系。堆上的内存释放。假设不是边缘的话,就不会释放给操作系统。而是继续留在地址空间内,而这部分空间是由C库的malloc来管理的。它可能在malloc内部实现了一个相似伙伴系统的管理算法,使得能够自己管理自己的内存区。可是你不得不信赖这样的C库相关的一切实现,这样的针对mmap的优势并非操作系统级别的,而是C库级别的。对于mmap而言,仅仅要你调用free,那么底层就会调用munmap,此时这段内存在地址空间就不再可用了,频繁的mmap/munmap操作当然会带来不小的开销,而且因为每次mmap的地址可能距离比較远,因此也会丧失局部性的优势。
       我认为。讨论这个问题和讨论TCP在内核中的各种优化属于一类问题。终于的结果就是,不要在内核做这样的比較了,全然交给用户程序或者库自身,相信用户程序能够完美解决。

内核仅仅要把内存给用户就能够了,怎么管理怎么使用全部用户程序自己负责。操作系统内核应该把这样的事情剥离出去。对于堆这样的内存区段而言。它在内核中的地位确实非常尴尬。它本来代表着一种内存管理方式,即一段连续的内存随着程序的分配和释放被切分为不同大小的小段,这些段组成一个堆数据结构,确实。在史前的实模式时代。物理内存确实是这么管理的,可是当保护模式明白区分了内核空间和用户空间时。内存的堆式管理就被移植到了C库,实际上它们一直都在C库,仅仅是说后来C库被安排到了用户空间而已。结果内核态的进程地址空间中就空留了一个堆的影子。却早就没有了堆的实质。这样的heap夹在程序和库之间的可能性是存在的,你自己通过遍历/proc/xx/maps就能够知道有多少,造成这样的尴尬的原因在于可运行文件布局原则和进程空间布局原则的不一致性,解决这样的不一致性当然要以可运行文件的建议为准,因为它是最先载入的。
       还有一个尴尬的地方在于。假设一个进程的堆被夹在了可运行程序映像和动态库映像之间,例如以下:
可运行程序|heap|动态库1|大量空暇|...
那么堆的扩展空间无疑受到了限制,紧跟着动态库1的有大量的本能够用于堆扩展的空间没,却因为动态库1本身被取消了资格,因为堆必须是连续的,在Linux上它仅仅是添加brk来进行扩展。

假设将堆的概念取消,那么地址空间变成以下这样:
可运行程序|空暇1|动态库1|空暇2|...
因此空暇1和空暇2能够组织成一个链表。在该链表中能够採用伙伴系统或者别的不论什么的管理算法。堆空间的连续性当初仅仅是为了让内存使用更加紧凑,这是时代的遗留,就像当初确切划分空间连续的代码段,数据段,BSS段而如今不须要了一样,对堆的连续性也要有新的理解。

毕竟在资源丰盈的年代做事就不能过于小家子气。


对现代操作系统进程地址空间的想法