可执行文件只有装载到内存以后才能被 CPU 执行。装载的基本过程就是把程序从外部存储器中读取到内存中的某个位置。
每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间, 这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。
32位平台下有4GB虚拟空间。我们的程序是否可以任意使用呢?很遗憾,不行。 因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址。Linux 下的“Segmentation fault”很多时候是因为进程访问了未经允许的地址
32位的CPU 下,程序使用的空间能不能超过4 GB 呢?这个问题其实应该从两个角度 来看,首先,问题里面的“空间”如果是指虚拟地址空间,那么答案是“否”。因为32位的 CPU 只能使用32位的指针,它最大的寻址范围是0到4 GB; 如果问题里面的“空间”指 计算机的内存空间,那么答案为“是”。
Intel 修改了页映射的方式,使得新的映射方式可以访问到更多的物 理内存。Intel 把这个地址扩展方式叫做 PAE
方法就是操作系统提供一个窗口映射的方 法,把这些额外的内存映射到进程地址空间中来。
程序执行时所需要的指令和数据必须在内存中才能够正常运行,当内存的数量不够时,根本的解决办法就是添加内存。但内存是昂贵且稀有的
后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理
页映射是很典型的动态装载方法,它是虚拟存储机制的一部分,随着虚拟存储的发明而诞生。
而是将内存和所有磁盘中的数据和指令按照“页” 为单位划分成若干个 页,以后所有的装载和操作的单位就是页。
从操作系统的角度来看, 一个进程最关键的特征是它拥有独立的虚拟地址空间, 这使得它有别于其他进程。
创建一个进程,然后装载相应的可执行文件并且执行。 在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
1.创建一个独立的虚拟地址空间。
一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间
实际上是创建映射函数所需要的相应的数据结构
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件
2.读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
上面那一步的页映射 关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。
当操作系统捕获到缺页错误时,它应知道程序当 前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。
这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域
3.将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行。
操作系统通过设置CPU 的指令寄存器将控制权转交给进程,由此进程开始执行。
从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。还记得ELF文件头中保存有入口地址吗?没错,就是这个地址。
上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。操 作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。
假设程序的入口地址为0x08048000,即刚好是.text段的起始地址。当CPU开始打算执行这个地址的指令时,发现页面0x08048000~0x08049000是个空页面,于是它 就认为这是一个页错误。CPU将控制权交给操作系统,操作系统将查询第二步的数据结构,然后找到空页面所在的VMA, 计算出 相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟 页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位 置重新开始执行。
在ELF 文件中,段的权限往往只有为数不多的几种组合,基本上是三种
在ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。 一个 ELF 文件中往往有十几个段,那么内存空间 的浪费是可想而知的。有没有办法尽量减少这种内存浪费呢?
方案就是: 对于相同权限的段,把它们合并到一起当作一个段进行映射。
ELF 可执行文件引入了一个概念叫做 “Segment”, 一 个 “Segment” 包含一个或多个属 性类似的“Section”。 正如我们上面的例子中看到的,如果将“ .text”段和“ .init”段合并在 一起看作是一个 “Segment”,那么装载的时候就可以将它们看作一个整体一起映射,也就是 说映射以后在进程虚存空间中只有一个相对应的VMA, 而不是两个,这样做的好处是可以 很明显地减少页面内部碎片,从而节省了内存空间。
“Segment” 的概念实际上是从装载的角度重新划分了ELF 的各个段,链接器会尽量把相同权限属性的段分配在同一空间。
总的来说,“Segment”和“Section”是从不同的角度来划分同一个ELF文件.这个在ELF中被称为不同的视图,从 “Section”的角度来看ELF文就 是链接视图,从 “Segment” 的角度来看就是执行视图
ELF可执行文件中有一个专门的数据结构叫做程序头表用来保存 “Segment” 的信息。因为ELF目标文件不需要被装载,所以它没有程序头表
操作系统通过使用VMA来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到栈、 堆等空间
在Linux下,我们可以通过查看“/proc”来查看进程的虚拟空间分布
sudo cat /proc/21963/maps
我们可以看到进程中有5个VMA, 只有前两个是映射到可执行文件中的两个Segment。另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域。我们可以看到有两个区域分别是堆和栈,它们的大小分别为140KB 和88KB。
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系
段 | 起始虚拟地址 | 大小 | 有效字节 | 偏移 | 权限 |
---|---|---|---|---|---|
SEG0 | 0x08048000 | 0x1000 | 127 | 34 | 可读可执行 |
SEG1 | 0x08049000 | 0x3000 | 9899 | 164 | 可读可写 |
SEG2 | 0x0804C000 | 0x1000 | 1988 | 只读 |
可以看到这种对齐方式在文件段的内部会有很多内部碎片,浪费磁盘空间。
为了解决这种问题,有些UNIX 系统采用了一个很取巧的办法,就是让那些各个段接壤 部分共享一个物理页面,然后将该物理页面分别映射两次
进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中
假设系统中有两个环境变量
HOME=/home/user
PATH=/usr/bin
Linux 进程初始堆栈示例:
栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,我们的例子里面是两个,即 “prog”和“123”,紧接的就是分布指向这两个参数字符串的指针;后面跟了一个0;接着是两个指向环境变量字符串的指针,它们分别指向字 符串 “HOME=/home/user” 和 “PATH=/usr/bin”; 后面紧跟一个0表示结束。
进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main()函数,也就是我们熟知的main()函数的两个 argc和 argv 两个参数,这两个参数分别对应这 里的命令行参数数量和命令行参数字符串指针数组。
Linux 系统是怎样装载ELF文件并且执行它的呢?
bash 进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的 新进程结束,然后继续等待用户输入命令。
在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。
execve()系统调用相应的入口是sys_execve(), 它会进行一些参数的检查复制之后,调用do_execve()。do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节。
读取文件的前128个字节的目的是判断文件的格式,每种可执行文件的格 式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数
当do_execve()读取了这128个字节的文件头部之后,然后调用search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程,并且调用相应的装载处理过程。
ELF可执行文件的装载处理过程叫做 load_elf_binary(),它的主要步骤是:
(1)检查ELF 可执行文件格式的有效性,比如魔数、程序头表中段 (Segment) 的数量。
(2)寻找动态链接的“,interp”段,设置动态链接器路径
(3)根据ELF 可执行文件的程序头表的描述,对ELF 文件进行映射
(4)初始化ELF 进程环境
(5)将系统调用的返回地址修改成ELF 可执行文件的入口点
当load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve()时系统调用的返回地址改成了被装载的ELF程序的入口地址了。所以当sys_execve() 系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。