《程序员的自我修养》-(5)可执行文件的装载与进程

169 阅读13分钟

这一章首先会介绍什么是进程的虚拟地址空间?为什么进程要有自己独立的虚拟地址空间?然后再看下装载的几种方式以及进程虚拟地址空间分布情况。

进程虚拟地址空间

每个程序运行起来后都有自己独立的虚拟地址空间(Virtual Address Sapce),这个虚拟地址空间大小由CPU 的位数决定的。比如32位的硬件平台决定了虚拟地址空间的地址为0 ~ 2^32 - 1,即0x00000000~0xFFFFFFFF,也就是我们常说的4GB 虚拟地址空间大小。64位的硬件平台以此类推,下文中讨论中以32位的地址空间为主。

因为程序在运行时处于操作系统的监管下,所以进程只能使用那些操作系统分配给进程的地址。在Linux 操作系统下虚拟地址空间分配如下:

image.png

整个4GB 被划分成两部分,其中操作系统占用1GB,剩下的3GB 都是留给进程使用的。但是进程并不能完全使用这3GB 的虚拟空间,其中有一部分是预留给其他用途的。

装载的方式

程序执行所需的指令和数据必须在内存中才能够正常运行,最简单的办法就是把指令和数据全部装入内存中,这就是最简单的静态装入的办法。但是很多时候程序所需的内存数量大于物理内存数量,当内存不够时最简单的办法就是添加内存。但是内存相对于磁盘来说是昂贵稀有的,后来研究发现程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的存放在磁盘里,这就是动态装入的基本原理。下面介绍两种很典型的动态装载方法。

覆盖装入

覆盖装入在没有发明虚拟存储之前被广泛使用,现在已经几乎被淘汰了。使用这种方法程序员在编写程序时必须手动将程序分成若干块,然后编写一个辅助程序也就是覆盖管理器(Overlay Manager)。例如有一个程序主模块main 分别会调用到模块A和模块B,但A和B之间不会互相调用,这三个模块大小分别是1024字节、512字节、256字节。所以理论上运行这个程序需要1792字节的内存,如果采用覆盖装入的办法,那么内存中可以这样安排:

image 1.png

A模块和B模块在内存中相互覆盖共享内存区域,这样比原来的方案节省了256字节空间。这是一个最简单的例子,事实上程序往往不止两个模块,在多个模块的情况下,需要将模块按照调用依赖关系组织成树状结构。例如下图的组织关系,在复杂的情况下覆盖管理器需要保证两点。

image 2.png

  • 在这个树状结构中任意模块到根节点模块叫做调用路径。当某个模块被调用时调用路径上的模块都必须在内存中。

  • 禁止跨树间调用。比如C不可以调用D、B、E、F。

覆盖装入是典型的时间换空间的方法。

页映射

页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。页映射将内存和所有磁盘中的数据和指令按照页(Page) 为单位划分为若干个页。之后所有的装载和操作的单位就是页。假设我们的机器上的内存有4个页,而程序有8个页。

image 3.png

如果程序执行入口在P0,这时装载管理器发现程序的P0不在内存中,于是将内存F0分配给P0并将P0的内容装入F0,运行一段时间发现需要使用到P5、P3、P6,它们分别被装入到了F1、F2、F3。但是这时程序需要访问P4,那么装载管理器必须做出选择,它必须放弃正在使用的4个页中的一个来装入P4。比如可以根据FIFO算法放弃F0,也可以根据其他算法放弃某个内存页。

目前几乎所有主流操作系统都是按照这种方式装载可执行文件的。

从操作系统角度看可执行文件的装载

从上面页映射的动态装入方式可以看出,可执行文件中的页可能被装入内存中的任意页。如果使用物理地址直接进行操作,那么每次页被装入时都需要进行重定位。在虚拟存储中,现代硬件MMU 都提供了地址转换功能。有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式和静态加载有很大的区别。

进程的建立

创建一个进程,然后加载可执行文件并且执行,在有虚拟存储的情况下,上述过程只需要做三件事:

  1. 创建一个独立的虚拟地址空间,虚拟空间是由一组页映射函数将虚拟空间的各个页映射至相应的物理内存,所以创建一个虚拟空间并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory) 就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误时候再进行设置。

  2. 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系。上面一步是虚拟空间到物理内存的映射关系。这一步是虚拟空间与可执行文件的映射关系。当程序发生页错误时,操作系统将该缺页从磁盘读取到内存中,再设置虚拟页和物理页的映射关系。所以这时操作系统应该知道当前所需的页在可执行文件的哪一位置。这就是虚拟空间与可执行文件之间的映射关系。这种关系只是保存在操作系统内部的一个数据结构。

  3. 将CPU 指令寄存器设置成可执行文件入口,启动运行。可以简单的看成操作系统执行一条跳转指令,直接跳转到可执行文件的入口地址。

页错误

上面步骤执行完之后,指令和数据并没有被装入内存,只是通过可执行文件头部信息建立了可执行文件和虚拟存储之间的映射关系。当程序开始执行时,发生了页错误,CPU 将控制权交给操作系统,利用上面第二步建立的数据结构找到该页所在的VMA(虚拟内存区域 Virtual Memory Area),并计算出该页在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将虚拟页与分配的物理页之间建立映射关系,然后把控制权还给进程,进程从页错误位置重新开始执行。

进程虚存空间分布

ELF 文件链接视图和执行视图

ELF 文件被映射时,每个段在映射时的长度应该是系统页长度的整数倍,如果不是,多余的部分也会占用一页,一个ELF文件中往往有几十个段,那么就会造成内存空间的浪费。从操作系统装载可执行文件的角度看,他并不关心可执行文件中各个段的实际内容,只关心一些和装载相关的问题,主要就是段的权限(可读、可写、可执行)。段的权限基本上分为三种:

  • 以代码段为代表的权限为可读可执行的段

  • 以数据段和BBS段为代表的权限为可读可写的段

  • 以只读数据为代表的权限为只读的段

那么为了减少内存空间浪费,我们可以想到一个简单的方案是:对于相同权限的段,把它们合并到一起当作一个段进行映射。 比如有两个段权限相同分别是.text 段为4097字节.init 段512字节,如果页大小为4KB,分别映射的话就要占用3个页,合并在一起映射的话占用2个页。

ELF可执行文件引入了一个概念叫做Segment,一个Segment 包含一个或者多个权限类似的Section。这样做的好处就像刚才的例子一样,明显的减少了页面内部碎片,从而节省了内存空间。Segment 从装载的角度重新划分了ELF文件的段。链接器在链接时会尽量把相同权限的段分配在同一空间。而系统正是按照Segment 而不是Section 来映射可执行文件的。所以Segment 和Section 是从不同的角度来划分同一个ELF文件。这个在ELF 中称为不同的视图(View),从Section 角度来看ELF文件就是链接视图(Linking View),从Segment 角度来看就是执行视图(Execution View)

描述Section 属性的结构叫做段表,描述Segment 的结构叫程序头表(Program Header),它描述了ELF文件改如何被操作系统映射到进程的虚拟空间。程序头表的结构如下:

typedef struct {
    Elf32_Word p_type;
    Elf32_Off p_offset;
    Elf32_Addr p_vaddr;
    Elf32_Addr p_paddr;
    Elf32_Word p_filesz;
    Elf32_Word p_memsz;
    Elf32_Word p_flags;
    Elf32_Word p_align;
} Elf32_Phdr;

我们来看下Elf32_Phdr 结构的各个成员的基本含义

成员含义
p_typeSegment 的类型,LOAD类型的常量为1,还有几个类型如DYNAMIC、INTERP 在动态连接时会介绍
p_offsetSegment 在文件中的偏移
p_vaddrSegment 的第一个字节在进程虚拟地址空间的起始位置,整个程序头表中所有LOAD类型元素按照p_addr 从小到大排列
p_paddrSegment 物理装载地址
p_fileszSegment 在ELF 文件中所占用的长度
p_memszSegment 在进程虚拟地址空间所占用的长度
p_flagsSegment 的权限属性,R可读、W可写、X可执行
p_alignSegment 的对齐属性,实际对齐字节等于2的p_align 次

堆和栈

在操作系统里面,VMA 除了被用来映射可执行文件中的各个Segment 外,操作系统通过使用VMA 来对进程的地址空间进行管理。我们知道进程执行的时候还会用到栈(Stack)和堆(Heap)等空间,事实上他们在进程的虚拟空间中表现也是以VMA 的形式存在。操作系统通过给进程空间划分出一个个VMA 来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA。一个进程基本上可以分为如下几种区域:

代码VMA,权限只读、可执行;有映像文件。

数据VMA,权限可读写、可执行;有映像文件。

堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。

栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。

堆的最大申请数量

Linux 下虚拟地址空间分配给进程本身的是3GB,那么程序真正可以用到的有多少呢,经过测试大概是2.9GB 左右的空间,具体数值会受到操作系统版本,程序本身的大小、用到的动态/共享库数量、栈大小等影响。有些操作系统使用一种随机地址空间分布计数,使得进程的堆空间变小。

段地址对齐

可执行文件最终是要被操作系统装载运行的,装载过程一般是通过虚拟内存的页映射机制完成的。假设我们有一个ELF文件,他有3个段(Segment)需要装载。

长度(字节)偏移(字节)权限
SEG012734可读可执行
SEG19899164可读可写
SEG21988只读

最简单的映射方式就是每个段分开映射,通常可执行文件的起始虚拟地址为0x08048000,那么该ELF文件映射后各个段的虚拟地址和长度如下

起始虚拟地址大小有效字节偏移权限
SEG00x080480000x10012734可读可执行
SEG10x080490000x3009899164可读可写
SEG20x0804C0000x1001988只读

整个可执行文件三个段总长度只有12014字节,却占了5个页,即20480字节,空间使用率只有58.6%。

为了解决这个问题,有些UNIX 系统采用了一个很巧的办法,就是让各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次。如下图所示:

image 4.png

这样内存空间得到了充分利用,原来要用到5个物理页,现在只需3个。这样对于一个物理页来说可能会包括多个段,以为段地址对齐的关系,各个段的虚拟地址就不再是系统页长度的整数倍了。

进程栈初始化

我们知道进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。下面来看下Linux 进程初始化后的栈结构。假设系统中有两个环境变量:

HOME=/home/user
PATH=/usr/bin
//运行命令
$prog 123

进程初始化后的堆栈如图:

image 5.png

栈顶寄存器esp 只想堆栈顶部,最前面4个字节表示命令行参数数量,即prog 和123,接着是指向这两个字符串的指针;后面跟了一个0,接着是执行环境变量的字符串指针,后面跟着0表示结束。进程启动后,程序的库部分会把堆栈里的初始化信息中的参数传递给main() 函数,也就是我们熟悉的argc 和argv 两个参数,这两个分别对应命令行参数数量,和命令参数字符串指针数组。