预计阅读本页时间:-
10.4 Linux中的内存管理
Linux的内存模型简单明了,这样使得程序可移植并且能够在内存管理单元大不相同的机器上实现Linux,比如:从没有内存管理单元的机器(如,原始的IBM PC)到有复杂分页硬件支持的机器。这一块设计领域在过去数十年几乎没有发生改变。下面要介绍该模型以及它是如何实现的。
10.4.1 基本概念
每个Linux进程都有一个地址空间,逻辑上有三段组成:代码、数据和堆栈段。图10-12a中的进程A就给出了一个进程空间的例子。代码段包含了形成程序可执行代码的机器指令。它是由编译器和汇编器把C、C++或者其他程序源码转换成机器代码而产生的。通常,代码段是只读的。由于难以理解和调试,自修改程序早在大约1950年就不再时兴了。因此,代码段既不增长也不减少,总之不会发生改变。

数据段包含了所有程序变量、字符串、数字和其他数据的存储。它有两部分,初始化数据和未初始化数据。由于历史的原因,后者就是我们所知道的BSS(历史上称作符号起始块)。数据段的初始化部分包括编译器常量和那些在程序启动时就需要一个初始值的变量。所有BSS部分中的变量在加载后被初始化为0。
广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元
例如,在C语言中可以在声明一个字符串的同时初始化它。当程序启动的时候,字符串要拥有其初始值。为了实现这种构造,编译器在地址空间给字符串分配一个位置,同时保证在程序启动的时候该位置包含了合适的字符串。从操作系统的角度来看,初始化数据跟程序代码并没有什么不同——二者都包含了由编译器产出的位串,它们必须在程序启动的时候加载到内存。
未初始化数据的存在实际上仅仅是个优化。如果一个全局变量未显式地初始化,那么C语言的语义说明它的初始值是0。实际上,大部分全局变量并没有显式初始化,因此都是0。这些可以简单地通过设置可执行文件的一个段来实现,其大小刚好等于数据所需的字节数,同时初始化包括缺省值为零的所有量。
然而,为了节省可执行文件的空间,并没有这样做。取而代之的是,文件包含所有显式初始化的变量,跟随在程序代码之后。那些未初始化的变量都被收集在初始化数据之后,因此编译器要做的就是在文件头部放入一个字段说明要分配的字节数。
为了清楚地说明这一点,再考虑图10-12a。这里代码段的大小是8KB,初始化数据段的大小也是8KB。未初始化数据(BSS)是4KB。可执行文件仅有16KB(代码+初始化数据),加上一个很短的头部来告诉系统在初始化数据后另外再分配4KB,同时在程序启动之前把它们初始化为0。这个技巧避免了在可执行文件中存储4KB的0。
为了避免分配一个全是0的物理页框,在初始化的时候,Linux就分配了一个静态零页面,即一个全0的写保护页面。当加载程序的时候,未初始化数据区域被设置为指向该零页面。当一个进程真正要写这个区域的时候,写时复制的机制就开始起作用,一个实际的页框被分配给该进程。
跟代码段不一样,数据段可以改变。程序总是修改它的变量。而且,许多程序需要在执行时动态分配空间。Linux允许数据段随着内存的分配和回收而增长和缩减,通过这种机制来解决动态分配的问题。有一个系统调用brk,允许程序设置其数据段的大小。那么,为了分配更多的内存,一个程序可以增加数据段的大小。C库函数malloc通常被用来分配内存,它就大量使用这个系统调用。进程地址空间描述符包含信息:进程动态分配的内存区域(通常叫做堆,heap)的范围。
第三段是栈段。在大多数机器里,它从虚拟地址空间的顶部或者附近开始,并且向下生长。例如,在32位x86平台上,栈的起始地址是0xC0000000,这是在用户态下对进程可见的3GB虚拟地址限制。如果栈生长到了栈段的底部以下,就会产出一个硬件错误同时操作系统把栈段的底部降低一个页面。程序并不显式地控制栈段的大小。
当一个程序启动的时候,它的栈并不是空的。相反,它包含了所有的环境变量以及为了调用它而向shell输入的命令行。这样,一个程序就可以发现它的参数了。比如,当输入以下命令
cp src dest
时,cp程序运行,并且栈上有字符串“cp src dest”,这样程序就可以找到源文件和目标文件的名字。这些字符串被表示为一个指针数组来指向字符串中的符号,使得解析更加容易。
当两个用户运行同样的程序,比如编辑器,可以在内存中立刻保持该编辑器程序代码的两个副本,但是并不高效。相反地,大多数Linux系统支持共享代码段。在图10-12a和图10-12c中,可以看到两个进程A和B拥有相同的代码段。在图10-12b中可以看到物理内存的一种可能布局,其中两个进程共享了同样的代码片段。这种映射是通过虚拟内存硬件来实现的。
数据段和栈段从来不共享,除非是在一个fork之后,并且仅仅是那些没有被修改的页面。如果二者之一要增长但是没有邻近的空间来增长,这并不会产生问题,因为在虚拟地址空间中邻近的页面并不一定要映射到邻近的物理页面上。
在有些计算机上,硬件支持指令和数据拥有不同的地址空间。如果有这个特性,Linux就可以利用它。例如,在一个32位地址的计算机上如果有这个特性,那么就有232 字节的指令地址空间和232 字节的数据地址空间。一个到0的跳转指令跳入到代码段的地址0,而一个从0的移动使用数据空间的地址0。这使得可用的数据空间加倍。
除了动态分配更多的内存,Linux中的进程可以通过内存映射文件来访问文件数据。这个特性使我们可以把一个文件映射到进程空间的一部分而该文件就可以像位于内存中的字节数组一样被读写。把一个文件映射进来使得随机读写比使用read和write之类的IO系统调用要容易的多。共享库的访问就是用这种机制映射进来后进行的。在图10-13中,我们可以看到一个文件被同时映射到两个进程中,但在不同的虚拟地址上。
