为什么Linux/GNU链接器选择地址0x400000?

21

我正在尝试在Linux x86_64上使用ELF可执行文件和GNU工具链进行实验:

我已经手动连接和剥离了一个“Hello World”测试程序test.s:

        .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

这个操作似乎将整个文件(从文件偏移0到0x10b,包括elf头部)加载到地址0x400000。

入口点是:

 Entry point address:               0x4000d4

这对应于文件中的0xd4偏移量,我们可以看到这个地址是机器码(mov $1, %rax1)的开始。

我的问题是为什么(gnu)链接器选择将文件映射到地址0x400000


1兆字节对我来说似乎是一个不错的整数,你会选择什么? - Carl Norum
ld由脚本驱动。默认脚本提到了0x400000,如果记得的话...否则请下载并查看binutils源代码。 - Basile Starynkevitch
1
@CarlNorum:你是在暗示0x400000是1兆字节吗?试试4兆字节。1兆=20位=5个4位十六进制数字=0x100000。 - Andrew Tomazos
糟糕 - 发现得不错。对此感到抱歉! - Carl Norum
相关链接:https://dev59.com/5Wcs5IYBdhLWcg3w64Ca - Ciro Santilli OurBigBook.com
2
Windows使用相同的默认基址,这就是为什么Windows选择它的原因:https://devblogs.microsoft.com/oldnewthing/20141003-00/?p=43923 - Paul
3个回答

12

起始地址通常由链接脚本设置。

例如,在GNU/Linux上,查看/usr/lib/ldscripts/elf_x86_64.x,我们可以看到:

...
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); \
    . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;

在该平台上,SEGMENT_START()函数的默认值为0x400000

您可以通过浏览链接器手册了解有关链接器脚本的更多信息:

% info ld Scripts

8
好的,为什么特别是0x400000,而不是其他值? - BarbaraKwarc
1
最好使用 ld --verbose - Jester

9

ld的默认链接脚本对于非PIE可执行文件使用了0x400000这个值。

PIE(位置无关可执行文件)没有默认基地址;它们总是被内核重新定位,使用内核的默认基地址0x0000555...再加上一些ASLR偏移量(除非禁用了该进程或系统范围内的ASLR)。ld对此没有控制权。请注意,大多数现代系统都将GCC配置为默认使用-fPIE -pie,因此它会将-pie传递给ld,并将C转换为汇编代码,使其具有位置无关性。如果以这种方式链接,手写汇编必须遵循相同的规则

那么为什么0x400000(4 MiB)是一个好的默认值呢?

它必须高于 mmap_min_addr = 65536 = 默认情况下的64K。

并且远离0会给我们更多的空间来防止使用偏移量读取.text.data/.bss内存(array[i],其中array为NULL)时出现NULL deref。即使不增加mmap_min_addr(这个值可以保证可执行文件不会被破坏),通常mmap会随机选择较高的地址,因此在实践中我们至少有4MiB的空间来防止NULL deref。

2M对齐是好的

这将把它放在页面表上一级的页面目录的开头,意味着相同数量的4K页表条目将分布在更少的2M页面目录条目中,节省内核页表内存并帮助页面行走硬件缓存。对于靠近上一级的1G子树开头的大型静态数组也是不错的选择。
我不知道为什么是4MiB而不是2MiB,或者开发者的理由是什么。4MiB是32位大页大小,没有PAE(4字节PTE,因此每个级别有10位而不是9位),但CPU必须使用x86-64页表才能处于64位模式。
低起始地址允许近2 GiB的静态数组。
(不使用更大的代码模型,其中至少需要以有时效率较低的方式处理大数组。有关代码模型的详细信息,请参见x86-64 System V ABI document中的第3.5.1节架构约束。)
非PIE可执行文件的默认代码模型(“small”)允许程序假定任何静态地址都在虚拟地址空间的低2GiB内。因此,.text/.rodata.data.bss 中的任何绝对地址都可以在机器码中用作32位符号扩展立即数,从而提高效率。
(This is not the case in a PIE or shared library: see 32-bit absolute addresses no longer allowed in x86-64 Linux? for the things you / the compiler can't do in x86-64 asm as a result, notably addss xmm0, [foo + rdi*4] instead requires a RIP-relative LEA to get the array start address into a register. x86-64's only RIP-relative addressing mode is [RIP+rel32], without any general-purpose registers.) 如果可执行文件的段/分段从虚拟地址空间底部开始,则几乎可以将整个2GiB用于文本+数据+bss。 (可能可以选择更高的默认值,并使大型可执行文件选择较低的地址以使其适合,但这将是一个更复杂的链接器脚本。) 这包括在.bss中的零初始化数组,它们不会使可执行文件变得庞大,只会增加内存中的进程映像。实际上,Fortran程序员比C和C++更容易遇到这个问题,因为静态数组在那里很受欢迎。例如,gfortran for dummies: What does mcmodel=medium do exactly?对默认的模型构建错误以及medium的x86-64汇编差异有很好的解释(在该模型中,不假定大小超过某个阈值的对象位于低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(或使用 movss arr+1073741820(%rip), %xmm0 从中加载;即使在非 PIE 模式下,当没有运行时变量索引时, RIP 相对寻址是加载/存储静态数据的常规方法),RIP 相对寻址也无法工作。这就是为什么小型 PIC 模型在文本、数据和 bss 的大小上也有 2GiB 的限制(加上段之间的间隔):所有静态数据和代码都需要在任何可能想要访问它的地方的 2GiB 范围内。

如果您的代码只通过运行时可变索引访问高元素或其地址,则只需要将每个数组的开始,即符号本身,放在低2 GiB中。我忘记链接器是否强制要求bss结束位于低2GiB内;可能是因为链接器脚本将一个符号放在那里,某些CRT启动代码可能会引用它。
脚注1:对于小于2GiB的代码模型,没有有用的更小尺寸。x86-64机器码使用8位或32位来表示立即数和寻址模式。8位(256字节)太小而无法使用,并且许多重要指令,例如call rel32mov r32, imm32[rip+rel32]寻址,只能使用4字节而不是1字节常量。

限制到低2 GiB(而不是4 GiB)意味着地址可以像mov edi, OFFSET arr一样安全地零扩展,或者像mov eax, [arr + rdi*4]一样符号扩展。请记住,地址并不是[reg + disp32]寻址模式的唯一用途;[rbp - 256]经常是有意义的,因此很好的是,x86-64机器码将disp8和disp32符号扩展为64位,而不是零扩展。

当写入32位寄存器时,如使用mov-immediate将地址放入寄存器中时,会发生对64位的隐式零扩展。其中,32位操作数大小比64位操作数大小更小,因此在机器码指令中使用。请参见如何将函数或标签的地址加载到寄存器中(该链接也涵盖了RIP相对LEA)。


32位Windows相关

雷蒙德·陈写了一篇文章,讲述了为什么相同的0x400000基地址是32位Windows的默认值。

他提到,默认情况下,DLL会加载到高地址,而低地址离那里很远。x86-64 SysV共享对象可以在任何有足够大的地址空间间隙的地方加载,内核默认靠近用户空间虚拟地址空间的顶部,即规范范围的顶部。但是,ELF共享对象必须是完全可重定位的,因此可以在任何地方正常工作。

32位Windows选择4MiB也是出于避免低64K(NULL deref)和选择遗留32位页表的页面目录的起点的考虑。(其中“largepage”大小为4M,而不是x86-64或PAE的2M)。这还涉及到Win95和Win3.1遗留内存映射原因,至少需要1MiB或4MiB,以及解决CPU错误等问题。


显然,我在一年前的另一个问题上已经写过这个答案了,为什么在x86_64 ABI中选择地址0x400000作为文本段的起始位置?,但是我忘记了。 - Peter Cordes

2

任务的虚拟地址空间的第零页被保持未映射,以便可以通过页故障异常捕获空指针引用,从而导致SIGSEGV。4 MB与“大页面”粒度匹配(与“普通页面”粒度4 KB相对),因此在具有4 MB页面粒度的设置中,0x000000到0x3FFFFF地址范围未映射,使0x400000成为任务虚拟地址空间中的第一个有效地址。


1
/proc/sys/vm/mmap_min_addr 在 x86-64 Linux 上的默认值为 64kiB,因此如果需要,可以将一些东西映射到较低的地址。(请参见 https://wiki.debian.org/mmap_min_addr)。这个想法有一定的道理:0x400000 是巨大页面对齐的,这对于透明巨大页面可能是有好处的。(但请注意,x86-64 巨大页面是 2MiB 和 1GiB;只有传统的 32 位页表有 4M 巨大页面。而 ld 对于 i386 有一个不同的默认基地址:0x08048000,略高于 128.28 MiB) - Peter Cordes
1
选择一个2M对齐且靠近1G区域开头的地址,意味着更有可能获得更密集的页表,即相同数量的PTE分配在更少的页目录中。然而,低4MiB内存不太可能被随机映射,因此通常您可以完全利用这4MiB缓冲区来防止空指针引用。 - Peter Cordes
据我所知,i386代码库使用0x8000000的基地址,以便将栈段放置在该基地址下方。这导致了隐式的守卫机制-TOS溢出到未映射的第零页会抛出异常,TOS下溢到默认情况下为只读的代码段也会抛出异常。选择128 MB是为了为栈段提供足够的空间。老实说,我对这种方法的可信度有所怀疑,因为无论在虚拟地址空间中的哪个位置,都可以通过用未映射的页面包围堆栈区域来实现相同的效果。 - Notorius Maximus
1
这不是当前Linux放置用户空间堆栈的位置。您是否在暗示原始设计完全不同,并且没有尊重堆栈增长限制的ulimit -s?当前机制实际上增加了映射(用于初始/主线程堆栈),而不仅仅是为现有逻辑映射中的新物理页面软页故障。 (您可以在/proc/<PID>/maps中看到它,如果ESP / RSP不在访问的地址下方,则不会增长。请参见使用'push'或'sub' x86指令时如何分配堆栈内存?获取一些详细信息) - Peter Cordes
1
当前的Linux将用户空间栈放置在或接近用户空间虚拟地址空间的顶部。在64位内核下,32位进程的栈地址类似于“0xffff....”之类,否则为“0x7ffff000”左右。 - Peter Cordes
我知道它按照你提到的方式工作。我只是写下了我从别人那里学到的东西。谁知道最初是否有一些意图以那种方式做事或者在虚拟地址空间的开头保留一些空间的不同原因。 - Notorius Maximus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接