可执行文件的装载

对于32位平台下的4G的虚拟空间,在默认情况下,Linux操作系统将进程的虚拟地址空间划分为两部分,其中操作系统本身用去了一部分:从地址0xC0000000到0xFFFFFFFF,共1GB。剩下的从0×00000000地址开始到0xBFFFFFFF共3GB的空间都是进程使用的。而对于Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2GB。

覆盖装入(Overlay)和页映射(Paging)是两种很类型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。

覆盖载入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。覆盖载入的方法是把挖掘内存潜力的任务交给了程序员,即是程序员在编写代码的同时要自己手动对程序模块进行内存和磁盘的切换,使不需要的代码切换出磁盘,腾出空间给需要执行的代码块。

与覆盖载入的原理相似,页映射也不是一下子就把程序的所有数据和指令都载入内存,而是将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分分成若干个页,以后所有的装载和操作的单位就是页。以目前的情况,硬件规定的页大小有4096字节、8192字节、2MB、4MB等。其实这个是属于操作系统存储管理的一部分,内存页的切换会使用先进先出、最少使用等等算法。

可执行文件的装载

可执行文件的装载和进程的建立大概经历3个步骤:

  • 首先是创建虚拟地址空间。一个虚拟空间是由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪个位置。
  • 将CPU指令寄存器设置成可执行文件入口,启动运行。这一步涉及了内核堆栈和用户堆栈的切换、CPU运行权限的切换等等。跳转到入口指令其实就是在ELF文件头中e_entry记录的可执行文件入口地址。

当ELF文件加载到进程后,进程的虚拟空间的分布

在ELF中文件,可以看到有很多段,包括.text、.init、.fini、.data、.bss等等。其实根据这些段的权限来组合,基本上可分为三种分类:

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

而实际上,对于相同权限的段,可以把它们合并到一起当作一个段来进行映射。因此在ELF可执行文件中引入了一个概念叫做“Segment”,一个“Segment”包含一个或多个属性类似的“Section”,这种“Segment”就是在操作系统教材中讲述存储系统的时候经常接触到的(如段页式…)。“Segment”的概念实际上是从装载的角度重新划分了ELF的各个段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一个空间。而对于这些“Segment”,在ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存“Segment”的信息,包括“Segment”的类型、“Segment”在文件中的偏移和第一个字节在进程虚拟地址空间的起始位置等等。

下面是用readelf看到的ELF可执行文件的程序头信息,还有”Segment”和”Section”映射的信息:

Elf file type is EXEC (Executable file)
Entry point 0x8048170
There are 6 program headers, starting at offset 52

Program Headers:
  Type                   Offset       VirtAddr         PhysAddr     FileSiz     MemSiz  Flg   Align
  LOAD                 0x000000 0x08048000 0x08048000 0x7c7e9  0x7c7e9 R E  0x1000
  LOAD                 0x07cf98 0x080c5f98   0x080c5f98  0x00788 0x022bc RW  0x1000
  NOTE                 0x0000f4 0x080480f4   0x080480f4  0x00044 0x00044 R     0x4
  TLS                    0x07cf98  0x080c5f98   0x080c5f98  0x00010 0x00028 R     0x4
  GNU_STACK      0x000000 0x00000000  0x00000000 0x00000 0x00000 RW  0x4
  GNU_RELRO      0x07cf98  0x080c5f98   0x080c5f98  0x00068 0x00068 R     0x1

 Section to Segment mapping:
  Segment Sections...
   00     .note.ABI-tag .note.gnu.build-id .init .text __libc_freeres_fn .fini .rodata __libc_subfreeres __libc_atexit .eh_frame .gcc_except_table
   01     .tdata .ctors .dtors .jcr .data.rel.ro .got .got.plt .data .bss __libc_freeres_ptrs
   02     .note.ABI-tag .note.gnu.build-id
   03     .tdata .tbss
   04
   05     .tdata .ctors .dtors .jcr .data.rel.ro .got .got.plt

在操作系统里面,除了给代码段和数据段等分配VMA之外,还会分配栈和堆的空间。因此,在操作系统里面可以根据相同的权限属性划分成几个VMA的区域:

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

进程启动时输入的参数

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

HOME=/home/user
PATH=/usr/bin

运行以下命令:

prog 123

此时,栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,我们的例子里面是两个,即“prog”和“123”,紧接着的就是分别指向这两个参数字符串的指针;后面跟一个0;接着是两个指向环境变量字符串的指针,它们分别指向字符串“HOME=/home/user”和“PATH=/usr/bin”;后面紧跟一个0表示结束。

This entry was posted in Operating System. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>