操作系统利用体系结构提供的VA到PA的转换机制实现虚拟内存管理。有了共享库的基础之后我们可以进一步理解虚拟内存管理了。首先分析例子:
【实际与上图存在出入,为方便下面的描述采用原书截图】
用ps命令查看当前终端下的进程,得知bash的进程id是29977,然后用cat /proc/29977/maps命令查看他的虚拟地址空间。/proc目录中的文件并不是真正的磁盘文件,而是由内核虚拟出来的文件系统,当前系统中运行的每一个进程在/proc下都有一个子目录,目录名就是该进程的id,查看目录下的文件可以得到该进程的相关信息。此外用pmap 29977命令也可以得到类似的输出结果。
进程地址空间:
x86平台的虚拟地址空间是0x0000 0000 ~ 0xffff ffff,大致上前3GB(0x0000 0000 ~ 0xbfff ffff)是用户空间,后1GB是是内核空间0x0804 8000-0x080f 4000是从/bin/bash加载到内存的,访问权限为r-x,表示Text Segment,包含.text段、.rodata段、.plt段等。0x080f 4000-0x080f 9000也是从/bin/bash加载到内存的,访问权限为rw-,表示Data Segment,包含.data段、.bss段等。
0x0928 3000-0x949 7000不是从磁盘文件加载到内存的,这段空间称为堆(Heap)。从0xb7ca 8000开始是共享库的映射空间,每个共享库也分为几个Segment,每个Segment有不同的访问权限。可以看到,从堆空间的结束地址(0x0949 7000)到共享库映射空间的起始地址(0xb7ca 8000)之间有很大的地址空洞,在动态分配内存时堆空间是可以向高地址增长的。堆空间的地址上限(0x0949 7000)称为Break,堆空间要向高地址增长就要抬高Break,映射新的虚拟内存页面到物理内存,这是通过系统调用brk实现的,malloc函数也是调用brk向内核请求分配内存的。
/lib/ld-2.8.90.so就是动态链接器/lib/ld-linux.so.2,后者是前者的符号链接。标有[vdso]的地址范围是linux-gate.so.1的映射空间,这个共享库是由内核虚拟出来的。0xbfac 5000-0xbfad a000是栈空间,其中高地址的部分保存着进程的环境变量和命令行参数,低地址的部分保存函数栈帧,栈空间是向低地址增长的,但显然没有堆空间那么大的可供增长的余地,因为实际的应用程序动态分配大量内存的并不少见,但是有几层深的函数调用并且每层调用都有很多局部变量的非常少见。总之,栈空间是可能用尽的,并且比堆空间更容易用尽,无穷递归会用尽栈空间最终导致段错误。
虚拟内存管理起了什么作用呢?我们可以从以下几个方面来理解:
第一,虚拟内存管理可以控制物理内存的访问权限。物理内存本身是不限制访问的,任何地址都可以读写,而操作系统要求不同的页面具有不同的访问权限,这是利用CPU模式和MMU的内存保护机制实现的。例如Text Segment被只读保护起来,防止被错误的指令意外改写,内核地址空间也被保护起来,防止在用户模式下执行错误的指令意外改写内核数据。
第二,虚拟内存管理最主要的作用是让每个进程都有独立的地址空间。所谓独立的地址空间是指,不同进程中的同一个VA被MMU映射到不同的PA,并且在某一个进程中访问任何地址都不可能访问到另外一个进程的数据。另一方面每个进程都认为自己独占整个虚拟地址空间,这样链接器和加载器的实现会比较容易,不必考虑各进程的地址范围是否冲突。
我们再打开一个终端窗口,看一下这个新的bash进程的地址空间,可以发现和先前的bash进程地址空间的布局差不多:
该进程也占用了0x0000 0000-0xbfff ffff的地址空间,Text Segment也是0x0804 8000-0x080f4000,Data Segment也是0x080f 4000-0x080f 9000,和先前的进程一模一样,因为这些地址是在编译链接时写进 /bin/bash 这个可执行文件的,两个进程都加载它。这两个进程在同一个系统中同时运行着,它们的Data Segment占用相同的VA,但是两个进程各自干各自的事情,显然Data Segment中的数据应该是不同的,相同的VA怎么会有不同的数据呢?因为它们被映射到不同的PA。如下图所示。
从图中还可以看到,两个进程都是 bash 进程,Text Segment是一样的,并且Text Segment是只读的,不会被改写,因此操作系统会安排两个进程的Text Segment共享相同的物理页面。由于每个进程都有自己的一套VA到PA的映射表,整个地址空间中的任何VA都在每个进程自己的映射表中查找相应的PA,因此不可能访问到其它进程的地址,也就没有可能意外改写其它进程的数据。另外,注意到两个进程的共享库加载地址并不相同,共享库的加载地址是在运行时决定的,而不是写在 /bin/bash 这个可执行文件中。但即使如此,也不影响两个进程共享相同物理页面中的共享库,当然,只有只读的部分是共享的,可读可写的部分不共享。使用共享库可以大大节省内存。比如 libc ,系统中几乎所有的进程都映射 libc 到自己的进程地址空间,而 libc 的只读部分在物理内存中只需要存在一份,就可以被所有进程共享,这就是“共享库”这个名称的由来了。现在我们也可以理解为什么共享库必须是位置无关代码了。比如 libc ,不同的进程虽然共享 libc 所在的物理页面,但这些物理页面被映射到各进程的虚拟地址空间时却位于不同的地址,所以要求 libc 的代码不管加载到什么地址都能正确执行。
第三,VA到PA的映射会给分配和释放内存带来方便,物理地址不连续的几块内存可以映射成虚拟地址连续的一块内存。比如要用 malloc 分配一块很大的内存空间,虽然有足够多的空闲物理内存,却没有足够大的连续空闲内存,这时就可以分配多个不连续的物理页面而映射到连续的虚拟地址范围。如下图所示: