为啥对共享库本身中定义的符号使用全局偏移表?

Posted

技术标签:

【中文标题】为啥对共享库本身中定义的符号使用全局偏移表?【英文标题】:Why use the Global Offset Table for symbols defined in the shared library itself?为什么对共享库本身中定义的符号使用全局偏移表? 【发布时间】:2019-08-30 10:08:24 【问题描述】:

考虑以下简单的共享库源代码:

library.cpp:

static int global = 10;

int foo()

    return global;

在 clang 中使用 -fPIC 选项编译,它会生成这个对象程序集 (x86-64):

foo(): # @foo()
  push rbp
  mov rbp, rsp
  mov eax, dword ptr [rip + global]
  pop rbp
  ret
global:
  .long 10 # 0xa

由于符号是在库中定义的,因此编译器按预期使用 PC 相对寻址:mov eax, dword ptr [rip + global]

但是,如果我们将 static int global = 10; 更改为 int global = 10; 使其成为具有外部链接的符号,则生成的程序集是:

foo(): # @foo()
  push rbp
  mov rbp, rsp
  mov rax, qword ptr [rip + global@GOTPCREL]
  mov eax, dword ptr [rax]
  pop rbp
  ret
global:
  .long 10 # 0xa

如您所见,编译器使用全局偏移表添加了一个间接层,在这种情况下这似乎完全没有必要,因为符号仍然在同一个库(和源文件)中定义。

如果符号是在另一个共享库中定义的,则 GOT 将是必要的,但在这种情况下感觉是多余的。为什么编译器仍在将这个符号添加到 GOT 中?

注意:我相信this question 与此类似,但是可能由于缺乏细节,答案并不相关。

【问题讨论】:

事实上,共享库符号可以被其他库重新定义。因此代码可以使用另一个库中的新符号结束。将其设置为外部(即公开)您允许重新定义它。我不记得这个功能的确切名称了。 这不是违反 ODR 规则吗? 我不记得确切的细节,但 ODR 是 C++ 的东西,而这是一个加载器机制。每个共享库只有一个符号定义。实际上,“重新定义”不是正确的术语,但我不记得技术术语了。 好的,找到了。符号可以是interposed。 @MargaretBloom:是的,这是我要链接的博客文章,以了解有关 Linux/Unix 动态链接的更多信息。如果您不需要/不希望符号参与符号插入,那么您希望将 ELF 可见性设置为 hidden 的原因是对您自己的全局变量和函数的低效访问定义/使用相同名称的库有自己的符号定义私有副本。 【参考方案1】:

全局偏移表有两个目的。一种是允许动态链接器“插入”与可执行文件或其他共享对象不同的变量定义。第二个是允许在某些处理器架构上为引用变量生成位置无关代码。

ELF 动态链接将整个进程、可执行文件和所有共享对象(动态库)视为共享一个全局命名空间。如果多个组件(可执行或共享对象)定义相同的全局符号,则动态链接器通常选择该符号的一个定义,并且所有组件中对该符号的所有引用都引用该定义。 (但是,ELF 动态符号解析很复杂,并且由于各种原因,不同的组件最终可能会使用同一个全局符号的不同定义。)

为了实现这一点,在构建共享库时,编译器将通过 GOT 间接访问全局变量。对于每个变量,将在 GOT 中创建一个条目,其中包含指向该变量的指针。正如您的示例代码所示,编译器随后将使用此条目来获取变量的地址,而不是尝试直接访问它。当共享对象被加载到进程中时,动态链接器将确定是否有任何全局变量已被另一个组件中的变量定义所取代。如果是这样,这些全局变量将更新其 GOT 条目以指向替代变量。

通过使用“隐藏”或“受保护”ELF 可见性属性,可以防止全局定义的符号被另一个组件中的定义取代,从而消除在某些架构上使用 GOT 的需要。例如:

extern int global_visible;
extern int global_hidden __attribute__((visibility("hidden")));
static volatile int local;  // volatile, so it's not optimized away

int
foo() 
    return global_visible + global_hidden + local;

当使用-O3 -fPIC 和 GCC 的 x86_64 端口编译时:

foo():
        mov     rcx, QWORD PTR global_visible@GOTPCREL[rip]
        mov     edx, DWORD PTR local[rip]
        mov     eax, DWORD PTR global_hidden[rip]
        add     eax, DWORD PTR [rcx]
        add     eax, edx
        ret 

如您所见,只有global_visible 使用GOT,global_hiddenlocal 不使用它。 “受保护”可见性的工作方式类似,它可以防止定义被取代,但使其对动态链接器仍然可见,因此其他组件可以访问它。 “隐藏”可见性将符号完全隐藏在动态链接器中。

为了允许共享对象在不同进程中加载​​不同地址而使代码可重定位的必要性意味着静态分配的变量,无论它们具有全局作用域还是局部作用域,在大多数情况下都不能用一条指令直接访问架构。正如您在上面看到的,我知道的唯一例外是 64 位 x86 架构。它支持与 PC 相关且具有大 32 位位移的内存操作数,这些位移可以到达同一组件中定义的任何变量。

在我熟悉的所有其他架构中,以位置相关的方式访问变量需要多条指令。具体如何因架构而异,但通常涉及使用 GOT。例如,如果您使用 GCC 的 x86_64 端口编译上面的示例 C 代码,使用 -m32 -O3 -fPIC 选项,您会得到:

foo():
        call    __x86.get_pc_thunk.dx
        add     edx, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
        push    ebx
        mov     ebx, DWORD PTR global_visible@GOT[edx]
        mov     ecx, DWORD PTR local@GOTOFF[edx]
        mov     eax, DWORD PTR global_hidden@GOTOFF[edx]
        add     eax, DWORD PTR [ebx]
        pop     ebx
        add     eax, ecx
        ret
__x86.get_pc_thunk.dx:
        mov     edx, DWORD PTR [esp]
        ret

GOT 用于所有三个变量访问,但如果您仔细观察,global_hiddenlocal 的处理方式与 global_visible 不同。对于后者,指向变量的指针通过 GOT 访问,前两个变量通过 GOT 直接访问。在 GOT 用于所有位置独立变量引用的架构中,这是一个相当常见的技巧。

32 位 x86 架构在这里有一个特殊之处,因为它具有大的 32 位位移和 32 位地址空间。这意味着可以通过 GOT 基址访问内存中的任何位置,而不仅仅是 GOT 本身。大多数其他架构只支持更小的位移,这使得某物与 GOT 基础的最大距离更小。使用此技巧的其他架构只会将小(本地/隐藏/受保护)变量放入 GOT 本身,大变量存储在 GOT 外部,并且 GOT 将包含指向该变量的指针,就像普通可见性全局变量一样。

【讨论】:

在您的 i386 PIC 示例中,变量未在 GOT 内 分配,只是相对于它的引用。 GCC 要求链接器使用local@GOTOFF 计算从 GOT 到local 的位移。我们可以通过查看指令在 Godbolt godbolt.org/z/0Zu-RM 上看到这一点:local.data 中定义,而不是在任何特殊部分。 (我使用了-g0,所以我可以查看指令,而不会出现调试指令的混乱。)我定义了其他变量,而不是外部变量。 global_visible 在另外两个旁边结束。 或者您是说 GOT 包含了可以用 32 位指针引用的整个 4GB 地址空间,包括所有其他部分? (由于 32 位指针在 4G 处换行,因此 disp32 可以从任何起点到达整个 4GB 中的任何位置。您在 x86-64 上获得 2GB 的大小限制,您希望能够从任何地方通过有符号的 32-位位移添加到 64 位指针,所以你不能回绕。) @PeterCordes 你是对的,在 i386 上,变量实际上并不位于一个连续的 GOT 中,因为它们不需要。我假设它们是基于生成的代码以及它如何在其他平台上工作的。 @A.S.我不认为 i386 ELF 具有使该工作正常的重定位。正如 Peter Cordes 所说,您只想将一个寄存器用作基址寄存器,而 @GOT 和 @GOTOFF 重定位要求该基址寄存器指向 GOT。除了@GOTOFF 重定位,您可以执行global_hidden - foo - 5[edx] 之类的操作,但这需要在调用thunk 后不调整EDX,而global_visible@GOT 需要调整后的EDX。 @PeterCordes i386 ELF 缺失的重定位可以使用程序计数器的偏移量而不是 GOT 作为基础,它可以让您访问 GOT 条目以获取变量使用 thunk 的未调整返回值。类似于mov _GLOBAL_OFFSET_TABLE_ - .Lpc_base + global_visible@GOT(%edx), %ebx,其中.Lpc_base 是指向thunk 调用指令之后的指令的标签。我之前的评论已经提到,您可以使用生成 R_386_PC 重定位的表达式替换 @GOTOFF。【参考方案2】:

除了罗斯里奇答案中的详细信息。

这是外部链接与内部链接。如果没有static,该变量具有外部链接,因此可以从任何其他翻译单元访问。任何其他翻译单元都可以将其声明为extern int global; 并访问它。

Linkage:

外部链接。可以从其他翻译单元的范围中引用该名称。具有外部链接的变量和函数也具有语言链接,这使得链接以不同编程语言编写的翻译单元成为可能。

在命名空间范围内声明的以下任何名称都具有外部链接,除非命名空间未命名或包含在未命名的命名空间中(C++11 起):

上面未列出的变量和函数(即未声明为静态的函数、未声明为静态的命名空间范围非常量变量以及声明为外部的任何变量);

【讨论】:

以上是关于为啥对共享库本身中定义的符号使用全局偏移表?的主要内容,如果未能解决你的问题,请参考以下文章

如何从共享库的文本部分获取偏移量和数据?

Linux 共享库链接错误(未定义符号)

erlang nif共享库上的未定义符号

共享库如何获得自己的基地址

C++ 自定义全局新建/删除覆盖系统库

使用 dlopen 加载动态库时抛出未定义的符号