1. 程式人生 > >關於記憶體對齊的問題

關於記憶體對齊的問題

在最近的專案中,我們涉及到了“記憶體對齊”技術。對於大部分程式設計師來說,“記憶體對齊”對他們來說都應該是“透明的”。“記憶體對齊”應該是編譯器的 “管轄範圍”。編譯器為程式中的每個“資料單元”安排在適當的位置上。但是C語言的一個特點就是太靈活,太強大,它允許你干預“記憶體對齊”。如果你想了解 更加底層的祕密,“記憶體對齊”對你就不應該再透明瞭。

一、記憶體對齊的原因
大部分的參考資料都是如是說的:
1、平臺原因(移植原因):不是所有的硬體平臺都能訪問任意地址上的任意資料 的;某些硬體平臺只能在某些地址處取某些特定型別的資料,否則丟擲硬體異常。
2、效能原因:資料結構(尤其是棧)應該儘可能地在自然邊界上對齊。 原因在於,為了訪問未對齊的記憶體,處理器需要作兩次記憶體訪問;而對齊的記憶體訪問僅需要一次訪問。

二、對齊規則
每個特定平臺上的編譯器都有自己的預設“對齊係數”(也叫對齊模數)。程式設計師可以通過預編譯命令#pragma pack(n),n=1,2,4,8,16來改變這一系數,其中的n就是你要指定的“對齊係數”。

規則:
1、資料成員對齊規則:結構(struct)(或聯合(union))的資料成員,第一個資料成員放在offset為0的地方,以後 每個資料成員的對齊按照#pragma pack指定的數值和這個資料成員自身長度中,比較小的那個進行。
2、結構(或聯合)的整體對齊規則:在 資料成員完成各自對齊之後,結構(或聯合)本身也要進行對齊,對齊將按照#pragma pack指定的數值和結構(或聯合)最大資料成員長度中,比較小的那個進行。
3、結合1、2顆推斷:當#pragma pack的n值等於或超過所有資料成員長度的時候,這個n值的大小將不產生任何效果。

三、試驗
我們通過一系列例子的詳細說明來證明這個規則吧!
我試驗用的編譯器包括GCC 3.4.2和VC6.0的C編譯器,平臺為Windows XP + Sp2。

我們將用典型的struct對齊來說明。首先我們定義一個struct:
#pragma pack(n) /* n = 1, 2, 4, 8, 16 */
struct test_t {
 int a;
 char b;
 short c;
 char d;
};
#pragma pack(n)
首先我們首先確認在試驗平臺上的各個型別的size,經驗證兩個編譯器的輸出均為:
sizeof(char) = 1
sizeof(short) = 2
sizeof(int) = 4

我們的試驗過程如下:通過#pragma pack(n)改變“對齊係數”,然後察看sizeof(struct test_t)的值。

1、1位元組對齊(#pragma pack(1))
輸出結果:sizeof(struct test_t) = 8 [兩個編譯器輸出一致]
分析過程:
1) 成員資料對齊
#pragma pack(1)
struct test_t {
 int a;  /* 長度4 < 1 按1對齊;起始offset=0 0%1=0;存放位置區間[0,3] */
 char b;  /* 長度1 = 1 按1對齊;起始offset=4 4%1=0;存放位置區間[4] */
 short c; /* 長度2 > 1 按1對齊;起始offset=5 5%1=0;存放位置區間[5,6] */
 char d;  /* 長度1 = 1 按1對齊;起始offset=7 7%1=0;存放位置區間[7] */
};
#pragma pack()
成員總大小=8

2) 整體對齊
整體對齊係數 = min((max(int,short,char), 1) = 1
整體大小 (size)=$(成員總大小) 按 $(整體對齊係數) 圓整 = 8 /* 8%1=0 */ [注1]

2、2位元組對齊(#pragma pack(2))
輸出結果:sizeof(struct test_t) = 10 [兩個編譯器輸出一致]
分析過程:
1) 成員資料對齊
#pragma pack(2)
struct test_t {
 int a;  /* 長度4 > 2 按2對齊;起始offset=0 0%2=0;存放位置區間[0,3] */
 char b;  /* 長度1 < 2 按1對齊;起始offset=4 4%1=0;存放位置區間[4] */
 short c; /* 長度2 = 2 按2對齊;起始offset=6 6%2=0;存放位置區間[6,7] */
 char d;  /* 長度1 < 2 按1對齊;起始offset=8 8%1=0;存放位置區間[8] */
};
#pragma pack()
成員總大小=9

2) 整體對齊
整體對齊係數 = min((max(int,short,char), 2) = 2
整體大小 (size)=$(成員總大小) 按 $(整體對齊係數) 圓整 = 10 /* 10%2=0 */

3、4位元組對齊(#pragma pack(4))
輸出結果:sizeof(struct test_t) = 12 [兩個編譯器輸出一致]
分析過程:
1) 成員資料對齊
#pragma pack(4)
struct test_t {
 int a;  /* 長度4 = 4 按4對齊;起始offset=0 0%4=0;存放位置區間[0,3] */
 char b;  /* 長度1 < 4 按1對齊;起始offset=4 4%1=0;存放位置區間[4] */
 short c; /* 長度2 < 4 按2對齊;起始offset=6 6%2=0;存放位置區間[6,7] */
 char d;  /* 長度1 < 4 按1對齊;起始offset=8 8%1=0;存放位置區間[8] */
};
#pragma pack()
成員總大小=9

2) 整體對齊
整體對齊係數 = min((max(int,short,char), 4) = 4
整體大小 (size)=$(成員總大小) 按 $(整體對齊係數) 圓整 = 12 /* 12%4=0 */

4、8位元組對齊(#pragma pack(8))
輸出結果:sizeof(struct test_t) = 12 [兩個編譯器輸出一致]
分析過程:
1) 成員資料對齊
#pragma pack(8)
struct test_t {
 int a;  /* 長度4 < 8 按4對齊;起始offset=0 0%4=0;存放位置區間[0,3] */
 char b;  /* 長度1 < 8 按1對齊;起始offset=4 4%1=0;存放位置區間[4] */
 short c; /* 長度2 < 8 按2對齊;起始offset=6 6%2=0;存放位置區間[6,7] */
 char d;  /* 長度1 < 8 按1對齊;起始offset=8 8%1=0;存放位置區間[8] */
};
#pragma pack()
成員總大小=9

2) 整體對齊
整體對齊係數 = min((max(int,short,char), 8) = 4
整體大小 (size)=$(成員總大小) 按 $(整體對齊係數) 圓整 = 12 /* 12%4=0 */


5、16位元組對齊(#pragma pack(16))
輸出結果:sizeof(struct test_t) = 12 [兩個編譯器輸出一致]
分析過程:
1) 成員資料對齊
#pragma pack(16)
struct test_t {
 int a;  /* 長度4 < 16 按4對齊;起始offset=0 0%4=0;存放位置區間[0,3] */
 char b;  /* 長度1 < 16 按1對齊;起始offset=4 4%1=0;存放位置區間[4] */
 short c; /* 長度2 < 16 按2對齊;起始offset=6 6%2=0;存放位置區間[6,7] */
 char d;  /* 長度1 < 16 按1對齊;起始offset=8 8%1=0;存放位置區間[8] */
};
#pragma pack()
成員總大小=9

2) 整體對齊
整體對齊係數 = min((max(int,short,char), 16) = 4
整體大小 (size)=$(成員總大小) 按 $(整體對齊係數) 圓整 = 12 /* 12%4=0 */

四、結論
8位元組和16位元組對齊試驗證明了“規則”的第3點:“當#pragma pack的n值等於或超過所有資料成員長度的時候,這個n值的大小將不產生任何效果”。另外記憶體對齊是個很複雜的東西,上面所說的在有些時候也可能不正 確。呵呵^_^

[注1]
什麼是“圓整”?
舉例說明:如上面的8位元組對齊中的“整體對齊”,整體大小=9 按 4 圓整 = 12
圓整的過 程:從9開始每次加一,看是否能被4整除,這裡9,10,11均不能被4整除,到12時可以,則圓整結束。

 

 

1  const

c  onst限定的物件表示編譯器可以將它放在只讀儲存器中,也就意味著在對其進行初始化之後就不能改變它的值。根據const使用的不同場合,大致可以分為三種情況,其一限定普通變數,其二限定函式引數,其三限定指標變數。

  第一和第二種情況最為簡單,語句①和語句②分別展示了它的用法。語句①定義了一個值為10的整型常量。語句②中的const表示在函式體中不能 修改src指向的區域中的資料,這與函式的拷貝功能相對應,只做它應該做的事情而不應該有其他副作用,編譯器可以利用這些資訊進行適當的優化。

  ① const int i=10;
  ② void *memcpy(void * dst, const void * src, size_t size);
  ③ const int *ptr;
  ④ int const *ptr;
  ⑤ int * const ptr;
  ⑥ int const * const ptr;

  第3種情況最為複雜,雖然只是const位置不同,但是卻可能具有完全不同的意義。一般,一個宣告語句由宣告說明符 (declspecifier)和一系列宣告子(declarator)兩部分組成,而且宣告說明符中的符號可以以任何次序出現。理解宣告的第一步是定位 說明符和宣告子的邊界。這很容易:所有的說明符都是關鍵字或者型別名,因此說明符終止於第一個不是以上型別之一的符號。例如,在語句③和④中第一個既不是 關鍵字也不是型別名的符號是“*”,即宣告說明符分別為const int和int const,由於宣告說明符中的符號可以以任意次序出現,因此語句③和④的含義是相同的。

  為了迅速弄清語句表達的含義,參考文獻[1]介紹了一種簡便的方法,其要點就是“逆序讀出定義”,如圖1所示。

按此在新視窗瀏覽圖片
圖1逆序讀出宣告的含義

2  static與extern

  static的含義隨著出現位置(全域性變數還是區域性變數)和修飾物件(變數還是函式)的不同而有很大的差別。下面各條目中的模組指的是一個原始檔或者一個翻譯單元:

  ① 位於函式體中的靜態變數在多次函式呼叫間會維持其值。
  ② 位於模組內(但在函式體外)的靜態變數可以被模組內的所有函式訪問,但不能被模組外其他函式訪問。也就是說,它是一個本地的全域性變數。
  ③ 位於模組內的靜態函式只能被此模組內的其他函式呼叫。也就是說,這個函式的作用域為宣告所在的模組。

  static int global;/*情況2*/
  static void foo(void)/*情況3*/
  {
    static int local = 0;/*情況1*/
  }

  為了清楚地理解static的3種用法,必須首先了解C語言中每個識別符號都具有的作用域、連結和儲存持續期等特性的含義。在ISO C99標準中,其定義如下:

  ① 物件的作用域指的是它僅在程式的某個區域中是可見的(即可以使用)。常見的作用域有檔案作用域和塊作用域。
  ② 物件的儲存持續期決定物件的生命週期,即在程式執行某段區間中為物件保留儲存區。有兩種型別的儲存持續期:靜態的和自動的。靜態儲存持續期的物件的生命週期為程式執行的全過程,它的值在程式啟動前僅初始化一次。
  ③ 連結指的是在不同作用域中宣告的或者同一個作用域中多次宣告的識別符號可以引用相同的物件或函式。有3種類型的連結:外部、內部和無。

  在情況②和③中,static分別用來修飾全域性變數global和函式foo,改變它們的連結特性,使它們具有內部連結。也就是說,只有在定義它們的翻譯單元或者檔案內才能使用它們,這對於建立模組化的軟體非常重要。

  與static相反,extern修飾的物件或函式具有外部連結。對於那些暴露給外部使用的介面函式應該使用extern限定,那些非介面函 數,例如工具函式或與實現細節相關的函式,則應該顯式地使用static限定。這是因為如果函式宣告不帶任何儲存類說明符,那麼它具有外部連結就好像使用 了extern一樣。

  在情況①中,static用來修飾區域性變數local,將local的儲存持續期由自動的改變為靜態的,這樣在foo函式的多次呼叫間會為其保 留值。注意作用域、連結和儲存持續期特性之間是正交的。例如在情況①中,雖然變數local的儲存持續期變成靜態的,但是它的作用域仍然是塊作用域。

3  volatile

  volatile關鍵字用來宣告這樣的物件,它們的值可能由於程式控制之外的事件而被潛在改變。volatile強制編譯器不會對其所限定的對 象進行任何優化,每次讀寫都必須訪問實際的儲存器而不能使用暫存器中的副本。在實踐中,它大量的用來描述一個對應於記憶體對映的輸入/輸出埠,例如飛利浦 公司LPC21xx系列ARM處理器的向量地址暫存器定義為:

  #define  VICVectAddr(*((volatile unsigned long *) 0xFFFFF030))

  其次,中斷服務例程中使用的非自動變數或者多執行緒應用程式中多個任務共享的變數也必須使用volatile進行限定。例如在下面的示例中,如果 沒有使用volatile限定g_Flag變數,編譯器看到在foo函式中並沒有修改g_Flag,可能只執行一次g_Flag讀操作並將g_Flag的 值快取在暫存器中,以後每次g_Flag讀操作都使用暫存器中的快取值而不進行儲存器訪問,導致some_action函式永遠無法執行。

  int g_Flag = 0;
  void foo(void){
    while(1){
      if(g_Flag){ some_action(); }
    }
  }
  void isr_service(void){
    g_Flag = 1;
  }

4  __packed

  在嵌入式軟體程式設計中,經常需要精確控制結構體在記憶體中的佈局和訪問非自然對齊的資料,但是C語言標準中並沒有統一的規定而是留給編譯器廠商自行 處理。在ARM C編譯器中,使用__packed關鍵字將任何型別的對齊設定為1位元組。在實踐中,__packed主要有兩個功能:其一,當它修飾指標時,表示此指標指 向的地址是非自然對齊的,編譯器會生成特殊的程式碼以確保獲得正確的結果;其二,當它修飾結構體、聯合或它們中的域時,可以用來建立沒有填充的結構。

  與其他RISC架構一樣,ARM處理器能夠高效地訪問對齊的資料,即字地址的末尾兩位為零,半字地址的最後一位為零,也稱這樣的資料位於它的自 然大小邊界或者是自然對齊的。ARM編譯器希望普通的“C”指標指向一個4位元組對齊記憶體地址,這樣它可以在程式碼中使用LDR/STR指令一次操作4個字 節,否則只能使用LDRB/LDRH等位元組/半字操作指令。相反如果指標指向一個非自然對齊的地址,例如如果一個整型指標指向地址0x8006,當然希望 裝載地址0x8006-0x8007-0x8008-0x8009處的資料,但是實際上ARM會對非自然對齊的地址進行轉換而從裝載地址 0x8004-0x8005-0x8006-0x8007處的資料。在下面的示例中(測試環境為uVision3),首先定義了一個大小為16位元組的整型 陣列,依次初始化為0,1,2,…,15。由於array是一個整型陣列,編譯器會確保它是4位元組對齊的,即指標pc指向一個4位元組對齊的地址。執行程式 後,可以看到如果對pc指標不加__packed標記進行修飾,將得到一個奇怪的0x01000302;而在添加了__packed關鍵字之後,就得到了 正確的結果。也就是說,如果要訪問非自然對齊的資料,必須使用__packed關鍵字顯式地標記出來。

  int main() { 
    int i, j, array[4];
    char *pc = (char *)array;
    for(i=0; i<16; i++){
      *(pc+i)=i;
    }
    /*確保pc指向一個4位元組對齊的地址*/
    while((int)pc & 0x3){ ++pc; }
    /*訪問非自然對齊的整型資料,i=0x01000302*/
    i = *((int*)(pc+2));
    //訪問“標明”為非自然對齊的資料 i=0x05040302
    j = *((__packed int*)(pc+2));
  }

  ARM編譯器總是保證程式中的變數、結構體或聯合中的域分配到自然對齊的地址。這意味著編譯器經常需要在各個域之間插入填充,以確保每個域的自 然對齊。通常來說,程式設計師可以對這些填充視而不見,但是也有例外,例如為了節省結構體佔用的空間,可以利用__packed去除填充。在瞭解了編譯器的填 充行為之後,可以通過調整域的順序來減小結構體佔用的空間。例如雖然結構體s1和s2的域相同,但是sizeof(s1)等於16,而 sizeof(s2)等於12。

  struct s1{
    int i1;
    short s1;
    int i2;
    short s2;
  };
  struct s2{
    int i1;
    int i2; 
    short s1;
    short s2;
  };

 

 

--------------------------------------------------------------------------------------------------------

關於我自己

  • * 一個正派但不正經的程式設計師

  • * 18年計算機專業碩士畢業生,騰訊SNG部門實習生,現加盟快手科技 ,後端研發工程師一枚

  • * 喜歡技術,喜歡網際網路

  • * 民遙控 ,趙雷、陳粒、宋冬野

  • * 公眾號:程式設計美學,時不時寫篇文章,偶爾數羊,其實說到底,只是想和你聊聊
    在這裡插入圖片描述