数据结构对齐:链接器还是编译器

4

数据结构对齐的任务是由谁完成的?是编译器、链接器、装载器还是像x86那样的硬件本身?编译器是否进行相对对齐寻址,以便在由链接器正确“放置”在已编译的可执行文件中时,数据结构始终对应于各自本地大小边界?此后,装载器还有哪些任务需要完成?

3个回答

3
答案是编译器和链接器都需要理解和处理对齐要求。编译器是这一对中的聪明者,因为只有它了解实际的结构、堆栈和变量对齐规则,但它会将一些关于所需对齐方式的信息传递给链接器,后者在生成最终可执行文件时也需要遵守这些要求。
编译器负责许多运行时对齐处理工作,并且通常也依赖于满足某些最小对齐方式的事实。1现有的答案已经涵盖了编译器的某些详细信息。
缺失的是链接器和加载程序框架也会处理对齐问题。一般来说,每个部分都有一个最小对齐属性,链接器会写入该属性,而加载程序会遵循它,确保该部分至少按该属性对齐边界加载。
不同的部分将具有不同的要求,代码的更改可能会直接影响这些要求。一个简单的例子是全局数据,无论它位于.bss.rodata.data还是其他某个部分中。这些部分的对齐方式至少与存储在其中任何对象的最大对齐要求一样大。
因此,如果您有一个只读(const)全局对象,它具有64字节对齐方式,则.rodata部分的最小对齐方式为64字节,并且链接器将确保满足此要求。
您可以使用objdump -h查看Algn列中任何对象文件的实际对齐要求。以下是一个随机示例:
Sections:
Idx Name          Size      VMA               LMA               File off  Algn  Flags
  0 .interp       0000001c  0000000000400238  0000000000400238  00000238  2**0  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .note.ABI-tag 00000020  0000000000400254  0000000000400254  00000254  2**2  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .note.gnu.build-id 00000024  0000000000400274  0000000000400274  00000274  2**2  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .gnu.hash     00000030  0000000000400298  0000000000400298  00000298  2**3  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .dynsym       00000288  00000000004002c8  00000000004002c8  000002c8  2**3  CONTENTS, ALLOC, LOAD, READONLY, DATA
  5 .dynstr       00000128  0000000000400550  0000000000400550  00000550  2**0  CONTENTS, ALLOC, LOAD, READONLY, DATA
  6 .gnu.version  00000036  0000000000400678  0000000000400678  00000678  2**1  CONTENTS, ALLOC, LOAD, READONLY, DATA
  7 .gnu.version_r 00000050  00000000004006b0  00000000004006b0  000006b0  2**3  CONTENTS, ALLOC, LOAD, READONLY, DATA
  8 .rela.dyn     00000060  0000000000400700  0000000000400700  00000700  2**3  CONTENTS, ALLOC, LOAD, READONLY, DATA
  9 .rela.plt     00000210  0000000000400760  0000000000400760  00000760  2**3  CONTENTS, ALLOC, LOAD, READONLY, DATA
 10 .init         0000001a  0000000000400970  0000000000400970  00000970  2**2  CONTENTS, ALLOC, LOAD, READONLY, CODE
 11 .plt          00000170  0000000000400990  0000000000400990  00000990  2**4  CONTENTS, ALLOC, LOAD, READONLY, CODE
 12 .plt.got      00000008  0000000000400b00  0000000000400b00  00000b00  2**3  CONTENTS, ALLOC, LOAD, READONLY, CODE
 13 .text         000021e2  0000000000400b10  0000000000400b10  00000b10  2**4  CONTENTS, ALLOC, LOAD, READONLY, CODE
 14 .fini         00000009  0000000000402cf4  0000000000402cf4  00002cf4  2**2  CONTENTS, ALLOC, LOAD, READONLY, CODE
 15 .rodata       00000700  0000000000402d00  0000000000402d00  00002d00  2**5  CONTENTS, ALLOC, LOAD, READONLY, DATA
 16 .eh_frame_hdr 000000b4  0000000000403400  0000000000403400  00003400  2**2  CONTENTS, ALLOC, LOAD, READONLY, DATA
 17 .eh_frame     000003d4  00000000004034b8  00000000004034b8  000034b8  2**3  CONTENTS, ALLOC, LOAD, READONLY, DATA
 18 .init_array   00000008  0000000000603e10  0000000000603e10  00003e10  2**3  CONTENTS, ALLOC, LOAD, DATA
 19 .fini_array   00000008  0000000000603e18  0000000000603e18  00003e18  2**3  CONTENTS, ALLOC, LOAD, DATA
 20 .jcr          00000008  0000000000603e20  0000000000603e20  00003e20  2**3  CONTENTS, ALLOC, LOAD, DATA
 21 .dynamic      000001d0  0000000000603e28  0000000000603e28  00003e28  2**3  CONTENTS, ALLOC, LOAD, DATA
 22 .got          00000008  0000000000603ff8  0000000000603ff8  00003ff8  2**3  CONTENTS, ALLOC, LOAD, DATA
 23 .got.plt      000000c8  0000000000604000  0000000000604000  00004000  2**3  CONTENTS, ALLOC, LOAD, DATA
 24 .data         00000020  00000000006040d0  00000000006040d0  000040d0  2**4  CONTENTS, ALLOC, LOAD, DATA
 25 .bss          000001c8  0000000000604100  0000000000604100  000040f0  2**5  ALLOC
 26 .comment      00000034  0000000000000000  0000000000000000  000040f0  2**0  CONTENTS, READONLY

这里的对齐要求从2 ** 0(不需要对齐)到2 ** 5(在32字节边界上对齐)不等。
除了您提到的候选者之外,运行时还需要具备对齐感知能力。这个话题有点复杂,但基本上可以确定malloc和相关函数返回适合任何基本类型对齐的内存(通常在64位系统上仅意味着8字节对齐),尽管当您谈论超对齐类型或C ++ alignas时,事情会变得更加复杂

0我最初将(编译时)链接器和(运行时)加载器分为一组,因为它们实际上是同一个硬币的两面(实际上大部分链接实际上是运行时链接)。然而,在更仔细地研究加载过程后,似乎加载器可能只会以其现有文件偏移量加载段(部分),自动遵守链接器设置的对齐方式。

1在像x86这样允许未对齐访问的平台上不太严格,但在对齐限制更严格的平台上,如果遇到不正确的对齐,代码可能会失败。


2

我认为正确的最短答案是:这是编译器的工作。

这就是为什么有各种 #pragma 和其他编译器级别的魔法旋钮可以控制对齐方式,当必要时可以使用。

我不认为C语言规定了那些其他组件(链接器和加载器)的存在。


1
C11标准中提到了链接,甚至在一个脚注中提到了链接器这个词;还有其他的提及,比如“翻译单元可以分别翻译,然后再链接以生成可执行程序。”- 当然,标准并没有规定必须存在单独的链接器。 - Antti Haapala -- Слава Україні
可以理解编译器设计者不会对链接器,更不用说加载器发表意见,但实际机制,我能否说取决于实现,其中包括链接器,更本地的是加载器? - user2338150
1
它们不需要被提及就能够具有对齐感知能力:标准规定了事物的工作方式,然后工具链以合理的方式实现它。在大多数情况下,这意味着整个工具链,从编译器到链接器再到加载器/运行时链接器都需要理解对齐。实际上,标准甚至不要求链接器或编译器(例如,您可以拥有符合规范的解释性C语言),但在实践中,这正是大多数平台上的实现方式。 - BeeOnRope

1
数据对齐与代码生成密切相关。考虑为具有局部变量在某些边界对齐的函数生成序言和尾声的所有负担。[实时示例] 下面的两个代码是从相同的函数生成的,但对齐方式不同(32B为左侧,4B为右侧)。
foo(double):                         foo(double):
   push    ebp                          lea     ecx, [esp+4]
   mov     ebp, esp                     and     esp, -8
   sub     esp, 40                      push    DWORD PTR [ecx-4]
   mov     eax, DWORD PTR [ebp+8]       push    ebp
   mov     DWORD PTR [ebp-40], eax      mov     ebp, esp
   mov     eax, DWORD PTR [ebp+12]      push    ecx
   mov     DWORD PTR [ebp-36], eax      sub     esp, 20
   fld1                                 mov     eax, ecx
   fstp    QWORD PTR [ebp-8]            fld1
   fld     QWORD PTR [ebp-40]           fstp    QWORD PTR [ebp-16]
   fstp    QWORD PTR [ebp-16]           fld     QWORD PTR [eax]
   fld     QWORD PTR [ebp-8]            fstp    QWORD PTR [ebp-24]
   fmul    QWORD PTR [ebp-16]           fld     QWORD PTR [ebp-16]
   leave                                fmul    QWORD PTR [ebp-24]
   ret                                  add     esp, 20
                                        pop     ecx
                                        pop     ebp
                                        lea     esp, [ecx-4]
                                        ret

虽然此示例涉及堆栈的对齐,但其目的是展示所引发的复杂性。结构对齐的工作方式相同。
为了将此责任推迟到链接器,编译器必须生成特定代码和大量元数据,以便链接器可以修补必要的指令。适应有限的链接器接口会导致生成次优代码。丰富链接器功能将把编译器-链接器边界向左移动,实际上使后者成为“有点像小型编译器”。
加载程序没有处理程序数据的手段 - 它必须加载任何程序,无论它们如何访问其数据,尝试将代码和数据尽可能视为不透明。特别地,加载器通常填充或重写可执行元数据,但不填充代码或数据。每次读取结构字段时使代码经过元数据将是一个没有合理性的巨大性能损失。
硬件本身不了解结构体,也不清楚程序的意图。
当被指示从X读取时,它将尽最大努力快速、正确地从X读取,但不会赋予任何含义给这个X
硬件只是按照指令执行任务。
如果无法执行,就会发出信号。x86架构对齐要求非常宽松,代价是潜在的操作延迟加倍(甚至更多)。
编译器负责对齐数据。
在此过程中,有两个引理非常方便1
  • 如果一个对象 a 相对于一个 Y对齐 的对象 bX对齐 的,且 X | YYX 的倍数),那么相对于同一参考系的 ba 也是 X对齐 的。

    例如,PE/ELF 文件中的节(以及某些 malloc 缓冲区)可以在特定边界(8 字节、16 字节、4KiB 等)上对齐加载。
    如果一个节被对齐加载到了 4KiB,则一旦它在内存中,所有的二次幂对齐都会自动得到尊重,即使它们是相对于该节的起始处取的,不管该节加载到哪里。

  • 在长度为 2X-1 的缓冲区 B 中,至少有一个地址 AX对齐 的,并且 2X-1 - (A-B) >= X (它有足够的空间来容纳大小为 X 的对象)。

    如果您需要将一个长度为 8 字节的对象对齐到 8 字节边界,并且该对象通常是 8 字节长的,则分配一个 16-1 = 15 字节的缓冲区将保证适当的地址存在于缓冲区的每个可能的起始地址处。

由于这两个引理和与加载器建立的约定,编译器可以在不使用其他工具的情况下完成其职责。

1 给出,不要过多解释。


@BeeOnRope 我们同意这一点。这在上面的第一个引理中已经说明了。 - Margaret Bloom
引理中的示例看起来是错误的:malloc 绝对不会将所有分配都对齐到4K:这意味着每个分配至少需要那么多的空间!实际上,小的分配通常对齐到8或16字节。因此,malloc通常不会返回适合于“超对齐”对象的内存(例如,您使用了#pragma align或其他对齐属性来增加对齐)。 - BeeOnRope
然后,部分加载也是错误的。部分绝对不会加载到页面边界。那样也是低效的!请查看我下面列出的非常简单的二进制文件中的部分:完全有26个部分,其中24个可以直接加载。如果它们必须在页面边界上,则需要24个4K页面。实际发生的是,exe的页面按原样映射,因此多个部分可能出现在同一页上。如果它们包含的部分意味着不同的权限,则某些页面可能被映射两次或更多次。 - BeeOnRope
所有这些都是为了说明链接器需要注意对齐,因为它需要将各个部分放置在文件内适当的v和f偏移量处,文件中存储有一个显式的ALGN字段来帮助它完成这一点(虽然我不确定加载器是否会使用它:各个部分应该已经出现在满足对齐要求的虚拟偏移量上 - 也许只是将ALGN检查作为完整性检查?我不知道)。 - BeeOnRope
1
@BeeOnRope 我在使用malloc时并不确定(正如我所写的),但是你的简单推理确实是正确的!关于ELF,在再次阅读ELF文件格式后,我认为你又是正确的:)章节必须具有虚拟地址和物理偏移余数模4KiB,但不按4KiB对齐。链接器肯定必须能够处理对齐(就像一个简单的readelf -s显示的那样),但仍然是编译器在合适的对齐方式上对链接器进行调整,所以我喜欢将其视为一种中立功能(就像“and”指令一样)。感谢您的反馈! - Margaret Bloom
显示剩余6条评论

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