为什么32位和64位系统上的“对齐方式”相同?

22

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

struct Z
{
    char s;
    __int64 i;
};

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

我对每个“平台”设置的期望:

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位数字的限制?

4个回答

15

每种原始类型的大小和alignof()(该类型的任何对象必须具有的最小对齐方式)都是ABI1设计选择,与体系结构的寄存器宽度分开。

结构打包规则也可以比仅将每个结构成员与其在结构内的最小对齐方式对齐更为复杂;这是ABI的另一部分。

针对32位x86的MSVC将__int64最小对齐方式设置为4,但其默认的结构打包规则将结构中的类型与结构的起始位置对齐到min(8,sizeof(T))(仅适用于非聚合类型)。这不是直接引用,而是我根据MSVC实际执行的MSVC文档链接从@P.W的回答中得出的意义。 (我怀疑文本中的“最小值”应该在括号外面,但也许他们正在就#pragma和命令行选项之间的交互提出不同的观点?)

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

请注意,ISO C++不保证原始类型具有alignof(T) == sizeof(T)。还要注意,MSVC对alignof()的定义与ISO C++标准不匹配:MSVC表示alignof(__int64) == 8,但某些__int64对象的对齐方式小于该值2
令人惊讶的是,即使MSVC不总是确保结构体本身具有超过4字节的对齐方式,我们仍然会获得额外的填充。除非您使用alignas()在变量或结构体成员上指定,以暗示该类型需要对齐(例如,函数内部堆栈上的局部struct Z tmp只有4字节的对齐方式,因为MSVC不使用额外的指令像and esp, -8这样将堆栈指针舍入到8字节边界)。
然而,在32位模式下,new/malloc确实会给您提供8字节对齐的内存,因此这对于动态分配的对象(这很常见)非常有意义。强制堆栈上的局部变量完全对齐将增加对齐堆栈指针的成本,但通过设置结构布局以利用8字节对齐的存储,我们可以获得静态和动态存储的优势。
这也可能是为了让32位和64位代码就共享内存的某些结构布局达成一致。 (但请注意,x86-64的默认值为min(16,sizeof(T)),因此如果存在任何不是聚合体(struct / union / array)且没有alignas 的16字节类型,则它们仍然不能完全达成结构布局上的一致。)
最小绝对对齐度为4来自32位代码可以假设的4字节堆栈对齐。在静态存储中,编译器会选择自然对齐方式,但对于结构体外面的变量,为了与SSE2矢量进行高效复制,可能会选择最多8或16个字节的对齐方式。
在大型函数中,MSVC可能会决定通过将堆栈对齐为8来提高性能,例如对于堆栈上的double变量,实际上可以使用单个指令进行操作,或者也可能是使用SSE2向量处理int64_t。请参见此2006年文章中的“堆栈对齐”部分:Windows Data Alignment on IPF, x86, and x64。因此,在32位代码中,您不能依赖int64_t*或double*被自然对齐。
(我不确定MSVC是否会自己创建甚至更少对齐的int64_t或double对象。如果您使用#pragma pack 1或-Zp1,则肯定是这样,但这会改变ABI。但是否则可能不会,除非您手动从缓冲区中划出一个int64_t的空间并且不注意对齐。但是假设alignof(int64_t)仍为8,那将是C++未定义的行为。)
如果你使用 alignas(8) int64_t tmp,MSVC会发出额外的指令来执行 and esp, -8。 如果你不这样做,MSVC不会做任何特殊处理,所以 tmp 是否最终对齐到8字节取决于运气。
其他设计也是可能的,例如i386 System V ABI(用于大多数非Windows操作系统)具有alignof(long long)=4sizeof(long long)=8 。这些选择
除结构外(例如全局变量或堆栈上的本地变量),现代编译器在32位模式下确实选择将int64_t对齐到8字节边界以提高效率(因此可以使用MMX或SSE2 64位加载或x87 fild进行int64_t-> double转换)。
这就是为什么i386 System V ABI的现代版本保持16字节的堆栈对齐的一个原因:因此可以有8字节和16字节对齐的本地变量。
当32位Windows ABI被设计时,奔腾CPU至少已经在地平线上了。Pentium拥有64位宽的数据总线,因此如果它是64位对齐的,则其FPU确实可以在单个缓存访问中加载64位双精度。

或者对于fild / fistp,在将其转换为/自double时,加载/存储64位整数。有趣的是,x86上自然对齐的访问最多可保证原子性达到64位,自Pentium以来:为什么x86上自然对齐的变量整数赋值是原子的?
注1:ABI还包括调用约定,或在MS Windows的情况下,可以使用函数属性(例如__fastcall)声明各种调用约定,但是原始类型(如long long)的大小和对齐要求也是编译器必须协商一致的,以便创建可相互调用的函数。(ISO C++标准只涉及单个“C++实现”;ABI标准是“C ++实现”如何使自己与其他实现兼容的方式。)
请注意,结构体布局规则也是ABI的一部分:编译器必须就结构体布局达成一致,以创建可相互传递结构体或指向结构体的指针的兼容二进制文件。否则,s.x = 10; foo(&x);可能会写入与预期不同的偏移量相对于结构体的基址,而分别编译的foo()(可能在DLL中)希望在该偏移量处读取。
注释2:
GCC也有这个C++ alignof()的bug,直到2018年g++8修复之后才被修复,这是在修复C11 _Alignof()之后的一段时间。请参见该错误报告以了解基于标准的一些讨论,其中得出结论:alignof(T)应该报告您可以看到的最小保证对齐方式,而不是为性能而希望的首选对齐方式。即使用具有小于alignof(int64_t)对齐方式的int64_t*是未定义行为。
(在x86上通常可以正常工作,但假设整数次数的向量化将达到16或32字节对齐边界的矢量化可能会失败。请参见使用gcc的为什么对mmap'ed内存进行非对齐访问有时会在AMD64上导致segfault?作为示例。)
gcc 的错误报告讨论了 i386 System V ABI,其具有与 MSVC 不同的结构填充规则:基于最小对齐而非首选。但现代 i386 System V 保持 16 字节堆栈对齐,所以只有在结构体内部(因为结构填充规则是 ABI 的一部分)编译器才会创建小于自然对齐的 int64_t 和 double 对象。无论如何,这就是 GCC 错误报告讨论结构成员作为特殊情况的原因。
与 MSVC 的 32 位 Windows 相反,struct-packing 规则与 alignof(int64_t) == 8 兼容,但堆栈上的局部变量始终可能不符合对齐要求,除非您使用 alignas() 明确请求对齐。
32 位 MSVC 具有奇怪的行为,即 alignas(int64_t) int64_t tmp 与 int64_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编译它时像这样(在Godbolt上,还包括32位GCC和结构体测试用例):

_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; 这是一个小的未优化。

传递指向非内联函数的指针证明它不仅仅是在本地弯曲规则。一些其他函数只会得到int64_t*作为参数,必须处理这个可能未对齐的指针,而没有获得任何关于它来自哪里的信息。

如果alignof(int64_t)真的是8,那么该函数可以用汇编手写方式编写,以一种在错误对齐指针时导致故障的方式。或者它可以使用SSE2内置函数(如_mm_load_si128())用C语言编写,要求16字节对齐,之前需要处理0或1个元素以达到对齐边界。

但是根据MSVC的实际行为,可能没有一个int64_t数组元素被16字节对齐,因为它们全部跨越8字节边界。


顺便提一下,我不建议直接使用编译器特定类型如__int64。你可以使用<cstdint>中的int64_t编写可移植代码。
在MSVC中,int64_t将与__int64相同。
在其他平台上,它通常是longlong longint64_t保证精确地为64位,没有填充,并且是2的补码形式(如果提供)。(所有针对普通CPU的理智编译器都是这样。C99和C++要求long long至少为64位,在具有8位字节和寄存器的机器上,long long通常恰好为64位,可以用作int64_t。或者,如果long是64位类型,则<cstdint>可能会将其用作typedef。)
我假设__int64long long在MSVC中是相同的类型,但是MSVC不强制执行严格别名,因此它们是否完全相同并不重要,只需使用相同的表示即可。

2
好的,我必须承认我从未想过我会得到这样一个回答,它涵盖了如此多的方面。谢谢! - Shen Yuan
1
@ShenYuan:我很惊讶一个足够简单的解释竟然如此复杂。MSVC的结构体打包规则(使用首选对齐而不是实际最小对齐)与我熟悉的i386 System V有很大不同,所以你问题中的sizeof(Z) == 16引起了我的好奇心。我知道32位Windows只保持栈4字节对齐,因此在本地变量上进行额外的栈对齐以及实际最小对齐是什么时,这是一个真正需要解决的谜题。 - Peter Cordes

13

填充不是由字大小决定的,而是由每种数据类型的对齐方式决定的。

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

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


2
默认对齐方式由字长确定。原因是单词在内存中的寻址使其完美地适合寄存器。在 x86(_64) 上,未对齐的数据需要进行移位操作才能处理。在其他平台上,例如 sun sparc,未对齐的数据将导致总线异常。如果要删除填充,请尝试在结构体定义中添加 __attribute__((packed))(GCC)。 - Nefrin
1
@Nefrin,你有相关的参考资料吗?我不知道C或C++内置数据类型有这样的行为。 - ComicSansMS
2
这并不是C/C++语言的行为,而是编译器的行为。 - Nefrin
2
@Nefrin:x86-64具有高效的非对齐加载,可以在硬件中处理所需的移位。是的,对于比整数寄存器宽的类型,在某些ABI(如i386 System V和32位Windows)中只能对齐到寄存器宽度。但是对于x86-64 System V,alignof(__int128) = 16,因此可以使用SSE向量或lock cmpxchg16b进行复制。但就C++标准而言,这完全取决于实现。并且结构体打包规则允许与您基于alignof(member)所期望的不同,正如P.W的答案所示,这适用于MSVC。 - Peter Cordes
2
@ComicSansMS: 在32位MSVC中,alignof(int64_t) == 8,但它实际上并不会确保栈上的本地变量具有这个最小对齐方式,因此这并不是任何int64_t对象所需的最小对齐方式。如果您使用alignas(8) int64_t tmp;,则会获得额外的指令来对齐堆栈指针,而使用int64_t tmp则不会获得这些指令。https://godbolt.org/z/lsuXAQ。正如@P.W的答案所示,结构体打包规则可以比相对于结构体开始填充到`alignof(T)`更复杂。 - Peter Cordes
显示剩余2条评论

9
这是与数据类型对齐要求相关的问题,如结构成员的填充和对齐所述。

每个数据对象都有一个对齐要求。除了结构、联合和数组之外的所有数据的对齐要求是对象的大小或当前打包大小(使用/Zp或pack pragma指定,以较小者为准)。

而结构成员对齐的默认值在/Zp(结构成员对齐)中指定。
以下是可用的打包值:
/Zp参数 效果 1 在1字节边界上打包结构体。与 /Zp 相同。 2 在2字节边界上打包结构体。 4 在4字节边界上打包结构体。 8(x86,ARM 和 ARM64 的默认值) 在8字节边界上打包结构体。 16(x64 的默认值) 在16字节边界上打包结构体。
由于x86的默认值为“/Zp8”,即8个字节,因此输出结果为16。
但是,您可以使用“/Zp”选项指定不同的打包大小。这里有一个带有“/Zp4”的实时演示,它将输出结果更改为12,而不是16。

-3

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

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

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


假设我们有一个具有16字节缓存行的CPU。 考虑一个类似这样的结构体:
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
};

3
不对,结构体的对齐方式取决于其中对齐要求最严格的成员变量。并非所有C语言类型的大小都与它们的对齐方式相同,特别是嵌套在其他结构体中的struct或数组等复合类型。但是,原始类型不能保证具有alignof(T)== sizeof(T)。在像i386 System V(32位x86 Linux)这样的ABI上,alignof(int64_t)== 4,因此原帖作者预期的sizeof(struct)== 12alignof(struct)== 4是正确的。 - Peter Cordes

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