程序员的自我修养 第6章 装载和动态链接

可执行文件只有在装载进内存之后才能被CPU执行。
程序是一些预先编译好的指令和数据集合的一个文件,是一个静态的概念。
进程是程序运行的一个过程,是一个动态的概念。

每一个程序都有自己独立的虚拟地址空间。这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说就是由CPU的位数决定的。
在这里插入图片描述
PAE(physical address extension)
Intel的地址总线从原先的32为扩展到36位地址,并且修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存,可以访问高达64G的物理内存。Intel把这个地址扩展方式叫做PAE。
windows下这方访问内存的方式叫AWE(address windowing extensions)
在应用程序中,只有32位的虚拟地址空间,应用程序如何使用大于常规的内存空间呢?一个常见的方法就是操作系统提供一个窗口映射的方法,把这些额外的内存映射到进程地址空间中来。应用程序可以根据需要来选择申请和映射。
在windows下这种访问内存的方式叫AWE(address windowing extensions)而linux等UNIX系统采用mmap系统调用来实现的。

装载方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行。最简单的方式就是将程序运行所需要的数据和指令全部装入。但是很多情况下程序运行所需要的内存比实际物理内存要大。
覆盖装入和页映射是两种很典型的动态装载方法。都是利用了程序的局部性原理。动态装入的思想就是程序用到哪个模块就将哪个模块装入内存,如果不用就暂时不用装入,存放在磁盘中。

覆盖装入
覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。
覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个辅助代码来管理这些模块何时应该驻留内存,何时应该被替换掉。这个辅助代码就是所谓的覆盖管理器。
在多个模块的情况下,程序员需要手工将模块按照他们之间的调用依赖关系组织成树状结构。这个树状结构中的从任何一个模块到树根都叫调用路径。而且禁止跨树间调用。
在这里插入图片描述
页映射
页映射是将内存和所有磁盘中的数据和指令按照页为单位活粉成若干个页,以后所有的装载和操作的单位就是页。
在这里插入图片描述
现代操作系统都提供了装载管理器的功能,也就是存储管理器。

从操作系统的角度看可执行文件的装载
进程的建立,创建一个进程,然后装载相应的可执行文件并且执行,在有虚拟存储的情况下,主要做的事情如下:

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

创建虚拟地址空间
一个虚拟地址空间是由一组页映射函数将虚拟空间的各个页映射到相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应的数据结构。在i386 linux下,创建虚拟地址空间实际上只是分配了一个页目录就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。

读取可执行文件头,并且建立虚拟地址空间与可执行文件的映射关系
这一步所做是虚拟地址空间与可执行文件的映射关系。当程序执行发生页错误的时候,操作系统将从物理内存中分配一个物理页,然后将该缺页从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常的运行。但是有一点就是,操作系统捕获到缺页错误的时候,他应该知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。这一步就是整个装载过程最重要的一步。

在这里插入图片描述
这种映射关系只是保存在操作系统中的一个数据结构,linux中将进程虚拟空间中的一个段叫做虚拟内存区域VMA,在windows中叫做虚拟段。
当程序执行发生段错误的时候,他就可以通过查找专业那个的一个数据结构来定位错误也在可执行文件中的位置。

将CPU指令寄存器设置成可执行文件的文件入口,启动运行。
操作系统通过设置CPU的指令寄存器将控制权交给进程。由此进程开始执行。

页错误
通过上面的步骤执行完以后,可执行文件的真正指令和数据并没有被装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。当CPU开始打算执行一个地址的指令时,发现对应的页面是一个空页面,就会认为这是一个页错误。CPU会将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。这是用就需要用到前面提到的可执行文件和虚存之间的映射结构。操作系统会查询这个数据结构,然后知道空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中的该虚拟页与分配的物理页之间建立映射关系,然后把控制权交给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误不断的发生,操作系统也会为集成分配相应的物理页来满足进程执行的需求。当进程所需要的内存超过可用的内存数量时,特别是多个进程在同时执行的饿时候,这时操作系统就需要精心组织和分配物理内存,甚至有时候很将分配给进程的物理内存暂时回收。
在这里插入图片描述
进程虚存空间的分布
因为ELF文件被映射的时候,是以系统页长度为单位进行喷配的,每一个段在映射时的长度都是系统也长度的整数倍。当可执行文件中的段数量很多时候,就会产生内存空间浪费的问题。
站在操作系统的角度装载可执行文件的角度,实际上操作系统并无关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,主要的是就是段的权限(可读、可写、可执行)。
ELF文件中,段的权限往往只有为数不多的几种组合。

  • 以代码段为代表,可读可执行
  • 以数据段和BSS段为代表,可读可写
  • 为只读数据段为代表,只读

对于相同权限的段,把它们合并到一起当做一个段进行映射。
ELF可执行文件引入Segment的概念。一个segment包含了一个或者多个属性相似的section。
在这里插入图片描述
在装载的时候按照segment整体一起映射,就是说映射以后进程虚存空间只有一个相对应的VMA,这样就可以棉线的减少页面内部碎片,节省了内存空间。

segment概念实际上是从装载的角度重新划分ELF的各个段。在将目标文件链接成可执行文件的时候,链接器就会尽量把相同权限的属性的段分配到同一个空间。在ELF中把这些属性相似的,连在一起的段叫做一个segment,而系统正式按照segment进行映射可执行文件的。

举例
在这里插入图片描述
gcc -static sectionmapping.c -o sectionmapping.elf
readelf -S sectionmapping.elf 查看各个段
在这里插入图片描述
可以看到可执行文件中有33个段。
readelf -l sectionmaping.elf 查看各个segment
在这里插入图片描述
就可以看到可执行文件只有5个segment。从装载的角度来看,只需要关系LOAD类型的segment,因为只有它是需要被映射的。其他的segment只是在装载的时候起辅助作用。
在这里插入图片描述
从上图可以看出,执行文件重新划分为三个部分,有一些段被归入可读可执行,统一映射到VMA0,另一部分是可读可写的,被映射到VMA1,还有一部分是程序装载时候没有被映射的,他们就是一些包含调试信息和字符串表的段,这些在程序执行的时候没有用,不需要映射。

所以总的来说,segment和section是从不同角度来划分ELF文件的。这称为ELF的不同视图。
从section的角度看ELF是链接视图。
从segment角度看ELF是执行视图。

ELF可执行文件中一个专门的数据结构叫做程序头表。用来保存segment信息。
在这里插入图片描述
如果p_memsz大于p_filesz,也即是segment在内存中分配的空间超过文件中实际的大小,多余的部分全部填充为0。这样做的好处就是在构造ELF可执行文件的时候就不要设置BSS的segment了,可以把segment的p_memsz扩大,那些额外的部分就是BSS。所以我们在前面只看到两个LOAD类型的segment,而不是三个。

堆和栈
在操作系统中,VMA除了被用来映射可执行文件中的各个segment以外,操作系统还可以通过VMAlain对进程的地址空间进行管理。进程在执行时用到的堆和栈也是以VMA的形式存在的。很多情况下,一个称重的栈和堆都有一个对应的VMA。
在这里插入图片描述
上面输出第一列是VMA的地址范围,第二列是VMA的权限,r(读)w(写)x(可执行)p(私有)s(共享),第三列是偏移,表示VMA对应的segment在映象文件中的偏移。第四列是映象文件所在的设备的主设备号与次设备号。第五列表示映象文件的节点号,第六列是映象文件的路径。

我们看到集成中有5个VMA,前两个映射到可执行文件中的两个segment。另外三个段主设备号和次设备号都是0,表示他们没有映射到文件中,这种VMA叫做匿名虚拟内存区域。有两个区域是heap和stack,这两个VMA在所有的进程中几乎都存在。栈一般也叫堆栈,每一个线程都有自己的堆栈,对于单线程来说,这个VMA堆栈就全部归自己使用。还有一个VMA叫vdso,地址位于内核空间,是一个内核模块,进程可以通过访问这个VMA跟内核进行通信。

综上,操作系统通过给进程空间或分出了一个个的VMA来管理进程空间的虚拟空间,基本原则就是将相同权限属性的、有相同映象文件的映射成一个VMA,一个进程分为如下几种VMA区域。

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

在这里插入图片描述
堆的最大申请数量
linux下虚拟地址空间分配为进程本身使用的是3G. 使用测试程序在linux只能2.9G,windows上只能1.5G左右。

段地址对齐
按照segment进行段对齐,还是会产生内存碎片。
在这里插入图片描述
Unix采用了取巧的办法,即让那些各个段接壤的部分共享一个物理页面,然后该物理页面分别映射两次。
在这里插入图片描述
如上图,对于SEG0和SEG1接壤的部分的那个物理页,系统将他们映射两份虚拟地址空间,一分为SEG0,一份为SEG1,其他的也都按照正常的业粒度进行映射。Unix系统也将ELF文件头看成一个段。也将其映射到进程的地址空间,这样做好处是进程中的某段区域就是整个ELF文件的映象,对于一些访问ELF文件头的操作,可以直接通过读写内存地址空间进行。
根据上面的段对齐方案,有一个规律,在ELF文件中,对于任何一个可装载的segment,它的p_vaddr除以对齐属性的余数等于p_offset除以对齐属性的余数。

进程栈初始化
进程刚刚开始的时候,需要知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。最常见的一个做法就是操作系统在进程启动前将这些信息保存到进程的虚拟空间的栈中。
例如,环境变量 HOME=/home/user, PATH=/usr/bin
运行prog 123
在这里插入图片描述
栈顶寄存器esp指向的位置是初始化以后堆栈的顶部。最前面的四个字节是命令行参数的数量。紧接着就是分别指向这两个参数的字符串指针,后面跟一个0.接着是指向环境变量字符串的指针,后面跟一个0结尾。
进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main函数,也就是我们所熟知的main的argc argv参数。

linux内核装载ELF过程简介

当linux系统在bash下输入一个命令执行ELF的时候。
首先在用户层面,bash进程会调用fork系统调用创建一个新的进程,然后新的进程调用execve系统调用执行指定的ELF文件。原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
在进入execve系统调用之后,linux内核就开始进行了真正的装载工作。在内核中,execve系统调用的入口函数是sys_execve,sys_execve进行一些参数的检查和复制之后,调用do_execve,do_execve首先查抄被执行的文件,如果找到文件,则读取文件的前128个字节。读128字节是为了文件的格式、然后调用search_binary_handler去搜索和匹配合适的可执行文件装载处理过程。linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handler会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。ELF客户自行文件的装载处理过程叫做load_elf_binary。load_elf_binary主要的步骤如下:

  • 检查ELF可执行文件格式的有效性,比如魔数、程序头表中的段的数量
  • 寻找动态链接的.interp段,设置动态链接器路径
  • 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射
  • 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是什么
  • 将系统调用的返回地址修改成ELF可执行文件的入口点。

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

windows PE的装载

对于PE文件,链接器在生产可执行文件的时候,往往将所有的段尽可能的合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的段。
对于PE装载,引入了一个RVA(relative virtual address)的概念,表示一个相对虚拟地址,是相对于PE文件的装载基地址的一个偏移地址。每个PE文件在装载时都会有一个装载目标地址,这个地址就是所谓的基地址。

装载一个PE可执行文件的过程如下:

  • 先读取文件的第一个页,在这个页中,包含了DOS头、PE文件头和段表
  • 检查进程地址空间中,目标地址是否可用,如果不可用,则另选一个装载地址。
  • 使用段表中的提供的信息,将PE文件中所有的段一一映射到地址空间中的相应位置。
  • 如果装载地址不是目的地址,则进行rebasing。
  • 装载所有PE文件所需要的dll文件
  • 对PE文件中的所有导入符号进行解析
  • 根据PE头中指定的参数,建立初始化堆和栈
  • 建立主线程并且启动进程

PE文件中,与装载相关的主要信息都包含在PE扩展头中和段表中。
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sundaygeek

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值