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

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

本文关键字:alignment 相同 系统 位和 为什么      更新时间:2023-10-16

我想知道编译器是否会在 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 位数字的限制?

每个基元类型的大小和alignof()(该类型的任何对象必须具有的最小对齐方式)是独立于体系结构寄存器宽度的 ABI1设计选择。

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

面向 32 位 x86 的 MSVC 为__int64提供 4 的最小对齐方式,但其默认的结构打包规则将结构中的类型对齐,以相对于结构的开头进行min(8, sizeof(T))(仅适用于非聚合类型)。这不是直接引用,这是我对 @P.W 答案中的 MSVC 文档链接的解释,基于 MSVC 似乎实际做了什么。 (我怀疑文本中的"以较少者为准"应该在参数之外,但也许他们对编译指示和命令行选项的交互提出了不同的观点?

(包含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 字节边界。

但是,new/malloc确实在 8 位模式下为您提供了 32 字节对齐的内存,因此这对于动态分配的对象(常见)非常有意义。 强制堆栈上的局部变量完全对齐会增加对齐堆栈指针的成本,但通过设置结构布局以利用 8 字节对齐的存储,我们获得了静态和动态存储的优势。


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

4 的

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

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

(我不确定 MSVC 是否会自行创建对齐程度更低的int64_tdouble对象。 当然是的,如果你使用#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 位加载/复制,或 x87fildint64_t -> 双重转换)。

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


当设计32位Windows ABI时,奔腾CPU至少在地平线上。 奔腾具有 64 位宽的数据总线,因此如果 64 位对齐,其FPU 确实可以在单个缓存访问中加载 64 位double

或者对于fild/fistp,在转换为/从double转换时加载/存储一个 64 位整数。 有趣的事实:自奔腾以来,在 x86 上保证最多 64 位的自然对齐访问是原子的:为什么在 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()错误,直到 2018 年为 g++8 修复了一段时间后,它在 C11_Alignof()中得到了修复。 请参阅该错误报告,了解基于标准引用的一些讨论,这些引用得出的结论是alignof(T)应该真正报告您可以看到的最小保证对齐方式,而不是您想要的性能首选对齐方式。 即使用对齐方式小于alignof(int64_t)int64_t*是未定义的行为。

(它通常在 x86 上工作正常,但int64_t假设整数次迭代将达到 16 或 32 字节对齐边界的矢量化可能会出错。 请参阅为什么在 AMD64 上对 mmap 内存的未对齐访问有时会出现段错误?以 GCC 为例。

gcc 错误报告讨论了 i386 System V ABI,它与 MSVC 具有不同的结构打包规则:基于最小对齐,而不是首选。 但是现代 i386 System V 保持 16 字节堆栈对齐,因此编译器只有在结构内部(因为结构打包规则是 ABI 的一部分)才会创建int64_tdouble低于自然对齐的对象。 无论如何,这就是为什么GCC错误报告将结构成员作为特例进行讨论的原因。

与带有 MSVC 的 32 位 Windows 相反,在 32 位 Windows 中,结构打包规则与alignof(int64_t) == 8兼容,但堆栈上的局部变量始终可能未对齐,除非您使用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 是这样编译的(在 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;这是一个轻微的优化遗漏。

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

如果alignof(int64_t)真的是 8,那么该函数可以用 asm 手写,就像在未对齐的指针上出错一样。 或者它可以用 C 编写,带有 SSE2 内部函数,例如需要 16 字节对齐的_mm_load_si128(),在处理 0 或 1 个元素以达到对齐边界后。

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


顺便说一句,我不建议直接使用特定于编译器的类型,例如__int64。 您可以使用<cstdint>中的int64_t编写可移植代码,也称为<stdint.h>

在 MSVC 中,int64_t的类型将与__int64相同。

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

我假设__int64long long在 MSVC 中是相同的类型,但 MSVC 无论如何都不会强制执行严格别名,因此它们是否是完全相同的类型并不重要,只是它们使用相同的表示形式。

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

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

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

这是中指定的数据类型的对齐要求问题 结构成员的填充和对齐

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

结构成员对齐的默认值在/Zp(结构成员对齐)中指定

下表描述了可用的包装值:

/Zp参数 效果 1
在 1 字节边界上打包结构。与/Zp.2 相同 在
2 字节边界上打包结构。
4 在 4 字节边界上打包结构。8
在 8 字节边界上打包结构(x86、ARM 和 ARM64 的默认值)。
16 在 16 字节边界上打包结构(x64 的默认值)。

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

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

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

这意味着如果结构中有一个 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
};