關於內存對齊的那些事

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的結果往往和想象中不大一樣。

讓我們來看一個例子:

struct MyStruct
{
    char a;         // 1 byte
    int b;          // 4 bytes
    short c;        // 2 bytes
    long long d;    // 8 bytes
    char e;         // 1 byte
};

我們可以看到,MyStruct中有5個成員,如果直接相加的話大小應該是16,但在32位MSVC裏它的sizeof結果是32。
之所以結果出現偏差,爲了保證這個結構體裏的每個成員都應該在它對齊了的內存位置上,而在某些位置插入了Padding。

下面我們嘗試考慮內存對齊,來計算一下這個結構體的大小。首先,我們可以假設MyStruct的整體偏移從0x00開始,這樣就可以暫時忽略MyStruct本身的對齊。這時,結構體的整體內存分佈如下圖所示:

我們可以看到,char和int之間;short和long long之間,爲了保證成員各自的對齊屬性,分別插入了一些Padding。
因此整個結構體會被填充得看起來像這樣:

struct MyStruct
{
    char a;         // 1 byte
    char pad_0[3];  // Padding 3
    int b;          // 4 bytes
    short c;        // 2 bytes
    char pad_1[6];  // Padding 6
    long long d;    // 8 bytes
    char e;         // 1 byte
    char pad_2[7];  // Padding 7
};

注意到上面加了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的內存佈局這時看起來應該像這個樣子:

下面我們來確定這個結構體類型本身的內存對齊是多少。爲了保證結構體內的每個成員都能夠放在它自然對齊的位置上,對這個結構體本身來說最理想的內存對齊數值應該是結構體裏內存對齊數值最大的成員的內存對齊數。
也就是說,對於上面的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來指定內存對齊:

#pragma pack(1) // 指定後面的內容內存對齊爲1
struct MyStruct
{
    char a;         // 1 byte
    int b;          // 4 bytes
    short c;        // 2 bytes
    long long d;    // 8 bytes
    char e;         // 1 byte
};
#pragma pack() // 還原默認的內存對齊

這時,MyStruct由於按1字節對齊,其中的所有成員都將變爲1字節對齊,因此sizeof(MyStruct)將等於16。
還有另外一個簡單的方法:
__declspec(align(64)) struct MyStruct
{
    char a;         // 1 byte
    int b;          // 4 bytes
    short c;        // 2 bytes
    long long d;    // 8 bytes
    char e;         // 1 byte
};

__declspec(align(64))將指定內存對齊爲64。比較坑的是,這種方法不能指定內存對齊小於默認對齊,也就是說它只能調大不能調小(__declspec(align(#)) can only increase alignment restrictions)。因此下面這樣寫會忽略掉declspec:
__declspec(align(1)) struct MyStruct // ...
// warning C4359: 'MyStruct': Alignment specifier is less than actual alignment (8), and will be ignored.

微軟的__declspec(align(#)),其#的內容可以是預編譯宏,但不能是編譯期數值:
#define XX 32
struct __declspec(align(XX)) MyStruct_1 {}; // OK
 
template <size_t YY>
struct __declspec(align(YY)) MyStruct_2 {}; // error C2059: syntax error: 'identifier'
 
static const unsigned ZZ = 32;
struct __declspec(align(ZZ)) MyStruct_3 {}; // error C2057: expected constant expression

Visual C++ Compiler November 2013 CTP之後,微軟終於支持編譯期數值的寫法了:
template <size_t YY>
struct __declspec(align(YY)) MyStruct_2 {}; // OK in 2013 CTP

__declspec(align(#))最大支持對齊爲8192(Valid entries are integer powers of two from 1 to 8192)。

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

#pragma pack(1)
struct MyStruct
{
    // ...
};
#pragma pack()

另外,也可以使用__attribute__((__aligned__((#)))):
struct __attribute__((__aligned__((1)))) MyStruct_1
{
    // ...
};
 
struct MyStruct_2
{
    // ...
} __attribute__((__aligned__((1))));

這東西寫上面寫下面都是可以的,但是不能寫在struct前面。
和MSVC一樣,__attribute__也只能把字節對齊改大,不能改小(The aligned attribute can only increase the alignment)。比較坑的是當你試圖改小的時候,gcc沒有任何編譯提示信息。
gcc可以接受一個宏或編譯期數值:
#define XX 1
struct __attribute__((__aligned__((XX)))) MyStruct_1 {}; // OK
 
template <size_t YY>
struct __attribute__((__aligned__((YY)))) MyStruct_2 {}; // OK
 
static const unsigned ZZ = 1;
struct __attribute__((__aligned__((ZZ)))) MyStruct_3 {};
//                                        ^
// error: requested alignment is not an integer constant

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操作符獲得內存對齊大小:

MyStruct xx;
std::cout << __alignof(xx) << std::endl;
std::cout << __alignof(MyStruct) << std::endl;

gcc則使用__alignof__:
MyStruct xx;
std::cout << __alignof__(xx) << std::endl;
std::cout << __alignof__(MyStruct) << std::endl;

需要注意的是,不論是__alignof還是__alignof__,對於對齊的計算都發生在編譯期。因此像下面這樣寫:
int a;
char& c = reinterpret_cast<char&>(a);
std::cout << __alignof__(c) << std::endl;

得到的結果將是1。

如果需要在運行時動態計算一個變量的內存對齊,比如根據一個void*指針指向的內存地址來判斷這個地址的內存對齊是多少,我們可以用下面這個簡單的方法:

__declspec(align(128)) long a = 0;
size_t x = reinterpret_cast<size_t>(&a);
x &= ~(x - 1);  // 計算a的內存對齊大小
std::cout << x << std::endl;

用這種方式得到的內存對齊大小可能比實際的大,因爲它是切實的獲得這個內存地址到底能被多大的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是很容易的:

#include <assert.h>
 
inline void* aligned_malloc(size_t size, size_t alignment)
{
    // 檢查alignment是否是2^N
    assert(!(alignment & (alignment - 1)));
    // 計算出一個最大的offset,sizeof(void*)是爲了存儲原始指針地址
    size_t offset = sizeof(void*) + (--alignment);
 
    // 分配一塊帶offset的內存
    char* p = static_cast<char*>(malloc(offset + size));
    if (!p) return nullptr;
 
    // 通過“& (~alignment)”把多計算的offset減掉
    void* r = reinterpret_cast<void*>(reinterpret_cast<size_t>(p + offset) & (~alignment));
    // 將r當做一個指向void*的指針,在r當前地址前面放入原始地址
    static_cast<void**>(r)[-1] = p;
    // 返回經過對齊的內存地址
    return r;
}
 
inline void aligned_free(void* p)
{
    // 還原回原始地址,並free
    free(static_cast<void**>(p)[-1]);
}

7. C++11中對內存對齊的操作

C++11標準裏統一了內存對齊的相關操作。

指定內存對齊使用alignas說明符:

alignas(32) long long a = 0;
 
#define XX 1
struct alignas(XX) MyStruct_1 {}; // OK
 
template <size_t YY = 1>
struct alignas(YY) MyStruct_2 {}; // OK
 
static const unsigned ZZ = 1;
struct alignas(ZZ) MyStruct_3 {}; // OK

注意到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(微軟暫不支持這個):
_Pragma("pack(1)")
struct MyStruct
{
    char a;         // 1 byte
    int b;          // 4 bytes
    short c;        // 2 bytes
    long long d;    // 8 bytes
    char e;         // 1 byte
};
_Pragma("pack()")

除了這些之外,alignas比__declspec、__attribute__強大的地方在於它還可以這樣用:
alignas(int) char c;

這個char就按int的方式對齊了。
獲取內存對齊使用alignof操作符:
MyStruct xx;
std::cout << alignof(xx) << std::endl;
std::cout << alignof(MyStruct) << std::endl;

相關注意點和前面介紹的__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 xx[32];
::new (xx) MyStruct;

但是char[32]是1字節對齊的,xx很有可能並不在MyStruct指定的對齊位置上。這時調用placement new構造內存塊,可能會引起效率問題或出錯,這時我們應該使用std::aligned_storage來構造內存塊:
std::aligned_storage<sizeof(MyStruct), alignof(MyStruct)>::type xx;
::new (&xx) MyStruct;

需要注意的是,當使用堆內存的時候我們可能還是需要aligned_malloc。因爲現在的編譯器裏new並不能在超出默認最大對齊後,還能保證內存的對齊是正確的。比如在MSVC 2013裏,下面的代碼:
struct alignas(32) MyStruct
{
    char a;         // 1 byte
    int b;          // 4 bytes
    short c;        // 2 bytes
    long long d;    // 8 bytes
    char e;         // 1 byte
};
 
void* p = new MyStruct;
// warning C4316: 'MyStruct' : object allocated on the heap may not be aligned 32

將會得到一個編譯警告。

C. std::max_align_t

返回當前平臺的最大默認內存對齊類型。malloc返回的內存,其對齊和max_align_t類型的對齊大小應當是一致的。
我們可以通過下面這個方式獲得當前平臺的最大默認內存對齊數:

std::cout << alignof(std::max_align_t) << std::endl;

D. std::align

這貨是一個函數,用來在一大塊內存當中獲取一個符合指定內存要求的地址。
看下面這個例子:

char buffer[] = "------------------------";
void * pt = buffer;
std::size_t space = sizeof(buffer) - 1;
std::align(alignof(int), sizeof(char), pt, space);

意思是,在buffer這個大內存塊中,指定內存對齊爲alignof(int),找一塊sizeof(char)大小的內存,並在找到這塊內存後,將地址放入pt,將buffer從pt開始的長度放入space。

關於這個函數的更多信息,可以參考這裏


關於內存對齊,該說的就是這麼多了。我們經常會看到內存對齊的應用,是在網絡收發包中。一般用於發送的結構體,都是1字節對齊的,目的是統一收發雙方(可能處於不同平臺)之間的數據內存佈局,以及減少不必要的流量消耗。

C++11中爲我們提供了不少有用的工具,可以讓我們方便的操作內存對齊。但是在堆內存方面,我們很可能還是需要自己想辦法。不過在平時的應用中,因爲很少會手動指定內存對齊到大於系統默認的對齊數,所以倒也不比每次new/delete的時候都提心吊膽。


參考文章:

1. Data structure alignment 2. About Data Alignment 3. #pragma pack 4. align (C++) 5. __alignof Operator 6. 6.57.8 Structure-Packing Pragmas 7. 5.32 Specifying Attributes of Types 8. C/C++ Data alignment 及 struct size深入分析 9. C++ 內存對齊 10. 結構/類對齊的聲明方式 11. 字節對齊(強制對齊以及自然對齊) 12. malloc函數字節對齊很經典的問題 13. C語言字節對齊 14. 網絡編程(9)內存對齊對跨平臺通訊的影響 15. Usage Issue of std::align 16. std::align and std::aligned_storage for aligned allocation of memory blocks


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章