Linux可执行文件的装载与进程1

程序和进程有什么区别

程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件,进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时(Runtime)也有一定的含义。有人做过一个很有意思的比喻,说把程序和进程的概念跟做菜相比较的话,那么程序就是菜谱,计算机的 CPU就是人,相关的厨具则是计算机的其他硬件,整个炒菜的过程就是一个进程。计算机按照程序的指示把输入数据加工成输出数据,就好像菜谱指导着人把原料做成美味可口的菜肴。从这个比喻中我们还可以扩大到更大范围,比如一个程序能在两个 CPU 上执行等。

虚拟地址空间

我们知道每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(VirtualAddressSpace),这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由 CPU 的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如 32 位的硬件平台决定了虚拟地址空间的地址为 0 到 232 - 1,即0x00000000~0xFFFFFFFF,也就是我们常说的4GB 虚拟空间大小而 64 位的硬平具有 64 位,它的拟地间达到了2字节,即 0x0000000000000000~0xFFFFFFFFFFFFFFFF,总共 17 179 869 184 GB,这个寻能力从现在来看,几乎是无限的,但是历史总是会嘲弄人,或许有一天我们会觉得 64 位的地址空间很小,就像我们现在觉得 32 位地址不够用一样。当人们第一次推出 32 位处理器的时候,很多人都在疑惑4 GB 这么大的地址空间有什么用。

我们在下文中以32 位的地址空间为主,64 位的与32 位类似。
那么32 位平台下的 4GB 虚拟空间,我们的程序是否可以任使用呢?很遗憾,不行因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。我们经常在 Windows 下到今人讨的“进程因非法操作需要关闭”或 Linux下的“Segmentation fault”很多时候是因为进程访问了米经允许的地址。
那么到底这 4 GB 的进程虚拟地址空间是怎样的分配状态呢?首先以 Linux 操作系统作为例子,默认情况下,Linux 操作系统将进程的虚拟地址空间做了如图 6-1 所示的分配。
在这里插入图片描述
整个4GB 被划分成两部分,其中操作系统本身用去了一部分:从地址0xC00000000 到0xFFFFFFFF,共1GB。剩下的从0x00000000 地址开始到 0xBFFFFFFF 共3GB 的空间都是留给进程使用的。那么从原则上讲,我们的进程最多可以使用 3 GB 的虚拟空间,也就是说整个进程在执行的时候,所有的代码、数据包括通过 C语言 malloc0)等方法申请的虚拟空间之和不可以超过3 GB。

装载的方式

程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的办法。

但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存。相对于磁盘来说,内存是昂贵且稀有的,这种情况自计算机磁盘诞生以来一直如此。所以人们想尽各种办法,希望能够在不添加内存的情况下让更多的程序运行起来,尽可能有效地利用内存。后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。

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

覆盖装入

覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。

页映射

页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。前面我们已经介绍了页映射的基本原理,这里我们再结合可执行文件的装载来阐述一下页映射是如何被应用到动态装载中去的。与覆盖装入的原理相似,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照“贞 (Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。

以目前的情况,硬件规定的页的人小有 4 096 字节、8 192 字节、2 MB、4 MB 等,最常见的 Intel IA32 处理器一般都使用 4 096 字节的页,那么512 MB的物理内存就拥有 512*1024 * 1024/4 096=131072个页。

页映射的基本机制

为了演示页映射的基本机制,假设我们的32位机器有16 KB 的内存,每个页大小为4 096字节,则共有 4 个页,如表 6-1 所示。
在这里插入图片描述

假设程序所有的指令和数据总和为 32 KB,那么程序总共被分为8 个页我们将它们编号为 P0~P7。很明显,16 KB 的内存无法同时将 32 KB 的程序装入,那么我们将按照动态装入的原理来进行整个装入过程。如果程序刚开始执行时的入口地址在 P0,这时装载管理器(我们假设装载过程由一个叫装载管理器的家伙来控制,就像覆盖管理器–样)发现程序的 P0 不在内存中,于是将内存 F0 分配给 P0,并且将 P0 的内容装入 F0; 运行一段时间以后,程序需要用到 P5,于是装载管理器将 P5 装入 F1;就这样,当程序用到 P3 和 P6 的时候,它们分别被装入到了 F2 和 F3,它们的映射关系如图 6-4 所示。
在这里插入图片描述
这个所谓的装载管理器就是现代的操作系统,更加准确地讲就是操作系统的存储管理器。目前几乎所有的主流操作系统都是按照这种方式装载可执行文件的,我们熟悉的 Windows 对 PE 文件的装及 Linux对 ELF 文件的装都是这样完成的。

进程的建立

事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

1. 创建一个独立的虚拟地址空间。

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

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

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。

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

让我们考虑最简单的情况,假设我们的 ELF 可执行文件只有一个代码段“text“,它的虚拟地址为 0x08048000,它在文件中的大小为 0x000e1,对齐为 0x000。由于虚拟存储的页映射都是以页为单位的,在 32 位的 Intel IA32 下一为 4 096 字节,所以32 位 ELF的对齐粒度为 0x1000。由于该text 段大小不到一个页,考虑到对齐该段占用一个段。所以~“旦该可执行文件被装载,可执行文件与执行该可执行文件进程的虚拟空间的映射关系如图 6-5 所示。
在这里插入图片描述

很明显,这种映射关系只是保存在操作系统内部的一个数据结构。
Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域 (VMA, Virtual Memory Area);
在 Windows 中将这个叫做虚拟段(Virtual Section),其实它们都是同一个概念。比如上例中,操作系统创建进程后,会在进程相应的数据结构中设置有一个text 段的 VMA:它在虚拟空间中的地址为0x08048000~0x08049000,它对应 ELF 文件中偏移为0的text,它的属性为只读(一般代码段都是只读的),还有一些其他的属性。

VMA是一个很重要的概念,它对于我们理解程序的装载执行和操作系统如何管理进程的虚拟空间有非常重要的帮助。

上面的例子中,我们描述的是最简单的只有一个段的可执行文件映射的情况。操作系统在内部保存这种结构,很明显是因为当程序执行发生段错误时,它可以通过查找这样的一个数据结构来定位错误页在可执行文件中的位置。

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

将 CPU 指令寄存器设置成可执行文件入口,启动运行。第三步其实也是最简单的一步,操作系统通过设置 CPU 的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU 运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。
还记得 ELF 文件头中保存有入口地址吗?没错,就是这个地址。

页错误

上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。

CPU 将控制权交给操作系统

假设在上面的例子中,程序的入口地址为 0x08048000,即刚好是text 段的起始地址。当CPU开始打算执行这个地址的指令时,发现页面 0x08048000~0x08049000 是个空页面,于是它就认为这是一个页错误(Page Fault)。CPU 将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。

进程从刚才页错误的位置重新开始执行

这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的 VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理贞之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。

操作系统也会为进程分配相应的物理页面来满足进程执行的需求

随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如图 6-6 所示。当然有可能进程所需要的内存会超过可用的内存数量,特别是在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回等,这就涉及了操作系统的虚拟存储管理。这里不再展开,有兴趣的读者可以参考相应的操作系统方面的资料。
在这里插入图片描述

进程虚存空间分布

对ELF文件没有概念的话可以参考这篇博文:ELF

ELF文件链接视图和执行视图

前面例子的可执行文件中只有一个代码段,所以它被操作系统装载至进程地址空间之后,相对应的只有一个VMA。不过实际情况会比这复杂得多,在一个正常的进程中,可执行文件中包含的往往不止代码段,还有数据段、BSS 等,所以映射到进程虚拟空间的往往不止一个段。
当段的数量增多时,就会产生空间浪费的问题。因为我们知道,ELF 文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。一个 ELF 文件中往往有十几个段,那么内存空间的浪费是可想而知的。有没有办法尽量减少这种内存浪费呢?

当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。ELF 文件中,段的权限往往只有为数不多的几种组合,基本上是三种:

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

那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射。比如有两个段分别叫“.text”和“.init”,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设text 为 4 097 字节,.init 为 512 字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成起映射的话只须占用两个页面,如图 6-7 所示。
在这里插入图片描述
ELF 可执行文件引入了一个概念叫做“Segment”,一个“Segment”包含一个或多个属性类似的“Section”。正如我们上面的例子中看到的,如果将“text”段和“.init”段合并在起看作是一个“Segment”,那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的 VMA,而不是两个,这样做的好处是可以很明显地减少页面内部碎片,从而节省了内存空间。

在这里插入图片描述

堆和栈

在操作系统里面,VMA 除了被用来映射可执行文件中的各个“Segment”以外,它还可以有其他的作用,操作系统通过使用 VMA 来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到栈(Stack)、堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以 VMA 的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。

堆的最大申请数量

Linux 下虚拟地址空间分给进程本身的是3GB(Windows 默认是2GB),那么程序真正可以用到的有多少呢?我们知道,一般程序中使用 malloc()函数进行地址空间的申请,那么malloc()到底最大可以申请多少内存呢?
在我的 Linux机器上运行上面这个程序的结果大概是2.9 GB左右的空间,
在Windows下运行这个程序的结果大概是 1.5 GB。
那malloc 的最大申请数会受到哪些因素的影响呢?
实际上,具体的数值会受到操作系统版本、程序本身大小、用到的动态/享库数量、大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。

段地址对齐

可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。对于 Intel 80x86 系列处理器来说默认的页大小为 4 096 字节,也就是说,我们要映射将一段物理内存和进程拟地址空间之间建立映射关系,这段内存空间的长度必须是 4096 的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是 4096 的整数倍。由于有着长度和起始地址的限制对于可执行文件来说,它应该尽量地优化自己的空间和地址的安排,以节省空间。我们就拿下面这个例子来看看,可执行文件在页映射机制中如何节省空间。

假设我们有一个 ELF 可执行文件,它有三个段 (Segment) 需要装载,我们将它们命名为 SEGO、SEG1 和 SEG2。
每个段的长度、在文件中的偏移如表 6-3 所示。
在这里插入图片描述
这是很常见的一种情况,就是每个段的长度都不是页长度的整数倍,一种最简单的映射办法就是每个段分开映射,对于长度不足·个页的部分则占一个页。通常 ELF 可执行文件的起始虚拟地址为 0x08048000,那么按照这样的映射方式,该 ELF 文件中的各个段的虚拟地址和长度如表 6-4 所示。
在这里插入图片描述
可以看到这种对齐方式在文件段的内部会有很多内部碎片,浪费磁盘空间。整个可执行文件的三个段的总长度只有 12 014 字节,却占据了5个页,即 20 480 字节,空间使用率只有58.6%。
为了解决这种问题,有些 UNIX 系统采用了一个很取巧的办法,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次(见图 6-10)

在这里插入图片描述

比如对于 SEGO和SEG1 的接壤部分的那个物理页,系统将它们映射两份到拟地址空间,一份为 SEG0,另外一份为 SEG1,其他的页都按照正常的页粒度进行映射

而且 UNIX 系统将 ELF 的文件头也看作是系统的一个段,将其映射到进程的地址空间,这样做的好处是进程中的某一段区域就是整个 ELF 文件的映像,对于一些须访问 ELF 文件头的操作(比如动链接器就须读取ELF 文件头)可以直接通过读写内存地址空间进行。
从某种角度看,好像是整个 ELF 文件从文件最开始到某个点结束,被逻辑上分成了以4096 字节为单位的若干个块,每个块都被装载到物理内存中,对于那些位于两个段中间的块,它们将会被映射两次。现在让我们来看看在这种方法下,上面例子中 ELF 文件的映射方式如表 6-5 所示。
在这里插入图片描述
在这种情况下,内存空间得到了充分的利用,我们可以看到,本来要用到 5 个物理页面也就是20 480字节的内存,现在只有3 个页面,即 12 288 字节这种映射方式下,对于个物理页面来说,它可能同时包含了两个段的数据,甚至可能是多于两个段,比如文件头、代码段、数据段和 BSS 段的长度加起来都没超过 4 096 字节,那么一个物理页面可能包含文件头、代码段、数BSS段。

参考资料–
《程序员的自我修养一一链接、装载与库》
ELF文件详解—初步认识

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

林树杰

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

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

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

打赏作者

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

抵扣说明:

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

余额充值