可执行文件只有装载到内存以后才能被CPU执行,早起装载基本上就是把程序从外部存储器读取到内存中某个位置。但是硬件MMU诞生,多进程,多用户,虚拟存储的操作系统,装载过程变得复杂起来。
作者介绍在ELF文件在linux下的装载过程。来为我们解答一些问题。
首先什么是进程的虚拟地址空间?
为什么进程要有自己独立的虚拟地址空间?
我们从历史的角度来看看装载的几种方式,包括覆盖装载、页映射。
接着还会介绍进程虚拟地址空间的分布情况。
6.1 进程虚拟地址空间
程序是一个静态的概念,进程是一个动态的概念。程序是一道菜谱,进程便是炒菜的一个过程。
我们知道每个程序运行起来以后,拥有自己的虚拟地址空间。其由CPU的位数决定。
硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小。
32位就02^31-1,即0x000000000xffffffff,也就是4GB;而64位则64位寻址能力,即2^64字节,即17179869184GB,看似是无限的,但是实际上在未来的一段时间后,我们也会觉得这个地址不够用。
那么在32位的4GB空间中,我们程序是否可以任意使用?
很遗憾,并不可以。因为操作系统还要监管程序运行,我们要给其分配一段空间。
Linux操作系统将进程的虚拟地址空间做了如图分配
我们只看左边,4gb被分成了两部分,用户和内核地址。原则上说我们最多用3GB的虚拟空间,但是在现代程序中,明显这是不够用的(这里不是讲64位,而是依然是32位,虽然64位才是一本万利的选择)。PAE机制能够让我们在32位下使用超过4GB的内存空间。这点我们后面会说。
Window则2gb/2gb这样分,也可以让操作系统占用的内存空间减少到1GB,在windows系统盘根目录下的Boot.ini加个“/3g”参数
PAE
从硬件层面来讲,Inter将32位地址线拓展到36位之后,修改了页映射的,这样可以访问更多的物理地址。这个扩展方式就叫做PAE。
那么应用程序该如何使用这些大于常规的内存呢?
一个很常见的方法是窗口映射,比如一段256MB的空间(0x10000000-20000000),程序可以从高于4GB的物理空间中多申请多个大小为256MB的物理空间,编号为A,B,C,D等,然后将这个窗口映射到不同的物理块,用到A时将0x10000000~0x20000000映射到A,用到B,C再映射到B,C对应的物理地址上去。
windows下,这种访问内存的操作方式叫做AWE(address windowing Extensions);Linux则用mmap()系统调用来实现。
当然这只是一种补救措施罢了。
6.2 装载的方式
程序在装载时拥有局部性原理,如果一股脑把程序全部丢进内存,那很明显是不够用的。而且内存很贵,增加内存也是不现实的。因此我们可以把常用的部分留在内存,不常用的放在磁盘,这就是动态装入的基本原理。
覆盖装入和页映射是两种典型的装载方式,这里我们来介绍。
覆盖装入
覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经淘汰了。但是它的一些思想还是很有意义的。
在一些现代嵌入式的内存受限环境下,特别是诸如DSP等,这种方法或许还有用武之地。
覆盖装入把挖掘内存潜力的任务交给了程序员,程序员在编写程序时要把其分成若干块,然后编写一个小小的辅助代码(覆盖管理器)来管理这些模块应该何时驻留内存之中,何时被替换掉。
最简单的情况下,一个main模块会调用到模块A和模块B,但是AB之间没有相互调用的关系。假设这三个模块的大小分别为1024,512,256字节。不考虑内存对齐,装载地址限制的情况下,理论上运行这个程序需要有1792个字节的内存。此时在内存中应该这样安排。
由于AB之间没有相互调用的依赖关系,因此可以相互覆盖。这样就省下了一些字节。
所以程序要把这些模块手动分成一个树状结构来表示其调用依赖关系。但是要注意两点:
- 这个树状结构中从任意一个模块到树的根的模块都叫调用路径。调用路径上的模块必须保存在内存中,如main->A->D。
- 禁止跨树间调用
当然跨模块间的调用要经过覆盖管理器,以确保所有被调用的模块都能够驻留在内存。
覆盖装入的速度肯定还是比较慢的。
页映射
它是虚拟存储机制的一部分,由其发明而诞生。这里我们结合可执行文件的装载来阐述一下页映射是如何应用到动态装载中去的。
页映射将磁盘中数据和指令按照“页”为单位划分为若干个页。硬件规定页的大小有4096字节、8192字节、2MB、4MB等。InterA32一般使用4096字节的页。
假设我们有如下页,有16KB大小内存,这些页的大小为4KB。
如果我们的程序是P0~P7有32KB的程序,我们16KB空间显然无法直接装入。
假设程序刚开始执行时的入口地址在P0,这时装载管理器(假设控制装载的叫这个名字)会把F0分配给P0,然后运行一段时间需要用到其他程序的页,如P5,P3,P6,会将P5分配到F1,P3分配到F2,P6分配到F3。
那么此时应该占满了16KB的内存了吧。如果还要装入P4,那么装载器必须做出选择,舍弃哪个页来装入。
我们有很多算法来决定选择哪个页,比如FIFO算法,或者最少使用算法LUA。
很多人可能猜到了,这个所谓的装载器其实就是我们的操纵系统,更精确的说,是它的存储管理器。目前几乎所有主流的操作系统都是按照这种方式装载的。
6.3 从操作系统角度看可执行文件的装载。
可执行文件中的页可以被装入任意页,从上面的页映射的动态装入的方式可以看到。
如果程序使用物理地址直接进行操作,那么每次页被装入时都需要进行重定位。
在虚拟存储中,现代硬件的MMU地址都提供地址转换的功能。有了硬件的地址转换和页映射机制,操作系统动态加载和静态加载有很大区别。本节我们将站在一个操作系统的角度来看可执行文件的装载。
进程的建立
从操作系统角度看,一个进程最关键的特征是它拥有独立的虚拟地址空间。
创建一个进程,然后装载相应的可执行文件并且执行,在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动并运行。
创建虚拟空间
一个虚拟空间由一组映射函数将虚拟空间的页映射到物理空间,创建虚拟空间不是创建映射函数,而是创建对应的数据结构。在i386Linux下,创建虚拟空间实际上只是分配一个页目录,甚至不需要设置映射关系,这些映射关系等到后面程序发生页错误再进行设置。
感觉有点绕?听不懂?不急,后面会解释
这里是 虚拟空间映射到物理空间 的过程
建立虚拟空间和可执行文件的映射
这一步做的是虚拟空间和可执行文件的映射关系。有一个机制:当程序执行发生页错误时,操作系统从物理内存分配一个物理页,然后将此页从磁盘读取到内存中,再设置虚拟页和物理页的映射关系,这样程序才能正常的运行。那么这个机制就有一个问题,它是如何知道缺页错误的程序所需要的页在可执行文件的哪个位置?这就是可执行文件和虚拟空间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上”装载”的过程。
可执行文件也被叫做映像文件。
考虑最简单的情况
设置可执行文件入口
第三步也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权移交给进程,然后进程开始执行。这一步看似简单,实际上在操作系统层面比较复杂,它涉及到内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度来看这一步可以简单的认为操作系统执行了一条跳转指令。其实也就是ELF文件头保存的入口地址。
页错误
上面步骤完成后,可执行文件的指令和数据都没有装入到内存中。操作系统只是通过可执行文件头部信息建立起可执行文件和进程虚拟内存的联系罢了。
假设程序的入口是0x08048000,即刚好是.text段的起始地址,当CPU执行命令时,会发现这是个空页面,于是会产生页错误。CPU将控制权交给操作系统,操作系统通过页错误处理机制来处理,然后通过装载过程第二步建立的数据结构来找到空页面所在的VMA(虚拟内存),计算出相应页面的偏移,然后分配一个物理内存页面,建立虚拟页和分配的物理页的映射关系。
随着进程执行,页错误不断产生,程序也不断“补全”
6.4 进程虚存空间分布
ELF文件链接视图和执行视图
如果我们按照链接时的节(Section)一一映射成一个页的整数倍,那么浪费的内存是可想而知的。
操作系统只关心装载相关的问题,最主要的是段(Section)的权限,这些权限往往只有几种组合。
基本上是这三种
- 以代码段为代表的可读可执行段
- 以数据段和bss段为代表的可读可写段
- 以只读数据为代表的只读段。
对于权限相同的段,我们可以把他们合成到一个段(Segment)进行映射
(Segment)和(Section)都可以是段,但是他们是不同视图下的说法,(Section)是链接视图,(Segment)是装载执行视图
ELF可执行文件有个程序头表,用来保存(Segment)信息。
目标文件没有头表,因为它不需要被装载。而ELF可执行文件和共享库文件都有。
头表结构体
各个成员基本含义
堆和栈
做多点x86和x64的题,大概能看出点规律,这里不做总结了。
还有个很特殊的VMA叫做“vdso”,它的地址已经位于内核空间(即大于0XC0000000)的地址。
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同属性、相同映像文件的映射成一个VMA,一个进程基本上可以分为如下几种VMA区域:
- 代码VMA,权限只读、可执行;有映像文件
- 数据VMA,权限可读可写,有映像文件
- 堆VMA,权限可读写、可执行(应该大多数不可以?);无映像文件,匿名,向上拓展
- 栈VMA,权限可读写,不可执行;无映像文件,匿名,向下拓展
堆的最大申请数量
自己实验吧hhh,现在是x64时代,可能有时候编译器会自动帮你优化了(或者是其他操作),注意一下。
段地址对齐
页是最小以映射单位。对于Intel80x86处理器来说,默认页大小为4096字节。
我们先拿一个例子来看看:
如果一个可执行文件有三个段需要装载,分别为SEG0、1、2。
如果对应起始地址为0x08048000,则如下表6.4
这里占据了5个页,20480字节,但是三个段总长度才12014字节,利用率才58.6%。
为了解决这个问题,有些UNIX系统采取了一个取巧的方法,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次(如下图)
因为段地址对齐的关系,各个段的虚拟地址就往往不是系统页面长度的整数倍了。
进程栈初始化
进程刚开始启动时,需要知道进程的一些运行环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法就是将它们保存到栈中。
假设有如下两个环境变量
HOME=/home/user
PATH=/usr/bin
比如我们运行程序的命令行是:
1 | $ prog 123 |
假设栈段底部地址为0xBF802000,那么进程初始化后堆栈就如图所示
6.5 Linux内核装载ELF过程简介
当我们在Linux系统的bash输入一个命令执行ELF程序时。
在用户层面bash进程会调用fork系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
在内核中,execve系统调用相应的入口是sys_execve(),进行参数检查后会调用do_execve()。
do_execve()首先会查找被执行的文件,找到文件,则读取文件前128字节(参考ELF结构),这个函数要判断该ELF文件的格式,每种可执行文件的格式的开头几个字节都很特殊,特别是开头四个字节,常被称作魔数
- 如果是ELF可执行文件,则头四字节为 0x7f、’e’、’l’、’f’;
- 如果是java,则是’c’、’a’、’f’、’e’;
- 如果是Shell脚本或者perl、python等,那么其第一行一定是”#/bin/sh”、”#!/usr/bin/perl”、”#!/usr/bin/python”
do_execve()检查之后会调用search_binary_handle()去搜索适合的可执行文件装载处理过程。
比如装载ELF的叫做load_elf_binary(),其有以下五个步骤
当其执行完毕后,原路返回。当其又sys_execve()返回用户态时,EIP寄存器直接跳转到ELF程序的入口地址,则ELF可执行文件装载完成。