跳转至

ELF 文件的装载与进程

最近在读《程序员的自我修养:链接,装载与库》,其实这本书跟 Android 开发的联系还挺紧密的,无论是 NDK 开发,或者是性能优化中一些常用的 Native Hoook 手段,都需要了解一些链接,装载相关的知识点。本文为读书笔记。

可执行文件只有被装载到内存中之后才能被 CPU 执行,那么 ELF 文件在 linux 中到底是如何装载的?什么是进程的虚拟地址空间,为什么进程要有自己的独立虚拟地址空间?本文主要回答了这些问题

进程虚拟地址空间

我们知道每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由 CPU 的位数决定的。32位 CPU 有 2^32 即4GB 虚拟空间,而 64 位 CPU 理论上支持 2^64 约 16777216 TB 的虚拟地址空间。但是很显然目前大部分设备用不到这么大的内存空间,目前很多 64 位 CPU 使用 40 位地址线,最大寻址空间仅为 1TB,加之别的种种原因,目前Windows 7 64位版最大仅能使用 192GB 内存,Windows 8 64位版最大仅能使用 512GB 内存,大多数 64 位 Android 设备的最大虚拟地址空间大小也是 512 GB。

下面我们以 32 位虚拟地址空间为例,看看虚拟地址空间是如何分配的

  • 整个 4 GB 被划分成了两部分
  • 操作系统占据了从 0xC0000000 到 0xFFFFFFFF 的 1 GB
  • 应用程序占据了从 0x00000000 到 0xBFFFFFFF 的 3 GB

装载的方式

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

但当程序所需内存大于物理内存时,就需要扩展内存,这显然成本较高,人们更期望的是在不添加内存的情况下让更多的程序运行起来。

人们发现程序运行具有局部性原理,于是采用动态装入,即将常用部分放入内存,不常用的数据存储在磁盘中,以实现高效利用内存。

页映射

页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。页映射不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。

页大小由硬件决定,最常见的页大小一般是 4096 字节,那么512 MB的物理内存就拥有512 * 1024 * 1024 / 4 096 = 131 072个页

如上图所示,我们看一个简单的例子,每个虚拟空间有 8 页,每页大小为 1 KB,那么虚拟地址空间就是 8 KB。我们假设该计算机有 13 条地址线,即拥有 2^13 的物理寻址能力,那么理论上物理空间可以多达 8KB。但是出于种种原因,我们实际只有 6KB 的物理内存,所以物理空间其实真正有效的只是前 6KB。

那么,当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据页保存在磁盘里,当需要用到的时候再把它从磁盘里取出来即可

我们把虚拟空间的页就叫虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page),图中的线表示映射关系

在上图中,进程中的部分虚拟页被映射到了物理内存页,比如 VP0 、VP1 和 VP7 映射到 PP0、PP2和PP3;而有部分页面却在磁盘中,比如 VP2 和 VP3 位于磁盘的 DP0 和 DP1 中;另外还有一些页面如 VP4、VP5 和 VP6 可能尚未被用到或访问到,它们暂时处于未使用的状态。

进程的 VP2 和 VP3 不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获到这个消息,就是所谓的页错误(Page Fault),然后操作系统接管进程,负责将 VP2 和 VP3 从磁盘中读出来并且装入内存,然后将内存中的这两个页与 VP2 和 VP3 之间建立映射关系

如果这时候程序只需要 VP0、VP1、VP2、VP3、VP7 这 5 个页,那么程序就能一直运行下去。但是问题很明显,如果这时候程序需要访问超出 6kb 的更多的页,那么装载管理器必须做出抉择,它必须放弃目前正在使用的内存页中的其中一个来装载。

至于选择哪个页,我们有很多种算法可以选择,比如最近最少使用算法。通过将常用的页加载到内存中,而将不常用的页保存在磁盘中,在需要时再从磁盘中装载相应页,从而实现了在不增加物理内存的情况下进行动态装入

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

进程的建立

一个程序的执行通常也就意味着:创建一个进程,然后装载相应的可执行文件并且执行。而这也可以具体化为下面 3 件事

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

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

这一步的主要工作就是创建一组从虚拟空间页到物理空间的映射,因此创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构

读取可执行文件头并创建映射

上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。

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

我们看一下简单的例子,假设我们的 ELF 可执行文件只有一个代码段.text,其虚拟地址为 0x08048000,它在文件中的大小为 0x000e1。由于虚拟存储的页映射都是以页为单位的,页大小通常为 4kb。由于该.text段大小不到一个页,考虑到对齐该段占用一整页。所以该文件被装载时将占用 0x08048000 到 0x08049000 的虚拟地址空间

这种映射关系只是保存在操作系统内部的一个数据结构,Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area)。比如上例中,操作系统创建进程后,会在进程相应的数据结构中设置有一个.text 段的 VMA:它在虚拟空间中的地址为0x08048000~0x08049000,对应ELF文件中偏移为 0 的 .text

运行可执行文件

操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行

简单来说就是操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址,也就是在 ELF 头文件中保存的地址

页错误

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

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

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

进程虚存空间分布

链接视图与执行视图

我们前面的例子只有一个代码段,因此被加载后相对应的也只有一个 VMA,但实际情况,一个 ELF 文件中往往有多个段,比如数据段,bss 等

因为 ELF 文件被映射时是以页为单位的,因此每个段在映射时的长度都是页的整数倍,如果不足一页也将占用一页。因此随着段数量的增多,也会导致更多的空间浪费问题

那么该如何减少这种内存浪费呢?

实际上,当操作系统装载可执行文件时,它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行) ,在 ELF 文件中,段的权限主要可以组合为以下几种:

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

因此最简单的思路:对于相同权限的段,把它们合并到一起当作一个段进行映射

上面我们把链接视图与执行视图中的内容都称为段,实际上它们是两个东西,只是翻译成了一样的词,在英文中,链接视图中的段是Section,而执行视图的段是Segment

Segment实际上就是多个属性类似的Section合并的结果,将多个Section合并成一个Segment整体映射,可以减少页面内部的内存碎片,减少内存占用。而正因为此,操作系统在执行阶段是以Segment来映射可执行文件的。

所以总的来说,SegmentSection是从不同的角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图,从Section的角度来看 ELF 文件就是链接视图,从Segment的角度来看就是执行视图。当我们在谈到 ELF 装载时,“段”专门指Segment;而在其他的情况下,“段”指的是Section

我们可以通过 readelf 命令来打印链接视图与执行视图的不同,比如我们有一个名为SectionMapping.elf的可执行文件

# 链接视图
readelf -S SectionMapping.elf

# 执行视视图
readelf -l SectionMapping.elf

这里就不打印结果了,我们来看下 ELF 文件与进程虚拟空间的映射关系如下

其实就是我们上面说的,多个属性类似的Section合并为一个Segment,作为一个整体映射到同一个 VMA 中

堆和栈

在操作系统中,VMA 不仅被用于映射 ELF 中的 Segment。实际上,进程执行过程中需要用到的栈(Stack)、堆(Heap)等空间,在进程虚拟空间中也是以 VMA 的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的 VMA。

在 Linux 系统中,我们可以通过查看/proc/$pid/maps文件来查看进程的虚拟空间分布

$ ./SectionMapping.elf &
[1] 1686

$ cat /proc/1686/maps
00400000-00401000 r--p 00000000 07:03 126044                             /workspaces/programmer-training/SectionMapping.elf
00401000-00495000 r-xp 00001000 07:03 126044                             /workspaces/programmer-training/SectionMapping.elf
00495000-004bc000 r--p 00095000 07:03 126044                             /workspaces/programmer-training/SectionMapping.elf
004bd000-004c0000 r--p 000bc000 07:03 126044                             /workspaces/programmer-training/SectionMapping.elf
004c0000-004c3000 rw-p 000bf000 07:03 126044                             /workspaces/programmer-training/SectionMapping.elf
004c3000-004c4000 rw-p 00000000 00:00 0 
012f5000-01318000 rw-p 00000000 00:00 0                                  [heap]
7ffe70214000-7ffe70236000 rw-p 00000000 00:00 0                          [stack]
7ffe70296000-7ffe7029a000 r--p 00000000 00:00 0                          [vvar]
7ffe7029a000-7ffe7029c000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image),上面的输出结果中,每列的含义如下

  • 第一列是VMA的地址范围,可以看到,堆在低地址,栈在高地址;
  • 第二列是VMA的权限,“r”表示可读,“w”表示可写,“x”表示可执行,“p”表示私有(COW, Copy on Write),“s”表示共享。
  • 第三列是偏移,表示 VMA 对应的Segment在映像文件中的偏移;
  • 第四列表示映像文件所在设备的主设备号和次设备号;
  • 第五列表示映像文件的节点号。
  • 最后一列是映像文件的路径。

我们可以看到进程中有 11 个 VMA,前 5 个是可以映射到 ELF 文件中的 Segment。其它段的文件所在设备主设备号和次设备号及文件节点号都是 0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域。我们可以看到其中有两个区域分别是堆(Heap)和栈(Stack),这两个 VMA 非常常见,几乎在所有的进程中存在。

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

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

一个常见进程的虚拟空间如下图所示:

段地址对齐

可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位,而页大小一般为 4096 字节

也就是说,我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是 4096 的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是 4096 的整数倍。

很明显这会导致严重的内存浪费问题,比如我们 ELF 文件中有如下 3 个 Segment 需要装载

起始虚拟地址 大小 有效字节 偏移 权限
SEG0 0x08048000 0x1000 127 34 可读可执行
SEG1 0x08049000 0x3000 9899 164 可读可写
SEG2 0x0804C000 0x1000 1988 只读

整个可执行文件的三个段的总长度只有 12014 字节,却占据了 5 个页,即 20480 字节,空间使用率只有 58.6%

为了解决这种问题,一种解决思路就是:让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次

比如段 A 的最后一页与段 B 的第一页接壤,将两个虚拟地址页与同一个物理页面关联起来,但是段 A 的最后一页虚拟地址只有前半部分有效,段 B 的第一页虚拟地址只有后半部分有效

具体的计算规则就不在这里缀述了,可以看一篇很好的总结:gcc编译时,链接器安排的【虚拟地址】是如何计算出来的?

Linux 内核装载 ELF 过程简介

下面我们简单总结一下 linux 内核装载静态链接 ELF 文件的过程

当我们在Linux系统的 bash 下输入一个命令执行某个 ELF 程序时,Linux 系统是怎样装载这个ELF 文件并且执行它的呢?

  • 首先在用户层面, bash 进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
  • 在进入execve()系统调用之后,Linux 内核就开始进行真正的装载工作。
  • 首先查找被执行的文件,如果找到文件,则读取文件的前 128 个字节,以获取文件开头的魔数。根据魔数确定文件格式,并且调用相应的装载处理过程。
  • 在 ELF 装载处理过程中,首先会检查 ELF 可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量
  • 接下来根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据等
  • 初始化 ELF 进程环境,在进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数
  • 将系统调用的返回地址修改成 ELF 可执行文件的入口点,对于静态链接就是 ELF 文件的文件头中 e_entry 所指的地址
  • sys_execve()系统调用结束,从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成

总结

相比之前的链接视图,本节主要介绍了执行视图的 ELF 文件,主要包括以下内容

  • ELF 文件是如何被装载到内存上的,为什么要使用页映射的方式将程序映射到进程地址空间
  • 从操作系统角度看 ELF 文件是如何装载的,当程序开始运行时发生页错误如何处理
  • 进程虚存空间具体是如何分布的,如何解决装载过程中的内存浪费问题,操作系统如何为程序的代码、数据、堆、栈在进程地址空间中分配,它们是如何分布的
  • Linux 系统是怎样装载并运行 ELF 文件的

最后更新: November 17, 2024