可执行文件的装载与进程一点小总结 《程序员的自我修养》·笔记

可执行文件的装载与进程小结

  • 进程的虚拟地址空间
      每个程序被运行起来之后都拥有自己独立的虚拟地址空间,这个虚拟地址空间的大小是CPU的位数决定的。比如,32位的硬件平台决定了虚拟地址空间的地址为(2^32-1),也就是我们常说的4GB虚拟内存的大小。
      需要注意的是,分配的4GB的虚拟空间并不是全部给进程的,比如,linux下1GB给操作系统,余下的3GB中基本上都分配给进程,但是3GB中的其中小部分要分配给其他用途;win下面按照2GB、2GB进行类似的划分。

  • 装载的方式

    • 装载的基本思想
        将程序最常用的部分驻留在内存。最常用的方法是页映射,如下。
    • 页映射
        要完成页映射就要将内存和磁盘中的数据和指令按“页”为单位划分成若干页,以后所有的装载和操作的单位就是页。下图就是可执行文件(虚拟空间)与物理内存的映射(不考虑程序运行的虚拟地址空间):

        关于页的操作有很多种情况,比如“内存满时的页置换”、“页错误”等等情况下采取的种种策略(FIFO、LUR)这里不再赘述。
  • 从操作系统的角度看可执行文件的装载方法

    • 进程的创建
        从操作系统的角度看,一个进程最关键的特征就是他有独立的虚拟地址空间,这使得它有别于其他进程,上述的映射关系直接使用物理地址进行操作,那么每次页装入的时候就要就行重定位,所以我们需要引入进程的虚拟运行地址空间。那么,下面就说一下从操作系统角度看一个程序被执行的大致过程:
        1.首先是创建程序对应的虚拟地址空间。即进行虚拟地址空间与程序执行的物理内存的映射(方向是进程虚拟空间到进程物理内存)。我们知道一个虚拟空间由一组页映射函数将虚拟空间各个页映射至相应的物理空间。此处所谓的“创建”并不是创建空间,而是创建虚拟空间到物理内存空间的映射函数所需要的一系列的数据结构,对于Linux就是创建一个“页目录”结构即可,并不需要设置虚拟页到物理页的映射关系。linux下将虚拟空间的各个页映射至相应的物理空间,实际上只是分配了一个页目录(Page Directory)就可以了,并且不用设置页映射关系,这些映射关系到后面程序发生页错误的时候再进行设置。
        2.读取可执行文件头,建立进程虚拟地址空间和可执行文件的映射关系。这一步将可执行文件空间与虚拟空间关联起来(方向是可执行文件虚拟空间到进程虚拟空间),使得发生缺页错误时,OS能够知道到可执行文件中的哪个位置去找到所需要加载到物理内存的内容;这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area);在Windows中将这个叫做虚拟段(Virtual Section)。
        【注意】由于可执行文件在装载的时候实际上是被映射的虚拟空间,所以可执行文件很多时候被称作映射文件。进程虚拟地址空间和可执行文件的映射关系如下:

        3.设置CPU的指令寄存器为可执行文件的入口地址,启动运行:OS将控制权交给了进程。从进程的角度看这一步可以简单的认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口(ELF文件头中保存了入口地址项)。
    • 页错误
        完成上述三个步骤之后,其实OS仅仅只是可执行文件与进程虚存之间建立起了映射——即通常意义上所说的程序加载到了内存,实际上这里说的是程序完全加载到了虚拟内存,但是代码和数据根本就没有加载到物理内存中,进程虚存与物理内存空间的映射关系其实也没有建立起来(上面也说了在“页错误阶段进行映射关系的设置”),这样程序一旦开始执行,将会立即出现缺页错误,即程序将要访问的进程虚存地址并没有映射到物理内存空间的某个page(虚拟页),(页错误的处理线程执行)此时OS会重新接管系统控制权,查询刚才第二步保存的可执行文件到进程虚存映射关系的数据结构,找到所缺的虚拟页对应于可执行文件中的偏移,然后在进程物理内存分配一个物理页,将可执行文件中的内容从磁盘读入到内存中,并将这个物理页(进程物理内存)与该虚拟页(进程虚存)建立起映射,然后OS将控制权重新交给进程,程序继续执行。如下图所示:
  • 进程虚存空间分布

    • ELF文件在映射到进程虚存的过程中是以系统的页作为单位的,那么每个段在映射时的长度都应该是系统页长度的整数倍;如果不是那么多余部分也将占用一页。这样的话内存浪费是大问题。
    • ELF文件中, 段的权限只有为数不多的几种组合:
      1.以代码段为代表的权限为可读可执行的段
      2.以数据段和BSS段为代表的权限为可读可写的段
      3.以只读数据段为代表的权限为只读的段。
      对于相同权限的段,把它们合并到一起当作一个段进行映射。如下图,".text"和".init"段都是可读可执行的,则进行合并,形成一个"segment":


    • 堆和栈
      kernel使用VMA划分来管理进程的虚拟地址空间。典型的进程包括代码:
      1.代码VMA(RE属性,有映像文件)
      2.数据VMA(RWE属性,有映像文件)
      3.堆VMA(RWE属性,无映像文件,向上扩展)
      4.栈VMA(RW属性,无映像文件,向下扩展)
      如下图所示:



      【需要注意】其实DATA segment对应的就是DATA VMA;CODE segment对应的就是CODE VMA。几乎在每一个进程的VMS视图中都可以看见[heap]和[stack]这两个VMA,但是这两个VMA在可执行文件中都没有对应的segment存在,所以它们被称之为匿名VMA。malloc()库函数就是从堆VMA中分配空间。

  • Linux内核装载ELF过程
      Linux环境下,fork系统调用将会创建一个与当前task完全一样的新task,直到应用程序调用exec*系列的Glibc库函数最终调用execve()系统调用之后,Linux内核才开始真正装载ELF可执行文件(映像文件)。execve内核入口为sys_execve(),随之调用do_execve()将查找这个可执行文件,如果找到则读取ELF可执行文件的前128个字节,然后调用search_binary_handle()通过ELF文件头中的e_ident得到可执行文件的Magic Number,判断出这是一个什么类型的可执行文件,并调用不同可执行文件的装载处理程序,对于ELF可执行文件而言,其装载处理程序为load_elf_binary(),这个函数将会把execve系统调用的返回地址修改为ELF可执行文件的入口点,对于静态链接得到的ELF文件即文件头中定义的e_entry,对于动态链接得到的ELF可执行文件则是动态链接器。一步一步返回到sys_execve()之后,因为返回地址已经被修改为了ELF程序入口地址了,所以系统调用返回到用户态之后,EIP指令寄存器将直接跳转到ELF程序入口地址,程序开始执行,装载完成。
    ELF文件的装载过程:

    fork -> execve() -> sys_execve() -> do_execve()

    do_execve() 读取文件的前128个字节判断文件的格式(一般根据魔数来判断,比如elf的头四个字节为:0x7F, e, l, f)。
      然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程,对于elf则调用load_elf_binary():

    • 检查ELF可执行文件格式的有效性
    • 寻找动态链接的“.interp”段,设置动态连接器路径
    • 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
    • 根据ELF进程环境,比如进程启动是EDX寄存器的地址应该是DT_FINI的地址。
    • 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,静态ELF可执行文件为e_entry所指的地址,对于动态ELF入口点为动态连接器。

    Load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve(),最后一步的系统调用返回地址改成了被装在的ELF程序入口地址。当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,新程序开始执行。

推荐阅读更多精彩内容