原文的网址链接为:https://developer.ibm.com/articles/pa-dalign/
因为从事着嵌入式的开发工作,所以经常遇到因为字节对齐的问题。所以在此系统性的学习,为什么要字节对齐,如何避免因为字节不对齐而引起的问题。
数据对齐:展翅高飞
--为了优化执行效率和正确性需要对齐你的数据
1.内存访问粒度
编程者习惯将内存看做一个简单的字节数组。在C及其后续的编码语言中,char *是普遍存在的,表示“一块内存”,甚至在Java™中有byte[]来表示原始内存。
图1.编程者眼中的内存
但是,计算机的处理器不会以字节大小的块来对内存进行读写。反而,处理器是以2,4,8,16甚至32字节块的形式来访问内存。我们将处理器访问内存的大小称为内存访问粒度。
图2.计算机处理器眼中的内存
本文将探讨高级编程者对内存的想法和现在处理器实际使用的内存的差异引起的一些有趣的问题。
如果你不理解你软件中的字节对齐问题,以下场景(按严重程度递增)都是可能发生的:
1)你的软件运行较慢
2)你的应用程序将被锁定
3)你的操作系统会崩溃
4)你的软件将悄无声息的失败,且产生不正确的结果
2.对齐基础
为了说明对齐背后的原则,可以做一个检测一个常量的任务,以及它如何受处理器访问粒度影响的。任务很简单,首先从地址0读取4个字节到处理器的寄存器中。然后从地址1中读出4个字节到相同的寄存器中。
首先检查在一个单字节内存访问粒度的处理器上会发生什么:
图3. 单字节内存访问粒度
这个工作模式符合天真的编程者对内存工作的理解:从地址0读取内存和从地址1读取内存一样,都需要4此内存读操作。现在看看在双字节粒度的处理器上会发生什么,比如最初的68000:
图4.双字节内存访问粒度
当处理器从地址0读数据时,双字节访问粒度的处理器访问内存次数是单字节访问粒度的处理器访问内存次数的一半。因为每次内存访问都需要固定的开销,所以最小化访问次数有助于提高处理性能。
但是,请注意从地址1读数据时处理器的工作过程。因为地址不是均匀的落在处理器的内存访问边界上,处理器需要额外进行处理。这样的地址被称为未对齐地址。由于地址1是未对齐的,因为两字节访问粒度的处理器必须执行额外的内存访问,从而降低了处理效率。
最后,看下四字节访问粒度处理器的工作过程,例如68030 or PowerPC® 601:
图5.四字节内存访问粒度
此类处理器可以从对齐的地址上读取四字节数据。同时注意从非对齐地址读取会增加访问次数。
现在大概了解了对齐数据访问背后的基础知识,接下来可以研究一些跟对齐相关的问题。
懒惰的处理器
当处理器执行访问一个未对齐内存时,处理器必须执行一些技巧。回到四字节粒度处理器访问地址1的例子上,你可以确切的指导处理器需要做些什么:
图6.处理器如何处理未对齐内存访问
处理器需要读取未对齐地址的第一块,并从第一个块中移出“不需要的”字节。然后需要读取第二块,并移出一些信息。最后,两者合并放在寄存器中,有很多工作要做。
有些处理器不想做这些工作。
最初的68000处理器,双字节访问粒度,缺乏处理未对齐地址的电路。当出现这样的地址时处理器会抛出异常。最初的MAC OS对这个异常并不友好,会要求用户重新机器。很糟糕。
后来的680x0系列处理器,例如68020,解除了这一限制,并执行了必要的工作。这就能解释为什么68020的旧代码移植到68000上会导致处理器崩溃。也能解释为什么之前就得MAC代码用奇数地址初始化指针。在最初的mac中,如果指针没有被分配到一个有效的地址中,MAC会立即进入调试状态,通常,可以用来检查链堆栈并指出错误在哪里。
所有的处理器都有有限的数量的晶体管来完成工作。增加未对齐地址访问支持会削减这种“晶体管预算”。这些晶体管可以用来加快处理器其他部分的工作速度,或者增加一些新的功能。
以速度出名的MIPS处理器就是牺牲了支持未对齐地址访问的处理器示例。MIPS是一个很好地处理器,它以更快地完成实际工作的名义消除了所有繁琐的操作。
PowerPC采用一种混合方法。到目前为止,每一个PowerPC处理器都有硬件支持非32bit位对齐的整数访问。虽然你仍然需要对未对齐访问付出性能代价,但它往往很小。
另一个方面,现代PowerPC处理器缺乏对64位浮点访问的硬件支持。当被要求从内存中加载一个未对齐的64bit的浮点数时,现代PowerPC处理器会抛出一个异常,并让操作系统在软件中执行对齐的杂务。在软件中执行对齐比在硬件中执行要慢很多。
速度
编写一些测试用例来说明未对齐内存访问对性能的损失。测试很简单:在十兆字节的缓存区中读,否定,写这些数字。这些测试有两个变量:
1)处理缓冲区的大小(以字节为单位)。首先,每次处理一个字节。然后每次处理两个字节、四个字节、八个字节。
2)缓冲区对齐。你将通过增加指向缓冲区的指针并再次运行每个测试来交错测试缓冲区对齐。
这些测试执行在PowerBook G4,主频800MHz.为了规避中断处理引起的性能波动,每个测试都运行10次。
首次是单字节操作的测试:
一次访问一字节的代码如下:
void Munge8( void ∗data, uint32_t size ) {
uint8_t ∗data8 = (uint8_t∗) data;
uint8_t ∗data8End = data8 + size;
while( data8 != data8End ) {
∗data8++ = ‑∗data8;
}
}
执行这个函数平均需要67364微妙。
现在修改成一次访问2字节,这将减少一半的内存访问次数:
一次访问2字节的代码如下:
void Munge16( void ∗data, uint32_t size ) {
uint16_t ∗data16 = (uint16_t∗) data;
uint16_t ∗data16End = data16 + (size >> 1); /∗ Divide size by 2. ∗/
uint8_t ∗data8 = (uint8_t∗) data16End;
uint8_t ∗data8End = data8 + (size & 0x00000001); /∗ Strip upper 31 bits. ∗/
while( data16 != data16End ) {
∗data16++ = ‑∗data16;
}
while( data8 != data8End ) {
∗data8++ = ‑∗data8;
}
}
这个函数平均需要48765微妙,比Munge8性能提升了38%。但是缓冲区是对齐的。如果缓冲区不对齐,所需时间将增加到66385微妙,性能将损失27%。下面的图表说明了对齐访问和非对齐访问的性能模式:
图7.访问单字节Vs访问双字节
你第一个关注的就是每次访问一个字节的内存都很慢。第二个有趣的事情是,当访问粒度为2字节时,只要地址不能被2整除,27%的速度损失就会出现。
现在提高要求,一次访问4字节:
一次访问4字节的代码如下:
void Munge32( void ∗data, uint32_t size ) {
uint32_t ∗data32 = (uint32_t∗) data;
uint32_t ∗data32End = data32 + (size >> 2); /∗ Divide size by 4. ∗/
uint8_t ∗data8 = (uint8_t∗) data32End;
uint8_t ∗data8End = data8 + (size & 0x00000003); /∗ Strip upper 30 bits. ∗/
while( data32 != data32End ) {
∗data32++ = ‑∗data32;
}
while( data8 != data8End ) {
∗data8++ = ‑∗data8;
}
}
该函数处理对齐的内存需要43043微妙,未对齐内存需要55775微妙。因此,在测试机器上,每次访问4字节未对齐的内存要比每次访问2字节对齐的内存要慢。
图8. 单字节Vs 双字节Vs 四字节
现在一次访问8字节:
一次访问8字节代码:
void Munge64( void ∗data, uint32_t size ) {
double ∗data64 = (double∗) data;
double ∗data64End = data64 + (size >> 3); /∗ Divide size by 8. ∗/
uint8_t ∗data8 = (uint8_t∗) data64End;
uint8_t ∗data8End = data8 + (size & 0x00000007); /∗ Strip upper 29 bits. ∗/
while( data64 != data64End ) {
∗data64++ = ‑∗data64;
}
while( data8 != data8End ) {
∗data8++ = ‑∗data8;
}
}
此函数处理一个对齐的内存只需要39085微妙,比每次处理4字节的性能提升10%左右。然而处理未对齐的缓冲区需要1841155微秒,比对齐访问慢两个数量级,性能损失高达4610%。
发生了什么?因为现在owerPC处理器缺乏对非对齐浮点访问的硬件支持,处理器会为每次非对齐访问抛出异常。操作系统捕获这个异常并在软件中执行对齐。这里有一张图表说明了惩罚,以及惩罚发生的时间:
图9.多字节访问比较
对于1字节、2字节和4字节的未对齐访问的惩罚与可怕的8字节未对齐访问的惩罚相比就相形见绌了。
也许这张去掉顶部(以及两个数据之间巨大差距)的图表会更清晰:
图10.多字节访问比较#2
这些数据中还隐藏了另一个现象,比较8字节访问速度在四字节边界时的表现:
图11.多字节访问比较#3
注意,在4字节和12字节边界上一次访问8字节的内存,比一次读取相同的内存4字节甚至2字节都要慢。虽然PowerPC处理器拥有对4字节对齐八字节双精度的硬件支持,但是依旧要付出性能代价。当然,这与上面的4610%的惩罚相比还是相差甚远的。这个测试结果可以知道,如果访问不对齐的内存,大块访问会比小块访问内存要慢。
原子性
所有现代处理器都提供原子指令。这些特殊指令对于同步两个或多个并发任务至关重要。顾名思义,原子指令必须是不可分割的——这就是为什么它们对于同步如此方便:它们不能被抢占。
事实证明,为了使原子指令正确执行,传递给它们的地址必须至少是4字节对齐的。这是因为原子指令和虚拟内存之间存在微妙的交互。
如果一个地址是未对齐的,它至少需要两次内存访问。但是,如果所需的数据跨越了虚拟内存的两个页面,会发生什么呢?这可能导致第一页是驻留的,而最后一页不是驻留的情况。在访问时,在指令的中间会生成一个页面错误,执行虚拟内存管理换入代码,破坏指令的原子性。为了保持简单和正确,68K和PowerPC都要求原子操作的地址始终至少是4字节对齐的。
不幸的是,当自动存储到未对齐的地址时,PowerPC不会抛出异常。相反,存储失败。这不友好,因为大多数原子函数都是在假设它们被抢占的情况下对失败的存储进行重试的。如果您尝试自动存储到一个未对齐的地址,那么这两种情况结合在一起,您的程序将进入一个死循环。太糟糕了。
Altivec
Altivec就是关于速度的。不对齐的内存访问会降低处理器的速度,并使宝贵的晶体管变得昂贵。因此,Altivec工程师借鉴了MIPS的经验,即不支持无对齐内存访问。因为Altivec一次使用16字节的内存块,所以传递给Altivec的所有地址必须是16字节对齐的。可怕的是如果你的地址不对齐会发生什么。
Altivec不会抛出异常来警告您未对齐的地址。相反,Altivec简单地忽略了地址较低的四位,在错误的地址上操作,并在前面收费。这意味着,如果您没有明确地确保所有数据都是对齐的,那么您的程序可能会悄无声息地损坏内存或返回不正确的结果。
Altivec的比特剥离方式有一个优势。因为您不需要显式地截断(对齐)地址,所以在将地址交给处理器时,这种行为可以节省一条或两条指令。
这并不是说Altivec不能处理未对齐的内存。您可以在Altivec编程环境手册(请参阅右侧的参考资料)中找到详细的说明。它需要更多的工作,但是由于内存与处理器相比是如此的慢,这种开销是惊人的低。
结构体对齐:
检查下面的结构体:
void Munge64( void ∗data, uint32_t size ) {
typedef struct {
char a;
long b;
char c;
} Struct;
这个结构的字节大小是多少?许多程序员会回答“6字节”。这是有道理的:a用一个字节,b用四个字节,c用另一个字节。1 + 4 + 1等于6。下面是它在内存中的布局:
表1.字节中的结构体大小
Field Type | Field Name | Field Offset | Field Size | Field End |
---|---|---|---|---|
char | a | 0 | 1 | 1 |
long | b | 1 | 4 | 5 |
char | c | 5 | 1 | 6 |
Total size in bytes: | 6 |
但是,如果您要求编译器输入sizeof(Struct),您得到的答案可能大于6,可能是8,甚至24。这有两个原因:向后兼容性和效率。
首先,向后兼容性。记住,68000是一个具有双字节内存访问粒度的处理器,在遇到奇怪的地址时会抛出异常。如果要从字段b读取或写入字段b,您将尝试访问一个奇怪的地址。如果没有安装调试器,旧的Mac OS将弹出一个带有一个按钮的系统错误对话框:Restart。呵呵!
所以,编译器不是按照你写的方式来布局你的字段,而是填充结构,使b和c驻留在相同的地址:
表2.编译器中的结构体大小
Field Type | Field Name | Field Offset | Field Size | Field End |
---|---|---|---|---|
char | a | 0 | 1 | 1 |
padding | 1 | 1 | 2 | |
long | b | 2 | 4 | 6 |
char | c | 6 | 1 | 7 |
padding | 7 | 1 | 8 | |
Total Size in Bytes: | 8 |
填充是将未使用的空间添加到结构中,以使字段按所需方式排列的行为。现在,当68020推出时,内置了对非对齐内存访问的硬件支持,这种填充就没有必要了。然而,它并没有造成任何伤害,甚至对性能也有一些帮助。
第二个原因是效率。现在,在PowerPC机器上,两字节对齐很好,但是四字节或八字节更好。您可能不再关心最初的68000被未对齐的结构卡住,但您可能关心潜在的4610%的性能损失,如果双字段在您设计的结构中没有对齐,就会发生这种情况。
结论:
如果你不理解并且不针对数据对齐进行显式编码:
1)您的软件可能会破坏性能,导致未对齐内存访问异常,这将调用非常昂贵的对齐异常处理程序。
2)应用程序可能试图自动存储到一个未对齐的地址,从而导致应用程序锁定。
3)您的应用程序可能试图向Altivec传递一个未对齐的地址,导致Altivec读取和/或写入内存的错误部分,悄无声地破坏数据或产生不正确的结果。