《程序员的自我修养——链接、装载与库》可执行文件的装载与进程(六)

虚拟地址空间

 总所周知,可执行文件需要装载到内存里才能被CPU执行,每个程序运行起来时都有自己的虚拟地址空间,这个空间大小由硬件平台决定,一般现在32位的是4G,64位的17179869184G。

虚拟地址空间扩展方法

PAE

 早期32位地址线只能访问最多4G的物理内存,但是后来扩展了36位地址线,Intel修改了页映射的方式,这个地址扩展方式称为 PAE。

AWE

 及时操作系统扩展了物理地址空间,但是程序的寻址范围依然是4G。程序如何使用扩展出来的空间也是一个问题。
 常见的方法是操作系统提供一个窗口映射的方式,把额外的内存空间按一定大小分块来映射到进程地址中来,程序根据需要选择申请和映射。在 Windows 下这种访问内存的操作称为AWE。

而 UNIX 则采用mmap()系统调用实现,感觉灵活得多。

 在远古时代 DOS 16位地址不够用时,也采用了类似16位CPU字长,20位的地址线,程序访问超过1MB内存时也有一些类似的方法,比如XMS。

装载方式

静态装载

 直接将程序所用到的指令和数据全部装入内存,这会造成内存浪费,资源利用率低

动态装载

 采用程序的局部性,将用到的模块装入内存,暂时不用的放在磁盘。

覆盖装入

 覆盖装入是将内存的管理交给开发人员来完成。
 开发人员在写程序时手工将程序分割成若干块,在编写一个辅助代码来管理各个模块,决定它们何时在内存中停留,何时替换到磁盘。开发人员需要手工将模块按照它们之间的调用依赖关系组织成树状结构。

覆盖管理器需要满足两点:

  • 在树状结构中从任意模块到根模块称为调用路径,当该模块被调用时,整个调用路径上的模块必须都在内存中。
  • 禁止跨树间调用

 这种方法主要应用于虚拟存储技术诞生之前,现在几乎淘汰了,或许在一些嵌入式内存受限的情况下还有使用的。

页映射

 页映射是虚拟存储机制的一部分,原理与覆盖装入的装入过程相似,只是内存和磁盘中所有的数据和指令都按照页为单位划分成了若干份,所有的装载和操作的单位就是页,并且存储页的管理交给了操作系统中的内存管理器。

这里操作系统如何找到磁盘中需要的页是采用 MMU(提供的地址转换或者映射功能)。

最常见页的大小为4096字节,其它还有8129字节、2MB、4MB等

 在内存中,当某个页的访问频率变少时,内存管理器将会把该页在内存中释放掉,若要重新访问该页数据时再从磁盘中装载。这种算法称之为 LUR(最少使用算法)。

眼下的Windows装载PE和linux装载ELF都是采用这种方式。

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

进程的建立

 每一个程序的执行都是一个新进程的创建,创建进程三个步骤:

  • 创建一个独立的虚拟地址空间
  • 读取可执行文件头,建立虚拟地址空间与可执行文件的映射关系。
  • 将 CPU 的指令寄存器设置成可执行文件的入口,启动执行。

创建一个独立的虚拟地址空间

 虚拟地址空间是由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,创建一个虚拟空间并不是创建空间而是创建映射函数所需的相应的数据结构。i386的linux下创建虚拟空间只是分配一个页目录,甚至都不设置页映射关系,这些由后面程序发生页错误的时候再进行。

读取可执行文件头,建立虚拟地址空间与可执行文件的映射关系

 上一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步是建立虚拟空间与可执行文件的映射关系。

解释一下,上一步提到当发生页错误时才把缺页从磁盘装入到内存,但是操作系统如何知道该缺页的位置呢?

答:由上面提到的两映射关系,一是虚拟内存到物理内存的映射,二是虚拟内存到可执行文件的映射。而前者遇到的缺页并没有进行虚拟内存到物理内存的映射,操作系统靠后者知道缺页的位置,并能将其装载入内存再进行缺页的虚拟内存到物理内存的映射。

将 CPU 的指令寄存器设置成可执行文件的入口,启动执行

 这一步看似简单,操作系统将指令寄存器交给进程,由进程开始执行。实际上在操作系统层面,涉及内核态和用户态的切换、CPU运行权限的切换等。

将 CPU 的指令寄存器设置成可执行文件的入口,启动执行

 这一步看似简单,操作系统将指令寄存器交给进程,由进程开始执行。实际上在操作系统层面,涉及内核态和用户态的切换、CPU运行权限的切换等。

页错误

 执行完上面的步骤之后,可执行文件并没有真正将指令和数据装载入内存,只是通过可执行文件的头部建立起了可执行文件和进程虚拟内存之间的映射关系而已。

 当操作系统执行到一个地址时,若这个地址指向页面是一个空白页,则触发一个页错误,CUP将控制权限交给操作系统,系统有专门的页错误处理例程来处理。操作系统回去查询上述第二部建立起来的数据结构,找到空页面所在的VMA(虚拟内存区域),计算出相应页面在可执行文件中的偏移,然后再从物理页面将进程中的该虚拟页与分配的物理页之间建立映射关系,最后再把控制权交给进程,从错误的地方再继续执行。

 随着进程的执行,页错误也不断的产生,这就需要操作系统去灵活的组织分配物理内存了,甚至还有将已分配的物理内存按优先级暂时回收等情况。

进程虚拟空间分布

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

 这部分从一个问题来了解,一个可执行文件通常有多个段,ELF 被映射时以系统的页长度作为单位的,每段映射的空间都是系统页的整倍数,而纯粹按照每个段都有相应的虚拟内存区域(VMA)做映射的话,会造成内存空间的大量浪费。

 从操作系统视角来看,可执行文件各段的内容并不重要,只需要跟装载链接相关的信息就行,这主要是各段的权限(RWX)。而段的权限只有少数几个组合:

  • 以代码段为代表的 RWX 可读可执行段
  • 以数据段和 BSS 段为代表的可读可写段
  • 以只读数据段为代表的只读段

 对于上述问题的解决方案就是,将相同权限的段合并一起当作一个段进行映射。

 ELF可执行文件引入了新概念叫 "Segment",一个 Segment 包含一个或多个属性类似的 "Section"。例如将 ".text"(可执行代码)和 ".init"(初始化代码)两个段合并之后在进程虚拟内存空间就只有一个 VMA 了,减少内存碎片。

注:"Segment" 和 "Section" 很难在中文翻译中进行区分,从链接的角度看是按 "Section" 存储的,从转载的角度看是按 "Segment" 划分的。个人理解上粗鲁的可以视为 "Segment" 就是 "Section" 在上述装载过程中合并在一起的集合并且映射为一个 VMA 。

 "Segment" 就是从装载的角度重新划分了 ELF 的各段。在将目标文件进行链接时,链接器会尽量将相同权限的段分配在同一空间。这些属性相似连接在一起的段合并称为一个 "Segment",而系统正是按照 "Segment" 来映射可执行文件的。

装载时说的段是 Segment,其它时候说的段是 Section。

 额外提一下 BSS 段,它与数据的唯一的区别在于:数据的从文件中初始化内容,而 BSS 段是为初始化的,也就是全0,不需要单独设立一个 "Segment",所以 BSS 段会被合并至数据段去,以节省空间。

堆栈与虚拟地址空间

 在操作系统中,VMA 除了用来映射 "Segment" 之外,也用来对进程的地址空间进行管理。例如堆栈空间在进程的虚拟空间中的表现形式也是 VMA。

 在 Linux 下,/proc 路径下可以看到进程的虚拟空间分布,每一条就是一个 VMA 。其中主设备号和次设备号以及文件节点都是0,表示该 VMA 没有映射到文件中,这种被称为匿名虚拟内存。

也可以看的出来了,像堆栈这样的匿名虚拟内存空间是根据进程执行过程中的需要而临时申请的内存,并不存在于文件本身中。

根据权限来分类 VMA:

  • 代码 VMA,RX,映射到文件
  • 数据 VMA,RWX,映射到文件
  • 堆 VMA,RWX,无映射文件
  • 栈 VMA,RW,无映射文件

冷知识,VMA 和 "Segment" 并非完全对应,有些权限相同的 "Section" 合并后,并不是所有都做映射,也就是说对于一个 "Segment" 只有一部分是需要映射到文件的,另一部分是不做映射的。

段地址对齐

这个概念前面已经提过了,就是段的大小必须是页的整倍数。这里解决一个问题:如果每个段分开映射,则不足一页的也占一页,造成内存浪费。

解决办法是,各段相邻的部分共享一个物理内存页,然后将该页分别映射两次

 正因为这个段地址对齐的关系,各段的虚拟地址往往就不再是页长度的整倍数了,但是必须是4字节或者8字节的整倍数。

进程栈初始化

 操作系统在进程启动前会将系统环境变量和进程的运行参数等信息提前保存到进程的虚拟地址空间的栈中,进程启动后程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main 函数。

内核装载

Linux内核装载ELF

 首先在用户层面,bash调用fork()系统调用创建一个新进程,然后新进程调用 execve() 系统调用执行指定的 ELF ,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。

 进入 execve() 系统调用后,Linux 内核开始真正的装载工作,execve() 的入口是 sys_execve(),它会进行一些执行参数的检查复制,然后调用 do_execve() 去找到可执行文件,读取文件的前128个字节。这128字节是为了判断文件格式,每种可执行文件的开头几字节都是很特殊的,尤其是开头4字节,被称为魔数,对魔数的判断可以确定文件格式和类型。例如 ELF 的魔数是 "0x7f e l f",如果是 shell 脚本或者 python 等解释语言,其开头往往是 "#!/bin/sh" 之类的。

 读取完这128字节后,调用 search_binary_handle() 去查询适合该文件格式的装载处理过程。Linux中所有支持的可执行文件格式都有相应的装载处理过程。

ELF 装载过程: load_elf_binary()
a.out 装载过程: load_aout_binary()
脚本程序装载过程: load_script()

 还是以 ELF 举例,其装载的主要步骤为:

  • 检查 ELF 可知文件的有效性
  • 寻找动态链接的 ".interp" 段,设置动态链接器路径
  • 根据 ELF 的程序头表描述,对 ELF 进行映射,比如代码、数据、只读数据
  • 初始化 ELF 进程环境
  • 将 sys_execve() 系统调用的返回地址修改成 ELF 文件的入口,静态链接则程序入口就是文件头中所包含的izhi,动态链接则程序入口就是动态链接器。

load_elf_binary() 执行完毕后,返回到 do_execve() 再返回至 sys_execve(),当 sys_execve() 系统调用从内核态返回到用户态时,指令寄存器之间跳到 ELF 的入口开始执行,至此装载完成。

Windows 装载 PE

 PE 文件所有段的起始页地址都是页的整倍数,不是整倍数的如上面所说向上补齐到页的整倍数。

PE 不像 ELF 一样有很多 "Section" 不得不用 "Segment" 来合并装载,PE 在链接输入文件时就尽可能的将各段合并,所有最终也只有代码段、数据段、BSS段等为数不多的几个段。

 PE中引入了一个叫 RVA(相对虚拟地址) 的概念,就是相对于 PE 文件的装载基地址的一个偏移地址。 PE 是可以装载到任意地址的,所有每次装载都有一个装载目标地址这就是基地址,无论基地址怎么变化 PE 文件中各个 RVA 都保持统一。

装载 PE 的步骤:

  • 读取文件的第一页,这里面包含了 DOS 头、PE 文件头和段表
  • 检查进程地址空间中目标地址是否可用,若不可用则选另一个装载地址。这个问题对于可执行文件本身是不存在的,因为之前会先装入进程模块,这个问题主要是针对 DLL 的。
  • 使用段表中提供的信息,将 PE 问价中所有的段一一映射到地址空间中相应位置
  • 如果装载地址不是目标地址则进行 Rebasing。
  • 装载所有所需的 DLL
  • 对于 PE 文件中的所有导入符号进行解析
  • 根据 PE 头中指定的参数,建立初始化堆栈空间
  • 建立主线程并且启动进程

发表评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据