进程虚拟地址空间
- 程序是一个静态概念,就是一些编译好的指令和数据集合的一个文件
- 进程是一个动态概念,它是程序运行时的一个过程。
- 比喻:程序是菜谱,
CPU
就是人,厨具就是其他硬件,整个炒菜过程就是一个进程 - 一般来说,C语言指针大小的位数与虚拟空间的位数相同,32位平台下的指针为32位,即4字节;64位平台下的指针位8字节
- 进程只能使用操作系统给它分配的空间,
32
位地址空间,进程只能使用3GB
,剩余的供给操作系统使用。 - 如果进程访问未经允许访问的空间,操作系统会捕获这些访问,并把这些行为当作非法操作,结束进程。
装载的方式
程序执行时所需要的指令和数据都必须在内存中才能够正常运行,最简单的方法就是将它们全部装入内存,这是最简单的静态装入的方法。但是一般情况,程序所需的内存数量大于物理内存的数量,所以我们需要使用别的方法运行多个程序
覆盖装入
覆盖装入主要依靠程序员进行设计,程序员必须手动将程序分割成若干个块,然后编写小的辅助代码管理这些模块何时应该驻留内存并且何时应该被替换掉。
一个案例
主模块main
会分别调用模块A
和模块B
,但是A
和B
之间不能相互调用。那么如果使用覆盖装入的方法,我们就不需要将A
和B
同时装入了,我们只需在需要A
的时候装入A
,需要B
的时候换出A
,装入B
即可。这样子,会节省内存
我们可以看到下图,
A
和B
公用这一块内存。所以,模块之间的依赖关系,何时使用何时不使用需要程序员清晰的指出来。
多个模块的情况下(
main
依赖于模块A
和模块B
,模块A
依赖于模块C
和模块D
…)
- 每个树状结构中任何一个模块到树的根(
main
)模块都叫调用路径。当该模块调用时,整个调用路径上的模块都必须在内存中。比如程序正在模块E
中执行代码,那么模块B
和模块main
都需要在内存中 - 禁止跨树间调用:任意一个模块不允许横跨过树状结构进行调用。比如模块
A
不能调用模块B,E,F
。因为覆盖管理器不能保证跨树间的模块能够存在于内存中
跨模块调用效率较低,因为这种调用都需经过覆盖管理器,而且一旦模块不在内存中,还需要从磁盘读取相应的模块,所以覆盖装入速度较慢
页映射
- 略
从操作系统角度看可执行文件的装载
以OS
角度看,进程的关键特征就是它拥有独立的虚拟地址空间,这使得它有别于其他进程。新进程的创建需要如下三件事情
- 创建一个独立的虚拟地址空间(创建数据结构,因为我们只需要映射关系)
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
- 将
CPU
的指令寄存器设置成可执行文件的入口地址,启动运行
创建虚拟地址空间
我们本质并不是创建一个真正的空间,因为页映射机制的存在,我们其实是创建映射函数所需要的相应的数据结构
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
上一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。
举个例子,我们知道程序执行发生页错误时,操作系统将从物理内存中分配一个物理页(物理内存也要按照相同大小分成一个个页框,然后再与页对应),然后将该缺页从磁盘中读取到内存中,再设置缺页的虚拟地址和物理页的映射关系,程序才能正常运行。
很明显,操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件的映射关系,也是传统意义上装载
的过程。
将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
操作系统通过设置CPU
的指令寄存器将控制权转交给进程,由此进程开始执行。这个入口地址,就是ELF
文件头中保持的入口地址
页错误
经过上述步骤,我们只是建立了可执行文件与虚拟空间的映射关系和虚拟空间与物理内存的映射关系,并未真正将指令和数据读入到内存中。
我们从程序的入口地址开始,按照上面的例子该地址是0x08048000
也正是.text
段的起始地址,当CPU
打算执行这个地址的指令时,在页表中找到该虚拟地址对应的物理地址,但是发现这是一个空页,所以认为这是一个页错误。CPU
将控制权交给操作系统。因为之前第二步,我们建立了可执行文件和虚拟空间的映射关系,所以操作系统可以轮询这个数据结构,找到空页面所在的VMA
,比如在.text
段那里发生了错误,OS
通过.text
段映射到的VMA
找到了VMA
地址。然后再从物理内存中分配一个物理页面(页框),然后建立这个页框和这个虚拟内存的映射关系。然后进程重新从这个位置开始执行。
进程虚拟内存空间分布
当段的数量增加时,就会产生空间浪费的问题,因为ELF
文件映射是以页为单位的,如果第二个部分超出了页一点点那么也要分配第二个页。这就会导致第二个页后面的内存都被浪费了。
以操作系统装载可执行文件的角度看待
- 它并不关心可执行文件各个段的实际内容,它只关心段的权限(可读,可写,可执行)
- 以代码段为代表的权限为可读可执行的段
- 以数据段和BSS段为代表的权限为可读可写的段
- 以只读数据段为代表的权限为只读的段
- 处理过程:对于相同权限的段,把它们合并到一起当作一个段进行映射。比如
.text
和.init
,它们包含的分别是程序的可执行代码和初始化代码,且权限都是可读可执行 - 引入新概念,
Segment
和Section
不同,但是容易翻译成同一个。一个Segment
包含一个或多个属性类似的Section
。我们就是将.text
段和.init
段合并在一起看作是一个Segment
,然后作为一个整体去映射。 Segment
实际上是从装载的角度上重新划分了ELF`的各个段
Linux内核装载ELF过程简介
当我们在Linux
系统的bash
下输入一个命令执行某个ELF
程序时,Linux
系统是怎样装载这个ELF
文件并且执行它的呢?
- 用户层面,
bash
进程调用fork
系统调用创建一个新进程,新进程调用execve
系统调用执行指定的ELF
文件。 execve
系统调用入口是sys_execve
,它会进行一些参数的检查复制,然后继续调用do_execve
。它会首先查找被执行的文件,如果找到文件,就读取文件的前128个字节来判断文件的格式。每种可执行文件的格式开头前4个字节是魔数,可以通过魔数判断文件的格式和类型。比如ELF
可执行文件格式头四个字节是0x7F, e, l, f
。如果是Shell
这种脚本,那么前两个字节#,!
就构成了魔数- 使用
do_execve
读取文件头后,然后调用search_binary_handle
去搜索和匹配合适的可执行文件装载处理过程。因为对应不同的文件,Linux
有不同的处理过程。对于ELF
可执行文件的装载处理过程叫做load_script
- 检查
ELF
可执行文件格式的有效性,比如魔数,程序头表中段的数量 - 寻找动态链接的
.interp
段,设置动态链接路径 - 根据
ELF
可执行文件的程序头表的描述,对ELF
文件进行映射,比如代码,数据,只读数据 - 初始化
ELF
进程环境,比如进程启动时EDX
寄存器的地址应该是DT_FINI
的地址 - 将系统调用的返回地址修改成
ELF
可执行文件的入口点。因此,又内核态返回用户态时,EIP
寄存器直接跳转到了ELF
程序的入口地址,新程序开始执行
- 检查