我最近在思考内存对齐的问题...这是我们通常不必考虑的事情,但我意识到一些处理器要求对象沿着4字节边界对齐。这到底意味着什么,哪些具体系统有对齐要求?
假设我有一个任意指针:
unsigned char* ptr
现在,我尝试从内存位置检索一个双精度值:
double d = **((double*)ptr);
这会引起问题吗?
我最近在思考内存对齐的问题...这是我们通常不必考虑的事情,但我意识到一些处理器要求对象沿着4字节边界对齐。这到底意味着什么,哪些具体系统有对齐要求?
假设我有一个任意指针:
unsigned char* ptr
现在,我尝试从内存位置检索一个双精度值:
double d = **((double*)ptr);
这会引起问题吗?
它肯定会在某些系统上引起问题。
例如,在基于ARM的系统上,您不能访问未对齐为4字节边界的32位单词。这样做将导致访问违规异常。在x86上,您可以访问此类非对齐数据,但性能略有降低,因为需要从内存中获取两个字,而不仅仅是一个。
4.1.1 字、双字、四字和双四字的对齐
字、双字和四字在内存中不需要按自然边界对齐。字、双字和四字的自然边界分别为偶数地址、可被4整除的地址和可被8整除的地址。然而,为了提高程序的性能,数据结构(特别是堆栈)应尽可能地按自然边界对齐。原因是处理器需要两个内存访问才能进行非对齐的内存访问;对齐的访问仅需要一个内存访问。跨越4字节边界或8字节边界的字或双字操作数被视为非对齐,需要两个独立的内存总线周期进行访问。
一些操作双四字的指令要求内存操作数按自然边界对齐。如果指定了非对齐操作数,则这些指令会生成通用保护异常(#GP)。双四字的自然边界是任何可被16整除的地址。其他操作双四字的指令允许非对齐访问(无需生成通用保护异常)。然而,访问非对齐数据需要额外的内存总线周期。
不要忘记,参考手册是负责任的开发人员和工程师获取信息的最终来源,因此如果您正在处理像英特尔CPU这样有良好文档记录的问题,只需查阅参考手册即可了解该问题的解决方案。
对齐方式会影响结构的布局。考虑下面这个结构体:
struct S {
char a;
long b;
};
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字节对齐意味着当指针被视为数字地址时,它是4的倍数。如果指针不是所需对齐的倍数,则它是未对齐的。编译器对某些类型施加对齐限制有两个原因:
如果您处于情况(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 *本身与任何其他内容之间的别名都是有效的,尽管如此。
char *p = new char[100]; char *ptr = p + 1;
如果double类型需要4字节对齐,则指针ptr现在是未对齐的。将ptr强制转换为double *
并读取一个double值是未定义的行为(即使您已将p [1]
到p [sizeof(double)]
设置为0)。 - Steve Jessop在x86上运行总是可行的,当然如果对齐会更有效率。
但如果你正在进行多线程操作,则需要注意读写撕裂问题。使用64位值,您需要一个x64机器才能在线程之间提供原子读/写。
例如,当另一个线程在0x00000000.FFFFFFFF和0x00000001.00000000之间递增时,如果您从另一个线程中读取该值,则另一个线程可能理论上读取0或1FFFFFFFF,尤其是如果该值跨越了缓存行边界。
我建议看Duffy的 "Concurrent Programming on Windows",因为它很好地讨论了内存模型,甚至在dot-net执行GC时提到了对齐陷阱。您要远离Itanium!
SPARC(Solaris机器)是另一种架构(至少在过去的某些时候),如果您尝试使用未对齐的值,则会出现故障(给出SIGBUS错误)。
作为Martin York的补充,malloc也对最大可能类型进行了对齐,即对所有内容都是安全的,就像“new”一样。 实际上,经常“new”只是使用malloc。
强制内存对齐在基于 RISC 的体系结构中更为常见,如MIPS。
这些类型的处理器的主要思想,据我所知,确实是一个速度问题。
RISC方法论的核心是拥有一组简单而快速的指令(通常每个指令一个内存周期)。这并不意味着它比CISC处理器少了指令,而是具有更简单、更快速的指令。
许多MIPS处理器,虽然可寻址8字节,但将对齐到字边界(通常为32位,但不总是),然后屏蔽掉适当的位。
这样做的想法是执行对齐加载+位掩码比尝试执行非对齐加载更快。
通常情况下(当然,这实际上取决于芯片组),执行非对齐加载会生成总线错误,因此RISC处理器会提供“非对齐加载/存储”指令,但这往往比相应的对齐加载/存储慢得多。
当然,这仍然没有回答为什么他们这样做的问题,即具有内存字对齐的优势是什么?
我不是硬件专家,我相信这里的某个人可以给出更好的答案,但我的两个最佳猜测是:
1. 当字对齐时,从缓存中获取速度可以快得多,因为许多高速缓存都组织成缓存行(从8到512字节不等),而且由于缓存内存通常比RAM昂贵得多,您希望充分利用它。
2. 访问每个内存地址可能会更快,因为它允许您通过“突发模式”读取(即在需要之前获取下一个连续地址)
请注意,以上所有内容都不是非对齐存储的严格不可能,我猜测(尽管我不知道)很多都取决于硬件设计选择和成本。