當(dāng)在C中定義了一個結(jié)構(gòu)類型時,它的大小是否等于各字段(field)大小之和?編譯器將如何在內(nèi)存中放置這些字段?ANSI C對結(jié)構(gòu)體的內(nèi)存布局有什么要求?而我們的程序又能否依賴這種布局?這些問題或許對不少朋友來說還有點模糊,那么本文就試著探究它們背后的秘密。
首先,至少有一點可以肯定,那就是ANSI C保證結(jié)構(gòu)體中各字段在內(nèi)存中出現(xiàn)的位置是隨它們的聲明順序依次遞增的,并且第一個字段的首地址等于整個結(jié)構(gòu)體實例的首地址。比如有這樣一個結(jié)構(gòu)體: struct vector{int x,y,z;} s; int *p,*q,*r; struct vector *ps; p = &s.x; q = &s.y; r = &s.z; ps = &s; assert(p < q); assert(p < r); assert(q < r); assert((int*)ps == p); // 上述斷言一定不會失敗 這時,有朋友可能會問:"標(biāo)準(zhǔn)是否規(guī)定相鄰字段在內(nèi)存中也相鄰?"。 唔,對不起,ANSI C沒有做出保證,你的程序在任何時候都不應(yīng)該依賴這個假設(shè)。那這是否意味著我們永遠無法勾勒出一幅更清晰更精確的結(jié)構(gòu)體內(nèi)存布局圖?哦,當(dāng)然不是。不過先讓我們從這個問題中暫時抽身,關(guān)注一下另一個重要問題————內(nèi)存對齊。 許多實際的計算機系統(tǒng)對基本類型數(shù)據(jù)在內(nèi)存中存放的位置有限制,它們會要求這些數(shù)據(jù)的首地址的值是某個數(shù)k(通常它為4或8)的倍數(shù),這就是所謂的內(nèi)存對齊,而這個k則被稱為該數(shù)據(jù)類型的對齊模數(shù)(alignment modulus)。當(dāng)一種類型S的對齊模數(shù)與另一種類型T的對齊模數(shù)的比值是大于1的整數(shù),我們就稱類型S的對齊要求比T強(嚴(yán)格),而稱T比S弱(寬松)。這種強制的要求一來簡化了處理器與內(nèi)存之間傳輸系統(tǒng)的設(shè)計,二來可以提升讀取數(shù)據(jù)的速度。比如這么一種處理器,它每次讀寫內(nèi)存的時候都從某個8倍數(shù)的地址開始,一次讀出或?qū)懭?個字節(jié)的數(shù)據(jù),假如軟件能保證double類型的數(shù)據(jù)都從8倍數(shù)地址開始,那么讀或?qū)懸粋€double類型數(shù)據(jù)就只需要一次內(nèi)存操作。否則,我們就可能需要兩次內(nèi)存操作才能完成這個動作,因為數(shù)據(jù)或許恰好橫跨在兩個符合對齊要求的8字節(jié)內(nèi)存塊上。某些處理器在數(shù)據(jù)不滿足對齊要求的情況下可能會出錯,但是Intel的IA32架構(gòu)的處理器則不管數(shù)據(jù)是否對齊都能正確工作。不過Intel奉勸大家,如果想提升性能,那么所有的程序數(shù)據(jù)都應(yīng)該盡可能地對齊。Win32平臺下的微軟C編譯器(cl.exe for 80x86)在默認(rèn)情況下采用如下的對齊規(guī)則: 任何基本數(shù)據(jù)類型T的對齊模數(shù)就是T的大小,即sizeof(T)。比如對于double類型(8字節(jié)),就要求該類型數(shù)據(jù)的地址總是8的倍數(shù),而char類型數(shù)據(jù)(1字節(jié))則可以從任何一個地址開始。Linux下的GCC奉行的是另外一套規(guī)則(在資料中查得,并未驗證,如錯誤請指正):任何2字節(jié)大小(包括單字節(jié)嗎?)的數(shù)據(jù)類型(比如short)的對齊模數(shù)是2,而其它所有超過2字節(jié)的數(shù)據(jù)類型(比如long,double)都以4為對齊模數(shù)。 現(xiàn)在回到我們關(guān)心的struct上來。ANSI C規(guī)定一種結(jié)構(gòu)類型的大小是它所有字段的大小以及字段之間或字段尾部的填充區(qū)大小之和。嗯?填充區(qū)?對,這就是為了使結(jié)構(gòu)體字段滿足內(nèi)存對齊要求而額外分配給結(jié)構(gòu)體的空間。那么結(jié)構(gòu)體本身有什么對齊要求嗎?有的,ANSI C標(biāo)準(zhǔn)規(guī)定結(jié)構(gòu)體類型的對齊要求不能比它所有字段中要求最嚴(yán)格的那個寬松,可以更嚴(yán)格(但此非強制要求,VC7.1就僅僅是讓它們一樣嚴(yán)格)。我們來看一個例子(以下所有試驗的環(huán)境是Intel Celeron 2.4G + WIN2000 PRO + vc7.1,內(nèi)存對齊編譯選項是"默認(rèn)",即不指定/Zp與/pack選項): typedef struct ms1 { char a; int b; } MS1; 假設(shè)MS1按如下方式內(nèi)存布局(本文所有示意圖中的內(nèi)存地址從左至右遞增): _____________________________ | | | | a | b | | | | +---------------------------+ Bytes: 1 4 因為MS1中有最強對齊要求的是b字段(int),所以根據(jù)編譯器的對齊規(guī)則以及ANSI C標(biāo)準(zhǔn),MS1對象的首地址一定是4(int類型的對齊模數(shù))的倍數(shù)。那么上述內(nèi)存布局中的b字段能滿足int類型的對齊要求嗎?嗯,當(dāng)然不能。如果你是編譯器,你會如何巧妙安排來滿足CPU的癖好呢?呵呵,經(jīng)過1毫秒的艱苦思考,你一定得出了如下的方案: _______________________________________ | |\\\\\\\\\\\| | | a |\\padding\\| b | | |\\\\\\\\\\\| | +-------------------------------------+ Bytes: 1 3 4 這個方案在a與b之間多分配了3個填充(padding)字節(jié),這樣當(dāng)整個struct對象首地址滿足4字節(jié)的對齊要求時,b字段也一定能滿足int型的4字節(jié)對齊規(guī)定。那么sizeof(MS1)顯然就應(yīng)該是8,而b字段相對于結(jié)構(gòu)體首地址的偏移就是4。非常好理解,對嗎?現(xiàn)在我們把MS1中的字段交換一下順序: typedef struct ms2 { int a; char b; } MS2; 或許你認(rèn)為MS2比MS1的情況要簡單,它的布局應(yīng)該就是 _______________________ | | | | a | b | | | | +---------------------+ Bytes: 4 1 因為MS2對象同樣要滿足4字節(jié)對齊規(guī)定,而此時a的地址與結(jié)構(gòu)體的首地址相等,所以它一定也是4字節(jié)對齊。嗯,分析得有道理,可是卻不全面。讓我們來考慮一下定義一個MS2類型的數(shù)組會出現(xiàn)什么問題。C標(biāo)準(zhǔn)保證,任何類型(包括自定義結(jié)構(gòu)類型)的數(shù)組所占空間的大小一定等于一個單獨的該類型數(shù)據(jù)的大小乘以數(shù)組元素的個數(shù)。換句話說,數(shù)組各元素之間不會有空隙。按照上面的方案,一個MS2數(shù)組array的布局就是: |<- array[1] ->|<- array[2] ->|<- array[3] ..... __________________________________________________________ | | | | | | a | b | a | b |............. | | | | | +---------------------------------------------------------- Bytes: 4 1 4 1 ___________________________________ | | |\\\\\\\\\\\| | a | b |\\padding\\| | | |\\\\\\\\\\\| +---------------------------------+ Bytes: 4 1 3 現(xiàn)在無論是定義一個單獨的MS2變量還是MS2數(shù)組,均能保證所有元素的所有字段都滿足對齊規(guī)定。那么sizeof(MS2)仍然是8,而a的偏移為0,b的偏移是4。 好的,現(xiàn)在你已經(jīng)掌握了結(jié)構(gòu)體內(nèi)存布局的基本準(zhǔn)則,嘗試分析一個稍微復(fù)雜點的類型吧。 typedef struct ms3 { char a; short b; double c; } MS3; 我想你一定能得出如下正確的布局圖: padding | _____v_________________________________ | |\| |\\\\\\\\\| | | a |\| b |\padding\| c | | |\| |\\\\\\\\\| | +-------------------------------------+ Bytes: 1 1 2 4 8 sizeof(short)等于2,b字段應(yīng)從偶數(shù)地址開始,所以a的后面填充一個字節(jié),而sizeof(double)等于8,c字段要從8倍數(shù)地址開始,前面的a、b字段加上填充字節(jié)已經(jīng)有4 bytes,所以b后面再填充4個字節(jié)就可以保證c字段的對齊要求了。sizeof(MS3)等于16,b的偏移是2,c的偏移是8。接著看看結(jié)構(gòu)體中字段還是結(jié)構(gòu)類型的情況: typedef struct ms4 { char a; MS3 b; } MS4; MS3中內(nèi)存要求最嚴(yán)格的字段是c,那么MS3類型數(shù)據(jù)的對齊模數(shù)就與double的一致(為8),a字段后面應(yīng)填充7個字節(jié),因此MS4的布局應(yīng)該是: _______________________________________ | |\\\\\\\\\\\| | | a |\\padding\\| b | | |\\\\\\\\\\\| | +-------------------------------------+ Bytes: 1 7 16 顯然,sizeof(MS4)等于24,b的偏移等于8。 在實際開發(fā)中,我們可以通過指定/Zp編譯選項來更改編譯器的對齊規(guī)則。比如指定/Zpn(VC7.1中n可以是1、2、4、8、16)就是告訴編譯器最大對齊模數(shù)是n。在這種情況下,所有小于等于n字節(jié)的基本數(shù)據(jù)類型的對齊規(guī)則與默認(rèn)的一樣,但是大于n個字節(jié)的數(shù)據(jù)類型的對齊模數(shù)被限制為n。事實上,VC7.1的默認(rèn)對齊選項就相當(dāng)于/Zp8。仔細(xì)看看MSDN對這個選項的描述,會發(fā)現(xiàn)它鄭重告誡了程序員不要在MIPS和Alpha平臺上用/Zp1和/Zp2選項,也不要在16位平臺上指定/Zp4和/Zp8(想想為什么?)。改變編譯器的對齊選項,對照程序運行結(jié)果重新分析上面4種結(jié)構(gòu)體的內(nèi)存布局將是一個很好的復(fù)習(xí)。 到了這里,我們可以回答本文提出的最后一個問題了。結(jié)構(gòu)體的內(nèi)存布局依賴于CPU、操作系統(tǒng)、編譯器及編譯時的對齊選項,而你的程序可能需要運行在多種平臺上,你的源代碼可能要被不同的人用不同的編譯器編譯(試想你為別人提供一個開放源碼的庫),那么除非絕對必需,否則你的程序永遠也不要依賴這些詭異的內(nèi)存布局。順便說一下,如果一個程序中的兩個模塊是用不同的對齊選項分別編譯的,那么它很可能會產(chǎn)生一些非常微妙的錯誤。如果你的程序確實有很難理解的行為,不防仔細(xì)檢查一下各個模塊的編譯選項。 思考題:請分析下面幾種結(jié)構(gòu)體在你的平臺上的內(nèi)存布局,并試著尋找一種合理安排字段聲明順序的方法以盡量節(jié)省內(nèi)存空間。 A. struct P1 { int a; char b; int c; char d; }; B. struct P2 { int a; char b; char c; int d; }; C. struct P3 { short a[3]; char b[3]; }; D. struct P4 { short a[3]; char *b[3]; }; E. struct P5 { struct P2 *a; char b; struct P1 a[2]; }; 參考資料: 【1】《深入理解計算機系統(tǒng)(修訂版)》, (著)Randal E.Bryant; David O‘Hallaron, (譯)龔奕利 雷迎春, 中國電力出版社,2004 【2】《C: A Reference Manual》(影印版), (著)Samuel P.Harbison; Guy L.Steele, 人民郵電出版社,2003 ================================================================== 另一帖 【答疑】字長對齊帶來的效率提升 作 者: goodluckyxl (被人遺忘的狗) 經(jīng)??吹接腥藛柶饘R有什么作用之類問題 因為今天和同事談到了ARM平臺下數(shù)據(jù)總線寬度及對齊方式對程序效率的影響問題 在定義結(jié)構(gòu)數(shù)據(jù)類型時,為了提高系統(tǒng)效率,要注意字長對齊原則。 正好有點感觸給大家談?wù)?本人水平有限的很有什么問題請朋友指正: 本文主要給大家解釋下所謂的對齊到底是什么?怎么對齊?為什么會對齊或者說對齊帶來什么樣的效率差異? 1. 先看下面的例子: #include <iostream.h> #pragma pack(4) struct A { char a; int b; }; #pragma pack() #pragma pack(1) struct B { char a; int b; }; #pragma pack() int main() { A a; cout<<sizeof(a);//8 B b; cout<<sizeof(b);//5 } 默認(rèn)的vc我記得是4字節(jié)對齊ADS下是一字節(jié)對齊 因為是c/c++社區(qū)大家對PC比較熟悉 我就談PC下的對齊 PC下設(shè)計放的太長時間的有錯誤就別客氣直接說 大家可以看到在ms的vc下按4字節(jié)對齊和1字節(jié)對齊的結(jié)果是截然不同的分別為8和5 為什么會有這樣的結(jié)果呢?這就是x86上字節(jié)對齊的作用。為了加快程序執(zhí)行的速度, 一些體系結(jié)構(gòu)以對齊的方式設(shè)計,通常以字長作為對齊邊界。對于一些結(jié)構(gòu)體變量, 整個結(jié)構(gòu)要對齊在內(nèi)部成員變量最大的對齊邊界,如A,整個結(jié)構(gòu)以4為對齊邊界,所以sizeof(a)為8,而不是5。 如果是原始我們概念下的的A中的成員將會一個挨一個存儲 應(yīng)該只有char+int只有5個字節(jié) 這個差異就是由于對齊導(dǎo)致的 顯然我們可以看到 A的對齊要比B浪費3個字節(jié)的存儲空間 那為什么還要采取對齊呢? 那是因為體系結(jié)構(gòu)的對齊和不對齊,是在時間和空間上的一個權(quán)衡。 字節(jié)對齊節(jié)省了時間。應(yīng)該是設(shè)計者考慮用空間換取時間。 為什么說對齊會提高效率呢節(jié)省時間?我想大家要理解的重點之重點就在這里了 在我們常用的PC下總線寬度是32位 1.如果是總線寬度對齊的話 那么所有讀寫操作都是獲取一個<=32位數(shù)據(jù)可以一次保證在數(shù)據(jù)總線傳輸完畢 沒有任何的額外消耗 |1|2|3|4|5|6|7|8| 從1開始這里是a的起始位置,5起始為b的位置 訪問的時候 如果訪問a一次在總線傳輸8位其他24位無效的 訪問b時則一次在總線上傳輸32完成 讀寫均是一次完整 插敘一下 讀操作先要將讀地址放到地址總線上然后下個時鐘周期再從外部 存儲器接口上讀回數(shù)據(jù)通過數(shù)據(jù)總線返回需要兩個周期 而寫操作一次將地址及數(shù)據(jù)寫入相應(yīng)總線就完成了 讀操作要比寫操作慢一半 2.我們看訪問數(shù)據(jù)時如果不對齊地址的情況 |1|2|3|4|5|6|7|8| 此時a的地址沒變還在1而因為是不對齊則b的位置就在2處 這時訪問就帶來效率上問題 訪問a時沒問題還是讀會一個字節(jié) 但是2處地址因為不是總線寬度對齊一般的CPU在此地址操作將產(chǎn)生error 如sparc,MIPS。它們在硬件的設(shè)計上就強制性的要求對齊。在不對齊的地址上肯定發(fā)生錯誤 但是x86是支持非對齊訪問的 它通過多次訪問來拼接得到的結(jié)果,具體做法就是從1地址處先讀回后三字節(jié)234 暫存起來 然后再由5地址處讀回一個字節(jié)5 與234進行拼接組成一個完整的int也就是b返回 大家看看如此的操作帶來的消耗多了不止三倍很明顯在字長對齊時效率要高許多 淡然這種效率僅僅是訪問多字節(jié)帶來的 如果還是進行的byte操作那效率差不了多少 目前的開發(fā)普遍比較重視性能,所以對齊的問題,有2種不同的處理方法: 1) 有一種使用空間換時間做法是顯式的插入reserved成員: struct A{ char a; char reserved1[3];//使用空間換時間 int b; }a; 2) 隨便怎么寫,一切交給編譯器自動對齊。 還有一種將邏輯相關(guān)的數(shù)據(jù)放在一起定義 代碼中關(guān)于對齊的隱患,很多是隱式的。比如在強制類型轉(zhuǎn)換的時候。下面舉個例子: unsigned int i = 0x12345678; unsigned char *p=NULL; unsigned short *p1=NULL; p=&i; *p=0x00; p1=(unsigned short *)(p+1); *p1=0x0000; 最后兩句代碼,從奇數(shù)邊界去訪問unsignedshort型變量,顯然不符合對齊的規(guī)定。 在x86上,類似的操作只會影響效率,但是在MIPS或者sparc上,可能就是一個error |
|