可执行文件的装载与进程
可执行文件只有装载到内存以后才能被CPU执行。
本章会介绍:
- 什么是进程的虚拟地址空间?
- 为什么进程要有自己独立的虚拟地址空间?
- 装载的几种方式,包括覆盖装载、页映射。
- 虚拟地址空间的分布情况,比如代码段、数据段、BSS段、堆、栈。
进程虚拟地址空间
程序是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行时的一个过程。
每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小。一般来说,C语言指针大小的位数与虚拟空间的位数相同,比如32位平台下的指针为32位,即4字节。
那么32位平台下的4GB虚拟空间是否可以任意使用呢?很遗憾,不行。因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控好处呢个系运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕捉到这些访问,将进程的这种访问当做非法操作,强制结束进程。我们经常在Windows下碰到令人讨厌的“进程因非法操作需要关闭”或Linux下的“Segmentation fault”很多时候是因为进程访问了未经允许的地址。并且,这4GB被操作系统本身用去了一部分。
那么32位的CPU下,程序使用的空间能不能超过4GB呢?如果空间指的是虚拟地址空间,那么答案是否;如果空间指的是计算机的内存空间,那么答案是肯定的。从硬件层面上来讲,原先的32位地址线只能访问最多4GB的物理内存。但是自从扩展至36位地址线之后,Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存。Intel把这个地址扩展方式叫做PAE(Physical Address Extension)。
当然扩展的物理地址空间,对于普通应用程序来说正常情况下感觉不到它的存在,因为这主要是操作系统的事,在应用程序里,只有32位的虚拟地址空间。那么应用该如何使用这些大于常规的内存空间呢?一个很常见的办法就是操作系统提供一个窗口映射的方法,把这些额外的内存映射到进程地址空间中来。应用程序可以根据需要来选择申请和映射。在Windows下,这种访问内存的操作方式叫做AWE(Address Windowing Extension);而像Linux等UNIX类操作系统则采用mmap()系统调用来实现。
当然这只是一种补救32位地址空间不够大时的非常规手段,真正的解决方法还是应该使用64位的处理器和操作系统。
装载的方式
程序执行时所需要的指令和数据必须在内存中才能正常运行,最简单的办法就是将其全都装入内存中,这就是静态装入的方法。但是很多情下程序锁需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存 。相对于磁盘来说,内存是昂贵且稀有的,这种情况自计算机磁盘诞生以来一直如此。所以人们想尽各种办法,希望能够在不添加内存的情况下让更多的程序运行起来,尽可能有效地利用内存。后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
覆盖装入
覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。这个小的辅助代码就是所谓的覆盖管理器(Overlay Manager)。在多个模块的情况下,程序员需要手工将模块按照它们之间的调用依赖关系组织成树状结构,覆盖管理器需要保证两点:
- 这个树状结构中从任何一个模块到树的根模块都叫调用路径。当该模块被调用时,整个调用路径上的模块必须都在内存中。
- 禁止跨树间调用。任意一个模块不允许跨过树状结构进行调用。
当然,由于跨模块间的调用都需要经过覆盖管理器,以确保所有被调用的模块都能够正确地驻留在内存,而且一旦模块没有在内存中,还需要从磁盘或其他存储器读取相应的模块,所以覆盖装入的速度比较慢,不过这也是一种折中的方案,是典型的利用时间换取空间的方法。
页映射
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。页映射是将内存和所有磁盘中的数据和指令按照页为单位划分成若干个页,以后所有的装载和操作的单位就是页。以目前的情况,硬件规定的页的大小有4KB、8KB、2MB、4MB等。
假设机器有4个页的内存,而程序有8个页。如果这时候程序只需要4个页,就能一直运行下去。但如果这时候需要访问第5个页,那么装载管理器必须做出抉择,它必须放弃目前正在使用的4个内存页中的其中一个来装载新的页。至于选择哪个页,我们有很多种算法可以选择,比如可以选择第一个被分配掉的内存页(FIFO先进先出算法);可以选择很少被访问到的页(LUR最少使用算法)。
从操作系统角度看可执行文件的装载
如果程序使用武力地址直接进行操作,那么每次页被装入时都需要进行重定位。在虚拟存储中,现代的硬件MMU都提供地址转换的功能。有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式跟静态加载有了很大的区别。
进程的建立
从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
- 创建一个独立的虚拟地址空间。
- 一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构。
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
- 当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。
- 这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area);在Windows中将这个叫做虚拟段(Virtual Section)。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
- 操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度来看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。
页错误
上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚拟之间的映射关系而已。当CPU开始打算执行这个地址的指令时,发现是个空页面,于是它就认为这是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。当然有可能进程所需要的内存会超过可用的内存数量,特别是在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回等,这就涉及了操作系统的虚拟存储管理。
进程虚存空间分布
ELF文件链接视图和执行视图
当段的数量增多时,就会产生空间浪费的问题。因为我们知道,ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。一个ELF文件中往往有十几个段,那么内存空间的浪费是可想而知的。
当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
- 以代码段为代表的权限为可读可执行的段。
- 以数据段和BSS段为代表的权限为可读可写的段。
- 以只读数据段为代表的权限为只读的段。
那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并一起当做一个段进行映射。这样做的好处是可以很明显地减少页面内部碎片,从而节省了内存空间。Segment的概念实际上是从装载的角度重新划分了ELF的各个段。将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。
所以总的来说,Segment和Section是从不同角度来划分同一个ELF文件。这个在ELF中被称为不同的视图(View),从Section的角度来看ELF文件就是链接视图(Linking View)。从Segment的角度来看就是执行视图(Execution View)。当我们在谈到ELF装载时,段专门指Segment;而在其他情况下,段指的是Section。
ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存Segment的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。它的结构体如下:
typedef struct {
Elf32_Word p_type; // 类型
Elf32_Off p_offset; // 在文件中的偏移
Elf32_Addr p_vaddr; // 第一个字节进程虚拟地址空间的起始位置
Elf32_Addr p_paddr; // 物理装载地址
Elf32_Word p_filesz;// 在ELF文件中所占空间的长度
Elf32_Word p_memsz; // 在进程虚拟地址空间中所占用的长度
Elf32_Word p_flags; // 权限属性(可读R、可写W、可执行X)
Elf32_Word p_align; // 对齐属性(2的p_align次方字节)
} Elf32_Phdr
堆和栈
在操作系统里面,VMA除了被用来映射可执行文件中的各个Segment以外,它还可以有其他的作用,操作系统通过使用VMA来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到堆和栈等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的堆和栈分别都有一个对应的VMA。
另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area)。我们可以看到有两个区域分别是堆和栈,这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理。栈一般也叫作堆栈,每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。另外有一个很特殊的VMA叫做“vdso”,它的地址已经位于内核空间了(即大于0xC0000000的地址),事实上它是一个内核的 模块,进程可以通过访问这个VMA来跟内核进行一些通信。
小结关于进程虚拟地址空间的概念:
- 操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;
- 基本原则是将相同权限属性的、有想用映像文件的映射成一个VMA;
- 一个进程基本上可以分为如下几种VMA区域:
- 代码VMA,权限只读、可执行;有映像文件。
- 数据VMA,权限可读写、可执行;有映像文件。
- 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
- 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。
堆的最大申请数量
malloc的最大申请数量会受到哪些因素的影响呢?实际上,具体的数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。
段地址对齐
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是页大小的整数倍,并且这段空间在物理内存和进程虚拟地址空间的起始地址必须是页大小的整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应该尽量地优化自己的空间和地址的安排,以节省空间。
一种最简单的映射办法就是每个段分开映射,对于长度不足一个页的部分则占一个页。这种对齐方式在文件段的内部会有很多内部碎片,浪费磁盘空间。为了解决这种问题,有些UNIX系统采用了一个很取巧的办法,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次。这种映射方式下,对于一个物理页面来说,它可能同时包含了两个段的数据,甚至可能是多于两个段。
因为段地址对齐的关系,各个段的虚拟地址就往往不是系统页面长度的整数倍了。
进程栈初始化
我们知道进程刚开始启动的时候,需知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是草早系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是VMA中的Stack VMA)。
进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main()函数,也就是我们熟知的main()函数的两个argc和argv两个参数,这两个参数分别对应这里的命令行参数数量和命令行参数字符串指针数组。
Linux内核装载ELF过程简介
ELF可执行文件的装载过程(略)
Windows PE的装载
PE文件的装载跟ELF有所不同,由于PE文件中,所有段的起始地址都是页的倍数,段的长度如果不是页的整数倍,那么在映射时向上补齐到页的整数倍,我们也可以简单地认为在32位的PE文件中,段的起始地址 和长度都是4096字节的整数倍。由于这个特点,PE文件的映射过错会比ELF简单得多,因为它无需考虑如ELF里面诸多段地址对齐之类的问题,虽然这样会浪费一些磁盘和内存空间。PE可执行文件的段的数量一般很少,不像ELF中经常有十多个"Section",最后不得不使用“Segment”的概念把它们合并到一起装载,PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的几个段。
在讨论结构的具体装载过程之前,我们要先引入一个PE里面很常见的术语叫做RVA(Relative Virtual Address),它表示一个相对虚拟地址。其实它的概念很简单,就是相当于文件中的偏移量的东西。它是相对于PE文件的装载基地址的一个偏移地址。每个PE文件在装载时都会有一个装载目标地址(Target Address),这个地址就是所谓的基地址(Base Address)。由于PE文件被设计成可以装载到任何地址,所以这个基地址并不是固定的,每次装载时都可能会变化。如果PE文件中的地址都使用绝对地址它们都要随着基地址的变化而变化。但是,如果使用RVA这样一种基于基地址的相对地址,那么无论基地址怎么变化,PE文件中的各个RVA都保持一致。
装载一个PE可执行文件是个比ELF文件相对简单的过程:
- 先读取文件中的第一个页,在这个页中,包含了DOS头、PE文件头和段表。
- 检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。这个问题对于可执行文件来说基本不存在,因为它往往是进程第一个装入的模块,所以目标地址不太可能被占用。
- 使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。
- 如果装载地址不是目标地址,则进行Rebasing。
- 装载所有PE文件所需要的DLL文件
- 对PE文件中的所有导入符号进行解析
- 根据PE头中指定的参数,建立初始化栈和堆。
- 建立主线程并且启动进程。
成员 | 含义 |
---|---|
Image Base | PE文件的优先装载地址 |
AddressOfEntryPoint | PE装载器准备运行的PE文件的第一个指令的RVA |
SectionAlignment | 内存中段对齐的粒度 |
FileAlignment | 文件中段对齐的粒度 |
MajorSubsystemVersion、MinorSubsystemVersion | 程序运行所需要的Win32子系统版本 |
SizeOfImage | 内存中整个PE映像体的尺寸 |
SizeOfHeaders | 所有头+节表的大小,也就是等于文件尺寸减去文件中所有节的尺寸 |
Subsystem | NT用来识别PE文件术语哪个子系统 |
SizeOfCode | 代码段的长度 |
SizeOfInitializedData | 初始化了的数据段长度 |
SizeOfUninitializedData | 未初始化的数据段长度 |
BaseOfCode | 代码段起始RVA |
BaseOfData | 数据段起始RVA |