编译时使用WITH_PIC (-DWITH_PIC, --with-pic)参数的作用是什么?

16

从源代码编译二进制文件时,是否生成PIC对象或不生成,有哪些实际差别?在什么情况下,某人会说:“我在编译MySQL时应该生成/使用PIC对象。”或者不需要呢?

我阅读了Gentoo的介绍-位置无关代码位置无关代码内部修复-fPIC错误的HOWTOLibtool的创建对象文件位置无关代码

来自PHP的./configure --help

--with-pic:尝试仅使用PIC / non-PIC对象[默认=同时使用]。

来自MySQL的cmake -LAH .

-DWITH_PIC:生成PIC对象

这些信息是一个好的开始,但让我有很多问题。

据我所知,它会在编译器中打开-fPIC,从而在生成的二进制文件/库中生成PIC对象。为什么我要这样做?或反之亦然。也许风险更大或潜在地使二进制文件不太稳定?也许在某些架构(例如amd64 / x86_64)上编译时应避免使用它?

MySQL默认构建设置为PIC = OFF。正式的MySQL发布版本设置为PIC = ON。而PHP“尝试同时使用两者”。在我的测试中,设置-DWITH_PIC = ON会导致稍微更大的二进制文件:

          PIC=OFF     PIC=ON
mysql     776,160    778,528
mysqld  7,339,704  7,476,024

据我所知,PIC代码可以构建为共享库。非PIC代码则不行。现在,许多编译器只编译PIC代码。PIC代码略微比非PIC代码效率低;这就是为什么过去默认情况下不使用它的原因。 - Jonathan Leffler
4个回答

27

有两个概念不应混淆:

  1. 可重定位二进制文件
  2. 位置无关代码

它们都处理类似的问题,但在不同的层面上。

问题

大多数处理器体系结构都具有两种寻址方式:绝对和相对。寻址通常用于两种类型的访问:访问数据(读取、写入等)和执行代码的不同部分(跳转、调用等)。这两种方式都可以绝对地进行(调用固定地址上的代码,在固定地址上读取数据)或相对地进行(向后跳转五个指令,相对于指针读取)。

相对寻址通常会损失速度和内存。速度方面,因为处理器必须从指针和相对值计算出绝对地址,才能访问真正的内存位置或真正的指令。内存方面,则是需要存储额外的指针(通常在寄存器中存储,这很快但也非常稀缺)。

绝对寻址并不总是可行的,因为当其纯粹实现时,必须在编译时知道所有地址。在许多情况下,这是不可能的。当调用来自外部库的代码时,可能无法知道操作系统将加载该库的内存位置。在堆上寻址数据时,也无法预先知道操作系统将为此操作保留哪个堆块。

然后有许多技术细节。例如,处理器体系结构只允许相对跳转到一定限度;所有更宽的跳转必须是绝对的。或者在具有非常广泛地址范围(例如64位甚至128位)的体系结构中,相对寻址将导致更紧凑的代码(因为可以使用16位或8位进行相对寻址,但绝对寻址必须始终为64位或128位)。

可重定位二进制文件

当程序使用绝对地址时,它们对地址空间的布局作出了非常强烈的假设。操作系统可能无法满足所有这些假设。为了解决这个问题,大多数操作系统可以使用一个技巧:二进制文件被额外的元数据所丰富。操作系统利用这些元数据在运行时修改二进制文件,使修改后的假设适应当前情况。通常,元数据描述了二进制代码中使用绝对定位的指令的位置。当操作系统加载二进制文件时,如果必要的话,会更改存储在这些指令中的绝对地址。
例如,在 ELF 文件格式中,"重定位表" 就是这些元数据的一个示例。
一些操作系统使用一个技巧,以便不必在运行之前始终处理每个文件:他们预处理文件并更改数据,使它们的假设在运行时很可能符合情况(因此不需要修改)。这个过程在 Mac OS X 中称为 "prebinding",在 Linux 中称为 "prelink"。
可重定位二进制文件是在链接器级别生成的。

位置无关代码(PIC)

编译器可以生成只使用相对寻址的代码。这可能意味着数据和代码都使用相对寻址,或者只有其中一类。例如,gcc 上的选项 "-fPIC" 表示强制使用相对寻址的代码(即只有相对跳转和调用)。然后,该代码可以在任何内存地址上运行而无需进行任何修改。在某些处理器架构中,这样的代码并不总是可能的,例如当相对跳转在其范围内受到限制时(例如,最多允许 128 条指令宽的相对跳转)。
位置无关代码是在编译器级别处理的。仅包含 PIC 代码的可执行文件不需要重定位信息。

何时需要 PIC 代码

在一些特殊情况下,绝对需要 PIC 代码,因为加载时无法进行重定位。以下是一些例子:
  • 某些嵌入式系统可以直接从文件系统运行二进制文件,而无需先将它们加载到内存中。通常情况下,当文件系统已经在内存中时,例如在ROM或FLASH内存中时,才会这样做。此时,可执行文件的启动速度更快,且不需要额外的(通常很少的)RAM。此功能称为“执行就地”。
  • 您正在使用一些特殊的插件系统。一个极端的例子是所谓的“shell code”,即通过安全漏洞注入的代码。通常情况下,您将不知道您的代码将在运行时位于何处,而相关的可执行文件也不会为您的代码提供重定位服务。
  • 操作系统不支持可重定位的二进制文件(通常因为资源稀缺,例如在嵌入式平台上)
  • 操作系统可以在运行多个程序之间缓存常用内存页。当二进制文件在重定位期间发生改变时,此缓存将不再起作用(因为每个二进制文件都有其自己版本的重定位代码)。
  • 何时应避免使用PIC

    1. 在某些情况下,编译器可能无法使所有内容都是位置无关的(例如因为编译器不够“聪明”或处理器架构过于受限)
    2. 由于指针操作太多,位置无关代码可能过慢或过大。
    3. 优化器可能会因为指针操作太多而出现问题,导致它无法应用必要的优化,使得可执行文件运行缓慢。

    建议/结论

    必须使用PIC代码,是因为某些特殊约束。在所有其他情况下,请使用默认值。如果您不知道这样的约束,请勿使用“-fPIC”。


    1
    主要原因是当您创建一个将被其他系统或许多软件使用的对象时(例如,系统库或作为软件套件的一部分的库,如MySQL),我看到PIC被用于Linux。
    例如,您可以为PHP、Apache和可能是MySQL编写模块,这些模块需要被这些工具加载,并且将在某个“随机”地址加载它们,并能够以最小的代码工作来执行它们的代码。实际上,在大多数情况下,这些系统会检查您的模块是否是PIC(位置无关代码,如queen3所强调的)模块,如果不是,则拒绝加载您的模块。
    这使得大部分代码都可以运行而无需进行所谓的重定位。重定位是将代码加载到基地址后添加的地址修改库代码的操作(尽管这完全安全)。对于动态库来说,这很重要,因为每次它们被不同进程加载时,它们可能会被赋予不同的地址(请注意,这与安全性无关,仅与您的进程可用地址空间有关)。然而,重定位意味着每个版本都是不同的,因为正如我刚才所说,您修改了为每个进程加载的代码,因此每个进程在内存中都有不同的版本(这意味着动态加载库并不能发挥其本应具有的作用!)
    PIC机制创建一个表,如其他人所述,该表特定于您的进程,以及那些库使用的读/写内存(.data),但库的其余部分(.text和.rodata部分)保持不变,这意味着可以从一个位置使用它们,供许多进程使用(尽管该库的地址可能从每个进程的角度看是不同的,但请注意,这是所谓的MMU的副作用:内存管理单元,它可以为任何物理地址分配虚拟地址。)
    在旧的系统中,例如SGI的著名IRIX系统,机制是为每个动态库预分配一个基地址。这是一种预重定位方式,使得每个进程都可以在一个特定位置找到该动态库,从而使其真正可共享。但是当你有数百个共享库时,为每个库预分配虚拟地址几乎是不可能运行像今天这样的大型系统的。更不用说一个库可能会升级,现在就会挤掉分配给它右边的那个地址了...当时的MMU比今天的MMU不够灵活,PIC也没有被视为好的解决方案。
    回答你关于mysql的问题,-DWITH_PIC可能是一个好主意,因为许多工具始终在运行,所有这些库将被加载一次并由所有工具重复使用。因此,在运行时,它将更快。如果没有PIC功能,它肯定必须一遍又一遍地重新加载相同的库,浪费很多时间。因此,多几兆字节可以为您节省数百万个周期,当您24/7运行进程时,这是相当长的时间!
    我认为一个汇编小例子能更好地解释我们在这里讨论的内容...
    当您的代码需要跳转到某个位置时,最简单的方法是使用跳转指令:
    jmp $someplace
    

    在这种情况下,$someplace被称为绝对地址。这是一个问题,因为如果您在不同的位置(不同的基地址)加载代码,则$someplace也会发生变化。为了缓解这个问题,我们有了重定位。这是一个表格,告诉系统将基地址添加到$someplace,以便jmp实际按预期工作。
    当使用PIC时,具有绝对地址的跳转指令会以以下两种方式之一转换:通过表格跳转或使用相对地址跳转。
    jmp $function_offset[%ebx] ; jump to the table where function is defined at function_offset
    bra $someplace ; this is relative to IP so no need to change anything
    

    如您所见,我在这里使用特殊指令bra(分支)而不是跳转来实现相对跳转。如果跳转到代码中同一部分的另一个位置,则可能会使用此方法,尽管在某些处理器中,这种跳转非常有限(即-128到+127字节!),但是在新的处理器中,限制通常为+/-2Gb。
    然而,jmp(或者在英特尔上是call指令的jsr)通常用于跳转到不同的函数或超出同一节代码。这样处理函数间调用更加清晰。
    在很多方面,您的大部分代码已经在PIC中了,除了:
    当您调用另一个函数(除了内联或内置函数)时
    当您访问数据时
    对于数据,我们面临一个类似的问题,我们需要通过mov从地址加载值。
    mov %eax, [$my_data]
    

    %my_data是一个绝对地址,需要重定位(即编译器会保存$my_data相对于节开始的偏移量,在加载时将库加载的基址加到mov指令中地址的位置。)

    这就是我们的表格与%ebx寄存器发挥作用的地方。地址的起始位置在表格中的某个特定偏移量处找到,可以检索出来访问数据。这需要两个指令:

    mov %eax, $data_pointer[%ebx]
    mov %eax, $my_data_offset[%eax]
    

    我们首先加载指向数据缓冲区开头的指针,然后从该指针中加载数据本身。这样做会稍微慢一些,但是第一次加载将被处理器缓存,因此反复重新访问它将是瞬间完成的(没有实际的内存访问)。


    2
    我不同意使用PIC会更快这一说法。确实,在加载应用程序和库时,速度更快。但在运行时,当库已经加载且真正的代码被执行时,没有使用PIC更快,因为没有寄存器被保留,并且在每个函数的开始处都不需要运行PIC代码。我对PIC库进行了分析,发现有很多时间(2-3%)只是用于进行ebx调整。 - queen3
    @queen3 我同意,PIC格式的加载速度更快。额外的jmp指令是透明的,因为它很可能被处理器吞噬掉了。然而,EBX额外的工作在运行时明显拖慢了整个过程。 - Alexis Wilke

    1

    您想采用这种编译方式有两个原因。

    第一,如果您想制作共享库。通常,在Linux上,共享库必须是PIC格式。

    第二,您可能想将主可执行文件编译为"PIE",它基本上是可执行文件的PIC格式。PIE是一种安全功能,允许对主可执行文件应用地址空间随机化。


    谢谢Tom,所以如果我使用_pic=ON编译MySQL,然后编译另一个需要MySQL共享库的应用程序,那么我就可以了吗?另一方面,如果我使用_pic=OFF编译MySQL,我将无法与可能需要MySQL共享库的其他应用程序共享库,这听起来对吗? - Jeff
    我对MySQL的了解不足以回答这个问题。 - Tom Tromey
    请注意,PIC与安全无关。它是一种共享共享对象(动态库,扩展模块等)的.text部分(即代码)的方式,使所有进程之间共享。我不知道PIE,但在内存中随机化代码位置听起来对我来说并不像一个安全功能。您可以通过检查可执行文件的IP地址立即了解代码在哪里。 - Alexis Wilke
    地址空间随机化可以防止某些缓冲区溢出攻击。搜索“ASLR”。现在大多数操作系统都提供它。 - Tom Tromey

    1

    共享库和可执行文件可以启用和禁用PIC代码进行构建。也就是说,如果您没有使用PIC构建它们,它们仍然可以被其他应用程序使用。但是,在某些情况下不支持非PIC库 - 但在Linux上有一定的限制。

    === 这是一个简短的解释,您不需要 ;-) ===

    PIC的作用是使代码位置无关。每个共享库都会加载到内存中的某个位置 - 出于安全原因,这个位置经常是随机的 - 因此代码中的“绝对”内存引用实际上不能是“绝对”的 - 实际上它们是相对于库的内存段起始地址的。加载库后,必须进行调整。

    这可以通过遍历所有库(它们的地址将存储在文件头中)并进行更正来完成。但这很慢,并且如果基地址不同,则“更正”图像无法在进程之间共享。

    因此,通常使用不同的方法。对内存的每个引用都通过特殊寄存器(通常是ebx)进行。当调用函数时,在开始时跳转到一个特殊的代码块,该代码块将ebx值调整为库的内存段地址。然后函数使用[ebx +已知偏移量]访问其数据。

    因此,每个程序只需要调整这个代码块,而不是每个函数和内存引用。请注意,如果已知该函数从同一共享库的其他函数中调用,则编译器/链接器可以省略PIC寄存器(ebx)的调整,因为已知它已经具有正确的值。在某些体系结构(尤其是x86_64),程序可以访问相对于IP(当前指令指针)的数据,它已经是绝对调整的,因此它消除了像ebx这样的特殊寄存器及其调整的需要。
    那么为什么要构建没有PIC的东西呢?
    首先,它会使您的程序减慢几个百分点,因为在每个函数的开头都会运行一个额外的代码来调整寄存器,并且优化器不可用的一个宝贵寄存器(仅适用于x86)。通常,函数无法知道它是否从同一库或另一个库中调用,因此即使是内部调用也会受到惩罚。因此,如果您想优化速度,请尝试使用不带PIC的编译。
    然后,代码大小会稍微大一些,就像您注意到的那样,因为每个函数将包含一些额外的指令来设置PIC寄存器。
    如果我们使用链接时优化(--lto开关)和受保护的函数可见性,使编译器知道哪些函数根本没有被外部调用,因此它们不需要PIC代码,就可以在一定程度上避免这种情况。但我还没有尝试过(至少目前为止)。
    那么为什么要使用PIC呢?因为它更安全(这对于地址空间随机化是必需的);因为并非所有系统都支持非PIC库;因为非PIC库的启动加载时间可能会更慢(整个代码段必须调整为绝对地址,而不仅仅是表存根);如果将已加载的库段加载到不同的空间中,则无法共享,并且可能会导致更多的内存使用。此外,并非所有的编译器/链接器标志都与非PIC库兼容(从我记得有关线程局部支持的内容),因此有时你将无法构建非PIC代码。
    因此,非PIC代码有一定的风险(不太安全),并且您并非总是能够获得它,但如果您需要它(例如,为了速度)- 为什么不尝试呢?

    我不知道C语言,但在C++中,它们从未编译以通过IP访问数据。有几个原因:首先,它不是C++语言的一部分,因此实际上不能使用;但主要原因是数据被放置在.rodata或.data节中,这些节与.text节可能位于完全不同的地址。那么...不幸吗?嗯...并不是因为实际上IP的偏移量仅为16位,因此+/-32Kb。另一个使处理器功能几乎无用的巨大限制。 - Alexis Wilke

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接