可执行文件的装载

Posted ty_laurel

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了可执行文件的装载相关的知识,希望对你有一定的参考价值。

学习程序员自我修养,随笔,以方便后边查阅。

进程虚拟地址空间

  每个程序被运行起来以后,将拥有独立的虚拟地址空间(virtual address space),该虚拟地址空间的大小由计算机的硬件平台决定,具体的说由CPU的位数决定。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址为 0 到 2^32 - 1 ,即0x00000000 ~ 0xFFFFFFFF,也就是常说的4GB虚拟空间大小;而64位的硬件平台具有64位寻址能力,它的虚拟地址空间达到了 2^64 字节,即0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF,总共17 179 869 184 GB.

  从程序的角度看,可以通过判断C语言程序中的指针所占的空间来计算虚拟地址空间的大小,一般来说,C语言指针大小的位数与虚拟空间的位数相同,如 32 位平台下的指针为 32 位,即 4字节; 64位平台下的指针为64位,即8字节。有些特殊情况这种规则不成立。
  对于32平台来说,4GB的虚拟内存空间,只有较低的3GB(从虚地址0x00000000 到 0xBFFFFFFF)供各个进程自己使用,称为用户空间;而最高的1GB(从虚地址0xC0000000 到 0xFFFFFFFF)供内核使用(所有的进程共享),称为内核空间,如下图所示。

装载的方式

  程序执行时所需要的指令和数据必须在内存中才能正常运行,最简单的就是将程序运行所需要的指令和数据全都装入内存中,这就是最简单的静态装入的办法。但是大多数情况程序所需的内存大于物理内存,内存昂贵,希望在不添加内存时让更多的程序运行起来。研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。

  覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。

覆盖装入在没有虚拟存储之前使用广泛,现在已经淘汰,跳过。

页映射

  页映射是虚拟存储机制的一部分,随着虚拟存储的发明而诞生。将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页。

实例:假设有台32位机器,共16KB内存,每个页为4KB(4096字节)。假设程序所有的指令和数据总和为32KB,那么程序总共被分为8个页,编号P0~P7.16KB的物理内存无法同时装入32KB程序,按照动态装入的原理进行整个装入过程。若程序刚开始执行时的入口地址在P0,这时装载管理器发现程序的P0不在内存中,于是将物理内存F0分配给P0,,并且将P0的内容装入F0;运行一段时间后,程序需要用到P5,于是装载管理器将P5装入F1;当程序用到P3、P6时,分别被装入F2和F3,如下映射:

  如图此时程序用到了P0、P3、P5和P6,占据了所有的物理内存,若程序需要访问P4,那么装载管理器(OS的存储管理器)必须做出抉择,它必须放弃目前正在使用的4个内存页中的一个来装载P4。至于选择哪个页,有多种算法可以选择,比如可以选择F0,因为它是第一个被分配掉的内存页(FIFO,先进先出算法);假设装载管理器发现F2很少被访问到,那么可以选择F2(LRU,Least Recently Used,最少使用算法)。目前主流的操作系统都是按照这种方式装载可执行文件。

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

  从OS角度看,一个进程最关键的特征是它拥有独立的虚拟地址空间,使得它有别于其他进程。很多时候一个程序被执行同时伴随着一个新进程的创建:创建一个进程,装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

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

1.创建虚拟地址空间
  一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构。
2.读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
  上一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,应该知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。这是整个装载过程中最重要的一步。

可执行文件在装载时实际上是被映射到虚拟空间,所以可执行文件又被叫做映像文件image。
Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area)。VMA是一个很重要的概念,对于我们理解程序的装载执行和操作系统如何管理进程的虚拟空间有非常重要的帮助。

3.将CPU指令寄存器设置成可执行文件入口,启动运行
  操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步在操作系统层面上比较复杂,涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。在进程的角度看可以简单地认为操作系统执行了一条跳转指令,直接跳转至可执行文件的入口地址,也就是ELF文件头保存的地址。

页错误

  上面的步骤执行完以后,实际上可执行文件的真正指令和数据都没有被转入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。当CPU准备执行某个地址的指令时,发现其是个空页面,就认为是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况,这时候转载过程的第二步建立的数据结构起到了关键作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权交还给进程,进程从刚才页错误的位置重新开始执行。

进程虚存空间分布

  操作系统只关心一些跟装载相关的问题,最主要的就是段的权限(可读、可写、可执行)。ELF问减重往往只有为数不多的几种组合,基本如下三种:

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

  操作系统通过使用VMA来对进程的地址空间进行管理,栈、堆在进程的虚拟空间中同样也是以VMA的形式存在。在Linux下,可以通过查看/proc/$PID/maps获取指定进程的虚拟空间分布:

第一列是VMA的地址范围;
第二列是VMA的权限,r-读,w-写,x-可执行,p-表示私有(COW,Copy on Write),s-共享;
第三列是偏移,表示VMA对应的Segment在映像文件中的偏移;
第四列表示映像文件所在设备的主设备号和次设备号;
第五列表示映像文件的节点号;
最后一列是映像文件的路径。

  该进程的5个VMA中,只有前两个是映射到可执行文件中的两个segment,另外3个主次设备号和节点号都是0,没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area)。堆占140KB,栈占88KB,这两个VMA几乎所有的进程都存在,malloc内存分配就是从堆里分配,堆由系统库管理。最后一个VMA叫做“vdso”,它的地址已经位于内核空间(即大于0xC0000000的地址),事实上它是一个内核模块,进程可以通过访问这个VMA来跟内核进行一些通信。
  操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几个VMA区域:

  • 代码VMA,只读、可执行;有映像文件。
  • 数据VMA,可读写、可执行;有映像文件。
  • 堆VMA,可读写,可执行;无映像文件,匿名,可向上扩展。
  • 栈VMA,可读写、不可执行;无映像文件,匿名,可向下扩展。

Linux内核装载ELF过程

  在Linux的bash下输入一个命令执行某个ELF程序时,首先在用户层面,bash进程会调用fork系统调用创建一个新的进程,然后新的进程调用execve系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
  在进入execve系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve系统调用相应函数定义在exec.c(3.14版内核)文件中:

SYSCALL_DEFINE3(execve,
        const char __user *, filename,
        const char __user *const __user *, argv,
        const char __user *const __user *, envp)

    return do_execve(getname(filename), argv, envp);

最终调用do_execve会查找被执行的文件,如果找到文件检查权限,则读取文件的前128字节(BINPRM_BUF_SIZE)。
为什么要读取前128字节呢?
众所周知,Linux支持的可执行文件不止ELF一种,还有a.out、Java程序和以“#!”开始的脚本程序,读取文件的前128字节目的是判断文件的格式,每种可执行文件的格式开头几个字节都是很特殊的,特别是开头4个字节,常常被称为魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。比如ELF的可执行文件格式的头4个字节为0x7F、e、l、f;而java的可执行文件格式头4个字节为c、a、f、e;如果被执行的是Shell或者perl、python等解释型脚本语言,那么他的第一行往往是”#!/bin/bash”或“#!/usr/bin/perl”或“#!/usr/bin/python”,这时候前两个字节‘#’和“!”就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。

  当do_execve()读取了前128字节的文件头部之后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF可执行文件的装载处理过程叫做load_elf_bingary();a.out可执行文件的装载处理过程叫做load_aout_binary();而装载可执行脚本程序的处理过程叫做load_script()。ELF的load_elf_binary()被定义在fs/Binfmt_elf.c,主要步骤是:

  • 1.检查ELF可执行文件格式的有效性。比如魔数、程序头表中段(segment)的数量;
  • 2.寻找动态链接的“.interp”段,设置动态链接器路径(与动态链接有关)。
  • 3.根据ELF可执行文件程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
  • 4.初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址。
  • 5.将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是iELFwenjian的文件头中e_entry所指的地址了对于动态链接的ELF可执行文件,程序入口点是动态链接器。

  当load_elf_binary()执行完毕,返回至do_execve,上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了。所以当execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。

以上是关于可执行文件的装载的主要内容,如果未能解决你的问题,请参考以下文章

页映射与可执行文件的装载

程序员的自我修养-链接装载与库-6 可执行文件的装载与进程

读书笔记|《程序员的自我修养》- 04 可执行文件的装载与进程

可执行文件的装载

《程序员自我修养》阅读笔记-可执行文件装载与进程

读书笔记|《程序员的自我修养》- 04 可执行文件的装载与进程