1.进程地址虚拟空间
程序是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库
叫做运行时也有一定的含义。
每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间,这个虚拟地址空间的大大小由计算机的硬件平台决定,具体的说是由 CPU 的位数决定的。
硬件决定了地址空间的最大理论上线,即硬件的寻址空间。比如,32位的硬件平台决定了虚拟地址空间的地址为 0 到 2^32 -1 ,也就是我们说的 4GB
虚拟空间大小;而 64 位的硬件平台具有64位寻址能力,总共 17 179 869 184G 。
我们可以通过判断 C 语言程序中的指针所占的空间来计算虚拟地址空间的大小。一般来说,C 语言指针大小的位数与虚拟空间的位数相同,如 32 位平台
下的指针为 32 位,即 4 字节。64 位平台的指针为 64 位,即 8 字节。
那么 32 位平台下的 4 G 虚拟空间,我们的程序是否可以任意使用呢? 不行。因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序
运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统会捕获
到这些访问,将进程的这种访问当作非法,强制结束进程。Linux 下的 "Segmentation fault" 很多时候是因为进程访问了未经允许的地址。
那么到底这4G的进程虚拟空间怎么分配?整个4G被划分成2个部分,其中操作系统本身去了一部分:从地址 0xC00000000 到 0xFFFFFFFF,共1G。剩下的
从 0x000000000 到 0xBFFFFFFF 共 3G 的空间都是留给进程使用的。那么从原则上将,我们的进程最多可以使用3G的虚拟空间,也就是说在整个进程执行的
时候,所有的代码,数据包括通过 C 语言 malloc() 等方法申请的虚拟空间之和不超过 3GB。
PAE :
32位的 CPU 下,程序使用的空间能不能超过4GB呢? 这个问题要从2个角度来看,首先,问题里面的 "空间" 如果是指虚拟地址空间,那么答案是 "否"。因为
32位的 cpu 只能使用 32 位的指针,它最大寻址范围是0到4GB;如果问题里面的 “空间” 是指计算机的内存空间,那么答案为 "是"。Intel 自从 1995年的
Pentium Pro CPU 开始采用了 36位的物理地址,也就是说最高可访问64GB的物理内存。
从硬件层面讲,原先的32位地址线只能访问最多4Gb的物理内存。但是自从扩展至36位地址线之后,Inter 修改了页映射方式,使得新的映射方式可以访问到更多
的物理内存。Intel 把这个地址扩展方式叫做 PAE(physical address extension)。
当然,扩展的物理地址空间,对于普通应用程序来说在正常情况下感觉不到它的存在,因为这主要是操作系统的事,在应用程序里,只有32位的虚拟地址空间。那么应用
程序如何使用大于常规的内存空间呢?一个很常见的方法就是操作系统提供一个窗口映射的方法,把这些额外的内存映射到进程的地址空间来。应用程序可以根据需要来选择
申请和映射,比如一个应用程序中 0x10000000 ~ 0x20000000 这一段 256MB的虚拟地址空间用来做窗口,程序可以从高于 4GB的物理空间申请多个大小为 256MB的物理空间,
编号为 A,B,C等,然后根据需要将这个窗口映射到不同的物理空间块,用到A时,将 0x10000000 ~ 0x20000000 映射到A,用到B,C时,再映射过去,如此重复操作即可。
在 Windows 下,这种访问内存的操作方式叫 AWE(address windowing extensions);而像 Linux 等 Unix类操作系统则采用 mmap()系统调用来实现。
2.装载的方式
程序执行时所需要的指令和数据必须在内存中才能正常运行,最简单的方法就是讲程序运行时所需的指令和数据全部装载入内存中,
这样程序就可以顺利运行,这就是最简单的静态装入的办法。但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量
不够时,根本的解决方法是加内存。相对于磁盘来说,内存是昂贵的。程序运行时是有局部性原理的,所以我们可以将程序最常用的部分
驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态载入的原理。
覆盖载入和页映射是2种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了局部性原理。
1.覆盖载入
覆盖载入在没有发明虚拟存储之前使用的比较广泛,现在几乎已经被淘汰了。覆盖载入的方法把挖掘内存的潜力的任务交给了程序员,程序员
在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。这个小的
辅助代码就是所谓的覆盖管理器(overlay Manager)。
覆盖管理器需要保证2点:
1.这个树状结构中从任何一个模块到树的根(也就是 mian)模块都叫调用路径。当该模块被调用时,整个调用路径上的模块必须都在内存中,
2.禁止跨树间调用。任意一个模块不允许跨过树状结构进行调用。因为覆盖管理器不能保证跨树间的模块能够存在于内存中。
当然,由于跨模块间的调用都需要经过覆盖管理器,以确保所有被调用到的模块都能够正确的驻留内存,而且一旦模块没有在内存中,还需要从磁盘
或者其他除尘器读取相应的模块,所以覆盖装入的速度肯定比较慢。不过这也是一种折中的方案,是典型的利用时间换取空间的方法。
2.页映射
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。与覆盖装入的原理相似,页映射也不是一下子把程序的所有的数据和指令都装入内存。
而是将内存和所有的数据和指令按照 "页" 为单位划分成若干个页,以后所有的装载和操作的单位就是页。以目前来看,硬件规定的页的大小有 4096字节,
8192,2MB, 4MB 等。
装载管理器就是现代的操作系统,更加准确的将就是操作系统的存储管理器。
3.从操作系统角度看可执行文件的装载
页面映射的动态装入的方式可以看到,可执行文件的页可能被装入的内存中的任意页。在虚拟存储中,现代的硬件MMU都提供了地址转换的功能。
有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式跟静态加载有了很大区别。
进程的建立:
从操作系统角度上看,一个进程最关键的特征是它拥有独立的虚拟地址空间。这使得它有别于其他进程。很多时候一个程序的执行同时伴随着一个
新的进程的创建。创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做3件事情:
1.创建一个独立的虚拟地址空间
一个虚拟空间是由一组页映射函数将虚拟空间的各个映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需的
相应的数据结构。在 i386 的 Linux 下,创建虚拟地址空间实际上只是分配一个页目录就可以了,甚至不设置页映射关系,这些映射关系等到后面
程序发生页错误的时候再进行设置。
2.读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,
操作系统将从物理内存中分配一个物理页,然后将该 "缺页" 从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。
但是很明显的一点是,当操作系统捕获到缺页错误时,它应该知道程序当前所需要的页在可执行文件中的哪个位置。这就是虚拟空间与可执行文件之间的映射
关系。从某种角度看,这一步是整个装载过程中最重要的一步,也是传统意义上的 "装载"的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件。
很明显,这种映射关系只是保存在操作系统内部的一个数据结构。Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, virtual memory area);
在 Windows 中将这个叫做虚拟段。
3.将 CPU 指令寄存器设置成可执行文件的入口地址,启动运行
操作系统通过设置 CPU 的指令寄存器将控制器转交给进程,由此进程开始执行。这一步看似简单,实际在操作系统层面比较复杂,它涉及内核堆栈和用户堆栈切换,
CPU 运行权限的切换。不过从进程的角度来看这一步可以简单的认为操作系统指令一条跳转指令,直接跳转到可执行文件的入口地址。ELF 文件头中保存的入口地址。
上述的步骤执行完成之后,其实可执行文件的真正指令和数据都没有被装载如内存中。操作系统知识通过可执行文件头部的信息建立起可执行文件和进程虚存之间的
映射关系而已。当 CPU 执行这个地址的指令的时候,如果发现是个空页面,于是它就认为这是个页错误(Page Fault).CPU 将控制器交给操作系统,操作系统有专门
的页错误处理例程来处理这种情况。这时候,我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统查询这个数据结构,然后找到空页面所在
的 VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制器还给进程,
进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断的产生,操作系统也会为进程分配相应的物理页来满足程序执行的需求。当然有可能进程所需的内存会超过可用的内存数量,特别是
在有多个进程同时执行的时候,这个时候操作系统就需要精心组织和分配物理内存,甚至有时候将分配给物理内存暂时收回等,这就涉及了操作系统的虚拟存储管理。
4.进程虚存空间分布
当段的数量增多时,就会产生空间浪费。因为我们知道,ELF 文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;
如果不是,那么多余的部分也将占用一个页。一个 ELF 文件往往有十几个段,那么内存空间的浪费可想而知。
当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的
是段的权限(可读,可写,可执行)。在 ELF 文件中,段的权限往往只有为数不多的几种组合,基本是以下3种:
1.以代码段为代表的权限位可读可执行的段
2.以数据段和 bss 段为代表的权限位可读可写的段
3.以只读数据段为代表的权限位只读的段
那么我们可以找到一个很简单的方案就是:对于相同的权限的段,把它们合并到一起当作一个段进行映射。
ELF 可执行文件引入了一个概念叫做 "segment", 一个 "segment" 包含了一个或多个属性类似的 "section"。正如我们上面看到的,如果将 ".text"段和 ".init"段
合并在一起看做是一个 'segment',那么装载的时候就可以将它们看做一个整体一起映射,也就是说映射以后的进程虚拟空间中只有一个对应的 VMA,而不是2个。这样做的好处是
可以明显的减少页面内部碎片,从而节省了内存空间。
很明显,从链接的角度看,ELF 文件是按照 'section' 存储的;从装载的角度看,ELF 文件又可以按照 'segment' 划分。
'segment'的概念实际上是从装载的角度重新划分了 ELF 的各个段。在将目标文件链接成可执行文件时,链接器会尽量把相同权限属性的段分配在同一个空间。比如可读可执行的
段都放在一起,这种段的典型是代码段;可读可写的段都放一起,这种段的典型是数据段。在 ELF 中把这些属性相似的,又连在一起的段叫做一个 'segment',而系统正式按照 'segment',
而不是 'section' 来映射可执行文件的。
我们可以使用 readelf 命令来查看 ELF 的 'segment'。正如描述 'section' 属性的结构叫做段表,描述 'segment' 的结构叫做程序头,它描述了 ELF 文件该如何被操作系统
映射到进程的虚拟空间。
readelf -l a.out :
Elf file type is EXEC (Executable file)
Entry point 0x400d0d
There are 6 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x00000000000b7fe4 0x00000000000b7fe4 R E 200000
LOAD 0x00000000000b8eb0 0x00000000006b8eb0 0x00000000006b8eb0
0x0000000000001860 0x0000000000004378 RW 200000
NOTE 0x0000000000000190 0x0000000000400190 0x0000000000400190
0x0000000000000044 0x0000000000000044 R 4
TLS 0x00000000000b8eb0 0x00000000006b8eb0 0x00000000006b8eb0
0x0000000000000020 0x0000000000000058 R 10
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x00000000000b8eb0 0x00000000006b8eb0 0x00000000006b8eb0
0x0000000000000150 0x0000000000000150 R 1
我们可以看到,这个可执行文件中共有5个 segment。从装载的角度看,我们目前只关心两个 'LOAD' 类型的segment,因为只有它是需要被映射的,其他的诸如 'NOTE','TLS',
'GNU_STACK'都是在装载时起辅助作用的。
具有相同属性的 'section' 被归类到一个 'segment',并且映射到同一个 VMA。
总的来说,'segment' 和 'section' 是从不同的角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图(View),从 'section'的角度看ELF 文件就是链接视图;
从 'segment' 的角度看就是执行视图。当我们在谈论到 ELF 装载时,"段" 专门指 'segment';而其他情况下,'段'指的是 'section'。
ELF 可执行文件中有一个专门的数据结构叫 做程序头表 用来保存 'segment'的信息。因为 ELF 目标文件不需要被装载,所以它没有程序头表,而 ELF 的可执行文件和共享库文件
都有。
对于 'LOAD' 类型的 'segment' 来说,p_memsz 的值不可小于 p_filesz,否则就是不符合常理的。但是,如果 p_memsz 的值大于 p_filesz又是什么意思呢?如果 p_memsz 大于
p_filesz ,就表示该 'segment' 在内存中所分配的空间大小超过文件实际的大小,这部分 '多余' 的部分则全部填充为 '0'。这样做的好处是,我们在构造 ELF 可执行文件时不需要再
额外设立 bss 的 'segment'了。因为数据段和 bss 段的唯一区别是:数据段从文件中初始化了内容,而 bss 段的内容全部初始化为 0.这也是我们在前面的例子中只看到了2个 'LOAD'
类型的段,而不是3个, bss 已经被合并到了数据类型的段里面了。
堆和栈:
在操作系统里面,VMA 除了被用来映射可执行文件中的各个 'segment' 以外,它还有其他的作用,操作系统通过对 VMA 对进程的地址空间进行管理。我们知道进程在执行的时候它还需要
用到栈,堆等空间,事实上它们在进程的虚拟空间中的表现也是以 VMA 的形式存在的,很多情况下,一个进程的堆和栈分别都有一个对应的 VMA。在 Linux 下,我们可以通过 '/proc' 来
查看进程的虚拟空间的分布:
[dao@tx_server ~]$ cat /proc/19974/maps
00400000-004b8000 r-xp 00000000 fd:01 362320 /home/daodao/a.out
006b8000-006bb000 rw-p 000b8000 fd:01 362320 /home/daodao/a.out
006bb000-006be000 rw-p 00000000 00:00 0
0090e000-00931000 rw-p 00000000 00:00 0 [heap]
7ffed81e5000-7ffed8206000 rw-p 00000000 00:00 0 [stack]
7ffed8318000-7ffed831a000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
上面的输出结果中,第一列是 VMA 的地址范围;第二例是 VMA 的权限,'r'表示可读,'w'表示可写,'x'表示可执行,'p'表示私有,'s'表示共享。第三列是偏移,表示 VMA 对应的 segment
在映像文件中的偏移;第四列表示映像文件所在的设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。
我们可以看到进程中有5个 VMA,只有前面2个是映射到可执行文件中的两个 segment 。另外3个段的文件所在的设备主设备号和次设备号以及文件节点号都是0,则表示它们没有映射到文件中,这种
VMA 叫做匿名虚拟内存区域。我们可以看到由2个区域分别是 堆和栈,它们的大小分别为 140kb 和 88kb。这2个 VMA 几乎在所有的进程中存在,我们在 C 语言程序中最常用的 malloc() 内存分配
函数就是从堆里分配的,堆由系统库管理。栈一般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对单线程的程序来讲,这个 VMA 堆栈就全部归它使用。另外一个很特殊的 VMA 叫做 'vdso',它的
地址已经位于内核空间了(即大于 0xC0000000 的地址),事实上它是一个内核的模块,进程可以通过访问这个 VMA 跟内核进行一些通信。
通过上面的例子,我们可以总结下关于进程虚拟地址空间的概念:
操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟地址空间;基本原则就是相同权限属性的,有相同映像文件的映射成一个 VMA;一个进程基本可以分为如下几种 VMA 区域:
1.代码 VMA,权限只读,可执行;有映像文件
2.数据 VMA ,权限可读写,可执行;有影响文件
3.堆 VMA,权限可读写,可执行,无映像文件,匿名,可向上扩展
4.栈 VMA,权限可读写,不执行,无映像文件,匿名,向下扩展
堆的最大申请数量:
malloc 的最大申请数量会受到哪些因素的影响呢?实际上,具体的数值会受到操作系统版本,程序本身大小,用到的动态/共享库数量,大小,程序栈数量,大小等,甚至有可能每次运行的结果都
会不一样,因为有些操作系统使用了一种叫做 随机地址空间分布的技术(主要是处于安全考虑,防止程序收到恶意攻击),使得进程的堆空间变小。
段地址对齐:
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。对于 Intel 80*86 系列处理器来说,默认页大小为
4096 字节,也就是说,我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存的空间的长度必须是 4096 的整倍数,并且这段空间的在物理内存和进程虚拟地址空间中的起始地址
必须是 4096的整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应该尽量的优化自己的空间和地址的安排,以节省空间。
进程栈初始化:
我们知道进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈
中(也就是 VMA 中的 stack VMA).
栈顶寄存器 esp 指向的位置是初始化后堆栈的顶部,最前面的4个字节表示命令行参数的数量。紧接着就是分布指向这2个参数的字符串的指针;后面跟了个0;接着是2个指向环境变量字符串的指针,
后面又跟着一个0表示结束。
进程在启动以后,程序的库部分会把堆栈里的初始化信息的参数信息传递给 main() 函数,也就是我们熟知的 main() 函数的2个 argc 和 argv 两个参数,这两个参数分别对应这里面的命令参数
数量和命令参数字符串指针数组。
5.Linux 内核装载 ELF 过程简介
当我们在 Linux 系统的 bash 下输入一个命令执行某个 ELF 程序时,Linux 系统是怎样装载这个 ELF 文件并且执行它呢?
首先在用户层面,bash 进程会调用 fork() 系统调用创建一个新的进程,然后新的进程调用 execve() 系统调用执行指定的 ELF 文件,原先的 bash
基础继续等待刚刚启动的新进程结束,然后继续等待用户的输入。execve() 系统调用被定义在 unistd.h, 它的原型如下:
int execve (const char *__path, char *const __argv[],char *const __envp[]) ;
它的3个参数分别为 程序文件名, 执行参数和环境变量。glibc 对 execvp() 系统进行了包装,提供了 execl(), execlp(), execle(), execv() 和
execvp() 等5个不同形式的 exec 系列 API,它们只是在调用形式上有所区别,但最终都会调用到 execve() 这个系统。
在进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作。在内核中,execve() 系统调用的相应入口是 sys_execve(),被定义在 arch\i386\kernel\Process.c。
sys_execve() 进行一些参数检查复制之后,调用 do_execve()。do_execve() 首先会检查被执行的文件,如果找到文件,则读取文件的前128个字节。为什么要这么做呢?因为我们知道,
Linux 支持的可执行文件不止 ELF 一种,还有 a.out, Java 程序和以 "#!" 开始的脚本文件。Linux 还可以支持更多的可执行文件的格式,如果有一天 Linux 须支持 Windows PE 的
可执行文件格式,那么我们可以编写一个支持 PE 装载的内核模块来实现 Linux 对 PE 文件的支持。这里 do_execve() 读取文件的前128个字节的目的是判断文件的格式,每种可执行文件的
格式开头几个字节都是很特殊的,特使是开头4个字节,常常被称为魔数,通过对魔数的判断,可以确定文件的格式和类型。比如 ELF 的可执行文件的格式的头4个字节为 0x7F,'e','l','f';
而 Java 的可执行文件的头4个字节为 'c','a','f','e';如果被执行的是 shell 脚本或者 perl,python 等这种解释型脚本,那么它的第一行往往是 "#!/bin/bash"等,这个时候前2个字节
'#' 和 '!' 就构成了魔数,系统一旦判断到这2个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当 do_execve() 读取了128个字节的文件头部后,然后调用 search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。Linux 中所有被支持的可执行文件格式都有相应的装载处理
过程,search_binary_handle() 会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如 ELF 可执行文件的装载处理过程叫做 load_elf_binary(); a.out 可执行文件的
装载处理过程叫做 load_aout_binary();而装载可执行脚本程序的处理过程叫做 load_script()。这里我们只关心 ELF 可执行文件的装载, load_elf_binary() 被定义子啊 fs/Binfmt_elf.c。
这个函数的代码比较长,它的主要步骤是:
1.检查 ELF 可执行文件格式的有效性,比如魔数,程序头表中段的数量;
2.寻找动态链接 '.interp' 段,设置动态链接器路径
3.根据 ELF 可执行文件的程序头表描述,对 ELF 文件进行映射,比如代码,数据,只读数据
4.初始化 ELF 进程环境,比如进程启动时 EDX 寄存器的地址应该是 DT_FINI 的地址
5.将系统调用的返回地址修改成 ELF 可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的 ELF 可执行文件,这个程序的入口就是 ELF 文件的文件头中 e_entry 所指的地址;对于
动态链接的 ELF 可执行文件,程序的入口点就是动态链接器。
当 load_elf_binary() 执行完毕,返回至 do_execve() 再返回值 sys_execve() 时,上面的第5步中已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了。所以当 sys_execve()系统
调用从内核态返回到用户态,EIP 寄存器直接跳转到了ELF 程序的入口地址,于是新的程序开始执行,ELF 可执行文件装载完成。
1.bash
2.fork() // 子进程调用 execve(),父进程等待子进程结束
3.execve()
4.装载
5.sys_execve() // 进行参数的复制和检查
6.do_execve() // 查找被执行的文件,读取文件的前128个字节
7.search_binary_handle() // 搜索和匹配合适的可执行文件装载处理过程
8.load_elf_binary()
局部性原理 页映射
1.进程的装载:
1.创建一个独立的虚拟地址空间
2.读取可执行文件头,并且设置虚拟空间与可执行文件的映射关系
3.将 CPU 指令寄存器设置成可执行文件的入口地址,启动运行
2.缺页:
当 CPU 执行这个地址的指令时,如果发现是个空页面,于是它就认为这是一个页错误。CPU 将控制器交给操作系统,操作系统有专门的页错误处理例程来处理这种错误。
这个时候,我们前面提到的装载过程的第二步建立的数据结构起了很关键的作用,操作系统查询这个数据结构,然后找到空页面所在的 VMA, 计算出相应的页面在可执行文件中
的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页面之间建立映射关系,然后把控制器还给进程,进程从刚才页错误的位置重新执行。
3.Linux 内核装载 ELF 过程
1.shell
2.fork()
3.execve()
4.装载
5.sys_execve()
6.do_execve() // 读取前 128 个字节, 确定可执行文件类型
7.search_binary_handle() // 搜索匹配合适的可执行文件装载处理过程
8.load_elf_binary() // 装载过程过程
9.load_script()
可执行文件的装载与进程:
1.进程地址虚拟空间