最近在读《程序员的自我涵养:链接,装载与库》,其实这本书跟 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
来映射可履行文件的。
所以总的来说,Segment
和Section
是从不同的视点来区分同一个 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 文件的