可执行文件只有装载到内存以后才能被CPU执行。
1. 进程虚拟地址空间
程序和进程有什么区别:程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时(Runtime)也有一定的含义。
每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址为0到2^32-1,即0x00000000~0xFFFFFFFF,也就是我们常说的4GB虚拟空间大小;而64位的硬件平台具有64位寻址能力,它的虚拟地址空间达到了2^64字节,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,总共17179869184GB。
从程序的角度看,我们可以通过判断C语言程序中的指针所占的空间来计算虚拟地址空间的大小。一般来说,C语言指针大小的位数与虚拟地址空间的位数相同,如32位平台下的指针为32位,即4字节;64位平台下的指针为64位,即8字节。当然有些特殊情况下,这种规则不成立。
在下文中以32位的地址空间为主,64位的与32位类似。
那么32位平台下的4GB虚拟空间,我们的程序是否可以任意使用呢?不行。因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。我们经常在Windows下碰到”进程因非法操作需要关闭”或Linux下的”Segmentation fault”很多时候是因为访问了未经允许的地址。
PAE(Physical Address Extension):从硬件层面上来讲,原先的32位地址线只能访问最多4GB的物理内存。但是自从扩展至36位地址线之后,Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存。Intel把这个地址扩展方式叫做PAE。扩展的物理地址空间,对于普通应用程序来说正常情况下感觉不到它的存在,因为这主要是操作系统的事,在应用程序里,只有32位的虚拟地址空间。那么应用程序该如何使用这些大于常规的内存空间呢?一个很常见的方法就是操作系统提供一个窗口映射的方法,把这些额外的内存映射到进程地址空间中来。在Windows下,这种访问内存的操作方式叫做AWE(Address Windowing Extensions);而像Linux等UNIX类操作系统则采用mmap()系统调用来实现。
2. 装载的方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的方法就是将程序运行所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的方法。但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存。程序运行时是有局部性原理,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想就是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
覆盖装入:在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。覆盖装入的方法把挖掘内存嵌入的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。这个小的辅助代码就是所谓的覆盖管理器(Overlay Manager)。覆盖装入是典型的利用时间换取空间的方法。
页映射:是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。与覆盖装入的原理相似,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照”页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位都是页。硬件规定页的大小有4096字节、8192字节、2MB、4MB等。
3. 从操作系统角度看可执行文件的装载
进程的建立:从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其它进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建。创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
(1). 首先是创建虚拟地址空间:一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
(2). 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系:上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该”缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上”装载”的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image)。
Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area),在Windows中将这个叫做虚拟段(Virtual Section),其实它们都是同一个概念。
(3). 将CPU指令寄存器设置成可执行文件入口,启动运行:操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。可执行文件入口即是ELF文件头中保存的入口地址。
页错误(Page Fault):随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页来满足进程执行的需求。
4. 进程虚存空间分布
ELF文件链接视图和执行视图:在一个正常的进程中,可执行文件中包含的往往不止代码段,还有数据段、BSS等,所以映射到进程虚拟空间的往往不止一个段。当段的数量增多时,就会产生空间浪费的问题。ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。一个ELF文件中往往有十几个段,那么内存空间的浪费是可想而知的。
ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:(1).以代码段为代表的权限为可读可执行的段;(2).以数据段和BSS段为代表的权限为可读可写的段;(3).以只读数据段为代表的权限为只读的段。对于相同权限的段,把它们合并到一起当作一个段进行映射。
ELF可执行文件引入了一个概念叫做”Segment”,一个”Segment”包含一个或多个属性类似的”Section”。从链接的角度看,ELF文件是按”Section”存储的;从装载的角度看,ELF文件又可以按照”Segment”划分。”Segment”的概念实际上是从装载的角度重新划分了ELF的各个段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。比如可读可执行的段都放在一起,这种段的典型是代码段;可读可写的段放在一起,这种段的典型是数据段。在ELF中把这些属性相似的、又连在一起的段叫做一个”Segment”,而系统正是按照”Segment”而不是”Section”来映射可执行文件的。
下面是一个很小的例子程序(SectionMapping.c):
#include
int main()
{
while (1) {
sleep(1000);
}
return 0;
}
使用静态链接的方式将其编译链接成可执行文件SectionMapping.elf,执行:
gcc -static SectionMapping.c -o SectionMapping.elf
使用readelf可以看到,可执行文件SectionMappint.elf中总共有31个段(Section),如下图所示:
正如描述”Section”属性的结构叫做段表,描述”Segment”的结构叫程序头(Program Header),它描述了ELF文件该如何被操作系统映射到进程的虚拟空间,执行结果如下图所示:
可以看到,这个可执行文件共有6个Segment。从装载的角度看,目前只关心两个”LOAD”类型的Segment,因为只有它是需要被映射的,其它的诸如”NOTE”、”TLS”、”GNU_STACK”都是在装载时起辅助作用的。所有相同属性的”Section”被归类到一个”Segment”,并且映射到同一个VMA。总的来说,”Segment”和”Section”是从不同的角度来划分同一个ELF文件。这个在ELF中被称为不同的视图(View),从”Section”的角度来看ELF文件就是链接视图(Linking View),从”Segment”的角度来看就是执行视图(Execution View)。当我们在谈到ELF装载时,”段”专门指”Segment”;而在其它的情况下,”段”指的是”Section”。
ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存”Segment”的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。跟段表结构一样,程序头表也是一个结构体数组,它的结构体Elf32_Phdr或Elf64_Phdr(声明在/usr/include/elf.h)如下:
/* Program segment header. */
typedef struct
{
Elf32_Word p_type; /* Segment type */
Elf32_Off p_offset; /* Segment file offset */
Elf32_Addr p_vaddr; /* Segment virtual address */
Elf32_Addr p_paddr; /* Segment physical address */
Elf32_Word p_filesz; /* Segment size in file */
Elf32_Word p_memsz; /* Segment size in memory */
Elf32_Word p_flags; /* Segment flags */
Elf32_Word p_align; /* Segment alignment */
} Elf32_Phdr;
typedef struct
{
Elf64_Word p_type; /* Segment type */
Elf64_Word p_flags; /* Segment flags */
Elf64_Off p_offset; /* Segment file offset */
Elf64_Addr p_vaddr; /* Segment virtual address */
Elf64_Addr p_paddr; /* Segment physical address */
Elf64_Xword p_filesz; /* Segment size in file */
Elf64_Xword p_memsz; /* Segment size in memory */
Elf64_Xword p_align; /* Segment alignment */
} Elf64_Phdr;
Elf32_Phdr或Elf64_Phdr结构体的几个成员与使用”readelf -l”打印文件头表显示的结果一一对应。结构体的各个成员的基本含义,如下表所示:
对于”LOAD”类型的”Segment”来说,p_memsz的值不可以小于p_filesz,否则就是不符合常理的。如果p_memsz大于p_filesz,就表示该”Segment”在内存中所分配的空间大小超过文件中实际的大小,这部分”多余”的部分则全部填充为”0”。这样做的好处是,我们在构造ELF可执行文件时不需要再额外设立BSS的”Segment”了,可以把数据”Segment”的p_memsz扩大,那些额外的部分就是BSS。因为数据段和BSS的唯一区别就是:数据段从文件中初始化内容,而BSS段的内容全都初始化为0。这也就是在前面的例子中只看到了两个”LOAD”类型的段,而不是三个,BSS已经被合并到了数据类型的段里面。
堆和栈:在操作系统里面,VMA除了被用来映射可执行文件中的各个”Segment”以外,它还可以有其它的作用,操作系统通过使用VMA来对进程的地址空间进行管理。进程在执行的时候它还需要用到栈(Stack)、堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。
在Linux下,可以通过查看”/proc”来查看进程的虚拟空间分布,如下图所示:
上图的输出结果中:第一列是VMA的地址范围;第二列是VMA的权限,”r”表示可读,”w”表示可写,”x”表示可执行,”p”表示私有(COW, Copy on Write),”s”表示共享。第三列是偏移,表示VMA对应的Segment在映像文件中的偏移;第四列表示映像文件所在设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。我们可以看到进程中有8个VMA,只有前两个是映射到可执行文件中的两个Segment。另外六个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存地址(Anonymous Virtual Memory Area)。我们可以看到有两个区域分别是堆(Heap)和栈(Stack),它们的大小分别为(0x0122d000-0x0120a000)/1024=140KB和(0x7ffc01c44000-0x7ffc01c23000)/1024=132KB。这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理。栈一般也叫做堆栈,每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。另外有一个很特殊的VMA叫做”vdso”,它的地址已经位于内核空间了,事实上它是一个内核的模块,进程可以通过访问这个VMA来跟内核进行一些通信。
进程虚拟地址空间的概念:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种VMA区域:(1). 代码VMA,权限只读、可执行;有映像文件。(2). 数据VMA,权限可读写、可执行;有映像文件。(3). 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。(4). 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。当我们在讨论进程虚拟空间的”Segment”的时候,基本上就是指上面的几种VMA。
堆的最大申请数量:32位,Linux下虚拟地址空间分给进程本身的是3GB(Windows默认是2GB),一般程序中使用malloc()函数进行地址空间的申请,那么malloc的最大申请数量会受到操作系统版本、程序本身大小、用到的动态/共享库数量大小、程序栈数量大小等,甚至有可能每次最大可申请数量都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。
段地址对齐:可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。对于Intel 80x86系列处理器来说,默认的页大小为4096字节,也就是说,我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是4096的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是4096的整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应该尽量地优化自己的空间和地址的安排,以节省空间。在ELF文件中,对于任何一个可装载的”Segment”,它的p_vaddr除以对齐属性的余数等于p_offset除以对齐属性的余数。
进程栈初始化:进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是VMA中的Stack VMA)。
5. Linux内核装载ELF过程简介
当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。execve()系统调用被声明在/usr/include/unistd.h中。Glibc对execve()系统调用进行了包装,提供了execl()、execlp()、execle()、execv()和execvp()等5个不同形式的exec系列API,它们只是在调用的参数形式上有所区别,但最终都会调用到execve()这个系统中。
在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节,目的是判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。比如ELF的可执行文件格式的头4个字节为0x7F、’E’、’L’、’F’;而Java的可执行文件格式的头4个字节为’c’、’a’、’f’、’e’;如果被执行的是Shell脚本或perl、python等这种解释型语言的脚本,那么它的第一行往往是”#!/bin/sh”或”#!/usr/bin/perl”或”#!/usr/bin/python”,这时候前两个字节’#’和”!”就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当do_execve()读取了这128个字节的文件头部以后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF可执行文件的装载处理过程叫做load_elf_binary();a.out可执行文件的装载处理过程叫做load_aout_binary();而装载可执行脚本程序的处理过程叫做load_script()。
load_elf_binary()的主要步骤是:
(1). 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
(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可执行文件加载完成。
6. Windows PE的装载
PE文件的装载跟ELF有所不同,由于PE文件中,所有段的起始地址都是页的倍数,段的长度如果不是页的整数倍,那么在映射时向上补齐到页的整数倍,我们也可以简单地认为在32位的PE文件中,段的起始地址和长度都是4096字节的整数倍。由于这个特点,PE文件的映射过程会比ELF简单得多,因为它无须考虑如ELF里面诸多段地址对齐之类的问题,虽然这种会浪费一些磁盘和内存空间。PE可执行文件的段的数量一般很少,不像ELF中经常有十多个”Section”,最后不得不使用”Segment”的概念把它们合并到一起装载,PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的几个段。
PE里面很常见的术语叫做RVA(Relative Virtual Address),它表示一个相对虚拟地址,就是相当于文件中的偏移量的东西。它是相对于PE文件的装载基地址的一个偏移地址。比如,一个PE文件被装载到虚拟地址(VA)0x00400000,那么一个RVA为0x1000的地址就是0x00401000。每个PE文件在装载时都会有一个装载目标地址(Target Address),这个地址就是所谓的基地址(Base Address)。由于PE文件被设计成可以装载到任何地址,所以这个基地址并不是固定的,每次装载时都可能会变化。如果PE文件中的地址都使用绝对地址,它们都要随着基地址的变化而变化。但是,如果使用RVA这样一种基于基地址的相对地址,那么无论基地址怎么变化,PE文件中的各个RVA都保持一致。
装载一个PE可执行文件过程:
(1). 先读取文件的第一个页,在这个页中,包含了DOS头、PE文件头和段表。
(2). 检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。这个问题对于可执行文件来说基本不存在,因为它往往是进程第一个装入的模块,所以目标地址不太可能被占用。主要是针对DLL文件的装载而言的。
(3). 使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。
(4). 如果装载地址不是目标地址,则进行Rebasing。
(5). 装载所有PE文件所需要的DLL文件。
(6). 对PE文件中的所有导入符号进行解析。
(7). 根据PE头中指定的参数,建立初始化栈和堆。
(8). 建立主线程并且启动进程。
PE文件中,与装载相关的主要信息都包含在PE扩展头(PE Optional Header)和段表。
GitHub:https://github.com/fengbingchun/Messy_Test