沿着4字节边界对齐

13

我最近在思考内存对齐的问题...这是我们通常不必考虑的事情,但我意识到一些处理器要求对象沿着4字节边界对齐。这到底意味着什么,哪些具体系统有对齐要求?

假设我有一个任意指针:

unsigned char* ptr

现在,我尝试从内存位置检索一个双精度值:

double d = **((double*)ptr);

这会引起问题吗?


请注意,双精度浮点数可能具有sizeof(double)对齐方式,这可能大于4。而且,sizeof(T)<4的类型永远不会在4字节边界上对齐 - 否则您无法对齐T[2]的两个元素! - MSalters
1
我试图想象一种需要您从未对齐的任意指针中读取双精度浮点数的程序设计。我想不出实际的场景 - 至少对于任何场景,都有更好的解决方案,这些解决方案没有对齐问题,并且更适合跨平台编码。 - Craig McQueen
只要ptr指向动态分配的内存,它就能正常工作。如果ptr指向静态数组(全局或本地),那么就没有任何保证。(有关详细信息,请参见下面的我的答案) - Martin York
我们能以高效的方式使指针对齐到4字节边界吗? - pravs
1
需要考虑的是,一些指令集架构(例如x86)允许您使用普通的加载指令将内存加载到32位寄存器中,即使使用的地址不是32位对齐的。大多数精简指令集架构则不允许这样做,并要求编译器发出额外的指令来进行两次加载以及一些位操作,以处理跨越边界的数据。无论在哪种体系结构上,它通常都表现不佳(最好情况下),假设编译器能够识别该情况。如果编译器不知道未对齐的访问(最坏情况),那么它将在x86上工作但在其他体系结构上则不行。 - Andon M. Coleman
9个回答

22

它肯定会在某些系统上引起问题。

例如,在基于ARM的系统上,您不能访问未对齐为4字节边界的32位单词。这样做将导致访问违规异常。在x86上,您可以访问此类非对齐数据,但性能略有降低,因为需要从内存中获取两个字,而不仅仅是一个。


11
一些ARM系统甚至会默默地访问相应的对齐地址,其中较低位为零,这可能导致难以发现的错误。 - starblue
如果使用任意字节位置作为laalto和starblue指出的指针,那么这绝对是ARM上的问题。但是,即使将它们用于字符数组,分配的内存块始终会具有足够的(即16字节)对齐方式。此外,在通过此技术跨平台时,请注意MSB/LSB。 - Adriaan
显然,ARM v6(通常)及以上版本(始终)定义未对齐访问以执行x86 / x64操作,除了LDM / STM和其他非“显着”的例外情况。 - Joe Amenta

14
这里是Intel x86/x64参考手册中关于对齐的描述:

4.1.1 字、双字、四字和双四字的对齐

字、双字和四字在内存中不需要按自然边界对齐。字、双字和四字的自然边界分别为偶数地址、可被4整除的地址和可被8整除的地址。然而,为了提高程序的性能,数据结构(特别是堆栈)应尽可能地按自然边界对齐。原因是处理器需要两个内存访问才能进行非对齐的内存访问;对齐的访问仅需要一个内存访问。跨越4字节边界或8字节边界的字或双字操作数被视为非对齐,需要两个独立的内存总线周期进行访问。

一些操作双四字的指令要求内存操作数按自然边界对齐。如果指定了非对齐操作数,则这些指令会生成通用保护异常(#GP)。双四字的自然边界是任何可被16整除的地址。其他操作双四字的指令允许非对齐访问(无需生成通用保护异常)。然而,访问非对齐数据需要额外的内存总线周期。

不要忘记,参考手册是负责任的开发人员和工程师获取信息的最终来源,因此如果您正在处理像英特尔CPU这样有良好文档记录的问题,只需查阅参考手册即可了解该问题的解决方案。


3
@onebyone:没错,但其他架构也有自己的参考手册。 - Tamas Czinege
3
是的,我的意思是有时候你想写的代码并不是为了特定的架构(实际上,这在我以前的情况下通常是常见的)。在这种情况下,CPU 参考手册无法提供帮助,你只能依靠 C++ 标准。 - Steve Jessop

5
是的,这可能会导致很多问题。C++标准实际上并不保证它能正常工作。你不能随意地在指针类型之间进行转换。
当你将char指针转换为double指针时,它使用一个reinterpret_cast,它应用了一种实现定义的映射。你不能保证结果指针包含相同的位模式,或者它将指向相同的地址或其他任何东西。更实际的是,你也不能保证你读取的值对齐正确。如果数据是作为一系列字符写入的,那么它们将使用char的对齐要求。
至于对齐意味着什么,基本上就是该值的起始地址应该可以被对齐大小整除。例如,地址16在1、2、4、8和16字节边界上对齐,因此,在典型的CPU上,这些大小的值可以存储在那里。
地址6没有按4字节边界对齐,因此我们不应该在那里存储4字节的值。
值得注意的是,即使在不强制或要求对齐的CPU上,访问不对齐的值通常仍会导致显著的减速。

4

对齐方式会影响结构的布局。考虑下面这个结构体:

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上访问未对齐的紧凑结构体成员通常会更慢,甚至可能导致异常。


为了进行良好的跨平台编程,您可能不希望“将结构体与数据格式匹配”。除非数据格式已经被设计成所有成员都对齐(例如TCP/IP协议,据我所知),但即使如此,您仍然会遇到字节序问题。 - Craig McQueen

4

是的,这可能会引起问题。

4字节对齐意味着当指针被视为数字地址时,它是4的倍数。如果指针不是所需对齐的倍数,则它是未对齐的。编译器对某些类型施加对齐限制有两个原因:

  1. 因为硬件无法从未对齐的指针加载该数据类型(至少不能使用编译器想要发出的负载和存储指令)。
  2. 因为硬件可以更快地从对齐的指针中加载该数据类型。

如果您处于情况(1)中,并且double是4字节对齐的,并且您尝试使用未对齐的char *指针运行代码,则很可能会遇到硬件陷阱。一些硬件不会触发陷阱。它只加载一个无意义的值并继续。但是,C ++标准没有定义可能发生的情况(未定义的行为),因此此代码可能会让您的计算机着火。

在x86上,您永远不会处于情况(1)中,因为标准的加载指令可以处理未对齐的指针。在ARM上,没有未对齐的加载,如果您尝试执行,则程序会崩溃(如果您很幸运。一些ARM会默默失败)。

回到您的示例,问题是为什么您要使用未对齐的char *尝试此操作。如果您通过double *成功将double写入该位置,那么您将能够读取它。因此,如果您最初有一个指向double的“适当”指针,将其强制转换为char *,现在再次进行强制转换,则无需担心对齐。

但是您说任意char *,因此我猜这不是您所拥有的。如果您从文件中读取一块数据,其中包含序列化的double,则必须确保满足平台的对齐要求才能进行此转换。如果您有表示双精度浮点数的8字节,那么您不能随意将其读入任何偏移的char *缓冲区中,然后转换为double *

最简单的方法是确保将文件数据读入适当的结构中。您还可以得到帮助,因为内存分配始终对包含的任何类型的最大对齐要求对齐。因此,如果您分配了足以包含double的缓冲区,则该缓冲区的开头具有double所需的任何对齐方式。因此,您可以将表示double的8个字节读入缓冲区的开头,进行强制转换(或使用联合)并读取double。

或者,您可以执行以下操作:

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确实指向您平台上有效的double表示的字节,那么这是保证有效的,因为double是POD类型,因此可以逐字节复制。如果您有很多双倍的负载,这可能不是最快的解决方案。

如果您正在从文件中读取,则实际上比这更多,如果您担心具有非IEEE双重表示或9位字节或其他异常属性的平台,在存储的双重表示中可能存在非值位。但是您实际上没有询问文件,我只是以此作为示例,并且在任何情况下,这些平台比您所询问的问题更为罕见,即double具有对齐要求。

最后,与对齐无关的是,如果您通过指向与double *不兼容的指针的强制转换获得了该char *,则还需要担心严格别名问题。 char *本身与任何其他内容之间的别名都是有效的,尽管如此。


1
例如,考虑以下代码:char *p = new char[100]; char *ptr = p + 1; 如果double类型需要4字节对齐,则指针ptr现在是未对齐的。将ptr强制转换为double *并读取一个double值是未定义的行为(即使您已将p [1]p [sizeof(double)]设置为0)。 - Steve Jessop

2

在x86上运行总是可行的,当然如果对齐会更有效率。

但如果你正在进行多线程操作,则需要注意读写撕裂问题。使用64位值,您需要一个x64机器才能在线程之间提供原子读/写。
例如,当另一个线程在0x00000000.FFFFFFFF和0x00000001.00000000之间递增时,如果您从另一个线程中读取该值,则另一个线程可能理论上读取0或1FFFFFFFF,尤其是如果该值跨越了缓存行边界。
我建议看Duffy的 "Concurrent Programming on Windows",因为它很好地讨论了内存模型,甚至在dot-net执行GC时提到了对齐陷阱。您要远离Itanium!


在32位x86上,可以使用CMPXCHG8B原子地访问对齐的64位值。在64位上也有相应的128位CMPXCHG16B。 - bdonlan

2

SPARC(Solaris机器)是另一种架构(至少在过去的某些时候),如果您尝试使用未对齐的值,则会出现故障(给出SIGBUS错误)。

作为Martin York的补充,malloc也对最大可能类型进行了对齐,即对所有内容都是安全的,就像“new”一样。 实际上,经常“new”只是使用malloc。


1
对齐要求的一个例子是使用矢量化(SIMD)指令时。(可以不使用对齐,但如果使用需要对齐的指令,速度会更快。)

1

强制内存对齐在基于 RISC 的体系结构中更为常见,如MIPS。
这些类型的处理器的主要思想,据我所知,确实是一个速度问题。
RISC方法论的核心是拥有一组简单而快速的指令(通常每个指令一个内存周期)。这并不意味着它比CISC处理器少了指令,而是具有更简单、更快速的指令。
许多MIPS处理器,虽然可寻址8字节,但将对齐到字边界(通常为32位,但不总是),然后屏蔽掉适当的位。
这样做的想法是执行对齐加载+位掩码比尝试执行非对齐加载更快。
通常情况下(当然,这实际上取决于芯片组),执行非对齐加载会生成总线错误,因此RISC处理器会提供“非对齐加载/存储”指令,但这往往比相应的对齐加载/存储慢得多。

当然,这仍然没有回答为什么他们这样做的问题,即具有内存字对齐的优势是什么? 我不是硬件专家,我相信这里的某个人可以给出更好的答案,但我的两个最佳猜测是:
1. 当字对齐时,从缓存中获取速度可以快得多,因为许多高速缓存都组织成缓存行(从8到512字节不等),而且由于缓存内存通常比RAM昂贵得多,您希望充分利用它。
2. 访问每个内存地址可能会更快,因为它允许您通过“突发模式”读取(即在需要之前获取下一个连续地址)

请注意,以上所有内容都不是非对齐存储的严格不可能,我猜测(尽管我不知道)很多都取决于硬件设计选择和成本。


你是不是想说“字节寻址”而不是“8字节寻址”? - Rocketmagnet

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