C結構體打包技藝

一直對結構體內存對齊,只知其一;不知其二, 發現不錯文章引用過來。git

字節對齊主要是爲了提升內存的訪問效率,好比intel 32爲cpu,每一個總線週期都是從偶地址開始讀取32位的內存數據,若是數據存放地址不是從偶數開始,則可能出現須要兩個總線週期才能讀取到想要的數據,所以須要在內存中存放數據時進行對齊。程序員

 

一般咱們說字節對齊不少時候都是說struct結構體的內存對齊,好比下面的結構體:github

struct A{
    char a;
    int b;
    short c;
}

在32位機器上char 佔1個字節,int 佔4個字節,short佔2個字節,一共佔用7個字節.可是實際真的是這樣嗎?算法

咱們先看下面程序的輸出:編程

#include <stdio.h>

struct A{
    char a;
    int b;
    short c;
};
int main(){
    struct A a;
    printf("A: %ld\n", sizeof(a));
    return 0;
}

測試輸出的結果是A: 12, 比計算的7多了5個字節。這個就是由於編譯器在編譯的時候進行了內存對齊致使的。數組

 

內存對齊主要遵循下面三個原則:緩存

  1. 結構體變量的起始地址可以被其最寬的成員大小整除
  2. 結構體每一個成員相對於起始地址的偏移可以被其自身大小整除,若是不能則在前一個成員後面補充字節
  3. 結構體整體大小可以被最寬的成員的大小整除,如不能則在後面補充字節

其實這裏有點不嚴謹,編譯器在編譯的時候是能夠指定對齊大小的,實際使用的有效對齊實際上是取指定大小和自身大小的最小值,通常默認的對齊大小是4。數據結構

 

再回到上面的例子,若是默認的對齊大小是4,結構體a的其實地址爲0x0000,可以被最寬的數據成員大小(這裏是int, 大小爲4,有效對齊大小也是4)整除,姑char a的從0x0000開始存放佔用一個字節即0x0000~0x0001,而後是int b,其大小爲4,故要知足2,須要從0x0004開始,因此在char a後填充三個字節,所以a對齊後佔用的空間是0x0000~0x0003,b佔用的空間是0x0004~0x0007, 而後是short c其大小是2,故從0x0008開始佔用兩個字節,即0x0008~0x000A。 此時整個結構體佔用的空間是0x0000~0x000A, 佔用11個字節,11%4 != 0, 不知足第三個原則,因此須要在後面補充一個字節,即最後內存對齊後佔用的空間是0x0000~0x000B,一共12個字節。多線程

下面是另外一篇講解架構

原文連接:http://www.catb.org/esr/structure-packing/

 

誰應閱讀本文

本文探討如何經過手工從新打包C結構體聲明,來減少內存空間佔用。你須要掌握基本的C語言知識,以理解本文所講述的內容。

若是你在內存容量受限的嵌入式系統中寫程序,或者編寫操做系統內核代碼,就有必要了解這項技術。若是數據集巨大,應用時常逼近內存極限,這項技術會有所幫助。假若你很是很是關心如何最大限度地減小處理器緩存段(cache-line)未命中狀況的發生,這項技術也有所裨益。

最後,理解這項技術是通往其餘C語言艱深話題的門徑。若不掌握,就算不上高級C程序員。當你本身也能寫出這樣的文檔,而且有能力明智地評價它以後,才稱得上C語言大師。

緣何寫做本文

2013年末,我大量應用了一項C語言優化技術,這項技術是我早在二十餘年前就已掌握的,但彼時以後,鮮有使用。

我須要減小一個程序對內存空間的佔用,它使用了上千(有時甚至幾十萬)C結構體實例。這個程序是cvs-fast-export,在將其應用於大規模軟件倉庫時,程序會出現內存耗盡錯誤。

經過精心調整結構成體員的順序,能夠在這種狀況下大幅減小內存佔用。其效果顯著——在上述案例中,能夠減小40%的內存空間。程序應用於更大的軟件倉庫,也不會因內存耗盡而崩潰。

但隨着工做展開,我意識到這項技術在近些年幾乎已被遺忘。Web搜索證明了個人想法,現今的C程序員們彷佛已再也不談論這些話題,至少從搜索引擎中看不到。維基百科有些條目涉及這一主題,但不曾有人完整闡述。

事出有因。計算機科學課程(正確地)引導人們遠離微觀優化,轉而尋求更理想的算法。計算成本一路走低,令壓榨內存的必要性變得愈來愈低。舊日裏,黑客們經過在陌生的硬件架構中跌跌撞撞學習——現在已很少見。

然而這項技術在關鍵時刻仍頗具價值,而且只要內存容量有限,價值就始終存在。本文意在節省C程序員從新發掘這項技術所需的時間,讓他們有精力關注更重要任務。

對齊要求

首先須要瞭解的是,對於現代處理器,C編譯器在內存中放置基本C數據類型的方式受到約束,以令內存的訪問速度更快。

在x86或ARM處理器中,基本C數據類型一般並不存儲於內存中的隨機字節地址。實際狀況是,除char外,全部其餘類型都有「對齊要求」:char可起始於任意字節地址,2字節的short必須從偶數字節地址開始,4字節的int或float必須從能被4整除的地址開始,8比特的long和double必須從能被8整除的地址開始。不管signed(有符號)仍是unsigned(無符號)都不受影響。

用行話來講,x86和ARM上的基本C類型是「自對齊(self-aligned)」的。關於指針,不管32位(4字節)仍是64位(8字節)也都是自對齊的。

自對齊可令訪問速度更快,由於它有利於生成單指令(single-instruction)存取這些類型的數據。另外一方面,如若沒有對齊約束,可能最終不得不經過兩個或更多指令訪問跨越機器字邊界的數據。字符數據是種特殊狀況,因其始終處在單一機器字中,因此不管存取何處的字符數據,開銷都是一致的。這也就是它不須要對齊的緣由。

我提到「現代處理器」,是由於有些老平臺強迫C程序違反對齊規則(例如,爲int指針分配一個奇怪的地址並試圖使用它),不只令速度減慢,還會致使非法指令錯誤。例如Sun SPARC芯片就有這種問題。事實上,若是你下定決心,並恰當地在處理器中設置標誌位(e18),在x86平臺上,也能引起這種錯誤。

另外,自對齊並不是惟一規則。縱觀歷史,有些處理器,由其是那些缺少桶式移位器(Barrel shifter)的處理器限制更多。若是你從事嵌入式系統領域編程,有可能掉進這些潛伏於草叢之中的陷阱。當心這種可能。

你還能夠經過pragma指令(一般爲#pragma pack)強迫編譯器不採用處理器慣用的對齊規則。但請別隨意運用這種方式,由於它強制生成開銷更大、速度更慢的代碼。一般,採用我在下文介紹的方式,能夠節省相同或相近的內存。

使用#pragma pack的惟一理由是——假如你需讓C語言的數據分佈,與某種位級別的硬件或協議徹底匹配(例如內存映射硬件端口),而違反通用對齊規則又不可避免。若是你處於這種困境,且不瞭解我所講述的內容,那你已深陷泥潭,祝君好運。

填充

咱們來看一個關於變量在內存中分佈的簡單案例。思考形式以下的一系列變量聲明,它們處在一個C模塊的頂層。

char *p;
char c; int x;

假如你對數據對齊一無所知,也許覺得這3個變量將在內存中佔據一段連續空間。也就是說,在32位系統上,一個4字節指針以後緊跟着1字節的char,其後又緊跟着4字節int。在64位系統中,惟一的區別在於指針將佔用8字節。

然而實際狀況(在x8六、ARM或其餘採用自對齊類型的平臺上)以下。存儲p須要自對齊的4或8字節空間,這取決於機器字的大小。這是指針對齊——極其嚴格。

c緊隨其後,但接下來x的4字節對齊要求,將強制在分佈中生成了一段空白,彷彿在這段代碼中插入了第四個變量,以下所示。

char *p;      /* 4 or 8 bytes */ char c; /* 1 byte */ char pad[3]; /* 3 bytes */ int x; /* 4 bytes */

字符數組pad[3]意味着在這個結構體中,有3個字節的空間被浪費掉了。老派術語將其稱之爲「廢液(slop)」。

若是x爲2字節short:

char *p;
char c; short x;

在這個例子中,實際分佈將會是:

char *p;      /* 4 or 8 bytes */ char c; /* 1 byte */ char pad[1]; /* 1 byte */ short x; /* 2 bytes */

另外一方面,若是x爲64位系統中的long:

char *p;
char c; long x;

咱們將獲得:

char *p;     /* 8 bytes */ char c; /* 1 byte */ char pad[7]; /* 7 bytes */ long x; /* 8 bytes */

若你一路仔細讀下來,如今可能會思索,何不首先聲明較短的變量?

char c;
char *p; int x;

假如實際內存分佈能夠寫成下面這樣:

char c;
char pad1[M]; char *p; char pad2[N]; int x;

MN分別爲幾何?

首先,在此例中,N將爲0,x的地址緊隨p以後,能確保是與指針對齊的,由於指針的對齊要求總比int嚴格。

M的值就不易預測了。編譯器如果剛好將c映射爲機器字的最後一個字節,那麼下一個字節(p的第一個字節)將剛好由此開始,並剛好與指針對齊。這種狀況下,M將爲0。

不過更有可能的狀況是,c將被映射爲機器字的首字節。因而乎M將會用於填充,以使p指針對齊——32位系統中爲3字節,64位系統中爲7字節。

中間狀況也有可能發生。M的值有可能在0到7之間(32位系統爲0到3),由於char能夠從機器字的任何位置起始。

假若你但願這些變量佔用的空間更少,那麼能夠交換xc的次序。

char *p;     /* 8 bytes */ long x; /* 8 bytes */ char c; /* 1 byte */

一般,對於C代碼中的少數標量變量(scalar variable),採用調換聲明次序的方式能節省幾個有限的字節,效果不算明顯。而將這種技術應用於非標量變量(nonscalar variable)——尤爲是結構體,則要有趣多了。

在講述這部份內容前,咱們先對標量數組作個說明。在具備自對齊類型的平臺上,char、short、int、long和指針數組都沒有內部填充,每一個成員都與下一個成員自動對齊。

在下一節咱們將會看到,這種狀況對結構體數組並不適用。

結構體的對齊和填充

一般狀況下,結構體實例以其最寬的標量成員爲基準進行對齊。編譯器之因此如此,是由於此乃確保全部成員自對齊,實現快速訪問最簡便的方法。

此外,在C語言中,結構體的地址,與其第一個成員的地址一致——不存在頭填充(leading padding)。當心:在C++中,與結構體類似的類,可能會打破這條規則!(是否真的如此,要看基類和虛擬成員函數是如何實現的,與不一樣的編譯器也有關聯。)

假如你對此有疑惑,ANSI C提供了一個offsetof()宏,可用於讀取結構體成員位移。

考慮這個結構體:

struct foo1 {
    char *p; char c; long x; };

假定處在64位系統中,任何struct fool的實例都採用8字節對齊。不出所料,其內存分佈將會像下面這樣:

struct foo1 {
    char *p; /* 8 bytes */ char c; /* 1 byte */ char pad[7]; /* 7 bytes */ long x; /* 8 bytes */ };

看起來彷彿與這些類型的變量單獨聲明別無二致。但假如咱們將c放在首位,就會發現狀況並不是如此。

struct foo2 {
    char c; /* 1 byte */ char pad[7]; /* 7 bytes */ char *p; /* 8 bytes */ long x; /* 8 bytes */ };

若是成員是互不關聯的變量,c即可能從任意位置起始,pad的大小則再也不固定。由於struct foo2的指針須要與其最寬的成員爲基準對齊,這變得再也不可能。如今c須要指針對齊,接下來填充的7個字節被鎖定了。

如今,咱們來談談結構體的尾填充(trailing padding)。爲了解釋它,須要引入一個基本概念,我將其稱爲結構體的「跨步地址(stride address)」。它是在結構體數據以後,與結構體對齊一致的首個地址。

結構體尾填充的通用法則是:編譯器將會對結構體進行尾填充,直至它的跨步地址。這條法則決定了sizeof()的返回值。

考慮64位x86或ARM系統中的這個例子:

struct foo3 {
    char *p; /* 8 bytes */ char c; /* 1 byte */ }; struct foo3 singleton; struct foo3 quad[4];

你覺得sizeof(struct foo3)的值是9,但實際是16。它的跨步地址是(&p)[2]。因而,在quad數組中,每一個成員都有7字節的尾填充,由於下個結構體的首個成員須要在8字節邊界上對齊。內存分佈就好像這個結構是這樣聲明的:

struct foo3 {
    char *p; /* 8 bytes */ char c; /* 1 byte */ char pad[7]; };

做爲對比,思考下面的例子:

struct foo4 {
    short s; /* 2 bytes */ char c; /* 1 byte */ };

由於s只須要2字節對齊,跨步地址僅在c的1字節以後,整個struct foo4也只須要1字節的尾填充。形式以下:

struct foo4 {
    short s; /* 2 bytes */ char c; /* 1 byte */ char pad[1]; };

sizeof(struct foo4)的返回值將爲4。

如今咱們考慮位域(bitfields)。利用位域,你能聲明比字符寬度更小的成員,低至1位,例如:

struct foo5 {
    short s; char c; int flip:1; int nybble:4; int septet:7; };

關於位域須要瞭解的是,它們是由字(或字節)層面的掩碼和移位指令實現的。從編譯器的角度來看,struct foo5中的位域就像2字節、16位的字符數組,只用到了其中12位。爲了使結構體的長度是其最寬成員長度sizeof(short)的整數倍,接下來進行了填充。

struct foo5 {
    short s; /* 2 bytes */ char c; /* 1 byte */ int flip:1; /* total 1 bit */ int nybble:4; /* total 5 bits */ int septet:7; /* total 12 bits */ int pad1:4; /* total 16 bits = 2 bytes */ char pad2; /* 1 byte */ };

這是最後一個重要細節:若是你的結構體中含有結構體成員,內層結構體也要和最長的標量有相同的對齊。假如你寫下了這段代碼:

struct foo6 {
    char c; struct foo5 { char *p; short x; } inner; };

內層結構體成員char *p強迫外層結構體與內層結構體指針對齊一致。在64位系統中,實際的內存分佈將相似這樣:

struct foo6 {
    char c; /* 1 byte */ char pad1[7]; /* 7 bytes */ struct foo6_inner { char *p; /* 8 bytes */ short x; /* 2 bytes */ char pad2[6]; /* 6 bytes */ } inner; };

它啓示咱們,能經過從新打包節省空間。24個字節中,有13個爲填充,浪費了超過50%的空間!

結構體成員重排

理解了編譯器在結構體中間和尾部插入填充的緣由與方式後,咱們來看看如何榨出這些廢液。此即結構體打包的技藝。

首先注意,廢液只存在於兩處。其一是較大的數據類型(須要更嚴格的對齊)跟在較小的數據類型以後。其二是結構體天然結束的位置在跨步地址以前,這裏須要填充,以使下個結構體能正確地對齊。

消除廢液最簡單的方式,是按對齊值遞減從新對結構體成員排序。即讓全部指針對齊成員排在最前面,由於在64位系統中它們佔用8字節;而後是4字節的int;再而後是2字節的short,最後是字符。

所以,以簡單的鏈表結構體爲例:

struct foo7 {
    char c; struct foo7 *p; short x; };

將隱含的廢液寫明,形式以下:

struct foo7 {
    char c; /* 1 byte */ char pad1[7]; /* 7 bytes */ struct foo7 *p; /* 8 bytes */ short x; /* 2 bytes */ char pad2[6]; /* 6 bytes */ };

總共是24字節。若是按長度重排,咱們獲得:

struct foo8 {
    struct foo8 *p; short x; char c; };

考慮到自對齊,咱們看到全部數據域之間都不需填充。由於有較嚴對齊要求(更長)成員的跨步地址對不太嚴對齊要求的(更短)成員來講,老是合法的對齊地址。重打包過的結構體只須要尾填充:

struct foo8 {
    struct foo8 *p; /* 8 bytes */ short x; /* 2 bytes */ char c; /* 1 byte */ char pad[5]; /* 5 bytes */ };

從新打包將空間降爲16字節。也許看起來不算不少,但假如這個鏈表的長度有20萬呢?將會聚沙成塔。

注意,從新打包不能確保在全部狀況下都能節省空間。將這項技術應用於更靠前struct foo6的那個例子,咱們獲得:

struct foo9 {
    struct foo9_inner { char *p; /* 8 bytes */ int x; /* 4 bytes */ } inner; char c; /* 1 byte */ };

將填充寫明:

struct foo9 {
    struct foo9_inner { char *p; /* 8 bytes */ int x; /* 4 bytes */ char pad[4]; /* 4 bytes */ } inner; char c; /* 1 byte */ char pad[7]; /* 7 bytes */ };

結果仍是24字節,由於c沒法做爲內層結構體的尾填充。要想節省空間,你須要得新設計數據結構。

棘手的標量案例

只有在符號調試器能顯示枚舉類型的名稱而非原始整型數字時,使用枚舉來代替#define纔是個好辦法。然而,儘管枚舉一定與某種整型兼容,但C標準卻沒有指明到底是何種底層整型。

請小心,重打包結構體時,枚舉型變量一般是int,這與編譯器相關;但也多是short、long、甚至默認爲char。編譯器可能會有progma預處理指令或命令行選項指定枚舉的尺寸。

long double是個相似的故障點。有些C平臺以80位實現,有些是128位,還有些80位平臺將其填充到96或128位。

以上兩種狀況,最好用sizeof()來檢查存儲尺寸。

最後,在x86 Linux系統中,double有時會破自對齊規則的例;在結構體內,8字節的double可能只要求4字節對齊,而在結構體外,獨立的double變量又是8字節自對齊。這與編譯器和選項有關。

可讀性與緩存局部性

儘管按尺寸重排是最簡單的消除廢液的方式,卻不必定是正確的方式。還有兩個問題須要考量:可讀性與緩存局部性。

程序不只與計算機交流,還與其餘人交流。甚至(尤爲是!)交流的對象只有未來你本身時,代碼可讀性依然重要。

笨拙地、機械地重排結構體可能有損可讀性。假若有可能,最好這樣重排成員:將語義相關的數據放在一塊兒,造成連貫的組。最理想的狀況是,結構體的設計應與程序的設計相通。

當程序頻繁訪問某一結構體或其一部分時,若能將其放入一個緩存段,對提升性能很有幫助。緩存段是這樣的內存塊——當處理器獲取內存中的任何單個地址時,會把整塊數據都取出來。 在64位x86上,一個緩存段爲64字節,它開始於自對齊的地址。其餘平臺一般爲32字節。

爲保持可讀性所作的工做(將相關和同時訪問的數據放在臨近位置)也會提升緩存段的局部性。這些都是須要明智地重排,並對數據的存取模式瞭然於心的緣由。

若是代碼從多個線程併發訪問同一結構體,還存在第三個問題:緩存段彈跳(cache line bouncing)。爲了儘可能減小昂貴的總線通訊,應當這樣安排數據——在一個更緊湊的循環裏,從一個緩存段中讀數據,而向另外一個寫入數據。

是的,某些時候,這種作法與前文將相關數據放入與緩存段長度相同塊的作法矛盾。多線程的確是個難題。緩存段彈跳和其餘多線程優化問題是很高級的話題,值得單獨爲它們寫份指導。這裏我所能作的,只是讓你瞭解有這些問題存在。

其餘打包技術

在爲結構體瘦身時,重排序與其餘技術結合在一塊兒效果最好。例如結構體中有幾個布爾標誌,能夠考慮將其壓縮成1位的位域,而後把它們打包放在本來可能成爲廢液的地方。

你可能會有一點兒存取時間的損失,但只要將工做集合壓縮得足夠小,那點損失能夠靠避免緩存未命中補償。

更通用的原則是,選擇能把數據類型縮短的方法。以cvs-fast-export爲例,我使用的一個壓縮方法是:利用RCS和CVS在1982年前還不存在這個事實,我棄用了64位的Unixtime_t(在1970年開始爲零),轉而用了一個32位的、從1982-01-01T00:00:00開始的偏移量;這樣日期會覆蓋到2118年。(注意:若使用這類技巧,要用邊界條件檢查以防討厭的Bug!)

這不只減少告終構體的可見尺寸,還能夠消除廢液和/或創造額外的機會來進行從新排序。這種良性串連的效果不難被觸發。

最冒險的打包方法是使用union。假如你知道結構體中的某些域永遠不會跟另外一些域共同使用,能夠考慮用union共享它們存儲空間。不過請特別當心並用迴歸測試驗證。由於若是分析出現一丁點兒錯誤,就會引起從程序崩潰到微妙數據損壞(這種狀況糟得多)間的各類錯誤。

工具

clang編譯器有個Wpadded選項,能夠生成有關對齊和填充的信息。

還有個叫pahole的工具,我本身沒用過,但聽說口碑很好。該工具與編譯器協同工做,生成關於結構體填充、對齊和緩存段邊界報告。

證實和例外

讀者能夠下載一段程序源代碼packtest.c,驗證上文有關標量和結構體尺寸的結論。

若是你仔細檢查各類編譯器、選項和罕見硬件的稀奇組合,會發現我前面提到的部分規則存在例外。越早期的處理器設計例外越常見。

理解這些規則的第二個層次是,知其什麼時候及如何會被打破。在我學習它們的日子裏(1980年代早期),咱們把不理解這些規則的人稱爲「全部機器都是VAX綜合症」的犧牲品。記住,世上全部電腦並不是都是PC。

相關文章
相關標籤/搜索