全局偏移表有两个目的。一种是允许动态链接器“插入”与可执行文件或其他共享对象不同的变量定义。第二个是允许生成位置无关代码以引用某些处理器架构上的变量。
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_hidden
不local
使用它。“受保护”可见性的工作方式类似,它防止定义被取代,但使其对动态链接器仍然可见,因此其他组件可以访问它。“隐藏”可见性将符号完全隐藏在动态链接器中。
使代码可重定位以允许共享对象在不同进程中加载不同地址的必要性意味着静态分配的变量,无论它们具有全局范围还是局部范围,都不能在大多数体系结构上直接使用单个指令访问。正如您在上面看到的,我知道的唯一例外是 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_hidden
并且local
处理方式与global_visible
. 对于后者,指向变量的指针通过 GOT 访问,前两个变量通过 GOT 直接访问。这是 GOT 用于所有位置独立变量引用的架构中相当常见的技巧。
32 位 x86 体系结构在这里有一个特殊之处,因为它具有大的 32 位位移和 32 位地址空间。这意味着可以通过 GOT 基址访问内存中的任何位置,而不仅仅是 GOT 本身。大多数其他架构只支持更小的位移,这使得某物与 GOT 基础的最大距离更小。使用此技巧的其他架构只会将小(本地/隐藏/受保护)变量放入 GOT 本身,大变量存储在 GOT 外部,并且 GOT 将包含指向该变量的指针,就像普通可见性全局变量一样。