《程序员的自我修养》读书笔记3

来源:互联网 发布:ajax 数据undefined 编辑:程序博客网 时间:2024/06/03 02:11

一、可执行文件的装载

1、可执行文件动态装入的方法

       程序运行时是有局部性原理的,所以可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。

       动态状态的两种典型方法:覆盖装入(Overlay) 和页映射(Paging);

       覆盖装载在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了,基本方法是:把挖掘内存潜力的任务交给程序员,程序员在编写程序时必须手工将程序分割成若干块,然后编写一个小的辅助代码(覆盖管理器(Overlay Manager))来管理这些模块何时应该驻留内存而何时应该被替换掉。

       页映射是随着虚拟存储的发明而诞生的,做法是将内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页,动态地装载需要的页;

       注:以目前的情况,硬件规定的页的大小有4096字节、8192字节、2MB、4MB等,最常见的Intel IA32处理器一般都使用4096字节的页。

      至于怎样动态地选择加载的页,这个是现代操作系统中的存储管理器的任务之一。


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

          总体上讲:从操作系统角度看,可执行文件的状态过程是:先创建一个进程,然后装载相应的可执行文件并且执行。

          (1)、进程的建立需要做三件事情:

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

           一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,创建一个虚拟空间实际上时创建映射函数所需要的相应的数据结构;这一步的页映射函数是虚拟空间到物理内存的映射关系。

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

           这一步所做的是虚拟空间与可执行文件的映射关系,只有确定了虚拟空间与可执行文件之间的映射关系,这样当程序发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该缺页从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。

       注:由于可执行文件在装载时实际上时被映射的虚拟空间,所以可执行文件很多时候又被叫做“映像文件(Image)”。


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

         这一步是最简单的,操作系统通过设置CPU的指令寄存器将控制权交给进程,由进程开始执行。从进程角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。


        (2)、页错误

          上述步骤完成后,其实可执行文件的真正指令和数据都没有被装入到内存中,操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚拟内存之间的映射关系而已。程序从入口地址开始执行时,当CPU开始打算执行这个地址的指令时,会发现页面是空页面,于是它认为这是一个页错误(Page Fault),操作系统会接着处理页错误,随着进程的执行,页错误也会不断产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需要。

         页错误的处理过程是,当发生页错误时,操作系统将查询一个数据结构(虚拟内存与可执行文件之间的映射关系),找到空页面所在的VMA( 虚拟内存区域 ),计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给程序,进程从刚才的页错误的位置重新开始执行。


 3、进程虚拟内存空间的分布

(1)、ELF文件链接视图和执行视图 

        操作系统在装载可执行文件时,不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。在映射时,对于相同权限的段,把它们合并到一起当做一个段进行映射。

        ELF文件引入了一个概念“Segment”,一个“Segment”包含一个或多个属性类似的“Section”。一个Segment被看作一个映射的整体,对应一个VMA,这样可以很明显地减少页面内部的碎片,从而节省了内存空间。

        描述链接时的段“Section”属性的结构叫做段表,而描述ELF到虚存的映射的结构的段“Segment”的结构叫做程序头(Program Header )。

        总体来说,“Segment” 和 “Section ”是从不同的角度来划分同一个ELF文件,这个在ELF中被称为不同的视图(View)。“Section”描述的是ELF文件的链接视图(Linking  View),“Segment”描述的是执行视图(Execution View)。

        程序头(Program Header)描述了“Segment”的结构,ELF可执行文件中有一个专门的数据结构叫做“程序头表(Program header Table)”用来保存“Segment”的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。程序头表是一个结构体数组,结构体如下:

     typedef struct {

             Elf32_Word     p_type;         //“Segment”的类型

             Elf32_Off          p_offset;      //“Segment”在文件中的偏移

             Elf32_Addr       p_vaddr;      //“Segment”的第一个字节在进程虚拟地址空间的起始位置

             Elf32_Addr       p_paddr;     //“Segment”的物理装载地址,即LMA(Load Memory Address )

             Elf32_Word      p_filesz;      //"Segment"在ELF文件中所占空间的长度

             Elf32_Word      p_memsz; //"Segment"早进程虚拟地址空间中所占的长度

             Eld32_Word     p_flags;     //"Segment"的权限属性

             Elf32_Word       p_align;    //"Segment"的对齐属性,实际对齐字节等于2的p_align次。

     } Elf32_Phdr;

 (2)、堆和栈

         进程执行时需要用到栈(Stack)、堆(Heap)等空间。一个进程中的栈 和堆分别都有一个对应的VMA。

        堆和栈都是匿名虚拟内存区域(Anonymous Virtual Memory Area)。这些区域没有映射到文件中。

       注:进程中有一个很特殊的VMA叫做“vdso”,它的地址已经位于内核空间了,事实上它是一个内核的模块,进程可以通过访问这个VMA来跟内核进行通信。

    (3)、通过以下方式可以计算堆的最大申请数量

     

#include <stdio.h>#include <stdlib.h>unsigned maximum = 0;int main( int argc, char *argv[] ){    unsigned blocksize[] = { 1024 * 1024, 1024, 1};    int i, count;    for( i=0; i<3; i++ )    {        for( count = 1; ; count++)        {            void *block = malloc( maximum + blocksize[i] * count );            if( block )            {                maximum = maximum + blocksize[i] * count;                free( block );            }            else            {                break;            }        }    }    printf( "maximum malloc size = %u bytes\n", maximum );}

      malloc的最大申请数量会受到 很多因素的影响:具体数值会受到操作系统版本、程序本身的大小、用到的动态/共享库数量和大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。

 

    (4)、段地址对齐

      页映射机制中,页是映射的最小单位,将物理内存和进程虚拟地址空间之间建立映射关系时,这段内存空间的长度必须是页大小的整数倍,并且这段空间在物理内存和集成虚拟地址空间中的起始地址必须是页大小的整数倍。为了节省空间,可执行文件在页映射机制中,要优化自己的空间和地址的安排。

       最简单的映射方法是将每个段分开映射,对于长度不足一个页的部分则占一个页。但是这种映射方法的空间使用率较低;

       UNIX系统采用的方法是:让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次,例如SEG0和SEG1的接壤部分的那个物理页,系统会将它们映射两份到虚拟地址空间,一份为SEG0,另一份是SEG1,其他的页都按照正常的页粒度进行映射。

       因为段地址对齐的关系,各个段的虚拟地址就往往不是系统页面长度的整数倍了。

     (5)、进程栈初始化

       进程刚启动时,要知道一些进程运行的环境,最基本的信息就是系统环境变量和进程的运行参数。最常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。

        我们知道,栈是向下生长的(即向低地址方向生长),在linux中首先压入堆栈的是环境变量字符串、然后是环境变量指针、命令行参数指针、命令行参数数量。栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,此时栈顶存放的是命令行参数的数量和命令行参数,这两个数值就是最终传递给main()函数的 argc(命令行参数数量)和argv (命令行参数字符串指针数组)两个参数。

      (6)、对进程虚拟地址空间的总结

       操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以划分为如下几种VMA区域:

       ** 代码VMA, 权限是 只读、可执行;有映像文件。

       ** 数据VMA, 权限是 可读写、可执行; 有映像文件。

       ** 堆VMA, 权限是可读写、可执行;无映像文件,匿名,可向上扩展。

       ** 栈VMA, 权限是可读写、不可执行;无映像文件,匿名,可向下扩展。

4、Linux内核装载ELF过程简介

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

       execve()的原型如下:
       int  execve( const char* filename, char* const argv[], char *const envp[] );

       三个参数分别是被执行的程序文件名、执行参数和环境变量。Glibc对execvp()系统调用进行了包装,提供了execl()、execlp()、execle()、execvp()等5个不同形式的exec系列API。

       一个使用fork()和execlp()简单实现的minibash例子:

#include <stdio.h>#include <sys/types.h>#include <unistd.h>int main(){char buf[1024] = { 0 };pid_t pid;while(1){printf( "minibash$" );scanf( "%s", buf );pid = fork();if( pid == 0 ){if( execlp( buf, 0) < 0){printf("exec error \n");}else if( pid > 0 ){int status;waitpid( pid, &status, 0);}else{printf( "fork error %d\n", pid );}}}return 0;}

       下面分析进入execve()系统调用之后,linux内核的动作:

      进入execve()系统调用之后,linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节来判断文件的格式,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF可执行文件的装载处理过程叫做load_elf_binary(),a .out可执行文件的装载处理过程叫做load_aout_binary(),可执行脚本程序的处理过程叫做load_script()。

       下面看一下load_elf_binary()的主要步骤:

      (1)、检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量;

       (2)、寻找动态链接的“.interp”段,设置动态连接器路径;

        (3)、根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据;

         (4)、初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址;

          (5)、将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。

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


5、Windows PE的装载过程

       PE文件的装载跟ELF所有不同,PE文件中,所有段的起始地址都是页的倍数;段的长度如果不是页的整数倍,那么在映射时向上补齐到页的整数倍。由于这个特点,PE文件的映射过程会比ELF简单得多,因为它无需考虑如ELF里面的诸多段地址对齐之类的问题。而且,对于PE文件而言,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的几个段,所以与ELF的段地址对齐相比,并不会浪费很多磁盘和内存空间。

       首先,介绍几个PE装载相关的几个概念:

       RVA( Relative Virtual Address),它表示一个相对虚拟地址。它是相对于PE文件的装载基地址的一个偏移地址。比如,一个PE文件被装载到虚拟地址(VA)0x00400000,那么一个RVA为0x1000的地址就是0x00401000;

       装载目标地址(Target Address),每个PE文件在装载时都会有一个装载目标地址,这个地址就是所谓的基地址(Base Address)。基地址并不固定,每次装载时都可能会变化。PE文件中使用的绝对地址,会随着基地址的变化而变化。


      PE的装载过程:

     (1)、先读取文件的第一个页,在这个页中,包含了DOS头、PE文件头和段表;

      (2)、检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。对于可执行文件来说此时装入的是进程的第一个可装入模块,所以目标地址不太可能被占用,而DLL文件的装载,可能会用目标地址被占用的情况,所以要另外选一个装载地址。

        (3)、使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置;

          (4)、如果装载地址不是目标地址,则进行Rebasing。

            (5)、装载所有PE文件所需要的DLL文件。

               (6)、对PE文件中的所有导入符号进行解析。

                 (7)、根据PE头中指定的参数,建立初始化栈和堆;

                   (8)、建立主线程并且启动进程。


注:PE文件中,与装载相关的主要信息都包含在PE扩展头(PE Optional Header)和段表,与装载相关的成员的含义如下:

        Image Base : PE文件的优先装载地址,对可执行文件来说,它一般是0x00400000,对于DLL来说它一般是0x10000000;

        AddressOfEntryPoint : PE装载器准备运行的PE文件的第一个指令的RVA。

        SectionAlignment:    内存中段对齐的粒度,默认情况下一般是系统页的大小。

        FileAlignment:      文件中段对齐的粒度,这个值必须是2的指数倍,从512到64KB,默认一般是512字节。

        MajorSubsystemVersion /MinorSubsystemVersion:程序运行所需要的Win32子系统版本。

        SizeOfImage:       内存中整个PE映像体的大小,它是所有头和节经过对齐处理后的大小。

        SizeOfHeaders :  所有头 + 节表的大小,也就是等毒文件尺寸减去文件中所有节的尺寸,可以以此值作为PE文件第一节的文件偏移量。

        Subsystem :         NT用来识别PE文件属于哪个子系统,对于大多数Win32程序,只有两类值:Windows GUI 和Windows CUI(控制台)。

        SizeOfCode:         代码段的长度

        SizeOfInitializedData:     初始化了的数据段长度

        SizeOfUninitializedData :     未初始化的数据段长度

        BaseOfCode:            代码段起始RVA

        BaseOfData:             数据段起始RVA


0 0
原创粉丝点击