为什么 "alignment" 在 32 位和 64 位系统上相同?

Why is the "alignment" the same on 32-bit and 64-bit systems?

我想知道编译器是否会在 32 位和 64 位系统上使用不同的填充,所以我在一个简单的 VS2019 C++ 控制台项目中编写了以下代码:

struct Z
{
    char s;
    __int64 i;
};

int main()
{
    std::cout << sizeof(Z) <<"\n"; 
}

我对每个 "Platform" 设置的预期:

x86: 12
X64: 16

实际结果:

x86: 16
X64: 16

由于x86 上的内存字长是4 个字节,这意味着它必须将i 的字节存储在两个不同的字中。所以我认为编译器会这样填充:

struct Z
{
    char s;
    char _pad[3];
    __int64 i;
};

请问这是什么原因?

  1. 为了与 64 位系统向前兼容?
  2. 由于在 32 位处理器上支持 64 位数字的限制?

填充不是由字长决定的,而是由每个数据类型的the alignment决定的。

在大多数情况下,对齐要求等于类型的大小。因此,对于像 int64 这样的 64 位类型,您将获得 8 字节(64 位)对齐。需要将填充插入到结构中,以确保该类型的存储以正确对齐的地址结束。

当使用在两种架构上具有 不同 大小的内置数据类型时,您可能会看到 32 位和 64 位之间的填充差异,例如指针类型(int*).

这是数据类型对齐要求的问题,如在 Padding and Alignment of Structure Members

Every data object has an alignment-requirement. The alignment-requirement for all data except structures, unions, and arrays is either the size of the object or the current packing size (specified with either /Zp or the pack pragma, whichever is less).

结构成员对齐的默认值在/Zp (Struct Member Alignment)

中指定

The available packing values are described in the following table:

/Zp argument Effect
1 Packs structures on 1-byte boundaries. Same as /Zp.
2 Packs structures on 2-byte boundaries.
4 Packs structures on 4-byte boundaries.
8 Packs structures on 8-byte boundaries (default for x86, ARM, and ARM64).
16 Packs structures on 16-byte boundaries (default for x64).

由于 x86 的默认值是 /Zp8,即 8 个字节,因此输出为 16。

但是,您可以使用 /Zp 选项指定不同的包装尺寸。
这是一个带有 /Zp4Live Demo,输出为 12 而不是 16。

结构的对齐方式是其最大成员的大小。

这意味着如果结构中有一个 8 字节(64 位)成员,则该结构将对齐到 8 字节。

在您描述的情况下,如果编译器允许结构对齐到 4 个字节,它可能会导致一个 8 字节的成员跨越缓存行边界。


假设我们有一个 CPU,它有一个 16 字节的缓存行。 考虑这样的结构:

struct Z
{
    char s;      // 1-4 byte
    __int64 i;   // 5-12 byte
    __int64 i2;  // 13-20 byte, need two cache line fetches to read this variable
};

大小和alignof()(该类型必须的任何object必须具有的最小对齐)是ABI1 设计选择与架构的寄存器宽度分开。

Struct-packing 规则也可能比将每个结构成员与其在结构内的最小对齐方式对齐更复杂;那是 ABI 的另一部分。

针对 32 位 x86 的 MSVC 为 __int64 最小 对齐 4,但 其默认 struct-packing 规则对齐类型相对于结构开始的 min(8, sizeof(T)) 的结构。(仅适用于 non-aggregate 类型)。那是 不是 的直接引述,这是我根据 MSVC 似乎实际做的,从 @P.W 的回答中对 MSVC docs link 的解释。 (我怀疑文本中的 "whichever is less" 应该在 parens 之外,但也许他们对 pragma 和 command-line 选项的交互有不同的看法?)

(包含 char[8] 的 8 字节结构在另一个结构中仍然只得到 1 字节对齐,或者包含 alignas(16) 成员的结构在另一个结构中仍然得到 16 字节对齐。 )

请注意,ISO C++ 不保证基本类型具有 alignof(T) == sizeof(T) 另请注意,MSVC 对 alignof() 的定义与 ISO 不匹配C++ 标准:MSVC 说 alignof(__int64) == 8,但有些 __int64 object 的对齐方式小于 2.


令人惊讶的是,我们得到了额外的填充,即使 MSVC 并不总是费心去确保结构本身有超过 4 字节的对齐,除非你用alignas() 在变量上,或在结构成员上暗示该类型。 (例如,函数内部堆栈上的局部 struct Z tmp 将只有 4 字节对齐,因为 MSVC 不使用像 and esp, -8 这样的额外指令将堆栈指针向下舍入到 8 字节边界。 )

然而,new / malloc 在 32 位模式下确实给你 8-byte-aligned 内存,所以这对 dynamically-allocated objects(常见)。强制堆栈上的局部变量完全对齐会增加对齐堆栈指针的成本,但通过设置结构布局以利用 8-byte-aligned 存储,我们可以获得静态和动态存储的优势。


这也可能旨在让 32 位和 64 位代码就共享内存的某些结构布局达成一致。 (但请注意,x86-64 的默认值是 min(16, sizeof(T)),因此如果有任何 16 字节类型不是聚合 (struct/union/array) 并且不没有 alignas.)


4 的最小绝对对齐来自 32 位代码可以采用的 4 字节堆栈对齐。在静态存储中,编译器将选择自然对齐,最多可能为 8或 16 字节用于结构外的变量,以便使用 SSE2 向量进行高效复制。

在较大的函数中,MSVC 可能出于性能原因决定将堆栈对齐 8,例如对于堆栈上的 double 变量,实际上可以使用单个指令进行操作,或者对于 int64_t 也可以使用 SSE2 向量。请参阅这篇 2006 年文章中的 堆栈对齐 部分:Windows Data Alignment on IPF, x86, and x64。因此,在 32 位代码中,您不能依赖 int64_t*double* 自然对齐。

(我不确定 MSVC 是否会自己创建对齐度更低的 int64_tdouble objects。当然,如果你使用 #pragma pack 1-Zp1,但这会改变 ABI。但否则可能不会,除非您手动从缓冲区中为 int64_t 雕刻 space 并且不必费心对齐它。但假设 alignof(int64_t) 仍然是 8,这将是 C++ 未定义的行为。)

如果您使用 alignas(8) int64_t tmp,MSVC 会向 and esp, -8 发出额外的指令。如果你不这样做,MSVC 不会做任何特别的事情,所以 tmp 是否以 8 字节对齐结束都是幸运的。


其他设计也是可能的,例如 i386 System V ABI(用于大多数 non-Windows 操作系统)有 alignof(long long) = 4sizeof(long long) = 8。这些选择

在结构之外(例如全局变量或堆栈上的局部变量),32 位模式下的现代编译器确实选择将 int64_t 对齐到 8 字节边界以提高效率(因此它可以加载/使用 MMX 或 SSE2 64 位加载复制,或 x87 fild 进行 int64_t -> 双重转换)。

这就是现代版本的 i386 System V ABI 保持 16 字节堆栈对齐的原因之一:因此 8 字节和 16 字节对齐的局部变量是可能的。


在设计 32 位 Windows ABI 时,Pentium CPU 至少在 horizon 上。 Pentium 有 64 位宽的数据总线, 所以它的 FPU 真的可以在单个缓存访问中加载 64 位 double 如果 它是 64 位对齐的.

或者对于fild/fistp,load/store转换为to/fromdouble时的64位整数。有趣的事实:自 Pentium 以来,x86 上保证自然对齐的高达 64 位的访问是原子的:Why is integer assignment on a naturally aligned variable atomic on x86?


脚注 1: 一个ABI 还包括一个调用约定,或者在 MS Windows 的情况下,可以选择各种调用约定,您可以使用 __fastcall 等函数属性声明这些调用约定,但是大小和 alignment-requirements像 long long 这样的原始类型也是编译器必须达成一致才能创建可以相互调用的函数的东西。 (ISO C++ 标准只讨论单个 "C++ implementation";ABI 标准是 "C++ implementations" 如何使它们彼此兼容。)

请注意 struct-layout 规则也是 ABI 的一部分:编译器必须在结构布局上彼此达成一致,以创建传递结构或指针的兼容二进制文件到结构。否则 s.x = 10; foo(&x); 可能会写入相对于结构基址的不同偏移量,而不是 separately-compiled foo()(可能在 DLL 中)期望读取它的位置。


脚注 2:

GCC 也有这个 C++ alignof() 错误,直到 fixed in 2018 for g++8 在针对 C11 _Alignof() 修复后的某个时间。请参阅该错误报告以进行一些基于标准引用的讨论,这些讨论得出的结论是 alignof(T) 应该真正报告您可以看到的最低保证对齐方式, 而不是 您想要的首选对齐方式表现。即使用小于 alignof(int64_t) 对齐的 int64_t* 是未定义的行为。

(它通常在 x86 上运行良好,但假设整数 int64_t 迭代次数将达到 16 或 32 字节对齐边界的矢量化可能会出错。有关示例,请参见 Why does unaligned access to mmap'ed memory sometimes segfault on AMD64?使用 gcc。)

gcc 错误报告讨论了 i386 System V ABI,它具有与 MSVC 不同的 struct-packing 规则:基于最小对齐,不是首选。但是现代 i386 System V 保持 16 字节堆栈对齐,因此编译器曾经创建 int64_tdouble object 未自然对齐。无论如何,这就是为什么 GCC 错误报告将结构成员作为特殊情况进行讨论的原因。

与 MSVC 的 32 位 Windows 相反,其中 struct-packing 规则与 alignof(int64_t) == 8 兼容,但堆栈上的局部变量总是潜在的 under-aligned除非您使用 alignas() 明确要求对齐。

32 位 MSVC 具有 alignas(int64_t) int64_t tmpint64_t tmp; 不同的奇怪行为,并发出额外的指令来对齐堆栈 。那是因为 alignas(int64_t) 类似于 alignas(8),它比实际的最小值更对齐。

void extfunc(int64_t *);

void foo_align8(void) {
    alignas(int64_t) int64_t tmp;
    extfunc(&tmp);
}

(32 位)x86 MSVC 19.20 -O2 像这样编译它(on Godbolt,还包括 32 位 GCC 和结构 test-case ):

_tmp$ = -8                                          ; size = 8
void foo_align8(void) PROC                       ; foo_align8, COMDAT
        push    ebp
        mov     ebp, esp
        and     esp, -8                             ; fffffff8H  align the stack
        sub     esp, 8                                  ; and reserve 8 bytes
        lea     eax, DWORD PTR _tmp$[esp+8]             ; get a pointer to those 8 bytes
        push    eax                                     ; pass the pointer as an arg
        call    void extfunc(__int64 *)           ; extfunc
        add     esp, 4
        mov     esp, ebp
        pop     ebp
        ret     0

但是如果没有 alignas(),或者有 alignas(4),我们会得到更简单的

_tmp$ = -8                                          ; size = 8
void foo_noalign(void) PROC                                ; foo_noalign, COMDAT
        sub     esp, 8                             ; reserve 8 bytes
        lea     eax, DWORD PTR _tmp$[esp+8]        ; "calculate" a pointer to it
        push    eax                                ; pass the pointer as a function arg
        call    void extfunc(__int64 *)           ; extfunc
        add     esp, 12                             ; 0000000cH
        ret     0

它可以 push esp 而不是 LEA/push;这是一个小的遗漏优化。

将指针传递给 non-inline 函数证明它不仅仅是在局部改变规则。其他一些只获得 int64_t* 作为 arg 的函数必须处理这个潜在的 under-aligned 指针,而没有获得有关它来自何处的任何信息。

如果 alignof(int64_t) 确实是 8,那么该函数在 asm 中可能是 hand-written,在指针未对齐时出错。或者它可以用 C 语言编写,带有 SSE2 内在函数,如 _mm_load_si128(),需要 16 字节对齐,在处理 0 或 1 个元素以达到对齐边界后。

但是根据 MSVC 的实际行为,int64_t 数组元素中的 none 可能按 16 对齐,因为它们 所有 跨越 8-字节边界。


顺便说一句,我不建议直接使用 compiler-specific 类型,例如 __int64。您可以使用 int64_t from <cstdint><stdint.h>.

编写可移植代码

在 MSVC 中,int64_t__int64 的类型相同。

在其他平台上,它通常是 longlong longint64_t 保证正好是 64 位,没有填充,如果提供的话,还有 2 的补码。 (所有针对普通 CPU 的理智编译器都是如此。C99 和 C++ 要求 long long 至少是 64 位,并且在具有 8 位字节和寄存器的机器上是 2 的幂,long long通常正好是 64 位,可以用作 int64_t。或者如果 long 是 64 位类型,那么 <cstdint> 可能会使用它作为 typedef。)

我假设 __int64long long 在 MSVC 中是相同的类型,但是 MSVC 并不强制执行 strict-aliasing 所以它们是否完全相同并不重要类型与否,只是它们使用相同的表示形式。