1. 程式人生 > >關於C語言中資料結構的記憶體對齊問題

關於C語言中資料結構的記憶體對齊問題

 當在C中定義了一個結構型別時,它的大小是否等於各欄位(field)大小之和?編譯器將如何在記憶體中放置這些欄位?ANSI   C對結構體的記憶體佈局有什麼要求?而我們的程式又能否依賴這種佈局?這些問題或許對不少朋友來說還有點模糊,那麼本文就試著探究它們背後的祕密。   
     首先,至少有一點可以肯定,那就是ANSI   C保證結構體中各欄位在記憶體中出現的位置是隨它們的宣告順序依次遞增的,並且第一個欄位的首地址等於整個結構體例項的首地址。比如有這樣一個結構體:

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);

//   上述斷言一定不會失敗
      這時,有朋友可能會問: "標準是否規定相鄰欄位在記憶體中也相鄰? "。   唔,對不起,ANSI   C沒有做出保證,你的程式在任何時候都不應該依賴這個假設。那這是否意味著我們永遠無法勾勒出一幅更清晰更精確的結構體記憶體佈局圖?哦,當然不是。不過先讓我們從這個問題中暫時抽身,關注一下另一個重要問題————記憶體對齊


      許多實際的計算機系統對基本型別資料在記憶體中存放的位置有限制,它們會要求這些資料的首地址的值是某個數k(通常它為4或8)的倍數,這就是所謂的記憶體對齊,而這個k則被稱為該資料型別的對齊模數(alignment   modulus)。當一種型別S的對齊模數與另一種型別T的對齊模數的比值是大於1的整數,我們就稱型別S的對齊要求比T強(嚴格),而稱T比S弱(寬鬆)。這種強制的要求一來簡化了處理器與記憶體之間傳輸系統的設計,二來可以提升讀取資料的速度。比如這麼一種處理器,它每次讀寫記憶體的時候都從某個8倍數的地址開始,一次讀出或寫入8個位元組的資料,假如軟體能保證double型別的資料都從8倍數地址開始,那麼讀或寫一個double型別資料就只需要一次記憶體操作。否則,我們就可能需要兩次記憶體操作才能完成這個動作,因為資料或許恰好橫跨在兩個符合對齊要求的8位元組記憶體塊上。某些處理器在資料不滿足對齊要求的情況下可能會出錯,但是Intel的IA32架構的處理器則不管資料是否對齊都能正確工作。不過Intel奉勸大家,如果想提升效能,那麼所有的程式資料都應該儘可能地對齊。Win32平臺下的微軟C編譯器(cl.exe   for   80x86)在預設情況下采用如下的對齊規則:   任何基本資料型別T的對齊模數就是T的大小,即sizeof(T)。比如對於double型別(8位元組),就要求該型別資料的地址總是8的倍數,而char型別資料(1位元組)則可以從任何一個地址開始。Linux下的GCC奉行的是另外一套規則(在資料中查得,並未驗證,如錯誤請指正):任何2位元組大小(包括單位元組嗎?)的資料型別(比如short)的對齊模數是2,而其它所有超過2位元組的資料型別(比如long,double)都以4為對齊模數。
     
      現在回到我們關心的struct上來。ANSI   C規定一種結構型別的大小是它所有欄位的大小以及欄位之間或欄位尾部的填充區大小之和。嗯?填充區?對,這就是為了使結構體欄位滿足記憶體對齊要求而額外分配給結構體的空間。那麼結構體本身有什麼對齊要求嗎?有的,ANSI   C標準規定結構體型別的對齊要求不能比它所有欄位中要求最嚴格的那個寬鬆,可以更嚴格(但此非強制要求,VC7.1就僅僅是讓它們一樣嚴格)。我們來看一個例子(以下所有試驗的環境是Intel   Celeron   2.4G   +   WIN2000   PRO   +   vc7.1,記憶體對齊編譯選項是 "預設 ",即不指定/Zp與/pack選項):
typedef   struct   ms1
{
   char   a;
   int   b;
}   MS1;
假設MS1按如下方式記憶體佈局(本文所有示意圖中的記憶體地址從左至右遞增):

+---------------------------+
|   |   |
|   a   |   b   |
|   |   |
+---------------------------+
1   Byte   4   byte   


因為MS1中有最強對齊要求的是b欄位(int),所以根據編譯器的對齊規則以及ANSI   C標準,MS1物件的首地址一定是4(int型別的對齊模數)的倍數。那麼上述記憶體佈局中的b欄位能滿足int型別的對齊要求嗎?嗯,當然不能。如果你是編譯器,你會如何巧妙安排來滿足CPU的癖好呢?呵呵,經過1毫秒的艱苦思考,你一定得出瞭如下的方案:
_______________________________________
|   |\\\\\\\\\\\|   |
|   a   |\\padding\\|   b   |
|   |\\\\\\\\\\\|   |
+-------------------------------------+
Bytes:   1   3   4
   這個方案在a與b之間多分配了3個填充(padding)位元組,這樣當整個struct物件首地址滿足4位元組的對齊要求時,b欄位也一定能滿足int型的4位元組對齊規定。那麼sizeof(MS1)顯然就應該是8,而b欄位相對於結構體首地址的偏移就是4。非常好理解,對嗎?現在我們把MS1中的欄位交換一下順序:
typedef   struct   ms2
{
int   a;
char   b;
}   MS2;
或許你認為MS2比MS1的情況要簡單,它的佈局應該就是
_______________________
|   |   |
|   a   |   b   |
|   |   |
+---------------------+
Bytes:   4   1   
因為MS2物件同樣要滿足4位元組對齊規定,而此時a的地址與結構體的首地址相等,所以它一定也是4位元組對齊。嗯,分析得有道理,可是卻不全面。讓我們來考慮一下定義一個MS2型別的陣列會出現什麼問題。C標準保證,任何型別(包括自定義結構型別)的陣列所佔空間的大小一定等於一個單獨的該型別資料的大小乘以陣列元素的個數。換句話說,陣列各元素之間不會有空隙。按照上面的方案,一個MS2陣列array的佈局就是:
| <-   array[1]   -> | <-   array[2]   -> | <-   array[3]   .....
__________________________________________________________
|   |   |   |   |
|   a   |   b   |   a   |   b   |.............
|   |   |   |   |
+----------------------------------------------------------
Bytes:   4   1   4   1
當陣列首地址是4位元組對齊時,array[1].a也是4位元組對齊,可是array[2].a呢?array[3].a   ....呢?可見這種方案在定義結構體陣列時無法讓陣列中所有元素的欄位都滿足對齊規定,必須修改成如下形式:
___________________________________
|   |   |\\\\\\\\\\\|
|   a   |   b   |\\padding\\|
|   |   |\\\\\\\\\\\|
+---------------------------------+
Bytes:   4   1   3
現在無論是定義一個單獨的MS2變數還是MS2陣列,均能保證所有元素的所有欄位都滿足對齊規定。那麼sizeof(MS2)仍然是8,而a的偏移為0,b的偏移是4。
好的,現在你已經掌握了結構體記憶體佈局的基本準則,嘗試分析一個稍微複雜點的型別吧。
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欄位應從偶數地址開始,所以a的後面填充一個位元組,而sizeof(double)等於8,c欄位要從8倍數地址開始,前面的a、b欄位加上填充位元組已經有4   bytes,所以b後面再填充4個位元組就可以保證c欄位的對齊要求了。sizeof(MS3)等於16,b的偏移是2,c的偏移是8。接著看看結構體中欄位還是結構型別的情況:
typedef   struct   ms4
{
char   a;
MS3   b;
}   MS4;
MS3中記憶體要求最嚴格的欄位是c,那麼MS3型別資料的對齊模數就與double的一致(為8),a欄位後面應填充7個位元組,因此MS4的佈局應該是:
_______________________________________
|   |\\\\\\\\\\\|   |
|   a   |\\padding\\|   b   |
|   |\\\\\\\\\\\|   |
+-------------------------------------+
Bytes:   1   7   16
顯然,sizeof(MS4)等於24,b的偏移等於8。
     
     在實際開發中,我們可以通過指定/Zp編譯選項來更改編譯器的對齊規則。比如指定/Zpn(VC7.1中n可以是1、2、4、8、16)就是告訴編譯器最大對齊模數是n。在這種情況下,所有小於等於n位元組的基本資料型別的對齊規則與預設的一樣,但是大於n個位元組的資料型別的對齊模數被限制為n。事實上,VC7.1的預設對齊選項就相當於/Zp8。仔細看看MSDN對這個選項的描述,會發現它鄭重告誡了程式設計師
不要在MIPS和Alpha平臺上用/Zp1和/Zp2選項,也不要在16位平臺上指定/Zp4和/Zp8(想想為什麼?)。改變編譯器的對齊選項,對照程式執行結果重新分析上面4種結構體的記憶體佈局將是一個很好的複習。
     
     到了這裡,我們可以回答本文提出的最後一個問題了。結構體的記憶體佈局依賴於CPU、作業系統、編譯器及編譯時的對齊選項,而你的程式可能需要執行在多種平臺上,你的原始碼可能要被不同的人用不同的編譯器編譯(試想你為別人提供一個開放原始碼的庫),那麼除非絕對必需,否則你的程式永遠也不要依賴這些詭異的記憶體佈局。順便說一下,如果一個程式中的兩個模組是用不同的對齊選項分別編譯的,那麼它很可能會產生一些非常微妙的錯誤。如果你的程式確實有很難理解的行為,不防仔細檢查一下各個模組的編譯選項。

#pragma   pack(4)
  class   TestB
  {
  public:
    int   aa;
    char   a;
    short   b;
    char   c;
  };
  int   nSize   =   sizeof(TestB);
  這裡nSize結果為12,在預料之中。
    現在去掉第一個成員變數為如下程式碼:
  #pragma   pack(4)
  class   TestC
  {
  public:
    char   a;
    short   b;
    char   c;
  };
  int   nSize   =   sizeof(TestC);
  按照正常的填充方式nSize的結果應該是8,為什麼結果顯示nSize為6呢?

事實上,很多人對#pragma   pack的理解是錯誤的。
#pragma   pack規定的對齊長度,實際使用的規則是:
結構,聯合,或者類的資料成員,第一個放在偏移為0的地方,以後每個資料成員的對齊,按照#pragma   pack指定的數值和這個資料成員自身長度中,比較小的那個進行。
也就是說,當#pragma   pack的值等於或超過所有資料成員長度的時候,這個值的大小將不產生任何效果。而結構整體的對齊,則按照結構體中最大的資料成員   和   #pragma   pack指定值   之間,較小的那個進行。

具體解釋
#pragma   pack(4)
  class   TestB
  {
  public:
    int   aa;   //第一個成員,放在[0,3]偏移的位置,
    char   a;   //第二個成員,自身長為1,#pragma   pack(4),取小值,也就是1,所以這個成員按一位元組對齊,放在偏移[4]的位置。
    short   b;   //第三個成員,自身長2,#pragma   pack(4),取2,按2位元組對齊,所以放在偏移[6,7]的位置。
    char   c;   //第四個,自身長為1,放在[8]的位置。
  };
這個類實際佔據的記憶體空間是9位元組
類之間的對齊,是按照類內部最大的成員的長度,和#pragma   pack規定的值之中較小的一個對齊的。
所以這個例子中,類之間對齊的長度是min(sizeof(int),4),也就是4。
9按照4位元組圓整的結果是12,所以sizeof(TestB)是12。
如果#pragma   pack(2)
        class   TestB
  {
  public:
    int   aa;   //第一個成員,放在[0,3]偏移的位置,
    char   a;   //第二個成員,自身長為1,#pragma   pack(4),取小值,也就是1,所以這個成員按一位元組對齊,放在偏移[4]的位置。
    short   b;   //第三個成員,自身長2,#pragma   pack(4),取2,按2位元組對齊,所以放在偏移[6,7]的位置。
    char   c;   //第四個,自身長為1,放在[8]的位置。
  };
//可以看出,上面的位置完全沒有變化,只是類之間改為按2位元組對齊,9按2圓整的結果是10。
//所以   sizeof(TestB)是10。
最後看原貼:
現在去掉第一個成員變數為如下程式碼:
  #pragma   pack(4)
  class   TestC
  {
  public:
    char   a;//第一個成員,放在[0]偏移的位置,
    short   b;//第二個成員,自身長2,#pragma   pack(4),取2,按2位元組對齊,所以放在偏移[2,3]的位置。
    char   c;//第三個,自身長為1,放在[4]的位置。
  };
//整個類的大小是5位元組,按照min(sizeof(short),4)位元組對齊,也就是2位元組對齊,結果是6
//所以sizeof(TestC)是6。

感謝   Michael   提出疑問,在此補充:
當資料定義中出現__declspec(   align()   )時,指定型別的對齊長度還要用自身長度和這裡指定的數值比較,然後取其中較大的。最終類/結構的對齊長度也需要和這個數值比較,然後取其中較大的。
可以這樣理解,   __declspec(   align()   )   和   #pragma   pack是一對兄弟,前者規定了對齊的最小值,後者規定了對齊的最大值,兩者同時出現時,前者擁有更高的優先順序。
__declspec(   align()   )的一個特點是,它僅僅規定了資料對齊的位置,而沒有規定資料實際佔用的記憶體長度,當指定的資料被放置在確定的位置之後,其後的資料填充仍然是按照#pragma   pack規定的方式填充的,這時候類/結構的實際大小和記憶體格局的規則是這樣的:
在__declspec(   align()   )之前,資料按照#pragma   pack規定的方式填充,如前所述。當遇到__declspec(   align()   )的時候,首先尋找距離當前偏移向後最近的對齊點(滿足對齊長度為max(資料自身長度,指定值)   ),然後把被指定的資料型別從這個點開始填充,其後的資料型別從它的後面開始,仍然按照#pragma   pack填充,直到遇到下一個__declspec(   align()   )。
當所有資料填充完畢,把結構的整體對齊數值和__declspec(   align()   )規定的值做比較,取其中較大的作為整個結構的對齊長度。
特別的,當__declspec(   align()   )指定的數值比對應型別長度小的時候,這個指定不起作用。