巨大的二进制文件
Source: Hacker News
在攻读博士学位并提交学术文章的过程中,我遇到的一个问题是,我构建的解决方案需要极大规模才能有效且有价值。
对我的论文投稿的回复常常声称此类问题并不存在;然而,我在业界(例如在 Google)期间确实观察到了这些问题,只是无法引用它们!
在这些超大型代码库中唯一出现的一个问题是巨大的二进制文件。
你见过最大的二进制(ELF 文件)是多少?我曾见过超过 25 GiB的二进制文件,包括调试符号。这怎么可能?
这些公司倾向于静态构建它们的服务,以加快启动速度并简化部署。在全球最大的一些代码库中静态包含所有代码,必然会导致巨大的二进制文件。
类似于音障,代码体积达到一定程度后会变得成问题,我们必须重新思考链接和构建代码的方式。对于 x86_64,这一点就是 2 GiB “重定位屏障”。
为什么是 2 GiB? 🤔
让我们看看位置无关代码是如何组合在一起的。
一个简单的例子
/* simple-relocation.c */
extern void far_function();
int main(void) {
far_function();
return 0;
}
编译文件:
gcc -c simple-relocation.c -o simple-relocation.o
使用 objdump 检查目标文件:
objdump -dr simple-relocation.o
0000000000000000 :
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: b8 00 00 00 00 mov $0x0,%eax
9: e8 00 00 00 00 call e
a: R_X86_64_PLT32 far_function-0x4
e: b8 00 00 00 00 mov $0x0,%eax
13: 5d pop %rbp
14: c3 ret
e8 字节是 CALL 操作码(它使用 32 位有符号相对偏移)。
目前偏移为 0(四个字节的 0)。objdump 还告诉我们,需要进行 重定位 来在最终阶段修正这段代码。
注意
-0x4是必需的,因为偏移是相对于指令指针 在 跳过 4 字节操作数之后的 位置。
我们可以使用 readelf 查看重定位条目:
readelf -r simple-relocation.o -d
Relocation section '.rela.text' at offset 0x170 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000a 000400000004 R_X86_64_PLT32 0000000000000000 far_function - 4
该条目告诉链接器,位于偏移 0x0a(CALL 指令立即数的起始位置)的 4 字节操作数必须被 far_function 的地址所填补。
添加被调用函数
/* far-function.c */
void far_function(void) {
}
编译它:
gcc -c far-function.c -o far-function.o
链接这两个目标文件:
gcc simple-relocation.o far-function.o -o simple-relocation
检查最终可执行文件:
objdump -dr simple-relocation
0000000000401106 :
401106: 55 push %rbp
401107: 48 89 e5 mov %rsp,%rbp
40110a: b8 00 00 00 00 mov $0x0,%eax
40110f: e8 07 00 00 00 call 40111b
401114: b8 00 00 00 00 mov $0x0,%eax
401119: 5d pop %rbp
40111a: c3 ret
000000000040111b :
40111b: 55 push %rbp
40111c: 48 89 e5 mov %rsp,%rbp
40111f: 90 nop
401120: 5d pop %rbp
401121: c3 ret
链接器已经计算出相对偏移 (0x07) 并正确地修补了 CALL 指令。
2 GiB 障碍
CALL 操作码 (e8) 使用 32 位有符号 位移,这将跳转范围限制在 ±2 GiB(2³¹ 字节)。因此,调用点只能到达前后 2 GiB 窗口内的代码。此限制被称为 2 GiB 重定位屏障。
当目标距离超过 2 GiB 时会怎样?
我们可以使用 链接脚本 强制链接器把 far_function 放得很远。
/* overflow.lds */
SECTIONS
{
/* 1. Standard low‑address sections */
. = 0x400000;
.text : {
simple-relocation.o(.text.*)
}
.rodata : { *(.rodata .rodata.*) }
.data : { *(.data .data.*) }
.bss : { *(.bss .bss.*) }
/* 2. Move the location counter far away */
. = 0x120000000; /* ≈4.5 GiB */
.text.far : {
far-function.o(.text*)
}
}
现在使用 LLVM 的 lld 链接(它的错误信息更清晰一些):
gcc simple-relocation.o far-function.o -T overflow.lds \
-o simple-relocation-overflow -fuse-ld=lld
输出:
ld.lld: error: :(.eh_frame+0x6c):
relocation R_X86_64_PC32 out of range:
5364513724 is not in [-2147483648, 2147483647]; references section '.text'
ld.lld: error: simple-relocation.o:(function main: .text+0xa):
relocation R_X86_64_PLT32 out of range:
5364514572 is not in [-2147483648, 2147483647]; references 'far_function'
>>> referenced by simple-relocation.c
>>> defined in far-function.o
链接器报告 重定位溢出,因为所需的位移无法装入有符号 32 位字段。
处理障碍
当我们遇到这个问题时,有几种选择,统称为 代码模型。合适的解决方案取决于我们访问的对象是:
- 数据(静态变量、常量)
- 代码(函数、跳转目标)
关于这些技术的深入讨论可参见博客文章 “Relocation overflow and code models” 作者 @maskray。
TL;DR
- x86‑64 的 CALL/JMP 指令使用 32 位有符号相对偏移,因此直接跳转的范围限制在 ±2 GiB。
- 巨大的静态二进制文件很容易超出此限制,导致链接时出现 重定位溢出 错误。
- 解决方案包括使用不同的 代码模型(如 small、medium、large 或 PIE)、间接跳转、跳板(trampoline),或 动态链接,以确保所有调用目标都在可达范围内。
在处理产生数十 GB 大小二进制文件的 超大代码库 时,理解并绕过 2 GiB 重定位屏障至关重要。
com/maskray — lld 的作者。
最简单的办法是使用 -mcmodel=large,它会把所有相对的 CALL 指令改为绝对的 JMP。
# Build the overflow example
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow
# Compile with the large code model
gcc -c simple-relocation.c -o simple-relocation.o -mcmodel=large -fno-asynchronous-unwind-tables
# Link again
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow
# Run
./simple-relocation-overflow
注意
为了演示的目的,我需要添加-fno-asynchronous-unwind-tables来禁用可能导致溢出的额外数据。
现在的反汇编是什么样的?
objdump -dr simple-relocation-overflow
0000000120000000 :
120000000: 55 push %rbp
120000001: 48 89 e5 mov %rsp,%rbp
120000004: 90 nop
120000005: 5d pop %rbp
120000006: c3 ret
00000000004000e6 :
4000e6: 55 push %rbp
4000e7: 48 89 e5 mov %rsp,%rbp
4000ea: b8 00 00 00 00 mov $0x0,%eax
4000ef: 48 ba 00 00 00 20 01 movabs $0x120000000,%rdx
4000f6: 00 00 00
4000f9: ff d2 call *%rdx
4000fb: b8 00 00 00 00 mov $0x0,%eax
400100: 5d pop %rbp
400101: c3 ret
不再只有单独的 CALL 指令;它已经变成了 MOVABS 加上 CALL 😲。这把指令大小从 5 字节(opcode + 4‑字节相对偏移)膨胀到了惊人的 12 字节(2‑字节 ABS opcode + 8‑字节绝对地址 + 2‑字节 CALL)。
显著的缺点
- 指令膨胀 – 每个调用从 5 字节增至 12 字节。在拥有数百万调用点的二进制文件中,这会迅速累积。
- 寄存器压力 – 为了完成跳转,我们消耗了一个通用寄存器(
%rdx)。
警告
我在构建基准测试时遇到了很多困难,未能展示 largemcmodel在 IPC(每周期指令数)上的更差表现,所以只能请大家相信我的结论。 🤷
我们希望保持小代码模型。还有哪些策略可以采用?
后续文章将继续展开讨论。