为什么 Linux/gnu 链接器选择地址 0x400000?
- 2024-10-28 08:37:00
- admin 原创
- 54
问题描述:
我正在 Linux x86_64 上试验 ELF 可执行文件和 gnu 工具链:
我已经(手动)链接并剥离了一个“Hello World”测试:
.global _start
.text
_start:
mov $1, %rax
...
变成 267 字节的 ELF64 可执行文件......
0000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
0000010: 0200 3e00 0100 0000 d400 4000 0000 0000 ..>.......@.....
0000020: 4000 0000 0000 0000 0000 0000 0000 0000 @...............
0000030: 0000 0000 4000 3800 0100 4000 0000 0000 ....@.8...@.....
0000040: 0100 0000 0500 0000 0000 0000 0000 0000 ................
0000050: 0000 4000 0000 0000 0000 4000 0000 0000 ..@.......@.....
0000060: 0b01 0000 0000 0000 0b01 0000 0000 0000 ................
0000070: 0000 2000 0000 0000 0000 0000 0000 0000 .. .............
0000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0000090: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000b0: 0400 0000 1400 0000 0300 0000 474e 5500 ............GNU.
00000c0: c3b0 cbbd 0abf a73c 26ef e960 fc64 4026 .......<&..`.d@&
00000d0: e242 8bc7 48c7 c001 0000 0048 c7c7 0100 .B..H......H....
00000e0: 0000 48c7 c6fe 0040 0048 c7c2 0d00 0000 ..H....@.H......
00000f0: 0f05 48c7 c03c 0000 0048 31ff 0f05 4865 ..H..<...H1...He
0000100: 6c6c 6f2c 2057 6f72 6c64 0a llo, World.
它有一个程序头(LOAD)并且没有部分:
There are 1 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x000000000000010b 0x000000000000010b R E 200000
这似乎在地址 0x400000 处加载了整个文件(文件偏移量 0 到 0x10b - elf 头和所有内容)。
入口点是:
Entry point address: 0x4000d4
它对应于文件中的 0xd4 偏移量,我们可以看到该地址是机器代码的起始地址(mov $1, %rax1
)
我的问题是为什么(如何)gnu 链接器选择0x400000
文件映射到的地址?
解决方案 1:
起始地址通常由链接脚本设置。
例如,在 GNU/Linux 上,/usr/lib/ldscripts/elf_x86_64.x
我们可以看到:
...
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); \n . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
该值是该平台上0x400000
该功能的默认值。SEGMENT_START()
您可以通过浏览链接器手册了解有关链接器脚本的更多信息:
% info ld Scripts
解决方案 2:
ld
的默认链接器脚本已0x400000
为非 PIE 可执行文件嵌入该值。
PIE(位置无关的可执行文件)没有默认基址;它们总是由内核重新定位,内核的默认值0x0000555...
加上一些 ASLR 偏移量,除非为此进程或整个系统禁用 ASLR。 ld
对此没有控制权。请注意,大多数现代系统将 GCC 配置-fPIE -pie
为默认使用,因此它会传递-pie
给,并将 C 转换为与位置无关的 asm。如果您以这种方式链接,ld
手写的 asm必须遵循相同的规则。
但是什么使得0x400000
(4 MiB) 成为一个好的默认值呢?
默认情况下,它必须高于mmap_min_addr
= 65536 = 64K。
而且远离 0 会提供足够的空间来防止使用偏移量读取.text
或.data
/.bss
内存(array[i]
其中array
为 NULL)的 NULL 解除引用。即使不增加mmap_min_addr
(这为不破坏可执行文件留出了空间),通常会mmap
随机选择高地址,因此在实践中我们至少有 4MiB 的 NULL 解除引用保护。
2M 对齐很好
这将它放在页表的下一级页目录的开头,这意味着相同数量的 4K 页表条目将被拆分到更少的 2M 页目录条目中,从而节省内核页表内存并帮助更好地进行页面遍历硬件缓存。对于大型静态数组,靠近下一级 1G 子树的开头也是不错的选择。
我不知道为什么是 4MiB 而不是 2MiB,也不知道开发人员的理由是什么。4MiB 是没有 PAE 的 32 位大页面大小(4 字节 PTE,因此每级 10 位而不是 9 位),但 CPU 必须使用 x86-64 页表才能处于 64 位模式。
较低的起始地址允许近 2 GiB 的静态数组
(不使用更大的代码模型,其中至少大型数组必须以有时效率较低的方式来寻址。有关代码模型的详细信息,请参阅x86-64 System V ABI 文档中的第 3.5.1 节“架构约束”。)
非 PIE 可执行文件的默认代码模型(“小”)允许程序假设任何静态地址都位于虚拟地址空间的低 2GiB 中。因此,/ 中的任何绝对地址都可以.text
在机器代码中用作 32 位符号扩展立即数,这样效率更高。.rodata
`.data`.bss
(在 PIE 或共享库中情况并非如此:请参阅 x86-64 Linux 中不再允许使用 32 位绝对地址?以了解您/编译器在 x86-64 asm 中无法执行的操作,特别是addss xmm0, [foo + rdi*4]
需要 RIP 相对 LEA 将数组起始地址放入寄存器中。x86-64 唯一的 RIP 相对寻址模式是 [RIP+rel32],没有任何通用寄存器。)
在虚拟地址空间底部附近启动可执行文件的节/段,几乎整个 2GiB 都可用于 text+data+bss 这么大。 (可能可以使用更高的默认值,并让大型可执行文件让 ld 选择较低的地址以使其适合,但这将是一个更复杂的链接器脚本。)
这包括 .bss 中的零初始化数组,这些数组不会使可执行文件变得很大,只会使进程映像在内存中变得很大。在实践中,Fortran 程序员遇到这种情况的次数比 C 和 C++ 程序员多,因为静态数组在那里很流行。例如,gfortran for dummies:mcmodel=medium 到底有什么作用?很好地解释了默认模型的构建错误small
,以及由此产生的 x86-64 asm 差异medium
(其中超过特定大小阈值的对象不被认为在代码的低 2G 或 +-2G 范围内。但代码和较小的静态数据仍然如此,因此速度损失很小。)
例如,static float arr[1UL<<28];
一个 1 GiB 的数组。如果你有 3 个这样的数组,它们不可能全部从低 2 GiB开始(这可能是手写 asm 所需的全部内容),更不用说让每个元素都可以访问了。
gcc -fno-pie
期望能够编译float *p = &arr[size-1];
为mov $arr+1073741820, %edi
,即 5 字节mov $imm32
。如果目标地址距离生成地址的代码超过 2GiB(或使用 从中加载),则 RIP-relative 也将不起作用movss arr+1073741820(%rip), %xmm0
;即使在非 PIE 中,当没有运行时变量索引时, RIP-relative 也是加载/存储静态数据的正常方式。这就是为什么小 PIC 模型对文本+数据+bss(加上段之间的间隙)也有 2GiB 的大小限制:所有静态数据和代码都需要在可能想要到达它的任何其他数据的 2GiB 范围内。
如果您的代码仅通过运行时变量索引访问高位元素或其地址,则只需将每个数组的开头(即符号本身)置于低位 2 GiB 中。我忘记了链接器是否强制将 bss 结尾置于低位 2GiB 中;它可能会强制这样做,因为链接器脚本会将某个 CRT 启动代码可能会引用的符号放在那里。
脚注 1:对于小于 2GiB 的代码模型,没有任何有用的较小尺寸。x86-64 机器代码使用 8 位或 32 位作为立即数和寻址模式。8 位(256 字节)太小而无法使用,并且许多重要的指令(例如call rel32
、mov r32, imm32
和[rip+rel32]
寻址)仅适用于 4 字节而不是 1 字节常量。
限制为低 2 GiB(而不是 4)意味着地址可以安全地进行零扩展(如mov edi, OFFSET arr
)或符号扩展(如 ) 。请记住,地址不是寻址模式mov eax, [arr + rdi*4]
的唯一用例;通常是有意义的,因此 x86-64 机器代码将 disp8 和 disp32 符号扩展为 64 位,而不是零扩展,这是很好的。[reg + disp32]
`[rbp - 256]`
隐式零扩展至 64 位发生在写入 32 位寄存器时,就像使用mov
-immediate 将地址放入寄存器一样,其中 32 位操作数大小是比 64 位操作数大小更小的机器代码指令。请参阅如何将函数或标签的地址加载到寄存器中(其中也涵盖了 RIP 相对 LEA)。
与 32 位 Windows 相关
Raymond Chen 写了一篇文章,解释了为什么32 位 Windows0x400000
默认使用相同的基址。
他提到,默认情况下,DLL 会加载到高地址,而低地址则远非如此。x86-64 SysV 共享对象可以在地址空间间隙足够大的任何地方加载,内核默认加载到用户空间虚拟地址空间的顶部附近,即规范范围的顶部。但 ELF 共享对象需要完全可重定位,因此可以在任何地方正常工作。
32 位 Windows 选择 4MiB 也是出于避免使用低 64K(NULL 取消引用)以及选择旧式 32 位页表的页目录的开头的动机。(其中“大页”大小为 4M,而不是 x86-64 或 PAE 的 2M。)由于 Win95 和 Win3.1 旧式内存映射的一系列原因,至少 1MiB 或 4MiB 是部分必要的,以及解决 CPU 错误之类的问题。
解决方案 3:
任务虚拟地址空间的页面零保持未映射状态,以便可以通过导致 SIGSEGV 的页面错误异常捕获空指针引用。4 MB 适合“大页面”粒度(而不是“普通页面”粒度 4 KB) - 因此在 4 MB 页面粒度的设置上,0x000000 到 0x3FFFFF 地址范围未映射,从而使 0x400000 成为任务虚拟地址空间中的第一个有效地址。
- 2024年20款好用的项目管理软件推荐,项目管理提效的20个工具和技巧
- 2024年开源项目管理软件有哪些?推荐5款好用的项目管理工具
- 项目管理软件有哪些?推荐7款超好用的项目管理工具
- 项目管理软件哪个最好用?盘点推荐5款好用的项目管理工具
- 项目管理软件有哪些最好用?推荐6款好用的项目管理工具
- 项目管理软件有哪些,盘点推荐国内外超好用的7款项目管理工具
- 2024项目管理软件排行榜(10类常用的项目管理工具全推荐)
- 项目管理软件排行榜:2024年项目经理必备5款开源项目管理软件汇总
- 2024年常用的项目管理软件有哪些?推荐这10款国内外好用的项目管理工具
- 项目管理必备:盘点2024年13款好用的项目管理软件