1. 程式人生 > >程式設計基石與實踐系列之失落的C語言結構體封裝藝術

程式設計基石與實踐系列之失落的C語言結構體封裝藝術

誰該閱讀這篇文章

本文是關於削減C語言程式記憶體佔用空間的一項技術——為了減小記憶體大小而手工重新封裝C結構體宣告。你需要C語言的基本知識來讀懂本文。

如果你要為記憶體有限制的嵌入式系統、或者作業系統核心寫程式碼,那麼你需要懂這項技術。如果你在處理極大的應用程式資料集,以至於你的程式常常達到記憶體的界限時,這項技術是有幫助的。在任何你真的真的需要關注將快取記憶體行未命中降到最低的應用程式裡,懂得這項技術是很好的。

最後,理解該技術是一個通往其他深奧的C語言話題的入口。直到你掌握了它,你才成為一個高階的C程式設計師。直到你可以自己寫出這篇文件並且可以理智地評論它,你才成為一位C語言大師。

我為什麼寫這篇文章

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

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

在這類情況下,有好些辦法能極大地減少記憶體使用的,比如小心地重新安排結構體成員的順序之類的。這可以獲得巨大的收益——在我的事例中,我能夠減掉大約40%的工作區大小,使得程式能夠在不崩潰的情況下處理大得多的程式碼庫。

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

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

但是這項技術仍然在重要的場合有價值, 並且只要記憶體有限,就能永存。本文目的就是讓C程式設計師免於重新找尋這項技術,而讓他們可以集中精力在更重要的事情上。

對齊要求(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上觸發此錯誤。

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

有時你可以通過編譯指示,強制讓你的編譯器不使用處理器正常的對齊規則,通常是#pragmapack。不要隨意使用,因為它會導致產生開銷更大、更慢的程式碼。使用我在這裡描述的技術,通常你可以節省同樣或者幾乎同樣多的記憶體。

#pragma pack的唯一好處是,如果你不得不將你的C語言資料分佈精確匹配到某些位級別的硬體或協議的需求,比如一個記憶體對映的硬體埠,要求違反正常的對齊才能奏效。如果你遇到那種情況,並且你還未理解我在這裡寫的這一切,你會有大麻煩的,我只能祝你好運了。

填充(Padding)

現在我們來看一個簡單變數在記憶體裡的分佈的例子。考慮在C模組的最頂上的以下一系列的變數宣告:

char *p;
char c;
int x;

如果你不知道任何關於資料對齊的事情,你可能會假設這3個變數在記憶體裡會佔據一個連續位元組空間。那也就是說,在一個32位機器上,指標的4位元組,之後緊接著1位元組的字元型,且之後緊接著4位元組的整型。在64位機器只在指標是8位元組上會有所不同。

這裡是實際發生的(在x86或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位元組的短整型會發生什麼

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位機上的長整型
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是零。x的地址,緊接在p之後,是保證指標對齊的,肯定比整型對齊更嚴格的。

M的值不太能預測。如果編譯器恰巧把c對映到機器字的最後一個位元組,下一個位元組(p的第一部分)會成為下一個機器字的第一個位元組,並且正常地指標對齊。M為零。

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

如果你想讓那些變數佔用更少的空間,你可以通過交換原序列中的xc來達到效果。

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

通常,對於C程式裡少數的簡單變數,你可以通過調整宣告順序來壓縮掉極少幾個位元組數,不會有顯著的節約。但當用於非標量變數(nonscalarvariables),尤其是結構體時,這項技術會變得更有趣。

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

在下一章,我們會看到對於結構體陣列,一樣的規則並不一定正確。

結構體的對齊和填充

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

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

(當你不能確定此類事情時,ANSIC提供了一個offsetof()巨集,能夠用來表示出結構體成員的偏移量。)

考慮這個結構體:

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)。要解釋這個,我需要介紹一個基本概念,我稱之為結構體的跨步地址(strideaddress)。它是跟隨結構體資料後的第一個地址,與結構體擁有同樣對齊方式

結構體尾隨填充的通常規則是這樣的:編譯器的行為就如把結構體尾隨填充到它的跨步地址。這條規則決定了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後面的一個位元組,struct foo4作為一個整體,只需要一個位元組的尾隨填充。它會像這樣分佈
struct foo4 {
    short s;     /* 2 bytes */
    char c;      /* 1 byte */
    char pad[1];
};

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

現在讓我們考慮位域(bitfield)。它們是你能夠宣告比字元寬度還小的結構體域,小到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 foo9 {
    char c;
    struct foo9_inner {
        char *p;
        short x;
    } inner;
};
內部結構體的char *p成員使得外部的結構體與內部的一樣成為指標對齊。在64位機器上,實際的分佈是像這樣的:
struct foo9 {
    char c;           /* 1 byte*/
    char pad1[7];     /* 7 bytes */
    struct foo9_inner {
        char *p;      /* 8 bytes */
        short x;      /* 2 bytes */
        char pad2[6]; /* 6 bytes */
    } inner;
};

這個結構體給了我們一個啟示,重新封裝結構體可能節省空間。24個位元組中,有13個位元組是用作填充的。超過50%的無用空間!

結構體重排序(reordering)

現在你知道如何以及為何編譯器要插入填充,在你的結構體之中或者之後,我們要考察你可以做些什麼來擠掉這些“水坑”。這就是結構體封裝的藝術。第一件需要注意的事情是,“水坑”僅發生於兩個地方。一個是大資料型別(有更嚴格的對齊要求)的儲存區域緊跟在一個較小的資料型別的儲存區域之後。另一個是結構體自然結束於它的跨步地址之前,需要填充,以使下一個例項可以正確對齊。消除“水坑”的最簡單的方法是按對齊的降序來對結構體成員重排序。就是說:所有指標對齊的子域在前面,因為在64位的機器上,它們會有8位元組。接下來是4位元組的整型;然後是2位元組的短整型;然後是字元域。

因此,舉個例子,考慮這個簡單的連結串列結構體:

struct foo10 {
    char c;
    struct foo10 *p;
    short x;
};
顯現出隱含的“水坑”,這樣:
struct foo10 {
    char c;          /* 1 byte */
    char pad1[7];    /* 7 bytes */
    struct foo10 *p; /* 8 bytes */
    short x;         /* 2 bytes */
    char pad2[6];    /* 6 bytes */
};
24個位元組。如果我們按大小重新排序,我們得到:
struct foo11 {
    struct foo11 *p;
    short x;
    char c;
};
考慮到自對齊,我們看到沒有資料域需要填充。這是因為一個較長的、有較嚴格對齊的域的跨步地址,對於較短的、較不嚴格對齊的域來說,總是合法對齊的起始地址。所有重封裝的結構體實際上需要的只是尾隨填充:
struct foo11 {
    struct foo11 *p; /* 8 bytes */
    short x;         /* 2 bytes */
    char c;          /* 1 byte */
    char pad[5];     /* 5 bytes */
};

我們重封裝的轉變把大小降到了16位元組。這可能看上去沒什麼,但是假設你有一個200k的這樣的連結串列呢?節省的空間累積起來就不小了。注意重排序並不能保證節省空間。把這個技巧運用到早先的例子,struct foo9,我們得到:

struct foo12 {
    struct foo12_inner {
        char *p;      /* 8 bytes */
        int x;        /* 4 bytes */
    } inner;
    char c;           /* 1 byte*/
};
把填充寫出來,就是這樣
struct foo12 {
    struct foo12_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語言最初是被設計用來 寫作業系統和其他接近硬體的語言。自動重排序會妨礙到系統程式設計師規劃結構體,精確匹配位元組和記憶體對映裝置控制塊的位級分佈的能力。

 難以處理的標量的情況

使用列舉型別而不是#defines是個好主意,因為符號偵錯程式可以用那些符號並且可以顯示它們,而不是未處理的整數。但是,儘管列舉要保證相容整型型別,C標準沒有明確規定哪些潛在的整型型別會被使用。

注意,當重新封裝你的結構體時,雖然列舉型別變數通常是整型,但它依賴於編譯器;它們可能是短整型、長整型、甚至是預設的字元型。你的編譯器可能有一個編譯指示或者命令列選項來強制規定大小。

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

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

最後,在x86下,Linux的雙精度型別有時是一個自對齊規則的特例;一個8位元組的雙精度資料在一個結構體內可以只要求4位元組對齊,雖然單獨的雙精度變數要求8位元組的自對齊。這依賴於編譯器及其選項。

可讀性和快取區域性性

儘管按大小重排序是消除“水坑”的最簡單的方式,但它不是必定正確的。還有兩個問題:可讀性和快取區域性性。

程式不只是與計算機的交流,還是與其他人的交流。程式碼可讀性是重要的,即便(或者尤其是!)交流的另一方不只是未來的你。

笨拙的、機械的結構體重排序會損害可讀性。可能的話,最好重排域,使得語義相關的資料段緊緊相連,能形成連貫的組群。理想情況下,你的結構體設計應該傳達到你的程式。

當你的程式經常訪問一個結構體,或者結構體的一部分,如果訪問常命中快取行(當被告知去讀取任何一個塊裡單個地址時,你的處理器讀取的整一塊記憶體)有助於提高效能。在64位x86機上一條快取行為64位元組,始於一個自對齊的地址;在其他平臺上經常是32位元組。

你應該做的事情是保持可讀性——把相關的和同時訪問的資料組合到毗鄰的區域——這也會提高快取行的區域性性。這都是用程式碼的資料訪問模式的意識,聰明地重排序的原因。

如果你的程式碼有多執行緒併發訪問一個結構體,就會有第三個問題:快取行反彈(cacheline bouncing)。為了減少代價高昂的匯流排通訊,你應該組織你的資料,使得在緊湊的迴圈中,從一條快取行中讀取,而在另一條快取行中寫。

是的,這與之前關於把相關資料組成同樣大小的快取行塊的指南有些矛盾。多執行緒是困難的。快取行反彈以及其它的多執行緒優化問題是十分高階的話題,需要整篇關於它們的教程。這裡我能做的最好的就就是讓你意識到這些問題的存在。

其它封裝技術

當重排序與其他技術結合讓你的結構體瘦身時效果最好。如果你在一個結構體裡有若干布林型標誌,舉個例子,可以考慮將它們減小到1位的位域,並且將它們封裝到結構體裡的一個本會成為“水坑”的地方。

為此,你會碰到些許訪問時間上的不利——但是如果它把工作區擠壓得足夠小,這些不利會被避免快取不命中的得益所掩蓋。

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

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

最 有風險的封裝形式是使用聯合體。如果你知道你結構體中特定的域永遠不會被用於與其他特定域的組合,考慮使用聯合體使得它們共享儲存空間。但你要額外小心, 並且用迴歸測試來驗證你的工作,因為如果你的生命週期分析即使有輕微差錯,你會得到各種程式漏洞,從程式崩潰到(更糟糕的)不易發覺的資料損壞。

工具

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

雖然我自己還沒用過,但是一些反饋者稱讚了一個叫pahole的程式。這個工具與編譯器合作,產生關於你的結構體的報告,記述了填充、對齊及快取行邊界。

證明及例外

你可以下載一個小程式的程式碼,此程式碼用來展示了上述標量和結構體大小的論斷。就是packtest.c

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

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

版本履歷

1.5 @ 2014-01-03 解釋了為什麼不自動做結構體成員的重排序。

1.4 @ 2014-01-06 關於x86Linux下雙精度的注意。

1.3 @ 2014-01-03 關於難以處理的標量例項、可讀性和快取區域性性及工具的段落。

1.2 @ 2014-01-02 修正了一個錯誤的地址計算。

1.1 @ 2014-01-01 解釋為什麼對齊的訪問會更快。提及offsetof。各種小修復,包括packtest.c的下載連結。

1.0 @ 2014-01-01 初版