尽管架构之间的细节差异很大,但我在这里所说的同样适用于 32 位 x86、64 位 x86,以及 ARM 和 PowerPC:面对相同的问题,几乎所有架构设计人员都使用了类似的解决方案。
在汇编级别有(粗略地说)四种“访问”,它们与“位置无关”系统相关:有函数调用(call
操作码)和数据访问,两者都可以针对同一实体中的任何一个实体对象(其中对象是“共享对象”,即 DLL 或可执行文件本身)或在另一个对象中。对堆栈变量的数据访问在这里无关紧要;我说的是对全局变量或静态常量数据的数据访问(特别是在源代码级别显示为文字字符串的内容)。在 C++ 上下文中,虚方法在内部由特殊表(称为“vtables”)中的函数指针引用;数据访问也是如此,即使方法是代码。
的call
操作码使用作为目标地址相对:它是一个偏移量计算出的电流指令指针之间(在技术上,该参数后的第一个字节call
码)和呼叫目标地址。这意味着同一对象内的函数调用可以在(静态)链接时完全解决;它们不会出现在动态符号表中,并且它们是“与位置无关的”。另一方面,对其他对象的函数调用(跨 DLL 调用,或从可执行文件到 DLL 的调用)必须通过一些由动态链接器处理的间接。该call
操作码仍必须跳“的地方”,并且动态连接要动态地进行调整。该格式试图实现两个特征:
- 惰性链接:调用目标仅在第一次使用时才被查找和解析。
- 共享页面:内存中的结构应尽可能与可执行文件中的相应字节保持相同,以促进多个调用之间的共享(如果两个进程加载相同的 DLL,则代码应仅在 RAM 中出现一次)和更容易分页(当 RAM 紧张时,作为文件中数据块的未修改副本的页面可以从物理 RAM 中逐出,因为它可以随意重新加载)。
由于共享是基于每页的,这意味着应该避免动态更改call
参数(操作码之后的几个字节)。call
相反,编译后的代码使用一个全局偏移表(或几个——我稍微简化一下)。基本上,call
跳转到执行实际调用的一小段代码,并受到动态链接器的修改。对于给定的对象,所有这些小包装器都存储在动态链接器将修改的页面中;这些页面与代码的偏移量是固定的,因此参数call
是在静态链接时计算的,不需要从源文件中修改。首次加载对象时,所有包装器都指向一个动态链接器函数,该函数在第一次调用时进行链接;该函数修改包装器本身以指向已解析的目标,以供后续调用。装配级别的杂耍很复杂,但效果很好。
数据访问遵循类似的模式,但它们没有相对寻址。也就是说,数据访问将使用绝对地址。该地址将在寄存器中计算,然后用于访问。CPU的x86行可以直接将绝对地址作为操作码的一部分;对于具有固定大小操作码的 RISC 体系结构,地址将作为两个或三个连续指令加载。
在非 PIE 可执行文件中,静态链接器知道数据元素的目标地址,后者可以直接在执行访问的操作码中对其进行硬编码。在 PIE 可执行文件或 DLL 中,这是不可能的,因为在执行之前目标地址是未知的(它取决于将加载到 RAM 中的其他对象,也取决于 ASLR)。相反,二进制代码必须再次使用 GOT。GOT 地址被动态计算到基址寄存器中。在 32 位 x86 上,基址寄存器是常规%ebx
的,以下代码是典型的:
call nextaddress
nextaddress:
popl %ebx
addl somefixedvalue, %ebx
第一个call
简单地跳转到下一个操作码(所以这里的相对地址只是一个零);因为这是 a call
,所以它将返回地址(也是popl
操作码的地址)压入堆栈,然后popl
提取它。此时,%ebx
包含 的地址popl
,因此一个简单的加法修改该值以指向 GOT 的开头。然后可以相对于 进行数据访问%ebx
。
那么通过将可执行文件编译为 PIE 会改变什么?其实不多。“PIE 可执行文件”意味着使主可执行文件成为 DLL,并像任何其他 DLL 一样加载和链接它。这意味着以下内容:
- 函数调用未修改。
- 从主可执行文件中的代码到主可执行文件中的数据元素的数据访问会产生一些额外的开销。所有其他数据访问均未更改。
数据访问的开销是由于使用传统寄存器指向 GOT:一个额外的间接寻址,一个用于此功能的寄存器(这会影响寄存器匮乏的架构,如 32 位 x86),以及一些额外的代码来重新计算指向 GOT 的指针。
但是,与对局部变量的访问相比,数据访问已经有些“慢”,因此编译后的代码已经尽可能缓存了此类访问(变量值保存在寄存器中,仅在需要时刷新;即使刷新,变量地址也保存在寄存器中)。由于全局变量在线程之间共享,因此更是如此,因此大多数使用此类全局数据的应用程序代码仅以只读方式使用它(执行写入时,它们是在互斥锁的保护下完成的,并且无论如何抓住互斥体都会产生更大的成本)。大多数 CPU 密集型代码将在寄存器和堆栈变量上工作,并且不会因使代码与位置无关而受到影响。
最多,将代码编译为 PIE 将意味着典型代码的大小开销约为 2%,而对代码效率没有可衡量的影响,所以这几乎不是问题(我从与参与 OpenBSD 开发的人讨论中得到这个数字;在尝试将准系统系统安装在引导软盘上的非常特殊的情况下,“+2%”对他们来说是个问题)。
不过,非 C/C++ 代码可能会遇到 PIE 问题。在生成编译代码时,编译器必须“知道”它是针对 DLL 还是针对静态可执行文件,以包含找到 GOT 的代码块。Linux 操作系统中不会有很多可能会导致问题的包,但 Emacs 将成为麻烦的候选者,它具有 Lisp 转储和重新加载功能。
请注意,Python、Java、C#/.NET、Ruby...中的代码完全超出了这一切的范围。PIE 用于 C 或 C++ 中的“传统”代码。