一、代碼 Demo
struct Struct1 {
char a; // 1 字節
double b; // 8 字節
int c; // 4 字節
short d; // 2 字節
} MyStruct1;
struct Struct2 {
double b; // 8 字節
char a; // 1 字節
int c; // 4 字節
short d; // 2 字節
} MyStruct2;
struct Struct3 {
double b; // 8 字節
char a; // 1 字節
short d; // 2 字節
int c; // 4 字節
} MyStruct3;
struct Struct4 {
double b; // 8 字節
char a; // 1 字節
short d; // 2 字節
struct Struct3 c; // 16 字節
} MyStruct4;
- (void)demo
{
NSLog(@"%zd %zd", sizeof(MyStruct1), sizeof(MyStruct2));
NSLog(@"%zd %zd", sizeof(MyStruct3), sizeof(MyStruct4));
}
24 24
16 32
可以看到 Struct1、Struct2、Struct3 的成員變量的數據類型都是相同的,僅僅調整了變量定義的順序,內存占用大小就發生了變化。
二、內存對齊規則
2.1 對齊系數
對齊系數,也叫對齊模數。
每個特定平臺上的編譯器都有默認的對齊系數。程序員可以通過預編譯命令 #pragma pack(n)
,n = 1, 2, 4, 8, 16 來改變這一系數,其中的 n 就是“對齊系數”。
#pragma pack(1)
struct Struct1 {
char a; // 1 字節
double b; // 8 字節
int c; // 4 字節
short d; // 2 字節
} MyStruct1;
struct Struct3 {
double b; // 8 字節
char a; // 1 字節
short d; // 2 字節
int c; // 4 字節
} MyStruct3;
- (void)demo
{
NSLog(@"%zd %zd", sizeof(MyStruct1), sizeof(MyStruct3));
}
15 15
僅修改了對齊系數,輸出結果從 24 16
變成了 15 15
。
蘋果默認的對齊系數為
8
。
2.2 對齊規則
struct 或 union 的內存對齊的規則:
-
數據成員普通數據類型
第一個數據成員放在偏移為 0 的地方,以后每個數據成員 M 的偏移為對齊系數 n 與 M 自身長度中較小那個數的整數倍,不夠整數倍的補齊。
struct Struct1 { char a; // 1 字節 double b; // 8 字節 int c; // 4 字節 } MyStruct1;
- a 的地址為 [0],內存共占用 1 個字節;
- b 的地址為 MIN(n, 自身長度) = MIN(8, 8) = 8,b 的地址為 8*x,在這里 x 取 1 即可,所以 b 的地址為 [8],補齊 a 后面 [1] ~ [7] 的空間,內存共占用 16 個字節;
- c 的地址為 MIN(n, 自身長度) = MIN(8, 4) = 4,所以 c 的地址為 4*x,在 a,b 放置之后,內存已經占用了 8 + 8 = 16 個,此時下一個地址正好是 4 的整數倍,所以 c 的地址為 [16],內存共占用 20 個字節。
-
數據成員為 struct 或 union 類型
struct 或 union 的數據成員還是 struct 或者 union 類型,則該數據成員 M 的“自身長度”為其內部最大元素的大小。
struct Struct3 { double b; // 8 字節 char a; // 1 字節 short d; // 2 字節 int c; // 4 字節 } MyStruct3; struct Struct4 { double b; // 8 字節 char a; // 1 字節 short d; // 2 字節 struct Struct3 c; // 16 字節 } MyStruct4; 16 32
根據這個規則,c 中含有 double、char、short、int 數據類型,它的自身長度就為 double(8),MIN(n, 自身長度) = MIN(8, 8) = 8,c 的地址偏移值為 8*x,前面的 b、a、d 已經占了 12 個字節,所以 x = 2,需要填充 4 個字節,最終 MyStruct4 共需要占 32 個字節。
-
struct 或 union 的整體對齊
在內部數據成員按照上述第一步完成各自對齊之后,結構體本身也要進行對齊。
將結構體的大小調整為對齊系數 n 與結構體中的最大長度的數據成員中較小那個的整數倍,不夠的補齊。
struct Struct1 { char a; // 1 字節 double b; // 8 字節 int c; // 4 字節 short d; // 2 字節 } MyStruct1;
對齊系數 n = 8,結構體中最大長度的數據成員類型為 double,最大成員長度 = 8,MIN(8, 8) = 8,原本已占用內存大小為 8+8+4+2 = 22,最接近的 8 的倍數值是 24,所以補齊 2 個字節,最終共占用 24 個字節。
補齊之后:
struct Struct1 { char a; // 1 字節 char _pad0[7]; // 填充 7 字節 double b; // 8 字節 int c; // 4 字節 short d; // 2 字節 char _pad1[2]; // 填充 2 字節,讓結構體的大小成為最大成員大小 double(8字節)的倍數 }
2.3 驗證
使用如下代碼打印結構體:
struct Struct1 {
char a; // 1 字節
double b; // 8 字節
int c; // 4 字節
short d; // 2 字節
} MyStruct1;
- (void)demo
{
NSLog(@"%zd", sizeof(MyStruct1));
NSLog(@"%ld, %ld, %ld, %ld", (long)&MyStruct1.a, (long)&MyStruct1.b, (long)&MyStruct1.c, (long)&MyStruct1.d);
}
24
4442808120, 4442808128, 4442808136, 4442808140
-
char a
的地址是 20,下一個內存地址為 20 + 1; -
double b
的地址是 28,從 21 直接跳到了 28,之間的為填充字節,下一個內存地址為 28 + 8; -
int c
的地址是 36,c 與 b 之間沒有填充字節,下一個內存地址為 36 + 4; -
short d
的地址是 40,d 與 c 之間沒有填充字節,下一個內存地址為 40 + 2; - 此時共占用 40 + 2 - 20 = 22 個字節,而打印出來 MyStruct1 占了 24 個字節,所以最后執行了 struct 的整體對齊,在末尾填充了兩個字節。
三、為什么要進行內存對齊
編譯器會為程序中的每個數據單元安排在適當的位置上,這個過程對于大部分程序員來說是透明的。內存對齊是由編譯器處理。
要想掌控這項技術,在了解內存對齊的規則后,還應該知道編譯器為什么會進行內存對齊。
很多 CPU(如基于 Alpha,IA-64,MIPS,和 SuperH 體系的)拒絕讀取未對齊數據
。當一個程序要求這些 CPU 讀取未對齊數據時,這時 CPU 會進入異常處理狀態并且通知程序不能繼續執行。舉個例子,在 ARM,MIPS,和 SH 硬件平臺上,當操作系統被要求存取一個未對齊數據時會默認給應用程序拋出硬件異常。所以,如果編譯器不進行內存對齊,那在很多平臺的上的開發將難以進行。
那么,為什么這些 CPU 會拒絕讀取未對齊數據?是因為未對齊的數據,會大大降低 CPU 的性能
。
四、CPU 存取原理
程序員通常認為內存印象,由一個個的字節組成。
但是,你的 CPU 并不是以字節為單位存取數據的
。CPU 把內存當成是一塊一塊的,塊的大小可以是 2,4,8,16 字節大小,因此 CPU 在讀取內存時是一塊一塊進行讀取的。每次內存存取都會產生一個固定的開銷,減少內存存取次數將提升程序的性能。所以 CPU 一般會以 2/4/8/16/32 字節為單位來進行存取操作。我們將上述這些存取單位也就是塊大小稱為(memory access granularity)內存存取粒度。
為了說明內存對齊背后的原理,我們通過一個例子來說明,從未地址與對齊地址讀取數據的差異。
這個例子很簡單:在一個存取粒度為 4 字節的內存中,先從地址 0 讀取 4 個字節到寄存器,然后從地址 1 讀取 4 個字節到寄存器。
當從地址 0 開始讀取數據時,是讀取對齊地址的數據,直接通過一次讀取就能完成。當從地址 1 讀取數據時,讀取的是非對齊地址的數據。需要讀取兩次數據才能完成。
而且在讀取完兩次數據后,還要將 0-3 的數據向上偏移 1 字節,將 4-7 的數據向下偏移 3 字節。最后再將兩塊數據合并放入寄存器。
對一個內存未對齊的數據進行了這么多額外的操作,這對 CPU 的開銷很大,大大降低了 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] 來手動對齊內存。
注意:Vc,Vs等編譯器默認是#pragma pack(8),gcc 默認是 #pragma pack(4),并且 gcc 只支持 1,2,4 對齊。