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

Why use the Global Offset Table for symbols defined in the shared library itself?

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

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

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

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

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

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

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

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

通过使用 "hidden" 或 "protected" 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没有使用。 "protected" 可见性的工作方式类似,它可以防止定义被取代,但使其对动态链接器仍然可见,以便其他组件可以访问它。 "hidden" 可见性从动态链接器中完全隐藏符号。

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

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

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 位地址 space。这意味着内存中的任何地方都可以通过 GOT 基址访问,而不仅仅是 GOT 本身。大多数其他架构只支持更小的位移,这使得某物与 GOT 基座的最大距离更小。使用此技巧的其他体系结构只会将小 (local/hidden/protected) 变量放入 GOT 本身,大变量存储在 GOT 外部,并且 GOT 将包含指向该变量的指针,就像普通可见性全局变量一样。

除了 Ross Ridge 答案中的详细信息。

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

Linkage:

External linkage. The name can be referred to from the scopes in the other translation units. Variables and functions with external linkage also have language linkage, which makes it possible to link translation units written in different programming languages.

Any of the following names declared at namespace scope have external linkage unless the namespace is unnamed or is contained within an unnamed namespace (since C++11):

  • variables and functions not listed above (that is, functions not declared static, namespace-scope non-const variables not declared static, and any variables declared extern);