Chinaunix首页 | 论坛 | 博客
  • 博客访问: 296704
  • 博文数量: 71
  • 博客积分: 30
  • 博客等级: 民兵
  • 技术积分: 217
  • 用 户 组: 普通用户
  • 注册时间: 2012-07-31 15:43
文章分类

全部博文(71)

文章存档

2016年(4)

2015年(2)

2014年(2)

2013年(63)

分类: LINUX

2013-05-10 13:22:50

第六章开始涉及可执行程序在操作系统环境下的装载,并在OS下以进程模型运行,有众多的重要概念,昨夜熬读至凌晨4点,基本了解了框架,但是不少地方还是有些混沌,特此重新梳理和理解~~~

 

基本的缩写:  VMS——进程虚拟地址空间   PMS——物理内存空间  EFS——可执行文件空间

 

  可执行文件和进程关系的厨房比喻的个人理解

计算机系统  ——饭店的厨房;
CPU Core    ——
厨师;
可执行程序  ——菜谱,菜谱自然可以被多次复用

输入和众多其他硬件设备——厨房的厨具和做菜原料;

输出        ——菜肴;
计算机用户  ——饭店的客人;
进程        ——厨师按照菜谱做出一道菜的过程;

多进程伪并发模型——厨师同时用多个灶台和厨具做多个菜,每个菜对应一个菜谱,根据客人的轻重缓急和各种菜的做法情况,厨师尽可能地在多个灶台和厨具之间来回切换;

单核多线程  ——只 有一个厨师,每个锅可以被分割为几个小块,每个小块负责该道菜的一个相对独立的环节,厨师在轮换到做这一道菜的时间片内(一个进程的时间片內)交替完成这 几个小块的烹制,最终各个小块共同完成这一道菜的烹制。并且这个厨师交替做的每个菜肴都可以在各自的锅中分割成这样的小块。

每 道较复杂的菜谱中包含多个步骤和技巧(代码中的多个函数),而每个锅内的小块(共享进程地址空间)均可以使用任意的这些步骤和技巧(共享进程代码段),共 同使用油盐酱醋与火力(共享进程数据段),但是每个小块都严格在自己的物理区域内(线程独立的栈空间),厨师严格按照按照菜谱中的规定步骤操作该小块(线 程独立的PC控制流)。

这就意味着,宏观上一眼看去厨师需要在多个灶台和炒锅之间来回跑以操作不同的菜肴制作(进程切换),而仔细地观察该厨师实际上是以不同的锅内的不同小块为奔波的对象(线程切换)——这样的厨师真的非常辛苦,效率到了一定程度之后也就无法再提高了!

多核多线程  ——厨师不再是一个而是多个,从而可以真正地使用多个灶台和厨具并行做菜,这种并行既可以是每个厨师做不同的菜,也可以是多个厨师同时做一个菜的多个小块,只要分块得当,这种方式效率高得多。

      进程的虚拟地址空间大小由CPU的字长决定,32位处理器能够寻址的进程虚拟地址空间为4GB,但是一个程序并不能使用所有的这4GB空间,这是因为进程的虚拟地址空间完全由OS kernel监管,程序只能访问进程地址空间中的合法区域,对于Linux来说,这个合法区域是0xC0000000以下的3GB空间,而对于Windows来说,合法区域则默认地只有2GB

计算机系统中能够被物理寻址的物理空间大小则由地址总线的宽度决定,Pentium Pro开始CPU的地址总线被拓宽到了36位=64GB,这就是所谓的PAE ( Physical Address Extension )方法。

当系统的物理内存大于一个进程的虚拟地址空间4GB的时候(虽然目前这种情况还并不多见),如果程序要使用多出来的内存,需要进行特殊的申请和映射,将4GB以外的物理内存一段段地映射到该进程虚拟地址空间的某个指定区域内(所谓的窗口),当然每次只能映射一段额外的物理内存——Windows系统中这就是所谓的AWE(Address Windowing Extension)地址访问方式,而Linux系统中则是通过mmap系统调用实现。

 

    现代计算机系统中的可执行程序载入都使用的是动态载入模式,即根据程序局部性原理,载入时只是将该程序最常用的部分载入物理内存,其他的部分留在磁盘中,需要时再进行载入,这就需要将程序进行模块化。典型的动态载入方式包括覆盖载入Overlay”页映射Paging”,二者的根本区别在于程序模块化的方式,Overlay方式把这个工作交给了程序员,而Paging方式则将这个工作交由编译器、链接器和OS来完成。

理论上说,可以直接将可执行文件进行分页,然后由OS存储管理器将这些文件页直接映射到物理内存中的物理页完成加载。这样的情况下,程序中就需要直接使用物理地址,而程序页每次加载到的物理页并不确定,所以这样每次程序页装入后都需要对程序中使用的物理地址进行重定位——MMU和虚拟地址空间的诞生则完全改变了程序加载的方式,进程的虚拟地址空间将程序空间和物理内存空间隔离开来,充当着物理内存空间和可执行文件空间之间的桥梁

       操作系统中创建一个进程,然后装载某个可执行文件并执行这个最常用的操作可以简单地由三步来完成:

1.       创建进程虚拟地址空间此处所谓的创建并不是创建空间,而是创建虚拟空间到物理内存空间的映射函数所需要的一系列的数据结构,对于Linux就是创建一个页目录结构即可,并不需要设置虚拟页到物理页的映射关系。
   
这一步将物理空间与虚拟空间关联起来;

2.       读取可执行文件头,建立进程虚拟地址空间和可执行文件的映射关系:这一步将可执行文件空间与虚拟空间关联起来,使得发生缺页错误时,OS能够知道到可执行文件中的哪个位置去找到所需要加载到物理内存的内容;这个映射关系保存在OS内部的一个数据结构中

3.       设置CPU的指令寄存器为可执行文件的入口地址,启动运行OS将控制权交给了进程

          完成上述三个步骤之后,其实OS仅仅只是将物理内存空间、可执行文件空间分别与进程虚拟地址空间关联起来,将可执行文件与VMS之间建立起了映射——即通常意义上所说的程序加载到了内存,实际上这里说的是程序完全加载到了虚拟内存——,但是代码和数据根本就没有加载到物理内存中,VMS与物理内存空间的映射关系其实也没有建立起来,这样程序一旦开始执行,将会立即出现缺页错误,即程序将要访问的VMS地址并没有映射到物理内存空间的某个page,此时OS会重新接管系统控制权,查询刚才保存的可执行文件到VMS映射关系的数据结构,找到所缺的虚拟页对应于可执行文件中的偏移,然后分配一个物理页,将可执行文件中的内容从磁盘读入到内存中,并将这个物理页与该虚拟页建立起映射,然后OS将控制权重新交给进程,程序继续执行。

      OS在看待进程虚拟地址空间时有两个角度:从物理内存的角度看,VMSPMS完全按照页映射的方式,即将VMS看作是固定大小的虚拟页(page)”的集合(page通常为4KB);

从可执行文件的角度看,EFSVMS的映射完全按照段映射的方式,虚拟空间中对应于ELF文件某一个段的区域称之为一个VMA,只是此处的这个中文字的含义在特定环境下对应于不同的概念。按照之前所描说的ELF可执行文件格式,指的是section,例如 .text .data .bss等,但是因为虚拟空间中的一个VMA可能由一个或多个虚拟page构成,并且VMA的对齐也是以一个页大小(例如4KB)为标准的,当一个section的大小取4KB的模很小的情况下,仍然要多占用一个page,最终由VMS映射到物理内存空间的时候会造成很大的内存浪费,尤其是大多数section其实是非常小的。同时,在OS进行EFSVMS映射的时候并不关心EFS中段的内容是数据还是代码,而关心的是段的读//执行等属性,所以ELF格式中引入了Segment的概念——ELF文件中的一个Segment是多个具有相同属性的section所组成的,OS在进行EFSVMS映射时也使用的是“Segment而非“Section,相应的VMS中的一个VMA也对应的是EFS中的一个Segment特别注意:VMASegment虽然对应,但是并不是完全一致,见后

       同是一个ELF文件,此段非彼段,section是为了编译/链接而产生的ELF划分方式,称之为“ELF的链接视图Segment则是为了加载而产生的ELF划分方式,称之为“ELF的执行视图。其实链接器在链接的过程中就已经完全考虑到了加载的需要,尽量将相同权限属性的section分配到相邻的空间以便形成Segment。使用 readelf -l 文件名 命令可以看到以Segment划分的ELF文件格式信息,而以 readelf -S 文件名 命令看到的则是以section来划分的ELF文件格式信息。

ELF文件中之前提到的段表(Section Header Table)类似,ELF文件中也有一个管理Segment的表,称之为程序头表(Program Header Table,其在ELF文件中的偏移量由ELF文件头结构体 Elf32_Ehdr 中的e_phoff 域表示。与段表和section的关系类似,程序头表也是Elf32_Phdr结构体数组,其中每个程序头(也就是segment)对应于一个Elf32_Phdr结构体:

p_type  —— segment的类型,elf.h中以PT_XX 的宏定义,其中类型为PT_LOADsegment为需要真正装载的;

p_offset—— segmentELF文件中的偏移量;

p_vaddr —— segmentVMS中的地址,即对应VMA的起始地址;

p_paddr —— segment的物理内存空间地址

p_filesz    —— segmentELF文件空间中占用的大小

p_memsz —— segmentVMS中占用的大小,通常大于p_filesz,这是为了.bss的需要,具体的做法是将比p_filesz多出来的VMS全部置0,这样就不用设立额外的.bss segment了,因为BSS段和数据段唯一不同的就是BSS段内容全部被初始化为0

p_flags —— segment的读R、写W、执行E属性;

p_align —— segmentVMS中的对齐要求,即2p_aligh幂次

        如前所说,VMS可以看作是page组成的,可以看作是VMA组成的,这其实也是对同一个虚拟地址空间的两种划分方法,这其实也就是之前在读其他书籍时看到的模糊不清的虚拟地址空间的段页管理机制,分段是针对EFS?VMS映射而言的,分页则是针对VMS?PMS而言的

    Os kernel使用VMA划分来管理进程的虚拟地址空间。典型的进程包括代码:

1.     VMARE属性,有映像文件)

2.     数据VMARWE属性,有映像文件)

3.     VMARWE属性,无映像文件,向上扩展)

4.     VMARW属性,无映像文件,向下扩展)

Linux系统中,不同进程虚拟地址空间视图可以通过 cat /proc/进程号/maps 来查看,其中每一行描述的就是一个VMA的信息,几乎在每一个进程的VMS视图中都可以看见[heap][stack]这两个VMA,但是这两个VMA在可执行文件中都没有对应的segment存在,所以它们被称之为匿名VMAmalloc()库函数就是从堆VMA中分配空间

        前面提到过虚拟空间中的一个VMA和可执行文件中的一个segment并不是完全一致地对应。VMS?EFS的映射是由操作系统内核负责的,自然VMA也是完全由操作系统管理的。Linux操作系统规定VMA要么映射到可执行文件的某个区域,要么完全不映射到任何文件(例如堆、栈这样的匿名VMA);但是ELF文件中包含.bss SHT_NOBITS类型sectionsegment中,那些SHT_NOBITS类型的section并没有实际的文件空间,所以这种情况下,VMAsegment就有所不同了。

        所谓的装载时的地址对齐是指一段物理内存于VMS建立映射关系的时候,这段空间长度必须为4096的整数倍,并且在PMSVMS中的起始地址也都必须是0x1000的整数倍。为了满足这样的对齐要求,如果将可执行文件中的每个segment分别单独映射到VMS中的一个VMA,每个VMA起始地址和长度均为0x1000的整数倍,则将可能形成很大的内存碎片(有效字节利用率很低的page——注意,此处虽然浪费的似乎是虚拟内存,但是这些利用率很低的虚拟page最终将可能被映射到物理内存空间,这样就成为了真正的内存碎片。

UNIX系统中均采用两次映射的方法来解决上述问题。两次映射的原理是:逻辑上将ELF可执行文件分为多个页块(每页4096字节),实际映射时,从ELF文件头处开始仍然以segment?VMA的方式进行,只是在碰到相邻segment接壤处并非页大小整数倍的时候,将两个相邻segment共用的那个逻辑页分别映射到VMS中相邻的两个虚拟页中,这样一来,进程虚拟地址空间中,与ELF文件中类型为PHT_LOADsegment对应的VMA的起始地址就不再是页长0x1000的整数倍了,并且这样本来在ELF中相邻两个segment对应的两个VMA在虚拟空间中也不相邻了,中间存在着一个虚拟页大小0x1000的区域。然后在进行VMS?PMS映射的时候,这样的两个相邻虚拟页映射到同一个物理页,这样一个物理页中就不再有为了满足对齐要求而存在的无效字节了。

        计算一个ELF可执行文件中可装载segmentPHT_LOAD类型的segment)在虚拟地址空间中的起始地址时(即对应VMA的起始地址)时,遵循如下公式:

p_vaddr % p_align = p_offset % p_align

对公式的理解为:因为将ELF文件头也映射到了虚拟地址空间中,两次映射使得VMS中多映射出来的虚拟空间大小恒为p_align的整数倍,所以任何一个segment在可执行文件中的偏移量p_vaddr p_offset N p_align,故而上述公式成立,以上例子描述中p_align为页长。

        一个程序在开始运行之前,操作系统会将当前的环境变量(例如Linux下的HOMEPATH)和程序运行所需参数(例如main()函数的argcargv)提前保存到该进程虚拟地址空间中的栈VMA中,这就是所谓的进程栈初始化工作,程序启动后,系统库函数会将这些栈中的参数信息传递给main函数。

        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程序入口地址,程序开始执行,装载完成。

        Windows系统的PE可执行文件的装载与ELF文件有所不同,PE文件中段的数量比ELF少得多,一般就只有程序段、数据段、BSS段等几个,所以Windows系统直接采用section(而不再像Linux使用Segment)映射到VMS中的虚拟段VSWindows不是称为VMA),并且PE需要加载的sectionVS的起始地址都扩展为页长的整数倍,这样Windows下加载PELinux加载ELF相对容易许多。

PE文件中引入了相对虚拟地址RVA的概念,这其实就相当于ELF中的文件偏移量,每一次PE文件在虚拟地址空间中都有一个不同的装载地址,但是文件中所有的RVA引用都不会变。

阅读(2404) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~