运行 PE 可执行文件
启动一个 PE 可执行程序的过程是相对简单的。
读入文件的第一页,其中有 DOS 头部,PE 头部和区段头部等。
确定地址空间的目标区域是否有效,如果不可用则另分配一块区域。
根据各区段头部的信息,将文件中的所有区段映射到地址空间的适当位置上。
如果文件并没有被加载到它的目标地址中,则进行重定位。
遍历导入区段中的 DLL 列表,将任何未加载的库都加载(该过程可以是递归的)。
解析所有在导入区段中的导入符号。
根据 PE 头部的值创建初始的栈和堆。
创建初始线程并启动该进程。
所有的现代链接器都可以处理库,即按照被链接程序的需要加入的目标文件集合。
一个库文件在创建后,链接器还要能够对它进行搜索。库的搜索通常发生在链接器的
bbs.theithome.com
第一遍扫描时,在所有单独的输入文件都被读入之后。如果一个或多个库具有符号目录,那
么链接器就将目录读入,然后根据链接器的符号表依次检查每个符号。如果该符号被使用但
是未定义,链接器就会将符号所属文件从库中包含进来。仅将文件标识为稍后加载是不够的,
链接器必须像处理那些在显式被链接的文件中的符号那样,来处理库里各个段中的符号。段
会记入段表,而符号,包括定义的和未定义的,都会记入全局符号表。一个库例程引用了另
一个库中例程的符号是相当普遍的现象,譬如诸如 printf 这样的高级 I/O 例程会引用像 put
c 或 write 这样的低级例程。
ELF 可执行文件
一个 ELF 可执行文件具有与可重定位 ELF 文件相同的通用格式,但对数据部分进行了调
整以使得文件可以被映射到内存中并运行。文件中会在 ELF 头部后面存在程序头部。程序头
bbs.theithome.com
部定义了要被映射的段。如图 15 所示为程序头部,是一个由段描述符组成的数组。
---------------------------------------------------------------------------------------------
图 3-15:ELF 程序头部
int type; //类型:可加载代码或数据,动态链接信息,等
int offset; //段在文件中的偏移量
int virtaddr; //映射段的虚拟地址
int physaddr; //物理地址,未使用
int filesize; //文件中的段大小
int memsize; //内存中的段大小(如果包含 BSS 的话会更大些)
int flags; //读,写,执行标志位
int align; //对齐要求,根据硬件页尺大小不同有变动
---------------------------------------------------------------------------------------------
一个可执行程序通常只有少数几种段,如代码和数据的只读段,可读写数据的可读写
段。所有的可加载区段都归并到适当类型的段中以便系统可以通过少数的一两个操作就可以
完成文件映射。
ELF 格式文件进一步扩展了 QMAGIC 格式的 a.out 文件中使用的“头部放入地址空间”的
技巧,以使得可执行文件尽可能的紧凑,相应付出的代价就是地址空间显得凌乱了些。一个
段可以开始和结束于文件中的任何偏移量处,但是段的虚拟起始必须和文件中起始偏移量具
有低位地址模对齐的关系,例如,必须起始于一页的相同偏移量处。系统必须将段起始所在
页到段结束所在页之间整个的范围都映射进来,哪怕在逻辑上该段只占用了被映射的第一页
和最后一页的一部分。图 16 所示为一个典型的段分布方式。
---------------------------------------------------------------------------------------------
图 3-16:ELF 可加载段
+---------------------+------------+------------+------------+
| | 文件偏移量 | 加载地址 | 类型 |
+---------------------+------------+-------------------------+
| ELF 头部 | 0 | 0x8000000 | |
+---------------------+------------+------------+------------+
| 程序头部 | 0x40 | 0x8000040 | |
+---------------------+------------+------------+------------+
| 只读文本 | 0x100 | 0x8000100 |可加载 |
|(尺寸为 0x4500) | | |可读,可执行|
+---------------------+------------+------------+------------+
| 可读/写数据 | 0x4600 | 0x8005600 |可加载 |
| (文件中尺寸为 0x2200| | |可读,可写 |
| 内存中尺寸为 0x3500)| | |可执行 |
+---------------------+------------+------------+------------+
| 不可加载信息和 | | | |
| 可选的区段头部 | | | |
bbs.theithome.com
+---------------------+------------+------------+------------+
---------------------------------------------------------------------------------------------
被映射的文本段包括 ELF 头部,程序头部,和只读文本,这样 ELF 头部和程序头部都会
在文本段开头的同一页中。文件中仅有的可读写数据段紧跟在文本段的后面。文件中的这一
页会同时被映射为内存中文本段的最后一页和数据段的第一页(以 copy-on-write 的方式)。
如果计算机具有 4K 的页,并在可执行文件中文本段结束于 0x80045ff,然后数据段起始于 0
x8005600。文件中的这一页(即同时存有文本和数据段的页)在内存 0x8004000 处被映射为
文本段的最后一页(头 0x600 个字节包含文本段中 0x8004000 到 0x80045ff 之间的内容),
并在 0x8005000 处被映射为数据段(0x600 以后的部分包含数据段从 0x8005600 到 0x80056ff
的内容)。
BSS 段也是在逻辑上也是跟在数据段的可读写区段后,在本例中长度为 0x1300 字节,
即文件中尺寸与内存中尺寸的差值。数据段的最后一页会从文件中映射进来,但是在随后操
作系统将 BSS 段清零时,copy-on-write 系统会该段做一个私有的副本。
如果文件中包含.init 或.fini 区段,这些区段会成为只读文本段的一部分,并且链接
器会在程序入口点处插入代码,使得在调用主程序之前会调用.init 段的代码,并在主程序
返回后调用.fini 区段的代码。
ELF 共享目标包含了可重定位和可执行文件的所有东西。它在文件的开头具有程序头部
表,随后是可加载段的各区段,包括动态链接信息。在构成可加载段的各区段之后的,是重
定位符号表和链接器在根据共享目标创建可执行程序时需要的其它信息,最后是区段表。
加载:
加载是将一个程序放到主存里使其能运行的过程。这一章我们看看加载过程,并将注
意力集中在加载那些已经链接好的程序。很多系统曾经都有过将链接和加载合为一体的链接
加载器,但是现在除了我知道的运行 MVS 的硬件和第十章将会谈到的动态链接器外,其它的
实际上已经基本消失了。链接加载器和单纯的加载器没有太大的区别,主要和最明显的区别
在于前者的输出放在内存重而不是在文件中。
基本加载
在第三章的目标文件设计中,我们已经接触了大多数加载的基本知识。依赖于程序是
通过虚拟内存系统被映射到进程地址空间,还是通过普通的 I/O 调用读入,加载会有一点小
小的差别。
在多数现代系统中,每一个程序被加载到一个新的地址空间,这就意味着所有的程序
都被加载到一个已知的固定地址,并可以从这个地址被链接。这种情况下,加载是颇为简单
的:
从目标文件中读取足够的头部信息,找出需要多少地址空间。
分配地址空间,如果目标代码的格式具有独立的段,那么就将地址空间按独立的段
划分。
将程序读入地址空间的段中。
将程序末尾的 bss 段空间填充为 0,如果虚拟内存系统不自动这么做得话。
如果体系结构需要的话,创建一个堆栈段(stack segment)。
设置诸如程序参数和环境变量的其他运行时信息。
开始运行程序。
如果程序不是通过虚拟内存系统映射的,读取目标文件就意味着通过普通的 read 系统
调用读取文件。在支持共享只读代码段的系统上,系统检查是否在内存中已经加载了该代码
段的一个拷贝,而不是生成另外一份拷贝。
在进行内存映射的系统上,这个过程会稍稍复杂一些。系统加载器需要创建段,然后
以页对齐的方式将文件页映射到段中,并赋予适当的权限,只读(RO)或写时复制(COW)。在
某些情况下,相同的页会被映射两次,一个在一个段的末尾,另一个在下一个段的开头,分
别被赋予 RO 和 COW 权限,格式上类似于紧凑的 UNIX a.out。由于数据段通常是和 bss 段是
紧挨着的,所以加载器会将数据段所占最后一页中数据段结尾以后的部分填充为 0(鉴于磁
盘版本通常会有一些符号之类的东西在那里),然后在数据分配足够的空页面覆盖 bss 段。
带重定位的基本加载
bbs.theithome.com
仅有一小部分系统还仍然为执行程序在加载时进行重定位,大多数都是为共享库在加
载时进行重定位。诸如 MS-DOS 的系统,很少使用硬件的重定位;另外一些如 MVS 的系统,
具有硬件重定位(却是从一个没有硬件重定位的系统继承来的);还有一些系统,具有硬件
重定位,但是却可以将多个可执行程序和共享库加载到相同的地址空间。所以链接器不能指
望某些特定地址是有效的。
如第七章讨论的,加载时重定位要比链接时重定位简单的多,因为整个程序作为一个
单元进行重定位。例如,如果一个程序被链接为从位置 0 开始,但是实际上被加载到位置 15
000,那么需要所有程序中的空间都要被修正为“加上 15000”。在将程序读入主存后,加载
器根据目标文件中的重定位项,并将重定位项指向的内存位置进行修改。
加载时重定位会表现出性能的问题,由于在每一个地址空间内的修正值均不同,所以
被加载到不同虚拟地址的代码通常不能在地址空间之间共享。MVS 使用的,并被 Windows 和
AIX 扩展的一种方法,使创建一个出现在多个地址空间的共享内存区域,并将常用的程序加
载到其中(MVS 将其称为 link pack 区域)。这仍然存在普通进程不能获取可写数据的单独复
本的问题,所以应用程序必须在编写时明确地为它可写区域分配空间。
启动一个 PE 可执行程序的过程是相对简单的。
读入文件的第一页,其中有 DOS 头部,PE 头部和区段头部等。
确定地址空间的目标区域是否有效,如果不可用则另分配一块区域。
根据各区段头部的信息,将文件中的所有区段映射到地址空间的适当位置上。
如果文件并没有被加载到它的目标地址中,则进行重定位。
遍历导入区段中的 DLL 列表,将任何未加载的库都加载(该过程可以是递归的)。
解析所有在导入区段中的导入符号。
根据 PE 头部的值创建初始的栈和堆。
创建初始线程并启动该进程。
所有的现代链接器都可以处理库,即按照被链接程序的需要加入的目标文件集合。
一个库文件在创建后,链接器还要能够对它进行搜索。库的搜索通常发生在链接器的
bbs.theithome.com
第一遍扫描时,在所有单独的输入文件都被读入之后。如果一个或多个库具有符号目录,那
么链接器就将目录读入,然后根据链接器的符号表依次检查每个符号。如果该符号被使用但
是未定义,链接器就会将符号所属文件从库中包含进来。仅将文件标识为稍后加载是不够的,
链接器必须像处理那些在显式被链接的文件中的符号那样,来处理库里各个段中的符号。段
会记入段表,而符号,包括定义的和未定义的,都会记入全局符号表。一个库例程引用了另
一个库中例程的符号是相当普遍的现象,譬如诸如 printf 这样的高级 I/O 例程会引用像 put
c 或 write 这样的低级例程。
ELF 可执行文件
一个 ELF 可执行文件具有与可重定位 ELF 文件相同的通用格式,但对数据部分进行了调
整以使得文件可以被映射到内存中并运行。文件中会在 ELF 头部后面存在程序头部。程序头
bbs.theithome.com
部定义了要被映射的段。如图 15 所示为程序头部,是一个由段描述符组成的数组。
---------------------------------------------------------------------------------------------
图 3-15:ELF 程序头部
int type; //类型:可加载代码或数据,动态链接信息,等
int offset; //段在文件中的偏移量
int virtaddr; //映射段的虚拟地址
int physaddr; //物理地址,未使用
int filesize; //文件中的段大小
int memsize; //内存中的段大小(如果包含 BSS 的话会更大些)
int flags; //读,写,执行标志位
int align; //对齐要求,根据硬件页尺大小不同有变动
---------------------------------------------------------------------------------------------
一个可执行程序通常只有少数几种段,如代码和数据的只读段,可读写数据的可读写
段。所有的可加载区段都归并到适当类型的段中以便系统可以通过少数的一两个操作就可以
完成文件映射。
ELF 格式文件进一步扩展了 QMAGIC 格式的 a.out 文件中使用的“头部放入地址空间”的
技巧,以使得可执行文件尽可能的紧凑,相应付出的代价就是地址空间显得凌乱了些。一个
段可以开始和结束于文件中的任何偏移量处,但是段的虚拟起始必须和文件中起始偏移量具
有低位地址模对齐的关系,例如,必须起始于一页的相同偏移量处。系统必须将段起始所在
页到段结束所在页之间整个的范围都映射进来,哪怕在逻辑上该段只占用了被映射的第一页
和最后一页的一部分。图 16 所示为一个典型的段分布方式。
---------------------------------------------------------------------------------------------
图 3-16:ELF 可加载段
+---------------------+------------+------------+------------+
| | 文件偏移量 | 加载地址 | 类型 |
+---------------------+------------+-------------------------+
| ELF 头部 | 0 | 0x8000000 | |
+---------------------+------------+------------+------------+
| 程序头部 | 0x40 | 0x8000040 | |
+---------------------+------------+------------+------------+
| 只读文本 | 0x100 | 0x8000100 |可加载 |
|(尺寸为 0x4500) | | |可读,可执行|
+---------------------+------------+------------+------------+
| 可读/写数据 | 0x4600 | 0x8005600 |可加载 |
| (文件中尺寸为 0x2200| | |可读,可写 |
| 内存中尺寸为 0x3500)| | |可执行 |
+---------------------+------------+------------+------------+
| 不可加载信息和 | | | |
| 可选的区段头部 | | | |
bbs.theithome.com
+---------------------+------------+------------+------------+
---------------------------------------------------------------------------------------------
被映射的文本段包括 ELF 头部,程序头部,和只读文本,这样 ELF 头部和程序头部都会
在文本段开头的同一页中。文件中仅有的可读写数据段紧跟在文本段的后面。文件中的这一
页会同时被映射为内存中文本段的最后一页和数据段的第一页(以 copy-on-write 的方式)。
如果计算机具有 4K 的页,并在可执行文件中文本段结束于 0x80045ff,然后数据段起始于 0
x8005600。文件中的这一页(即同时存有文本和数据段的页)在内存 0x8004000 处被映射为
文本段的最后一页(头 0x600 个字节包含文本段中 0x8004000 到 0x80045ff 之间的内容),
并在 0x8005000 处被映射为数据段(0x600 以后的部分包含数据段从 0x8005600 到 0x80056ff
的内容)。
BSS 段也是在逻辑上也是跟在数据段的可读写区段后,在本例中长度为 0x1300 字节,
即文件中尺寸与内存中尺寸的差值。数据段的最后一页会从文件中映射进来,但是在随后操
作系统将 BSS 段清零时,copy-on-write 系统会该段做一个私有的副本。
如果文件中包含.init 或.fini 区段,这些区段会成为只读文本段的一部分,并且链接
器会在程序入口点处插入代码,使得在调用主程序之前会调用.init 段的代码,并在主程序
返回后调用.fini 区段的代码。
ELF 共享目标包含了可重定位和可执行文件的所有东西。它在文件的开头具有程序头部
表,随后是可加载段的各区段,包括动态链接信息。在构成可加载段的各区段之后的,是重
定位符号表和链接器在根据共享目标创建可执行程序时需要的其它信息,最后是区段表。
加载:
加载是将一个程序放到主存里使其能运行的过程。这一章我们看看加载过程,并将注
意力集中在加载那些已经链接好的程序。很多系统曾经都有过将链接和加载合为一体的链接
加载器,但是现在除了我知道的运行 MVS 的硬件和第十章将会谈到的动态链接器外,其它的
实际上已经基本消失了。链接加载器和单纯的加载器没有太大的区别,主要和最明显的区别
在于前者的输出放在内存重而不是在文件中。
基本加载
在第三章的目标文件设计中,我们已经接触了大多数加载的基本知识。依赖于程序是
通过虚拟内存系统被映射到进程地址空间,还是通过普通的 I/O 调用读入,加载会有一点小
小的差别。
在多数现代系统中,每一个程序被加载到一个新的地址空间,这就意味着所有的程序
都被加载到一个已知的固定地址,并可以从这个地址被链接。这种情况下,加载是颇为简单
的:
从目标文件中读取足够的头部信息,找出需要多少地址空间。
分配地址空间,如果目标代码的格式具有独立的段,那么就将地址空间按独立的段
划分。
将程序读入地址空间的段中。
将程序末尾的 bss 段空间填充为 0,如果虚拟内存系统不自动这么做得话。
如果体系结构需要的话,创建一个堆栈段(stack segment)。
设置诸如程序参数和环境变量的其他运行时信息。
开始运行程序。
如果程序不是通过虚拟内存系统映射的,读取目标文件就意味着通过普通的 read 系统
调用读取文件。在支持共享只读代码段的系统上,系统检查是否在内存中已经加载了该代码
段的一个拷贝,而不是生成另外一份拷贝。
在进行内存映射的系统上,这个过程会稍稍复杂一些。系统加载器需要创建段,然后
以页对齐的方式将文件页映射到段中,并赋予适当的权限,只读(RO)或写时复制(COW)。在
某些情况下,相同的页会被映射两次,一个在一个段的末尾,另一个在下一个段的开头,分
别被赋予 RO 和 COW 权限,格式上类似于紧凑的 UNIX a.out。由于数据段通常是和 bss 段是
紧挨着的,所以加载器会将数据段所占最后一页中数据段结尾以后的部分填充为 0(鉴于磁
盘版本通常会有一些符号之类的东西在那里),然后在数据分配足够的空页面覆盖 bss 段。
带重定位的基本加载
bbs.theithome.com
仅有一小部分系统还仍然为执行程序在加载时进行重定位,大多数都是为共享库在加
载时进行重定位。诸如 MS-DOS 的系统,很少使用硬件的重定位;另外一些如 MVS 的系统,
具有硬件重定位(却是从一个没有硬件重定位的系统继承来的);还有一些系统,具有硬件
重定位,但是却可以将多个可执行程序和共享库加载到相同的地址空间。所以链接器不能指
望某些特定地址是有效的。
如第七章讨论的,加载时重定位要比链接时重定位简单的多,因为整个程序作为一个
单元进行重定位。例如,如果一个程序被链接为从位置 0 开始,但是实际上被加载到位置 15
000,那么需要所有程序中的空间都要被修正为“加上 15000”。在将程序读入主存后,加载
器根据目标文件中的重定位项,并将重定位项指向的内存位置进行修改。
加载时重定位会表现出性能的问题,由于在每一个地址空间内的修正值均不同,所以
被加载到不同虚拟地址的代码通常不能在地址空间之间共享。MVS 使用的,并被 Windows 和
AIX 扩展的一种方法,使创建一个出现在多个地址空间的共享内存区域,并将常用的程序加
载到其中(MVS 将其称为 link pack 区域)。这仍然存在普通进程不能获取可写数据的单独复
本的问题,所以应用程序必须在编写时明确地为它可写区域分配空间。