我最近开始考虑对齐...这是我们通常不必考虑的事情,但我意识到某些处理器需要对象沿 4 字节边界对齐。这究竟意味着什么,哪些特定系统有对齐要求?
假设我有一个任意指针:
unsigned char* ptr
现在,我正在尝试从内存位置检索双精度值:
double d = **((double*)ptr);
这会引起问题吗?
它肯定会在某些系统上引起问题。
例如,在基于 ARM 的系统上,您无法寻址未与 4 字节边界对齐的 32 位字。这样做会导致访问冲突异常。在 x86 上,您可以访问此类未对齐的数据,尽管性能会受到一点影响,因为必须从内存中获取两个单词而不是一个单词。
以下是Intel x86/x64 参考手册中关于对齐的说明:
4.1.1 字、双字、四字、双四字的对齐
字、双字和四字在内存中不需要在自然边界上对齐。字、双字和四字的自然边界分别是偶数地址、可被 4 整除的地址和可被 8 整除的地址。但是,为了提高程序的性能,数据结构(尤其是堆栈)应尽可能在自然边界上对齐。原因是处理器需要两次内存访问才能进行未对齐的内存访问;对齐访问只需要一次内存访问。跨越 4 字节边界的字或双字操作数或跨越 8 字节边界的四字操作数被认为是未对齐的,并且需要两个单独的内存总线周期才能访问。
一些对双四字进行操作的指令要求内存操作数在自然边界上对齐。如果指定了未对齐的操作数,这些指令将生成通用保护异常 (#GP)。双四字的自然边界是可被 16 整除的任何地址。对双四字进行操作的其他指令允许非对齐访问(不会产生一般保护异常)。但是,需要额外的内存总线周期来访问内存中未对齐的数据。
不要忘记,参考手册是负责的开发人员和工程师的最终信息来源,因此,如果您正在处理诸如英特尔 CPU 之类的文档,只需查看参考手册中有关该问题的内容即可。
是的,这可能会导致许多问题。C++ 标准实际上并不能保证它会起作用。您不能只是在指针类型之间任意转换。
当您将 char 指针转换为双指针时,它使用 a reinterpret_cast
,它应用实现定义的映射。您不能保证生成的指针将包含相同的位模式,或者它会指向相同的地址,或者其他任何东西。在更实际的情况下,您也不能保证您正在读取的值正确对齐。如果数据被写成一系列字符,那么它们将使用字符的对齐要求。
至于对齐是什么意思,本质上只是值的起始地址应该能被对齐大小整除。例如,地址 16 在 1、2、4、8 和 16 字节边界上对齐,因此在典型的 CPU 上,这些大小的值可以存储在那里。
地址 6 没有在 4 字节边界上对齐,所以我们不应该在那里存储 4 字节值。
值得注意的是,即使在不强制或不要求对齐的 CPU 上,访问未对齐的值通常仍会显着减慢速度。
对齐会影响结构的布局。考虑这个结构:
struct S {
char a;
long b;
};
在 32 位 CPU 上,此结构的布局通常为:
a _ _ _ b b b b
要求是 32 位值必须在 32 位边界上对齐。如果结构更改如下:
struct S {
char a;
short b;
long c;
};
布局将是这样的:
a _ b b c c c c
16 位值在 16 位边界上对齐。
有时,如果您想将结构与数据格式匹配,您可能想要打包结构。通过使用编译器选项或者#pragma
您可以删除多余的空间:
a b b b b
a b b c c c c
但是,在现代 CPU 上访问打包结构的未对齐成员通常会慢得多,甚至可能导致异常。
是的,这可能会导致问题。
4-alignment 仅仅意味着指针,当被认为是一个数字地址时,是 4 的倍数。如果指针不是所需对齐的倍数,则它是未对齐的。编译器对某些类型设置对齐限制的原因有两个:
如果您在情况 (1) 中,并且 double 是 4 对齐的,并且您尝试使用char *
不是 4 对齐的指针来编写代码,那么您很可能会遇到硬件陷阱。一些硬件没有陷阱。它只是加载一个无意义的值并继续。但是,C++ 标准没有定义可能发生的情况(未定义的行为),因此此代码可能会使您的计算机着火。
在 x86 上,您永远不会遇到情况 (1),因为标准加载指令可以处理未对齐的指针。在 ARM 上,没有未对齐的加载,如果您尝试加载,那么您的程序会崩溃(如果幸运的话。一些 ARM 会默默地失败)。
回到你的例子,问题是你为什么要尝试这个char *
不是 4 对齐的。如果你通过 a 成功地写了一个 double double *
,那么你就可以读回来。因此,如果您最初有一个指向 double 的“正确”指针,您将其转换为该指针,而char *
您现在正在回退,您不必担心对齐问题。
但是您说的是任意char *
的,所以我想这不是您所拥有的。如果您从包含序列化双精度的文件中读取数据块,则必须确保满足平台的对齐要求才能执行此转换。如果你有 8 个字节代表某种文件格式的双精度,那么你不能随便将它读入任何偏移量的 char* 缓冲区,然后转换为double *
.
最简单的方法是确保将文件数据读入合适的结构。内存分配总是与它们大到可以容纳的任何类型的最大对齐要求对齐,这也对您有所帮助。因此,如果您分配的缓冲区足够大以包含双精度,那么该缓冲区的开头将具有双精度所需的任何对齐方式。因此,您可以将表示双精度的 8 个字节读入缓冲区的开头,强制转换(或使用联合)并读取双精度。
或者,您可以执行以下操作:
double readUnalignedDouble(char *un_ptr) {
double d;
// either of these
std::memcpy(&d, un_ptr, sizeof(d));
std::copy(un_ptr, un_ptr + sizeof(d), reinterpret_cast<char *>(&d));
return d;
}
这保证是有效的(假设 un_ptr 确实指向您平台的有效双精度表示的字节),因为双精度是 POD,因此可以逐字节复制。如果您要加载很多双打,它可能不是最快的解决方案。
如果您正在从文件中读取数据,那么如果您担心具有非 IEEE 双重表示或 9 位字节或其他一些不寻常属性的平台可能存在非值,那么实际上还有更多内容存储的双精度表示中的位。但是您实际上并没有询问文件,我只是将其作为示例,并且无论如何,这些平台比您要询问的问题要少得多,即 double 具有对齐要求。
最后,与对齐完全无关,如果您char *
通过与别名不兼容的指针的强制转换得到它,您还需要担心严格的别名double *
。不过,别名在char *
其自身和其他任何事物之间都是有效的。
在 x86 上它总是会运行,当然在对齐时会更有效。
但如果你是多线程,那么请注意读写撕裂。使用 64 位值,您需要一台 x64 机器来为您提供线程之间的原子读写。
如果说你从另一个线程读取值,当它说在 0x00000000.FFFFFFFF 和 0x00000001.00000000 之间递增时,那么理论上另一个线程可能会读取 0 或 1FFFFFFFF,特别是如果说值 STRADDLED A CACHE-LINE 边界。
我推荐 Duffy 的“Windows 上的并发编程”,因为它很好地讨论了内存模型,甚至在 dot-net 执行 GC 时提到了多处理器上的对齐陷阱。你想远离安腾!
SPARC(Solaris 机器)是另一种架构(至少在过去的某些时候),如果您尝试使用未对齐的值,它将阻塞(给出 SIGBUS 错误)。
作为 Martin York 的附录,malloc 也与可能的最大类型对齐,即它对所有内容都是安全的,例如“新”。事实上,经常“新”只是使用 malloc。
对齐要求的一个示例是使用矢量化 (SIMD) 指令时。(它可以在没有对齐的情况下使用,但如果你使用一种需要对齐的指令会更快)。
强制内存对齐在基于RISC的架构(如 MIPS )中更为常见。
这些类型的处理器 AFAIK 的主要思想实际上是速度问题。
RISC 方法就是拥有一组简单而快速的指令(通常每条指令一个内存周期)。这并不一定意味着它的指令比 CISC 处理器少,而是它具有更简单、更快的指令。
许多 MIPS 处理器,虽然 8 字节可寻址将是字对齐的(通常但不总是 32 位)然后屏蔽掉适当的位。
这个想法是,执行对齐的加载 + 位掩码比尝试执行未对齐的加载更快。通常(当然这实际上取决于芯片组),执行未对齐的加载会产生总线错误,因此 RISC 处理器将提供“未对齐的加载/存储”指令,但这通常会比相应的对齐加载/存储慢得多.
当然,这仍然不能回答他们为什么这样做的问题,即对齐记忆字有什么好处?我不是硬件专家,我相信这里有人可以给出更好的答案,但我的两个最佳猜测是:
1. 字对齐时从缓存中获取会快得多,因为许多缓存被组织成缓存行(从 8 到 512 字节不等)并且由于缓存通常比 RAM 贵得多,因此您希望充分利用它。
2.访问每个内存地址可能要快得多,因为它允许您通过“突发模式”读取(即在需要之前获取下一个顺序地址)
请注意,对于未结盟的商店,以上任何一项都不是绝对不可能的,我猜(尽管我不知道)其中很多都归结为硬件设计选择和成本