C++的杂七杂八:关于内存对齐的那些事

/ 5评 / 2

Published by orzz.org(). (http://orzz.org/about-data-structure-alignment/)

1. 内存对齐(Data Structure Alignment)是什么

内存对齐,或者说字节对齐,是一个数据类型所能存放的内存地址的属性(Alignment is a property of a memory address)。
这个属性是一个无符号整数,并且这个整数必须是2的N次方(1、2、4、8、……、1024、……)。
当我们说,一个数据类型的内存对齐为8时,意思就是指这个数据类型所定义出来的所有变量,其内存地址都是8的倍数。

当一个基本数据类型(fundamental types)的对齐属性,和这个数据类型的大小相等时,这种对齐方式称作自然对齐(naturally aligned)。
比如,一个4字节大小的int型数据,默认情况下它的字节对齐也是4。

2. 为什么我们需要内存对齐


这是因为,并不是每一个硬件平台都能够随便访问任意位置的内存的。

微软的MSDN里有这样一段话

Many CPUs, such as those based on Alpha, IA-64, MIPS, and SuperH architectures, refuse to read misaligned data. When a program requests that one of these CPUs access data that is not aligned, the CPU enters an exception state and notifies the software that it cannot continue. On ARM, MIPS, and SH device platforms, for example, the operating system default is to give the application an exception notification when a misaligned access is requested.

大意是说,有不少平台的CPU,比如Alpha、IA-64、MIPS还有SuperH架构,若读取的数据是未对齐的(比如一个4字节的int在一个奇数内存地址上),将拒绝访问,或抛出硬件异常。

另外,在维基百科里也记载着如下内容

Data alignment means putting the data at a memory offset equal to some multiple of the word size, which increases the system's performance due to the way the CPU handles memory.

意思是,考虑到CPU处理内存的方式(32位的x86 CPU,一个时钟周期可以读取4个连续的内存单元,即4字节),使用字节对齐将会提高系统的性能(也就是CPU读取内存数据的效率。比如你一个int放在奇数内存位置上,想把这4个字节读出来,32位CPU就需要两次。但对齐之后一次就可以了)。

3. 内存对齐带来的数据结构大小变化

因为有了内存对齐,因此数据在内存里的存放就不是紧挨着的,而是可能会出现一些空隙(Data Structure Padding,也就是用于填充的空白内容)。因此对基本数据类型来说可能还好说,对于一个内部有多个基本类型的结构体(struct)或类而言,sizeof的结果往往和想象中不大一样。

让我们来看一个例子:

我们可以看到,MyStruct中有5个成员,如果直接相加的话大小应该是16,但在32位MSVC里它的sizeof结果是32。
之所以结果出现偏差,为了保证这个结构体里的每个成员都应该在它对齐了的内存位置上,而在某些位置插入了Padding。

下面我们尝试考虑内存对齐,来计算一下这个结构体的大小。首先,我们可以假设MyStruct的整体偏移从0x00开始,这样就可以暂时忽略MyStruct本身的对齐。这时,结构体的整体内存分布如下图所示:
MyStruct的内存布局
我们可以看到,char和int之间;short和long long之间,为了保证成员各自的对齐属性,分别插入了一些Padding。
因此整个结构体会被填充得看起来像这样:

注意到上面加了Padding的示意结构体里,e的后面还跟了7个字节的填充。这是因为结构体的整体大小必须可被对齐值整除,所以“char e”的后面还会被继续填充7个字节好让结构体的整体大小是8的倍数32。

我们可以在gcc + 32位linux中尝试计算sizeof(MyStruct),得到的结果是24。
这是因为gcc中的对齐规则和MSVC不一样,不同的平台下会使用不同的默认对齐值(The default alignment is fixed for a particular target ABI)。在gcc + 32位linux中,大小超过4字节的基本类型仍然按4字节对齐。因此MyStruct的内存布局这时看起来应该像这个样子:
alignment_3

下面我们来确定这个结构体类型本身的内存对齐是多少。为了保证结构体内的每个成员都能够放在它自然对齐的位置上,对这个结构体本身来说最理想的内存对齐数值应该是结构体里内存对齐数值最大的成员的内存对齐数。
也就是说,对于上面的MyStruct,结构体类型本身的内存对齐应该是8。并且,当我们强制对齐方式小于8时,比如设置MyStruct对齐为2,那么其内部成员的对齐也将被强制不能超过2。

为什么?因为对于一个数据类型来说,其内部成员的位置应该是相对固定的。假如上面这个结构体整体按1或者2字节对齐,而成员却按照各自的方式自然对齐,就有可能出现成员的相对偏移量随内存位置而改变的问题。
比如说,我们可以画一下整个结构体按1字节对齐,并且结构体内的每个成员按自然位置对齐的内存布局:
不同的起始位置导致不同的相对数据偏移
上面的第一种情况,假设MyStruct的起始地址是0x01(因为结构体本身的偏移按1字节对齐),那么char和int之间将会被填充2个字节的Padding,以保证int的对齐还是4字节。
如果第二次分配MyStruct的内存时起始地址变为0x03,由于int还是4字节对齐,则char和int之间将不会填充Padding(填充了反而不对齐了)。
以此类推,若MyStruct按1字节对齐时不强制所有成员的对齐均不超过1的话,这个结构体里的相对偏移方式一共有4种。

因此对于结构体来说,默认的对齐将等于其中对齐最大的成员的对齐值。并且,当我们限定结构体的内存对齐时,同时也限定了结构体内所有成员的内存对齐不能超过结构体本身的内存对齐。

4. 指定内存对齐

在C++98/03里,对内存对齐的操作在不同的编译器里可能有不同的方法。

在MSVC中,一般使用#progma pack来指定内存对齐:

这时,MyStruct由于按1字节对齐,其中的所有成员都将变为1字节对齐,因此sizeof(MyStruct)将等于16。
还有另外一个简单的方法:

__declspec(align(64))将指定内存对齐为64。比较坑的是,这种方法不能指定内存对齐小于默认对齐,也就是说它只能调大不能调小(__declspec(align(#)) can only increase alignment restrictions)。因此下面这样写会忽略掉declspec:

微软的__declspec(align(#)),其#的内容可以是预编译宏,但不能是编译期数值:

Visual C++ Compiler November 2013 CTP之后,微软终于支持编译期数值的写法了:

__declspec(align(#))最大支持对齐为8192(Valid entries are integer powers of two from 1 to 8192)。

下面再来看gcc。gcc和MSVC一样,可以使用#pragma pack:

另外,也可以使用__attribute__((__aligned__((#)))):

这东西写上面写下面都是可以的,但是不能写在struct前面。
和MSVC一样,__attribute__也只能把字节对齐改大,不能改小(The aligned attribute can only increase the alignment)。比较坑的是当你试图改小的时候,gcc没有任何编译提示信息。
gcc可以接受一个宏或编译期数值:

gcc的__attribute__((__aligned__((#))))支持的上限受限于链接器(Note that the effectiveness of aligned attributes may be limited by inherent limitations in your linker)。

5. 获得内存对齐

同样的,在C++98/03里,不同的编译器可能有不同的方法来获得一个类型的内存对齐。

MSVC使用__alignof操作符获得内存对齐大小:

gcc则使用__alignof__:

需要注意的是,不论是__alignof还是__alignof__,对于对齐的计算都发生在编译期。因此像下面这样写:

得到的结果将是1。

如果需要在运行时动态计算一个变量的内存对齐,比如根据一个void*指针指向的内存地址来判断这个地址的内存对齐是多少,我们可以用下面这个简单的方法:

用这种方式得到的内存对齐大小可能比实际的大,因为它是切实的获得这个内存地址到底能被多大的2^N整除。

6. 堆内存的内存对齐

我们在讨论内存对齐的时候很容易忽略掉堆内存。我们经常会使用malloc分配内存,却不理会这块内存的对齐方式,仿佛堆内存不需要考虑内存对齐一样。
实际上,malloc一般使用当前平台默认的最大内存对齐数对齐内存。比如MSVC在32位下一般是8字节对齐;64位下则是16字节(In Visual C++, the fundamental alignment is the alignment that's required for a double, or 8 bytes. In code that targets 64-bit platforms, it’s 16 bytes)。这样对于常规的数据都是没有问题的。
但是如果我们自定义的内存对齐超出了这个范围,则是不能直接使用malloc来获取内存的。

当我们需要分配一块具有特定内存对齐的内存块时,在MSVC下应当使用_aligned_malloc;而在gcc下一般使用memalign等函数。

其实自己实现一个简易的aligned_malloc是很容易的:

7. C++11中对内存对齐的操作

C++11标准里统一了内存对齐的相关操作。

指定内存对齐使用alignas说明符:

注意到MyStruct_3编译是OK的。在C++11里,只要是一个编译期数值(包括static const)都支持alignas(the assignment-expression shall be an integral constant expression,参考ISO/IEC-14882:2011,7.6.2 Alignment specifier,第2款)。
但是需要小心的是,目前微软的编译器(Visual C++ Compiler November 2013 CTP)在MyStruct_3的情况下仍然会报error C2057。
另外,alignas同前面介绍的__declspec、__attribute__一样,只能改大不能改小(参考ISO/IEC-14882:2011,7.6.2 Alignment specifier,第5款)。如果需要改小,比如设置对齐为1的话,仍然需要使用#pragma pack。或者,可以使用C++11里#pragma的等价物_Pragma(微软暂不支持这个):

除了这些之外,alignas比__declspec、__attribute__强大的地方在于它还可以这样用:

这个char就按int的方式对齐了。
获取内存对齐使用alignof操作符:

相关注意点和前面介绍的__alignof、__alignof__并无二致。
除了alignas和alignof,C++11中还提供了几个有用的工具。

A. std::alignment_of

功能是编译期计算类型的内存对齐。
std里提供这个是为了补充alignof的功能。alignof只能返回一个size_t,而alignment_of则继承自std::integral_constant,因此拥有value_type、type、operator()等接口(或者说操作)。

B. std::aligned_storage

这是个好东西。我们知道,很多时候需要分配一块单纯的内存块,比如new char[32],之后再使用placement new在这块内存上构建对象:

但是char[32]是1字节对齐的,xx很有可能并不在MyStruct指定的对齐位置上。这时调用placement new构造内存块,可能会引起效率问题或出错,这时我们应该使用std::aligned_storage来构造内存块:

需要注意的是,当使用堆内存的时候我们可能还是需要aligned_malloc。因为现在的编译器里new并不能在超出默认最大对齐后,还能保证内存的对齐是正确的。比如在MSVC 2013里,下面的代码:

将会得到一个编译警告。

C. std::max_align_t

返回当前平台的最大默认内存对齐类型。malloc返回的内存,其对齐和max_align_t类型的对齐大小应当是一致的。
我们可以通过下面这个方式获得当前平台的最大默认内存对齐数:

D. std::align

这货是一个函数,用来在一大块内存当中获取一个符合指定内存要求的地址。
看下面这个例子:

意思是,在buffer这个大内存块中,指定内存对齐为alignof(int),找一块sizeof(char)大小的内存,并在找到这块内存后,将地址放入pt,将buffer从pt开始的长度放入space。

关于这个函数的更多信息,可以参考这里


关于内存对齐,该说的就是这么多了。我们经常会看到内存对齐的应用,是在网络收发包中。一般用于发送的结构体,都是1字节对齐的,目的是统一收发双方(可能处于不同平台)之间的数据内存布局,以及减少不必要的流量消耗。

C++11中为我们提供了不少有用的工具,可以让我们方便的操作内存对齐。但是在堆内存方面,我们很可能还是需要自己想办法。不过在平时的应用中,因为很少会手动指定内存对齐到大于系统默认的对齐数,所以倒也不比每次new/delete的时候都提心吊胆。


参考文章:

Published by orzz.org(). (http://orzz.org/about-data-structure-alignment/)

  1. shines77说道:

    已阅~~ 不错, 写得比较全面了.

  2. qicosmos说道:

    漏了一点:alignof和alignment_of有点差异,alignof还可以用于变长类型,比如alignof(Args)…,而std::alignment_of则不行。

  3. DengHe.NET说道:

    “不过有意思的是,这样算下来sizeof(MyStruct)应该等于24才对”

    BUG了。长度加起来都有 25 了。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据