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

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

什么是堆,什么是栈,什么是数据段,什么是代码段...这些都是历史遗留问题,如今编程真的没有必要在意这些了!不要被/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段而现在不需要了一样,对堆的连续性也要有新的理解。毕竟在资源丰盈的年代做事就不能过于小家子气。