被遺忘的C結構體封裝技術

1. 誰該閱讀本文

本文是關於如何減少C程序的內存佔用的:手工重新排列C結構體的成員聲明來減小尺寸。爲了讀懂它,你需要基本的C語言知識。

如果你想爲內存受限的嵌入式系統或操作系統內核寫代碼,你需要了解該技術。 如果你在處理很大量的應用程序數據時經常超出了內存限制,或是你非常想要減小緩存不命中的次數,瞭解該技術是很有用的。

最後,理解該技術是其它難懂的C語言概念的入口。 你不是高級的C程序員除非你掌握了它。你不是C語言大師除非你自己能寫出這樣的文件並能聰明地評論它。

2. 我爲什麼寫這篇文章

寫這篇文章的起因是,2013年底我發現自己大量地使用一個C語言優化技術,而這種技術自從我二十多年前學習後就很少使用。

我的程序使用數千甚至數萬個C結構實例,我需要減小內存佔用。 該程序是cvs-fast-export , 它在處理巨大的源碼庫時,會因內存不夠而退出。

在這種情況下有方法可以極大地減小內存佔用,比如小心地重排結構成員的順序。 這可以取得明顯的效果:以我的情況爲例,我能把工作時的內存佔用減小40%,使程序能處理更大的源碼庫而不退出。

在處理問題並回味我的做法時,我意識到這種技術在今天大半被遺忘了。 做一個簡單的網頁搜索,可以看出至少在搜索引擎能夠看到的地方,C程序員已經不怎麼討論它了。 有幾個維基百科詞條提到了它,但我覺得沒人說得很全面。
 
這種現象也情有可原。 計算機課程(正確地)指引人們避開微觀的優化而去尋找更優的算法。 硬件價格的下降也使擠壓內存佔用變得沒有必要。 還有,hacker們以前用這種技術時,常在奇特的硬件架構上碰壁,當然,這種情況現在比較少見了。

但該技術仍在重要的情況下有用武之地,而且只要內存有限制,就會有用。 這篇文章的目的是避免C程序員重新發現該技術,使他們能專注於更重要的事情。

3. 對齊的要求

首先要理解的是,在現代處理器上,C編譯器在內存裏存放基本數據類型時是受限的:以最快存取速度爲目標。
在X86或ARM上,基本數據類型並不是存放在任意內存地址上的。 每種類型除了char都有對齊要求(alignment requirement); char類型可以開始於任何地址,但2字節的short類型必須存放在偶數地址上,4字節的整型或浮點型必須放在能被4整除的位置上,而8字節的long或double型必須放在能被8整除的地址上。有符號或無符號沒有差別

用術語來講就是,基本C類型在X86和ARM上都是自對齊的(self-aligned)。指針,不管是32位(4字節)還是64位(8字節)也是自對齊的。
自對齊能存取得更快是因爲它能用一條指令來存取該類型數據。 另一方面,如果沒有對齊限制,代碼可能會在跨機器字邊界存取的時候使用兩條以上的指令。 字符是特殊情況: 不管它在們在機器字的哪個位置,存取代價都是一樣的。所以它們沒有對齊要求。

我說“在現代處理器上”,是因爲在有些更老的處理器上,強迫你的C代碼違反對齊限制(比如,把一個奇數地址轉換爲int指針並試圖使用它)不僅會讓你的代碼變慢,還會造成非法指令異常。 比如在Sun SPARC芯片上就是這樣。 事實上,只要有足夠的決心和正確的硬件標誌(e18),你也可以在X86上觸發該異常。

自對齊還不是唯一的規則。 歷史上,有些處理器(特別是那些沒有barrel shifters的)有更嚴格的規則。如果你在做嵌入式系統,你可能撞到這些暗礁。要有心理準備。

有時你可以讓編譯器不遵守處理器的正常對齊規則,一般是使用pragma,比如 #pragma pack。 請不要隨意使用,因爲它會生成開銷更大、更慢的代碼。 通過使用我介紹的技術,你可以節省同樣、甚至更多的內存。

使用#pragma pack的唯一合理理由是,你需要C數據分佈完全匹配某些硬件或協議,比如一個經過內存映射的物理端口,則不違反對齊規則就無法做下去。 如果你處在那種情況,而不理解本文的內容,你會遇到大麻煩,祝你好運。

4. 填充(padding)

現在我們來看一個簡單的例子,變量在內存中的分佈。 考慮在C模塊的頂部,有這些變量聲明:

char *p;
char c;
int x;

如果你不知道數據對齊,你可能會假定這三個變量在內存裏佔用連續的字節。 即,在32位機器上4字節的指針後面會緊跟1字節的char,而它後面會緊跟4字節的int。在64位機器上,唯一的差別是指針是8字節的。

這是(在x86或ARM或任何自對齊的機器上)實際的情況:p 存儲在4字節或8字節對齊的位置上(由機器的字長決定)。 這是指針對齊-可能的最嚴格的情況。

c的存儲緊跟着p。但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 */

另一方面,如果是在64位機上,x 是一個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;

M 和 N 應該是多少?

首先,N 是0。 x 的地址緊接着p,保證了x 是指針對齊的,而指針對齊肯定比整型對齊更嚴。

c極有可能被映射到機器字的第一個字節上。 因此M是能讓p滿足指針對齊的數目-在32位機上是3,在64位上是7。

中間情況也是可能的。 因爲char有可能被安排在一個機器字中的任意位置,M有可能是0到7(在32位機上是0到3)。

如果你想讓這些變量佔用較少的空間,你可以交換x和c的位置:

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

通常對於數量較少的C程序中的標量來說,通過調整聲明順序獲得的區區幾個字節可能沒什麼大不了。這種技術如果應用到非標量變量-特別是結構,會變得更加有趣。
在我們繼續之前,先說一下標量數組。 在一個自對齊類型的平臺上,char/short/int/long/pointer 數組內部沒有填充;每個成員都跟在前一個成員後面,自動對齊了。

在下一節我們將看到,在結構體數據裏,以上規律並不一定正確。

5. 結構體的對齊和填充

總的來說,結構體實例會和它的最寬成員一樣對齊。 編譯器這樣做因爲這是保證所有成員自對齊以獲得快速存取的最容易方法。

而且,在C中,結構的地址等於它的第一個成員的地址-沒有前導填充。 注意:在C++中,形似結構的類可能會破壞這個規則!(跟基類和虛函數如何實現有關,也因編譯器而異。)

(當你對此有疑惑時,你可以使用ANSI C 提供的offset()宏來得到結構成員的偏移。)

考慮這個結構:

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

假定是在一臺64位機上,那麼任何struct foo1的實例都是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。  跨步地址即quad[0].p的地址,這樣,在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後面的一個字節,struct foo4只有一字節的拖尾填充。 就像這樣:

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%的空間浪費了!
6. 結構成員重排
理解了編譯器在結構體中間和尾部插入填充的原因和方式後,我們要檢查一下如何擠壓這些溢出(slop)。 這就是結構體壓縮技術。

首先我們注意到溢出只發生在兩個地方。 一個是較大的數據類型(從而需要更嚴格的對齊)跟在較小的數據後面。 另一個是結構體自然結束的位置到跨步地址之間需要填充,以使下一個相同結構能正確地對齊。

最簡單的消除溢出的方式是按對齊值的遞減來排序成員。 即讓指針對齊的成員排在最前面,因爲在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 */
};

注意重排並不能保證節省空間。 把它應用到先前的例子, 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不能放進內層結構的拖尾填充。 爲了節省這些空間你要重新設計數據結構。

7. 怪異數據類型

如果符號調試器能顯示枚舉類型的名稱而不原始的數字,使用枚舉來代替#define是個好辦法。然而,雖然枚舉必須與某種整型兼容,C標準卻沒有指定到底是何種整型。

請當心重打包結構體的時候,枚舉型變量通常是int,這跟編譯器相關;但它們也可能是short,long,甚至默認是char。你的編譯器可能會有progma或命令行選項指定枚舉的尺寸。

long double 是個類似的故障點。 有些C平臺以80位實現它,有些是128位,而一些80位平臺把它填充到96或128位。

在以上兩種情況下最好用sizeof()來檢查存儲的尺寸。

8. 可讀性和cache局部性

按成員尺寸重排是最簡單的消除溢出的方式,但不一定是正確的方式。 還有兩個問題:可讀性和cache局部性。

程序不僅與計算機交流,還與人類交流。 特別當交流的觀衆是將來的你的時候,代碼可讀性更重要的。

一個笨拙的、機械的重排可能影響可讀性。有可能的話,最好這樣重排成員:使得語義相關的數據放在一起,形成連貫的組。 最理想的是,結構體的設計要與程序的設計相互溝通。

當你的程序頻繁地存取某個結構或它的一部分,如果存取總是能放進一條cache 行,對提高性能是很有幫助的。cache 行是這樣的內存塊,當處理器要去取該內存塊內的任何單個地址時,會把整個內存塊都取出來。 在64位x86上,一條cache 行是64字節,開始於自對齊的地址。在其它平臺上通常是32字節。

你爲保持可讀性而做的事-把相關的和同時要存取的數據放在相鄰的位置-也會提高cache行局部性。 它們都是聰明地重排、把數據的存取模式放在心上的原因。

如果你的代碼從多個線程上同時存取一個結構體,會有第三個問題:cache line bouncing。 爲了減少昂貴的總線通信,你應該這樣安排數據,使得在一個更緊的循環裏,從一條cache line 裏讀數據,而往另一條寫數據。

是的,這種做法與前面說的把相關的數據放入與cache line長度相同的塊矛盾。多線程是困難的。 Cache line bouncing 和其它多線程優化問題是很高級的話題,值得單獨爲它們寫個指導。 這裏我能做的只是讓你瞭解有這些問題存在。

9. 其它打包技術

在爲你的結構瘦身的時候,重排序與其它技術結合在一起工作得最好。如果你在結構裏有幾個布爾標誌,可以考慮把它們壓縮成1位的位域,然後把它們打包放在本來可能成爲slop(溢出)的地方。

你可能會有一點兒存取時間的損失-但如果它把工作空間壓縮得足夠小,那點損失可以從避免cache miss 來補償。

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

每樣縮短法不僅減小了結構的可見尺寸,還可以消除溢出或創造額外的機會來進行重新排序。 這種效果的良性互動是不難被觸發的。

最冒險的打包方法是使用union。 如果你知道結構體中的某些域永遠不會跟另一些域一起使用,考慮用union使它們共享存儲空間。 不過請特別小心,要用迴歸測試驗證你的做法。因爲如果你的分析有一丁點兒錯誤,就會有從程序崩潰到(更糟的)微妙的數據損壞。

10. 工具

有個叫 pahole 的工具, 我自己沒有使用過它,不過有一些反饋說它挺好的。該工具與編譯器協同工作,輸出關於結構體的填充、對齊和cache line 邊界的報告。

11. 證明和例外

這個小程序演示了關於標量和結構體的尺寸的斷言。 你可以下載它的源碼 packtest.c 。

如果你仔細檢查各種編譯器、選項和罕見硬件的奇怪組合,你會發現我前面提到的規則有例外。 越是舊的處理器設計例外越是常見。

理解這些規則的第二個層次是,何時和如何期望這些規則會被破壞。 在我學習它們的日子裏(1980年代早期),我們把不理解這些的人叫做“世上所有的機器都是VAX 綜合症”的犧牲品。 記住,並不是世上所有的電腦都是PC。

12. 版本

1.3 @ 2014-01-03增加怪異數據類型、可讀性和cache局部性、工具小節。1.2 @ 2014-01-02修正一個錯誤的地址計算。1.1 @ 2014-01-01解釋爲何對齊存取更快。 提到offsetof。多個小的修補,加上packtest.c下載鏈接。1.0 @ 2014-01-01初始版本。

原文地址:https://blog.csdn.net/yuwen_dai/article/details/17784109

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