一个iOS程序员的自我修养(四)可执行文件的装载

1,119

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

进程的建立

一个进程最关键的特征是它拥有独立的虚拟地址空间,大小由CPU的位数决定。从创建进程到可执行文件装载的过程如下:

  1. 创建虚拟地址空间。 在 i386 的 Linux 下,创建虚拟地址空间实际上只是分配一个页目录,相当于提前划分了一个目录,但里面并没有实质性的内容。可执行文件被操作系统装载正是通过这种页映射的机制,相当于为目录填充信息。页映射是虚拟存储机制重要的一部分,内存中和磁盘中的数据按照“页”为单位划分成若干个页,在 iOS 操作系统中,一页的大小为 16kb。

  2. 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。 当操作系统去内存读取可执行文件时,如果内存并未加载该页,它是个空白页,程序会发生页错误,系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系。当系统捕捉到缺页错误时,它应当知道当前所需要的页在可执行文件中的哪个位置,这就需要建立虚拟空间与可执行文件的映射关系。

由于可执行文件在装载时实际上是被映射到虚拟空间,所以可执行文件很多时候又被叫做映像文件(image)。

其中一个 Segment 映射到虚拟空间中后被称作为虚拟内存区域 VMA(Virtual Memory Area)。例如只读权限的 Section 被集合到一起组合成只读的 Segment,它被映射到虚拟内存空间中可能是一个页或多个页,它们被统称为一个 VMA。关于Segment 和 Section 细节参考程序员的自我修养(二)-- Mach-O里面有什么

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

页错误

上面的步骤只是通过可执行文件头部的信息建立起可执行文件和进程虚拟内存之间的映射关系,并没有将真正的指令和数据装入到内存中。假设 CPU 打算开始从入口地址执行时发现在内存上是个空页时,就认为这是一个“页错误”,然后操作系统会去第二部建立的映射关系中查询这个空页面所在的位置,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中虚拟页与分配的物理页之间建立映射关系,然后把控制权还给进程,进程从刚才页错误的位置重新开始执行。

前段时间很火的二进制重排可以节省启动时间的原因正是基于此,它将 app 启动过程中所需要的函数集中到一起可以减少启动过程中需要加载页的数量,从而减少发生Page Fault 的次数。

进程虚拟空间分布

可执行文件的链接视图和执行视图

操作系统分配内存空间并非以段为单位(例如前文提到的 text 段、data 段等),因为每个段在映射的时候都应该是页的整数倍,如果不是,多余的部分将会占用一个页,往往一个可执行文件都有十几个段甚至几十个段(指 Section),会浪费大量内存空间,所以操作系统并不关心段的内容,而是关心段的权限,可读、可写、可执行等,这样会将多个权限相同的段合并到一块加载,这个合并后的段就是 Mach-O里面有什么 讲到的 “Segment”。“Segment” 和 “Section” 从不同的角度划分了同一个可执行文件,这在可执行文件中被称为不同的视图,从 “Section” 角度来看是链接视图,从 “Segment” 角度来看就是执行视图。

堆和栈

进程在执行的时候除了需要二进制文件外,还需要用到堆栈空间,事实上它们在进程的虚拟空间中也是以 VMA 形式存在的,一个进程中的堆和栈分别都对应着一个 VMA。一个进程上基本分为如下几种 VMA 区域:

  1. 代码VMA,权限只读、可执行:有映像文件。
  2. 数据VMA,权限可读写、可执行:有映像文件。
  3. 堆VMA,权限可读写、可执行:无映像文件,可向上扩展(堆由低地址向高地址)。
  4. 栈VMA,权限可读写,不可执行:无映像文件,可向下扩展(栈由高地址向地地址)。

有无映像文件指的是是否映射到了可执行文件中。

摘自《程序员的自我修养》167页。

上图可以看出,这个可执行文件被重新的划分为了三个部分:有一些段被归入到了可读可执行,它们被统一映射到 CODE VMA,另外一部分是可读可写的,它们被映射到了DATA VMA,还有一部分在程序装载时没有被映射,它们是一些包含调试信息和字符串表等段,这些段在程序执行时没有用,所以不需要被映射。另外虚拟内存空间还会有两个多出来的 VMA,分别代表着堆栈。

段地址对齐

可执行文件最终要被操作系统装载运行,一般是通过虚拟内存页映射机制完成的,在 iOS 操作系统中,页的大小为 16kb,就是说物理内存和虚拟内存之间建立映射关系,大小必须是 16kb 的整数倍。但是这种对齐方式在内存上会产生很多碎片,为了解决这个问题操作系统内部让各个段接壤的部分共享一个段(这个段指的是 Segment 非 Section)。从某种角度来看,整个可执行文件从文件的开始到结束被分成了 16kb 大小的若干块,每个块都被装入到物理内存中,对于两个段接壤的部分,系统会再映射一次,保证物理内存没有碎片的情况下,虚拟内存中的页仍然只会包含一种段。

参考

《程序员的自我修养》