二进制
请提供您希望翻译的完整文本内容,我会按照要求保留源链接并进行简体中文翻译。
2 GiB “重定位屏障”——为何巨型二进制在 x86‑64 上会失效
在攻读博士并提交学术文章的过程中,我遇到的一个问题是,我已经为需要大规模才能有效且有价值的问题构建了解决方案。
对我的论文投稿的回复常常声称此类问题并不存在;然而,我在业界(例如在 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 位有符号相对偏移)。- 操作数目前是
00 00 00 00,因为实际地址尚未确定。 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³¹‑1)。
因此,一个调用点最多只能向前或向后跳约 2 GiB。这就是 “2 GiB 屏障”。
当目标位置更远时会怎样?
我们可以强制链接器把 far_function 放得离 main 很远,从而触发该限制……
Source: https://maskray.me/blog/2023-05-14-relocation-overflow-and-code-models
一个链接脚本。
/* 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 for the “far” island */
. = 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 位字段。
如何处理这个障碍?
这是一整个涉及 代码模型(small、kernel、medium、large)以及 代码 与 数据 引用区别的主题。
简而言之:
| 情形 | 常见解决方案 |
|---|---|
| 调用或跳转距离 > 2 GiB | 使用 间接 调用/跳转(例如通过寄存器或 PLT 条目),或使用 large 代码模型编译(-mcmodel=large)。 |
| 访问静态数据距离 > 2 GiB | 使用 RIP‑相对 寻址并配合 large 代码模型,或先将地址加载到寄存器中。 |
| 混合静态链接和动态链接 | 依赖动态链接器的 PLT/GOT 机制,自动生成间接引用。 |
关于这些主题的深入讨论可以在 @maskray 的博客文章 “Relocation overflow and code models” 中找到:
https://maskray.me/blog/2023-05-14-relocation-overflow-and-code-models
(如果链接失效,请搜索标题和作者。)
要点
- 对巨大的代码库进行 Static linking 很容易生成超过单个相对跳转 2 GiB 范围的二进制文件。
- x86-64 CALL/JMP 指令使用 signed 32‑bit displacement,因此直接跳转的范围被限制在 ±2 GiB。
- 当链接器无法在该范围内放入位移时,就会出现 relocation overflow。
- 常见的解决办法包括 indirect calls/jumps、different code models,或 dynamic linking(PLT/GOT)。
理解这种 “relocation barrier” 对于为 mega‑binaries(数十 GB)设计构建系统,以及决定静态链接是否真的适合特定组织,都是至关重要的。
使用 -mcmodel=large 避免重定位溢出
最简单的解决方案是使用 -mcmodel=large 编译,它会把所有相对 CALL 指令改为绝对跳转 (JMP)。
# 构建可执行文件
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow
# 使用大代码模型编译
gcc -c simple-relocation.c -o simple-relocation.o -mcmodel=large -fno-asynchronous-unwind-tables
# 再次链接(同上)
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow
# 运行
./simple-relocation-overflow
注意
-fno-asynchronous-unwind-tables用于禁用额外的 unwind‑table 数据,否则在本示例中可能导致溢出。
切换到 -mcmodel=large 后的反汇编
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‑字节 MOVABS opcode + 8‑字节绝对地址 + 2‑字节 CALL)。
大代码模型的缺点
- 指令膨胀 – 每次调用现在占用 12 字节,而不是 5 字节。在调用点很多的二进制中,这会显著增加代码体积。
- 寄存器压力 – 需要额外的通用寄存器(示例中的
%rdx)来保存绝对地址。
警告
我在构造基准测试时很难得到大mcmodel对 IPC(每周期指令数)产生可测量下降的结果。请相信我,这种影响并非微不足道。 🤷
保持小代码模型
如果想继续使用小代码模型,就需要探索其他策略(例如重新组织段、使用跳板或拆分二进制)。更多思路将在后续文章中讨论。