首先通過一段代碼來描述內存對齊的現象。
struct x_ {
char a; // 1 byte
int b; // 4 bytes
short c; // 2 bytes
char d; // 1 byte
} MyStruct1;
struct y_ {
int b; // 4 bytes
char a; // 1 byte
char d; // 1 byte
short c; // 2 bytes
} MyStruct2;
NSLog(@"%lu,%lu", sizeof(MyStruct1), sizeof(MyStruct2));
上述代碼打印出來的結果為:12,8
為什么相同的結構體,只是交換了變量 ab 和 cd 在結構體中的順序他們的大小就改變了呢?這就是“內存對齊”的現象。
內存對齊規則
在了解為什么要進行內存對齊之前,先來了解一下內存對齊的規則:
數據成員對齊規則:struct 或 union (以下統稱結構體)的數據成員,第一個數據成員放在偏移為 0 的地方,以后每個數據成員的偏移為 #pragma pack 指定的數值和這個數據成員自身長度中較小那個的整數倍。
數據成員為結構體:如果結構體的數據成員還為結構體,則該數據成員的“自身長度”為其內部最大元素的大小。(struct a 里存有 struct b,b 里有char,int,double等元素,那 b “自身長度”為 8)
結構體的整體對齊規則:在數據成員按照 #1 完成各自對齊之后,結構體本身也要進行對齊。對齊會將結構體的大小增加為 #pragma pack 指定的數值和結構體最大數據成員長度中較小那個的整數倍。
#pragma pack (n) 表示設置為 n 字節對齊。 Xcode 默認為 8 字節對齊。當設置為 #pragma pack (1) 時就代表不進行內存對齊,上述代碼打印的結果就都為 8。
MyStruct1 的進行對齊后結構為:
// Shows the actual memory layout
struct x_ {
char a; // 1 byte
char _pad0[3]; // padding to put 'b' on 4-byte boundary
int b; // 4 bytes
short c; // 2 bytes
char d; // 1 byte
char _pad1[1]; // padding to make sizeof(x_) multiple of 4
}
為了進行驗證,我們通過如下代碼打印結構體:
long a = (long)&MyStruct1.a;
long b = (long)&MyStruct1.b;
long c = (long)&MyStruct1.c;
long d = (long)&MyStruct1.d;
NSLog(@"%ld,%ld,%ld,%ld", a, b, c, d);
輸出的結果為:4296671176,4296671180,4296671184,4296671186。他們的內存占用符合內存對齊的規則。
char a + char _pad0[3] : 4296671176 // 占用 6 7 8 9
int b : 4296671180 // 占用 0 1 2 3
short c : 4296671184 // 占用 4 5
char d + char _pad1[1] : 4296671186 // 占用 6 7 8 9
通過上述規則進行對齊后的 MyStruct1 增加了 4 個字節變為 12 字節。而 MyStruct2 的所有數據成員和結構體本身都正好符合了內存對齊的規則,所以沒有增加任何大小正好為 8 字節。
為什么要進行內存對齊
內存對齊應該是編譯器的管轄范圍。編譯器會為程序中的每個數據單元安排在適當的位置上,這個過程對于大部分程序員來說都應該是透明的。但如果你想了解更加底層的秘密,“內存對齊”就不應該對你透明了。
要想掌控這項技術,在了解內存對齊的規則后,還應該知道編譯器為什么會進行內存對齊。
很多 CPU(如基于 Alpha,IA-64,MIPS,和 SuperH 體系的)拒絕讀取未對齊數據。當一個程序要求這些 CPU 讀取未對齊數據時,這時 CPU 會進入異常處理狀態并且通知程序不能繼續執行。舉個例子,在 ARM,MIPS,和 SH 硬件平臺上,當操作系統被要求存取一個未對齊數據時會默認給應用程序拋出硬件異常。所以,如果編譯器不進行內存對齊,那在很多平臺的上的開發將難以進行。
那么,為什么這些 CPU 會拒絕讀取未對齊數據?是因為未對齊的數據,會大大降低 CPU 的性能。下邊會進行詳細的解釋。
CPU 存取原理
程序員通常認為內存就像所有字節堆起來的數組。
但是,你的 CPU 并不是以字節為單位存取數據的。每次內存存取都會產生一個固定的開銷,減少內存存取次數將提升程序的性能。所以 CPU 一般會以 2/4/8/16/32 字節為單位來進行存取操作。我們將上述這些存取單位稱為內存存取粒度。
為了說明內存對齊背后的原理,我們通過一個例子來說明從未地址與對齊地址讀取數據的差異。這個例子很簡單:在一個存取粒度為 4 字節的內存中,先從地址 0 讀取 4 個字節到寄存器,然后從地址 1 讀取 4 個字節到寄存器。
當從地址 0 開始讀取數據時,是讀取對齊地址的數據,直接通過一次讀取就能完成。當從地址 1 讀取數據時讀取的是非對齊地址的數據。需要讀取兩次數據才能完成。
而且在讀取完兩次數據后,還要將 0-3 的數據向上偏移 1 字節,將 4-7 的數據向下偏移 3 字節。最后再將兩塊數據合并放入寄存器。
這對 CPU 的開銷很大。所以有些處理器才不情愿為你做這些工作。
歷史
最初的 68000 處理器的存取粒度是雙字節,沒有應對非對齊內存地址的電路系統。當遇到非對齊內存地址的存取時,它將拋出一個異常。最初的 Mac OS 并沒有妥善處理這個異常,它會直接要求用戶重啟機器。實在是悲劇。
隨后的 680x0 系列,像 68020,放寬了這個的限制,支持了非對齊內存地址存取的相關操作。這解釋了為什么一些在 68020 上正常運行的舊軟件會在 68000 上崩潰。這也解釋了為什么當時一些老 Mac 編程人員會將指針初始化成奇數地址。在最初的 Mac 機器上如果指針在使用前沒有被重新賦值成有效地址,Mac 會立即跳到調試器。通常他們通過檢查調用堆棧會找到問題所在。
所有的處理器都使用有限的晶體管來完成工作。支持非對齊內存地址的存取操作會消減“晶體管預算”,這些晶體管原本可以用來提升其他模塊的速度或者增加新的功能。
以速度的名義犧牲非對齊內存存取功能的一個例子就是 MIPS。為了提升速度,MIPS 幾乎廢除了所有的瑣碎功能。
PowerPC 各取所長。目前所有的 PowerPC 都在硬件上支持非對齊的 32 位整型的存取。雖然犧牲掉了一部分性能,但這些損失在逐漸減少。
Power 是 1991 年,Apple、IBM、Motorola 組成的 AIM 聯盟所發展出的微處理器架構。PowerPC 是整個 AIM 聯盟平臺的一部分,并且是到目前為止唯一的一部分。但蘋果電腦自 2005 年起,將旗下電腦產品轉用 Intel CPU。
現今的 PowerPC 處理器缺少對非對齊的 64-bit 浮點型數據的存取的硬件支持。當被要求從非對齊內存讀取浮點數時,PowerPC 會拋出異常并讓操作系統在軟件層面處理內存對齊。軟件解決內存對齊要比硬件慢得多。經過 IBM 在 PowerPC 測試,他們效率的差異大概在 4610%。
總結
在 iOS 開發中編譯器會幫我們進行內存對齊。所以這些問題都無需考慮。但如果編譯器沒有提供這些功能,而且 CPU 也不支持讀取非對齊數據,CPU 就會拋出硬件異常交給操作系統處理,從而產生 4610% 的差異。如果 CPU 支持讀取非對齊數據,相比對齊數據,你還是要承擔額外的開銷造成的損失。誠然,這種損失絕不會像 4610% 那么大,但還是不能忽略的。
了解了這些后,當我們再聲明結構體時就應該合理的安排內部數據的順序,從而使其占用盡可能小的內存。你也許覺得這并沒有什么卵用,但蘋果在 Runloop 的源碼中就使用了 _padding[3] 來手動對齊內存。
struct __CFRunLoopMode {
CFRuntimeBase _base;
pthread_mutex_t _lock; /* must have the run loop locked before locking this */
CFStringRef _name;
Boolean _stopped;
char _padding[3];
CFMutableSetRef _sources0;
CFMutableSetRef _sources1;
CFMutableArrayRef _observers;
CFMutableArrayRef _timers;
//……
};
博客:xuyafei.cn
簡書:jianshu.com/users/2555924d8c6e
微博:weibo.com/xuyafei86
Github:github.com/xiaofei86