14

从源代码编译二进制文件时,生成PIC 对象之间的实际区别是什么?在什么时候有人会说,“我应该在编译 MySQL 时生成/使用 PIC 对象。” 或不?

我已经阅读了Gentoo 的 Position Independent Code 简介Position Independent Code internalsHOWTO fix -fPIC errorsLibtool 的 Creating object filesPosition Independent Code

从 PHP 的./configure --help

--with-pic:尝试仅使用 PIC/非 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
4

4 回答 4

23

有两个概念不应该混淆:

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

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

问题

大多数处理器架构有两种寻址方式:绝对寻址和相对寻址。寻址通常用于两种类型的访问:访问数据(读取、写入等)和执行代码的不同部分(跳转、调用等)。两者都可以绝对完成(调用位于固定地址的代码,读取固定地址的数据)或相对(跳转到五个指令,相对于指针读取)。

相对寻址通常会消耗速度和内存。速度,因为处理器必须先根据指针和相对值计算绝对地址,然后才能访问实际内存位置或实际指令。内存,因为必须存储额外的指针(通常在寄存器中,这非常快但内存也非常稀缺)。

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

然后是很多技术细节。例如,处理器架构将只允许相对跳转到某个限制;所有更宽的跳跃都必须是绝对的。或者在地址范围很广(例如 64 位甚至 128 位)的架构上,相对寻址将导致代码更紧凑(因为相对地址可以使用 16 位或 8 位,但绝对地址必须始终为 64 位或128 位)。

可重定位的二进制文件

当程序使用绝对地址时,它们会对地址空间的布局做出非常强烈的假设。操作系统可能无法满足所有这些假设。为了缓解这个问题,大多数操作系统可以使用一个技巧:二进制文件丰富了额外的元数据。然后操作系统在运行时使用此元数据来更改二进制文件,因此修改后的假设适合当前情况。通常元数据描述二进制指令的位置,使用绝对定位。当操作系统随后加载二进制文件时,它会在必要时更改存储在这些指令中的绝对地址。

这些元数据的一个示例是 ELF 文件格式的“重定位表”。

一些操作系统使用了一个技巧,因此它们不需要总是在运行之前处理每个文件:它们预处理文件并更改数据,因此它们的假设很可能适合运行时的情况(因此不需要修改)。这个过程在 Mac OS X 上称为“预绑定”,在 Linux 上称为“预链接”。

可重定位二进制文​​件在链接器级别生成。

位置无关代码 (PIC)

编译器可以生成仅使用相对寻址的代码。这可能意味着数据和代码的相对寻址或仅针对这些类别之一。gcc 上的选项“-fPIC”例如意味着强制执行代码的相对寻址(即仅相对跳转和调用)。然后代码可以在任何内存地址上运行而无需任何修改。在某些处理器架构上,这样的代码并不总是可能的,例如当相对跳转在其范围内受到限制时(例如,最多允许 128 条指令宽的相对跳转)。

与位置无关的代码在编译器级别处理。只包含 PIC 代码的可执行文件不需要重定位信息。

什么时候需要 PIC 代码

在某些特殊情况下,绝对需要 PIC 代码,因为在加载期间重新定位是不可行的。一些例子:

  1. 一些嵌入式系统可以直接从文件系统运行二进制文件,而无需先将它们加载到内存中。当文件系统已经在内存中时,通常是这种情况,例如在 ROM 或闪存中。然后可执行文件启动得更快,并且不需要额外的(通常是稀缺的)RAM 部分。此功能称为“就地执行”。
  2. 您正在使用一些特殊的插件系统。一个极端的情况是所谓的“shell 代码”,即使用安全漏洞注入的代码。然后,您通常不知道您的代码在运行时将位于何处,并且相关的可执行文件不会为您的代码提供重定位服务。
  3. 操作系统不支持可重定位的二进制文件(通常是由于资源稀缺,例如在嵌入式平台上)
  4. 操作系统可以缓存正在运行的程序之间的公共内存页面。在重定位期间更改二进制文件时,此缓存将不再起作用(因为每个二进制文件都有自己的重定位代码版本)。

何时应避免 PIC

  1. 在某些情况下,编译器可能无法使所有位置独立(例如,因为编译器不够“聪明”或者因为处理器架构过于受限)
  2. 由于许多指针操作,位置无关代码可能太慢或太大。
  3. 优化器可能对许多指针操作有问题,因此它不会应用必要的优化,并且可执行文件将像糖蜜一样运行。

建议/结论

由于某些特殊限制,可能需要 PIC 代码。在所有其他情况下,坚持使用默认值。如果您不了解此类约束,则不需要“-fPIC”。

于 2013-08-12T11:18:22.387 回答
1

我看到在 Linux 下使用 PIC 的主要原因是当您创建一个将由另一个系统或许多软件使用的对象时(即系统库或作为软件套件一部分的库,如 MySQL。)

例如,您可以为 PHP、Apache 和可能的 MySQL 编写模块,这些模块需要由这些工具加载,这将发生在某个“随机”地址,他们将能够以最少的工作量执行其代码代码。实际上,在大多数情况下,这些系统会检查您的模块是否是 PIC(位置独立代码,如queen3 下划线)模块,如果不是,它们会拒绝加载您的模块。

这允许您的大部分代码运行而无需执行所谓的重定位。重定位是对加载代码的基地址的地址的补充,它会修改库的代码(尽管它是非常安全的。)这对于动态库很重要,因为每次它们都由不同的进程加载,它们可能被赋予不同的地址(请注意,这与安全性无关,仅与您的进程可用的地址空间有关。)但是,重定位意味着每个版本都是不同的,因为正如我刚才所说,您修改的代码是为每个进程加载,因此每个进程在内存中都有不同的版本(这意味着动态加载库的事实并没有像其他方式那样做!)

正如其他人所提到的,PIC 机制会创建一个特定于您的进程的表,这些库使用的读/写内存 (.data) 也是如此,但库的其余部分(.text 和 .rodata 部分)仍然存在完整意味着它可以被来自那个位置的许多进程使用(尽管该库的地址可能与每个进程的观点不同,请注意这是所谓的 MMU:内存管理单元的副作用,它可以将虚拟地址分配给任何物理地址。)

过去,在 SGI 著名的 IRIX 系统等系统中,机制是为每个动态库预先分配一个基地址。这是一个预重定位,这样每个进程都会在那个特定位置找到动态库,使其真正可共享。但是,当您拥有数百个共享库时,为每个共享库预先分配一个虚拟地址将使我们几乎不可能运行像我们今天这样的大型系统。而且我什至不会谈论这样一个事实,即一个库可能会升级,然后会碰到一个被分配地址的库……只有当时的 MMU 不如今天的通用,而 PIC 还没有被视为一个很好的解决方案。

要回答您关于 mysql 的问题,-DWITH_PIC 可能是一个好主意,因为许多工具一直在运行,所有这些库将被加载一次并被所有工具重用。所以在运行时,它会更快。如果没有 PIC 功能,它肯定必须一遍又一遍地重新加载同一个库,浪费大量时间。因此,再增加几 Mb 每秒可以为您节省数百万个周期,当您 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 (branch) 而不是跳转来获得相对跳转。如果您要跳转到同一段代码中的另一个位置,这是可能的,尽管在某些处理器中这种跳转非常有限(即 -128 到 +127 字节!)但对于较新的处理器,该限制通常为 +/-2Gb。

然而,jmp(或用于跳转到子程序的 jsr,在 INTEL 上是调用指令)通常在跳转到不同的函数或在同一段代码之外时使用。这对于处理函数间调用来说要干净得多。

在许多方面,您的大部分代码已经在 PIC 中,除了:

  • 当您调用另一个函数(内联或内部函数除外)时
  • 当您访问数据时

对于我们有类似问题的数据,我们想从一个带有 mov 的地址加载一个值:

mov %eax, [$my_data]

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

这就是我们的表与 %ebx 寄存器发挥作用的地方。地址的开头位于表中的某个特定偏移量处,可以检索它以访问数据。这需要两条指令:

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

我们首先加载指向数据缓冲区开头的指针,然后从该指针加载数据本身。它有点慢,但是第一次加载将由处理器缓存,因此一遍又一遍地重新访问它无论如何都是瞬时的(没有实际的内存访问。)

于 2013-08-06T00:01:46.853 回答
1

共享库和可执行文件可以在启用和禁用 PIC 代码的情况下构建。即,如果您在没有 PIC 的情况下构建它们,它们仍然可以被其他应用程序使用。但是,并非所有地方都支持非 PIC 库 - 但在 Linux 上存在,但有一些限制。

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

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

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

因此,通常使用不同的方法。对内存的每次引用都是通过一个特殊的寄存器(通常是 ebx)完成的。当一个函数被调用时,一开始它会跳转到一个特殊的代码块,该代码块将 ebx 值调整为库的内存段地址。然后该函数使用 [ebx + know offset] 访问其数据。

所以对于每个程序,只有这个代码块需要调整,而不是每个函数和内存引用。

请注意,如果知道函数是从同一个共享库的其他函数中调用的,则编译器/链接器可以省略 PIC 寄存器 (ebx) 调整,因为已知它已经具有正确的值。在某些架构(尤其是 x86_64)中,程序可以访问与 IP(当前指令指针)相关的数据,这已经是绝对调整的,因此它消除了对 ebx 等特殊寄存器及其调整的需要。

=== 不看可以跳过的部分到此结束 ===

那么你为什么要在没有 PIC 的情况下构建一些东西呢?

嗯,首先它会使你的程序减慢几个百分点,因为在每个函数的开始,都会运行一个额外的代码来调整寄存器,并且优化器没有一个宝贵的寄存器可用(仅限 x86)。通常函数不知道它是从同一个库还是从另一个库调用的,因此即使是内部调用也会受到惩罚。因此,如果您想优化速度 - 尝试在没有 PIC 的情况下进行编译。

然后,正如您所注意到的,代码大小有点大,因为每个函数将包含更多设置 PIC 寄存器的指令。

如果我们使用链接时优化(--lto 开关)和受保护的函数可见性,可以在某种程度上避免这种情况,以便编译器知道哪些函数根本不被外部调用,因此它们不需要 PIC 代码。但我还没有尝试过(还)。

为什么要使用 PIC?因为它更安全(这是地址空间随机化所必需的);因为并非所有系统都支持非 PIC 库;因为非 PIC 库的启动加载时间可能会更慢(整个代码段必须调整为绝对地址,而不仅仅是表存根);如果加载的库段被加载到不同的空间,则不能共享它们(即可能会导致使用更多的内存)。然后,并非所有编译器/链接器标志都与非 PIC 库兼容(据我记得有一些关于线程本地支持的内容),因此有时您根本无法构建非 PIC 代码。

因此,非 PIC 代码风险更大(安全性较低),您无法始终获得它,但如果您需要它(例如,为了速度) - 为什么不呢。

于 2013-08-05T20:56:53.407 回答
1

您想要以这种方式编译实际上有两个原因。

一,如果你想做一个共享库。通常,Linux 上的共享库必须是 PIC。

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

于 2013-08-04T01:53:32.533 回答