(轉)失落的C語言結構體封裝藝術

目錄
1. 誰該閱讀這篇文章程序員

2. 我爲何寫這篇文章算法

3.對齊要求小程序

4.填充數組

5.結構體對齊及填充緩存

6.結構體重排序網絡

7.難以處理的標量的狀況數據結構

8.可讀性和緩存局部性多線程

9.其餘封裝的技術架構

10.工具併發

11.證實及例外

12.版本履歷

 

1. 誰該閱讀這篇文章

本文是關於削減C語言程序內存佔用空間的一項技術——爲了減少內存大小而手工從新封裝C結構體聲明。你須要C語言的基本知識來讀懂本文。

若是你要爲內存有限制的嵌入式系統、或者操做系統內核寫代碼,那麼你須要懂這項技術。若是你在處理極大的應用程序數據集,以致於你的程序經常達到內存的界限時,這項技術是有幫助的。在任何你真的真的須要關注將高速緩存行未命中降到最低的應用程序裏,懂得這項技術是很好的。

最後,理解該技術是一個通往其餘深奧的C語言話題的入口。直到你掌握了它,你才成爲一個高端的C程序員。直到你能夠本身寫出這篇文檔而且能夠理智地評論它,你才成爲一位C語言大師。

 

2. 我爲何寫這篇文章

本文之因此存在,是由於在2013年末,我發現我本身在大量使用一項C語言的優化技術,我早在二十多年前就已經學會了該技術,不過在那以後並沒怎麼使用過。

我須要減少一個程序的內存佔用空間,它用了幾千——有時是幾十萬個——C結構體的實例。這個程序是cvs-fast-export,而問題在於處理巨大的代碼庫時,它曾因內存耗盡的錯誤而瀕臨崩潰。

在這類狀況下,有好些辦法能極大地減小內存使用的,好比當心地從新安排結構體成員的順序之類的。這能夠得到巨大的收益——在個人事例中,我可以減掉大約40%的工做區大小,使得程序可以在不崩潰的狀況下處理大得多的代碼庫。

當我解決這個問題,而且回想我所作的工做時,我開始發現,我在用的這個技術現今應被忘了大半了。一個網絡調查確認,C程序員好像已經再也不談論該技術了,至少在搜索引擎能夠看到的地方不談論了。有幾個維基百科條目觸及了這個話題,可是我發現沒人能全面涵蓋。

實際上這個現象也是有合理的理由的。計算機科學課程(應當)引導人們避開細節的優化而去尋找更好的算法。機器資源價格的暴跌已經使得壓榨內存用量變得不那麼必要了。並且,想當年,駭客們曾經學習如何使用該技術,使得他們在陌生的硬件架構上撞牆了——如今已經不太常見的經歷。

可是這項技術仍然在重要的場合有價值, 而且只要內存有限,就能永存。本文目的就是讓C程序員免於從新找尋這項技術,而讓他們能夠集中精力在更重要的事情上。

 

3. 對齊要求(Alignment Requirement)

要明白的第一件事是,在現代處理器上,你的C編譯器在內存裏對基本的C數據類型的存放方式是受約束的,爲的是內存訪問更快。

在x86或者ARM處理器上,基本的C數據類型的儲存通常並非起始於內存中的任意字節地址。而是,每種類型,除了字符型之外,都有對齊要求;字符能夠起始於任何字節地址,可是2字節的短整型必須起始於一個偶數地址,4字節整型或者浮點型必須起始於被4整除的地址,以及8字節長整型或者雙精度浮點型必須起始於被8整除的地址。帶符號與不帶符號之間沒有差異。

這個的行話叫:在x86和ARM上,基本的C語言類型是自對齊(self-aligned)的。指針,不管是32位(4字節)亦或是64位(8字節)也都是自對齊的。

自對齊使得訪問更快,由於它使得一條指令就完成對類型化數據的取和存操做。沒有對齊的約束,反過來,代碼最終可能會不得不跨越機器字的邊界作兩次或更屢次訪問。字符是特殊的狀況;不管在一個單機器字中的何處,存取的花費都是同樣的。那就是爲何字符型沒有被建議對齊。

我說「在現代的處理器上」是由於,在一些舊的處理器上,強制讓你的C程序違反對齊約束(比方說,將一個奇數的地址轉換成一個整型指針,並試圖使用它)不只會使你的代碼慢下來,還會形成非法指令的錯誤。好比在Sun的SPARC芯片上就曾經這麼幹。實際上,只要夠決心並在處理器上設定正確(e18)的硬件標誌位,你仍然能夠在x86上觸發此錯誤。

此外,自對齊不是惟一的可能的規則。歷史上,一些處理器(特別是那些缺乏移位暫存器的)有更強的限制性規則。若是你作嵌入式系統,你也許會在跌倒在這些叢林陷阱中。注意,這是有可能的。

有時你能夠經過編譯指示,強制讓你的編譯器不使用處理器正常的對齊規則,一般是#pragma pack。不要隨意使用,由於它會致使產生開銷更大、更慢的代碼。使用我在這裏描述的技術,一般你能夠節省一樣或者幾乎一樣多的內存。

#pragma pack的惟一好處是,若是你不得不將你的C語言數據分佈精確匹配到某些位級別的硬件或協議的需求,好比一個內存映射的硬件端口,要求違反正常的對齊才能奏效。若是你遇到那種狀況,而且你還未理解我在這裏寫的這一切,你會有大麻煩的,我只能祝你好運了。

 

 

4. 填充(Padding)

如今咱們來看一個簡單變量在內存裏的分佈的例子。考慮在C模塊的最頂上的如下一系列的變量聲明:

1
2
3
char *p;
char c;
int x;

若是你不知道任何關於數據對齊的事情,你可能會假設這3個變量在內存裏會佔據一個連續字節空間。那也就是說,在一個32位機器上,指針的4字節,以後緊接着1字節的字符型,且以後緊接着4字節的整型。在64位機器只在指針是8字節上會有所不一樣。

這裏是實際發生的(在x86或ARM或其餘任何有自對齊的處理器類型)。p的存儲地址始於一個自對齊的4字節或者8字節邊界,取決於機器的字長。這是指針對齊——多是最嚴格的狀況。

緊跟着的是c的存儲地址。可是x的4字節對齊要求,在內存分佈上形成了一個間隙;變成了恰似第四個變量插在其中,像這樣:

1
2
3
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字節的短整型會發生什麼:

1
2
3
char *p;
char c;
short x;

在那個狀況下,實際的內存分佈會變成這樣:

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

另外一方面,若是x是一個在64位機上的長整型

1
2
3
char *p;
char c;
long x;

最終咱們會獲得:

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

若是你已仔細看到這裏,如今你可能會想到越短的變量聲明先聲明的狀況:

1
2
3
char c;
char *p;
int x;

若是實際的內存分佈寫成這樣:

1
2
3
4
5
char c;
char pad1[M];
char *p;
char pad2[N];
int x;

咱們能夠說出M和N的值嗎?

首先,在這個例子中,N是零。x的地址,緊接在p以後,是保證指針對齊的,確定比整型對齊更嚴格的。

M的值不太能預測。若是編譯器恰巧把c映射到機器字的最後一個字節,下一個字節(p的第一部分)會成爲下一個機器字的第一個字節,而且正常地指針對齊。M爲零。

c更可能會被映射到機器字的第一個字節。在那個狀況下,M會是以保證p指針對齊而填補的數——在32位機器上是3,64位機器上是7。

若是你想讓那些變量佔用更少的空間,你能夠經過交換原序列中的x和c來達到效果。

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

一般,對於C程序裏少數的簡單變量,你能夠經過調整聲明順序來壓縮掉極少幾個字節數,不會有顯著的節約。但當用於非標量變量(nonscalar variables),尤爲是結構體時,這項技術會變得更有趣。

在咱們講到非標量變量以前,讓咱們講一下標量數組。在一個有自對齊類型的平臺上,字符、短整型、整型、長整型、指針數組沒有內部填充。每一個成員會自動自對齊到上一個以後(譯者注:原文 self-aligned at the end of the next one 似有誤)。

在下一章,咱們會看到對於結構體數組,同樣的規則並不必定正確。

 

5. 結構體的對齊和填充

總的來講,一個結構體實例會按照它最寬的標量成員對齊。編譯器這樣作,把它做爲最簡單的方式來保證全部成員是自對齊,爲了快速訪問的目的。

並且,在C語言裏,結構體的地址與它第一個成員的地址是相同的——沒有前置填充。注意:在C++裏,看上去像結構體的類可能不遵照這個規則!(遵不遵照依賴於基類和虛擬內存函數如何實現,並且因編譯器而不一樣。)

(當你不能肯定此類事情時,ANSI C提供了一個offsetof()宏,可以用來表示出結構體成員的偏移量。)

考慮這個結構體:

1
2
3
4
5
struct foo1 {
     char *p;
     char c;
     long x;
};

假設一臺64位的機器,任何struct foo1的實例會按8字節對齊。其中的任何一個的內存分佈看上去無疑應該像這樣:

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

它的分佈就剛好就像這些類型的變量是單獨聲明的。可是若是咱們把c放在第一個,這就不是了。

1
2
3
4
5
6
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上的這個例子:

1
2
3
4
5
6
7
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字節的邊界上。內存分佈就如結構體像這樣聲明:

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

做爲對照,考慮下面的例子:

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

由於s只需對齊到2字節, 跨步地址就只有c後面的一個字節,struct foo4做爲一個總體,只須要一個字節的尾隨填充。它會像這樣分佈

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

而且sizeof(struct foo4)會返回4。

如今讓咱們考慮位域(bitfield)。它們是你可以聲明比字符寬度還小的結構體域,小到1位,像這樣:

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

關於位域須要知道的事情是,它們以字或字節級別的掩碼和移位指令來實現。從編譯器的觀點來看,struct foo5的位域看上去像2字節,16位的字符數組裏只有12位被使用。接着是填充,使得這個結構體的字節長度成爲sizeof(short)的倍數即最長成員的大小。

1
2
3
4
5
6
7
8
9
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 */
};

這裏是最後一個重要的細節:若是你的結構體含有結構體的成員,裏面的結構體也須要按最長的標量對齊。假設若是你寫成這樣:

1
2
3
4
5
6
7
struct foo6 {
     char c;
     struct foo5 {
         char *p;
         short x;
     } inner;
};

內部結構體的char *p成員使得外部的結構體與內部的同樣成爲指針對齊。在64位機器上,實際的分佈是像這樣的:

1
2
3
4
5
6
7
8
9
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%的無用空間!

 

6. 結構體重排序(reordering)

如今你知道如何以及爲什麼編譯器要插入填充,在你的結構體之中或者以後,咱們要考察你能夠作些什麼來擠掉這些「水坑」。這就是結構體封裝的藝術。

第一件須要注意的事情是,「水坑」僅發生於兩個地方。一個是大數據類型(有更嚴格的對齊要求)的存儲區域緊跟在一個較小的數據類型的存儲區域以後。另外一個是結構體天然結束於它的跨步地址以前,須要填充,以使下一個實例能夠正確對齊。

消除「水坑」的最簡單的方法是按對齊的降序來對結構體成員重排序。就是說:全部指針對齊的子域在前面,由於在64位的機器上,它們會有8字節。接下來是4字節的整型;而後是2字節的短整型;而後是字符域。

所以,舉個例子,考慮這個簡單的鏈表結構體:

1
2
3
4
5
struct foo7 {
     char c;
     struct foo7 *p;
     short x;
};

顯現出隱含的「水坑」,這樣:

1
2
3
4
5
6
7
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個字節。若是咱們按大小從新排序,咱們獲得:

1
2
3
4
5
struct foo8 {
     struct foo8 *p;
     short x;
     char c;
};

考慮到自對齊,咱們看到沒有數據域須要填充。這是由於一個較長的、有較嚴格對齊的域的跨步地址,對於較短的、較不嚴格對齊的域來講,老是合法對齊的起始地址。全部重封裝的結構體實際上須要的只是尾隨填充:

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

咱們重封裝的轉變把大小降到了16字節。這可能看上去沒什麼,可是假設你有一個200k的這樣的鏈表呢?節省的空間累積起來就不小了。

注意重排序並不能保證節省空間。把這個技巧運用到早先的例子,struct foo6,咱們獲得:

1
2
3
4
5
6
7
struct foo9 {
     struct foo9_inner {
         char *p;      /* 8 bytes */
         int x;        /* 4 bytes */
     } inner;
     char c;           /* 1 byte*/
};

把填充寫出來,就是這樣

1
2
3
4
5
6
7
8
9
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不能轉換到內部結構體成員的尾隨填充。爲了得到節省空間的好處,你須要從新設計你的數據結構。

自從發佈了這篇指南的初版,我就被問到了,若是經過重排序來獲得最少的「水坑」是如此簡單,爲何C編譯器不自動完成呢?答案是:C語言最初是被設計用來寫操做系統和其餘接近硬件的語言。自動重排序會妨礙到系統程序員規劃結構體,精確匹配字節和內存映射設備控制塊的位級分佈的能力。

 

7. 難以處理的標量的狀況

使用枚舉類型而不是#defines是個好主意,由於符號調試器能夠用那些符號而且能夠顯示它們,而不是未處理的整數。可是,儘管枚舉要保證兼容整型類型,C標準沒有明確規定哪些潛在的整型類型會被使用。

注意,當從新封裝你的結構體時,雖然枚舉類型變量一般是整型,但它依賴於編譯器;它們多是短整型、長整型、甚至是默認的字符型。你的編譯器可能有一個編譯指示或者命令行選項來強制規定大小。

long double類型也是個類似的麻煩點。有的C平臺以80位實現,有的是128, 還有的80位的平臺填充到96或128位。

在這兩種狀況下,最好用sizeof()來檢查存儲大小。

最後,在x86下,Linux的雙精度類型有時是一個自對齊規則的特例;一個8字節的雙精度數據在一個結構體內能夠只要求4字節對齊,雖然單獨的雙精度變量要求8字節的自對齊。這依賴於編譯器及其選項。

 

8. 可讀性和緩存局部性

儘管按大小重排序是消除「水坑」的最簡單的方式,但它不是一定正確的。還有兩個問題:可讀性和緩存局部性。

程序不僅是與計算機的交流,仍是與其餘人的交流。代碼可讀性是重要的,即使(或者尤爲是!)交流的另外一方不僅是將來的你。

笨拙的、機械的結構體重排序會損害可讀性。可能的話,最好重排域,使得語義相關的數據段牢牢相連,能造成連貫的組羣。理想狀況下,你的結構體設計應該傳達到你的程序。

當你的程序常常訪問一個結構體,或者結構體的一部分,若是訪問常命中緩存行(當被告知去讀取任何一個塊裏單個地址時,你的處理器讀取的整一塊內存)有助於提升性能。在64位x86機上一條緩存行爲64字節,始於一個自對齊的地址;在其餘平臺上常常是32字節。

你應該作的事情是保持可讀性——把相關的和同時訪問的數據組合到毗鄰的區域——這也會提升緩存行的局部性。這都是用代碼的數據訪問模式的意識,聰明地重排序的緣由。

若是你的代碼有多線程併發訪問一個結構體,就會有第三個問題:緩存行反彈(cache line bouncing)。爲了減小代價高昂的總線通訊,你應該組織你的數據,使得在緊湊的循環中,從一條緩存行中讀取,而在另外一條緩存行中寫。

是的,這與以前關於把相關數據組成一樣大小的緩存行塊的指南有些矛盾。多線程是困難的。緩存行反彈以及其它的多線程優化問題是十分高級的話題,須要整篇關於它們的教程。這裏我能作的最好的就就是讓你意識到這些問題的存在。

 

9. 其它封裝技術

當重排序與其餘技術結合讓你的結構體瘦身時效果最好。若是你在一個結構體裏有若干布爾型標誌,舉個例子,能夠考慮將它們減少到1位的位域,而且將它們封裝到結構體裏的一個本會成爲「水坑」的地方。

爲此,你會碰到些許訪問時間上的不利——可是若是它把工做區擠壓得足夠小,這些不利會被避免緩存不命中的得益所掩蓋。

更廣泛的,尋找縮小數據域大小的方式。好比在cvs-fast-export裏,我用的一項壓縮技術裏用到了在1982年以前RCS和CVS代碼庫還不存在的知識。我把64位的Unix time_t(1970年做爲起始0日期)減小到32位的、從1982-01-01T00:00:00開始的時間偏移量;這會覆蓋2118年前的日期。(注意:若是你要玩這樣的花招,每當你要設定字段,你都要作邊界檢查以防討厭的錯誤!)

每個這樣被縮小的域不只減小了你結構體顯在的大小,還會消除「水坑」,且/或建立額外的機會來獲得域重排序的好處。這些效果的良性疊加不可貴到。

最有風險的封裝形式是使用聯合體。若是你知道你結構體中特定的域永遠不會被用於與其餘特定域的組合,考慮使用聯合體使得它們共享存儲空間。但你要額外當心,而且用迴歸測試來驗證你的工做,由於若是你的生命週期分析即便有輕微差錯,你會獲得各類程序漏洞,從程序崩潰到(更糟糕的)不易發覺的數據損壞。

 

10. 工具

C語言編譯器有個-Wpadded選項,能使它產生關於對齊空洞和填充的消息。

雖然我本身還沒用過,可是一些反饋者稱讚了一個叫pahole的程序。這個工具與編譯器合做,產生關於你的結構體的報告,記述了填充、對齊及緩存行邊界。

 

11. 證實及例外

你能夠下載一個小程序的代碼,此代碼用來展現了上述標量和結構體大小的論斷。就是packtest.c

若是你瀏覽足夠多的編譯器、選項和不常見的硬件的奇怪組合,你會發現針對我講述的一些規則的特例。若是你回到越舊的處理器設計,就會越常見。

比知道這些規則更進一步,是知道如何以及什麼時候這些規則會被打破。在我學習它們的那些年(1980年代早期),咱們把不懂這些的人稱爲「世界都是VAX綜合徵」的受害者。記住世界上不僅有PC。

 

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

譯文:http://blog.jobbole.com/57822/

相關文章
相關標籤/搜索