引言
考慮下面的結構體定義:
1 typedef struct{
2 char c1;
3 short s;
4 char c2;
5 int i;
6 }T_FOO;
假設這個結構體的成員在內存中是緊湊排列的,且c1的起始地址是0,則s的地址就是1,c2的地址是3,i的地址是4。
現在,我們編寫一個簡單的程序:
1 int main(void){
2 T_FOO a;
3 printf("c1 -> %d, s -> %d, c2 -> %d, i -> %d\n",
4 (unsigned int)(void*)&a.c1 - (unsigned int)(void*)&a,
5 (unsigned int)(void*)&a.s - (unsigned int)(void*)&a,
6 (unsigned int)(void*)&a.c2 - (unsigned int)(void*)&a,
7 (unsigned int)(void*)&a.i - (unsigned int)(void*)&a);
8 return 0;
9 }
運行后輸出:
1 c1 -> 0, s -> 2, c2 -> 4, i -> 8
為什么會這樣?這就是字節對齊導致的問題。
本文在參考諸多資料的基礎上,詳細介紹常見的字節對齊問題。因成文較早,資料來源大多已不可考,敬請諒解。
一 什么是字節對齊
現代計算機中,內存空間按照字節劃分,理論上可以從任何起始地址訪問任意類型的變量。但實際中在訪問特定類型變量時經常在特定的內存地址訪問,這就需要各種類型數據按照一定的規則在空間上排列,而不是順序一個接一個地存放,這就是對齊。
二 對齊的原因和作用
不同硬件平臺對存儲空間的處理上存在很大的不同。某些平臺對特定類型的數據只能從特定地址開始存取,而不允許其在內存中任意存放。例如Motorola 68000 處理器不允許16位的字存放在奇地址,否則會觸發異常,因此在這種架構下編程必須保證字節對齊。
但最常見的情況是,如果不按照平臺要求對數據存放進行對齊,會帶來存取效率上的損失。比如32位的Intel處理器通過總線訪問(包括讀和寫)內存數據。每個總線周期從偶地址開始訪問32位內存數據,內存數據以字節為單位存放。如果一個32位的數據沒有存放在4字節整除的內存地址處,那么處理器就需要2個總線周期對其進行訪問,顯然訪問效率下降很多。
因此,通過合理的內存對齊可以提高訪問效率。為使CPU能夠對數據進行快速訪問,數據的起始地址應具有“對齊”特性。比如4字節數據的起始地址應位于4字節邊界上,即起始地址能夠被4整除。
此外,合理利用字節對齊還可以有效地節省存儲空間。但要注意,在32位機中使用1字節或2字節對齊,反而會降低變量訪問速度。因此需要考慮處理器類型。還應考慮編譯器的類型。在VC/C++和GNU GCC中都是默認是4字節對齊。
三 對齊的分類和準則
主要基于Intel X86架構介紹結構體對齊和棧內存對齊,位域本質上為結構體類型。
對于Intel X86平臺,每次分配內存應該是從4的整數倍地址開始分配,無論是對結構體變量還是簡單類型的變量。
3.1 結構體對齊
在C語言中,結構體是種復合數據類型,其構成元素既可以是基本數據類型(如int、long、float等)的變量,也可以是一些復合數據類型(如數組、結構體、聯合等)的數據單元。編譯器為結構體的每個成員按照其自然邊界(alignment)分配空間。各成員按照它們被聲明的順序在內存中順序存儲,第一個成員的地址和整個結構的地址相同。
字節對齊的問題主要就是針對結構體。
3.1.1 簡單示例
先看個簡單的例子(32位,X86處理器,GCC編譯器):
【例1】設結構體如下定義:
1 struct A{
2 int a;
3 char b;
4 short c;
5 };
6 struct B{
7 char b;
8 int a;
9 short c;
10 };
已知32位機器上各數據類型的長度為:char為1字節、short為2字節、int為4字節、long為4字節、float為4字節、double為8字節。那么上面兩個結構體大小如何呢?
結果是:sizeof(strcut A)值為8;sizeof(struct B)的值卻是12。
結構體A中包含一個4字節的int數據,一個1字節char數據和一個2字節short數據;B也一樣。按理說A和B大小應該都是7字節。之所以出現上述結果,就是因為編譯器要對數據成員在空間上進行對齊。
3.1.2 對齊準則
先來看四個重要的基本概念:
1) 數據類型自身的對齊值:char型數據自身對齊值為1字節,short型數據為2字節,int/float型為4字節,double型為8字節。
2) 結構體或類的自身對齊值:其成員中自身對齊值最大的那個值。
3) 指定對齊值:#pragma pack (value)時的指定對齊值value。
4) 數據成員、結構體和類的有效對齊值:自身對齊值和指定對齊值中較小者,即有效對齊值=min{自身對齊值,當前指定的pack值}。
基于上面這些值,就可以方便地討論具體數據結構的成員和其自身的對齊方式。
其中,有效對齊值N是最終用來決定數據存放地址方式的值。有效對齊N表示“對齊在N上”,即該數據的“存放起始地址%N=0”。而數據結構中的數據變量都是按定義的先后順序存放。第一個數據變量的起始地址就是數據結構的起始地址。結構體的成員變量要對齊存放,結構體本身也要根據自身的有效對齊值圓整(即結構體成員變量占用總長度為結構體有效對齊值的整數倍)。
以此分析3.1.1節中的結構體B:
假設B從地址空間0x0000開始存放,且指定對齊值默認為4(4字節對齊)。成員變量b的自身對齊值是1,比默認指定對齊值4小,所以其有效對齊值為1,其存放地址0x0000符合0x0000%1=0。成員變量a自身對齊值為4,所以有效對齊值也為4,只能存放在起始地址為0x0004~0x0007四個連續的字節空間中,符合0x0004%4=0且緊靠第一個變量。變量c自身對齊值為 2,所以有效對齊值也是2,可存放在0x0008~0x0009兩個字節空間中,符合0x0008%2=0。所以從0x0000~0x0009存放的都是B內容。
再看數據結構B的自身對齊值為其變量中最大對齊值(這里是b)所以就是4,所以結構體的有效對齊值也是4。根據結構體圓整的要求, 0x0000~0x0009=10字節,(10+2)%4=0。所以0x0000A~0x000B也為結構體B所占用。故B從0x0000到0x000B 共有12個字節,sizeof(struct B)=12。
之所以編譯器在后面補充2個字節,是為了實現結構數組的存取效率。試想如果定義一個結構B的數組,那么第一個結構起始地址是0沒有問題,但是第二個結構呢?按照數組的定義,數組中所有元素都緊挨著。如果我們不把結構體大小補充為4的整數倍,那么下一個結構的起始地址將是0x0000A,這顯然不能滿足結構的地址對齊。因此要把結構體補充成有效對齊大小的整數倍。其實對于char/short/int/float/double等已有類型的自身對齊值也是基于數組考慮的,只是因為這些類型的長度已知,所以他們的自身對齊值也就已知。
上面的概念非常便于理解,不過個人還是更喜歡下面的對齊準則。
結構體字節對齊的細節和具體編譯器實現相關,但一般而言滿足三個準則:
1) 結構體變量的首地址能夠被其最寬基本類型成員的大小所整除;
2) 結構體每個成員相對結構體首地址的偏移量(offset)都是成員大小的整數倍,如有需要編譯器會在成員之間加上填充字節(internal adding);
3) 結構體的總大小為結構體最寬基本類型成員大小的整數倍,如有需要編譯器會在最末一個成員之后加上填充字節{trailing padding}。
對于以上規則的說明如下:
第一條:編譯器在給結構體開辟空間時,首先找到結構體中最寬的基本數據類型,然后尋找內存地址能被該基本數據類型所整除的位置,作為結構體的首地址。將這個最寬的基本數據類型的大小作為上面介紹的對齊模數。
第二條:為結構體的一個成員開辟空間之前,編譯器首先檢查預開辟空間的首地址相對于結構體首地址的偏移是否是本成員大小的整數倍,若是,則存放本成員,反之,則在本成員和上一個成員之間填充一定的字節,以達到整數倍的要求,也就是將預開辟空間的首地址后移幾個字節。
第三條:結構體總大小是包括填充字節,最后一個成員滿足上面兩條以外,還必須滿足第三條,否則就必須在最后填充幾個字節以達到本條要求。
【例2】假設4字節對齊,以下程序的輸出結果是多少?
1 /* OFFSET宏定義可取得指定結構體某成員在結構體內部的偏移 */
2 #define OFFSET(st, field) (size_t)&(((st*)0)->field)
3 typedef struct{
4 char a;
5 short b;
6 char c;
7 int d;
8 char e[3];
9 }T_Test;
10
11 int main(void){
12 printf("Size = %d\n a-%d, b-%d, c-%d, d-%d\n e[0]-%d, e[1]-%d, e[2]-%d\n",
13 sizeof(T_Test), OFFSET(T_Test, a), OFFSET(T_Test, b),
14 OFFSET(T_Test, c), OFFSET(T_Test, d), OFFSET(T_Test, e[0]),
15 OFFSET(T_Test, e[1]),OFFSET(T_Test, e[2]));
16 return 0;
17 }
執行后輸出如下:
1 Size = 16
2 a-0, b-2, c-4, d-8
3 e[0]-12, e[1]-13, e[2]-14
下面來具體分析:
首先char a占用1個字節,沒問題。
short b本身占用2個字節,根據上面準則2,需要在b和a之間填充1個字節。
char c占用1個字節,沒問題。
int d本身占用4個字節,根據準則2,需要在d和c之間填充3個字節。
char e[3];本身占用3個字節,根據原則3,需要在其后補充1個字節。
因此,sizeof(T_Test) = 1 + 1 + 2 + 1 + 3 + 4 + 3 + 1 = 16字節。
3.1.3 對齊的隱患
3.1.3.1 數據類型轉換
代碼中關于對齊的隱患,很多是隱式的。例如,在強制類型轉換的時候:
1 int main(void){
2 unsigned int i = 0x12345678;
3
4 unsigned char *p = (unsigned char *)&i;
5 *p = 0x00;
6 unsigned short *p1 = (unsigned short *)(p+1);
7 *p1 = 0x0000;
8
9 return 0;
10 }
最后兩句代碼,從奇數邊界去訪問unsigned short型變量,顯然不符合對齊的規定。在X86上,類似的操作只會影響效率;但在MIPS或者SPARC上可能導致error,因為它們要求必須字節對齊。
又如對于3.1.1節的結構體struct B,定義如下函數:
1 void Func(struct B *p){
2 //Code
3 }
在函數體內如果直接訪問p->a,則很可能會異常。因為MIPS認為a是int,其地址應該是4的倍數,但p->a的地址很可能不是4的倍數。
如果p的地址不在對齊邊界上就可能出問題,比如p來自一個跨CPU的數據包(多種數據類型的數據被按順序放置在一個數據包中傳輸),或p是經過指針移位算出來的。因此要特別注意跨CPU數據的接口函數對接口輸入數據的處理,以及指針移位再強制轉換為結構指針進行訪問時的安全性。
解決方式如下:
1) 定義一個此結構的局部變量,用memmove方式將數據拷貝進來。
1 void Func(struct B *p){
2 struct B tData;
3 memmove(&tData, p, sizeof(struct B));
4 //此后可安全訪問tData.a,因為編譯器已將tData分配在正確的起始地址上
5 }
注意:如果能確定p的起始地址沒問題,則不需要這么處理;如果不能確定(比如跨CPU輸入數據、或指針移位運算出來的數據要特別小心),則需要這樣處理。
2) 用#pragma pack (1)將STRUCT_T定義為1字節對齊方式。
3.1.3.2 處理器間數據通信
處理器間通過消息(對于C/C++而言就是結構體)進行通信時,需要注意字節對齊以及字節序的問題。
大多數編譯器提供內存對其的選項供用戶使用。這樣用戶可以根據處理器的情況選擇不同的字節對齊方式。例如C/C++編譯器提供的#pragma pack(n) n=1,2,4等,讓編譯器在生成目標文件時,使內存數據按照指定的方式排布在1,2,4等字節整除的內存地址處。
然而在不同編譯平臺或處理器上,字節對齊會造成消息結構長度的變化。編譯器為了使字節對齊可能會對消息結構體進行填充,不同編譯平臺可能填充為不同的形式,大大增加處理器間數據通信的風險。
下面以32位處理器為例,提出一種內存對齊方法以解決上述問題。
對于本地使用的數據結構,為提高內存訪問效率,采用四字節對齊方式;同時為了減少內存的開銷,合理安排結構體成員的位置,減少四字節對齊導致的成員之間的空隙,降低內存開銷。
對于處理器之間的數據結構,需要保證消息長度不會因不同編譯平臺或處理器而導致消息結構體長度發生變化,使用一字節對齊方式對消息結構進行緊縮;為保證處理器之間的消息數據結構的內存訪問效率,采用字節填充的方式自己對消息中成員進行四字節對齊。
數據結構的成員位置要兼顧成員之間的關系、數據訪問效率和空間利用率。順序安排原則是:四字節的放在最前面,兩字節的緊接最后一個四字節成員,一字節緊接最后一個兩字節成員,填充字節放在最后。
舉例如下:
1 typedef struct tag_T_MSG{
2 long ParaA;
3 long ParaB;
4 short ParaC;
5 char ParaD;
6 char Pad; //填充字節
7 }T_MSG;
3.1.3.3 排查對齊問題
如果出現對齊或者賦值問題可查看:
1) 編譯器的字節序大小端設置;
2) 處理器架構本身是否支持非對齊訪問;
3) 如果支持看設置對齊與否,如果沒有則看訪問時需要加某些特殊的修飾來標志其特殊訪問操作。
3.1.4 更改對齊方式
主要是更改C編譯器的缺省字節對齊方式。
在缺省情況下,C編譯器為每一個變量或是數據單元按其自然對界條件分配空間。一般地,可以通過下面的方法來改變缺省的對界條件:
- 使用偽指令#pragma pack(n):C編譯器將按照n個字節對齊;
- 使用偽指令#pragma pack(): 取消自定義字節對齊方式。
另外,還有如下的一種方式(GCC特有語法):
- __attribute((aligned (n))): 讓所作用的結構成員對齊在n字節自然邊界上。如果結構體中有成員的長度大于n,則按照最大成員的長度來對齊。
- __attribute__ ((packed)): 取消結構在編譯過程中的優化對齊,按照實際占用字節數進行對齊。
【注】__attribute__機制是GCC的一大特色,可以設置函數屬性(Function Attribute)、變量屬性(Variable Attribute)和類型屬性(Type Attribute)。詳細介紹請參考:
http://www.unixwiz.net/techtips/gnu-c-attributes.html
下面具體針對MS VC/C++ 6.0編譯器介紹下如何修改編譯器默認對齊值。
1) VC/C++ IDE環境中,可在[Project]|[Settings],C/C++選項卡Category的Code Generation選項的Struct Member Alignment中修改,默認是8字節。
VC/C++中的編譯選項有/Zp[1|2|4|8|16],/Zpn表示以n字節邊界對齊。n字節邊界對齊是指一個成員的地址必須安排在成員的尺寸的整數倍地址上或者是n的整數倍地址上,取它們中的最小值。亦即:min(sizeof(member), n)。
實際上,1字節邊界對齊也就表示結構成員之間沒有空洞。
/Zpn選項應用于整個工程,影響所有參與編譯的結構體。在Struct member alignment中可選擇不同的對齊值來改變編譯選項。
2) 在編碼時,可用#pragma pack動態修改對齊值。具體語法說明見附錄5.3節。
自定義對齊值后要用#pragma pack()來還原,否則會對后面的結構造成影響。
【例3】分析如下結構體C:
1 #pragma pack(2) //指定按2字節對齊
2 struct C{
3 char b;
4 int a;
5 short c;
6 };
7 #pragma pack() //取消指定對齊,恢復缺省對齊
變量b自身對齊值為1,指定對齊值為2,所以有效對齊值為1,假設C從0x0000開始,則b存放在0x0000,符合0x0000%1= 0;變量a自身對齊值為4,指定對齊值為2,所以有效對齊值為2,順序存放在0x0002~0x0005四個連續字節中,符合0x0002%2=0。變量c的自身對齊值為2,所以有效對齊值為2,順序存放在0x0006~0x0007中,符合 0x0006%2=0。所以從0x0000到0x00007共八字節存放的是C的變量。C的自身對齊值為4,所以其有效對齊值為2。又8%2=0,C只占用0x0000~0x0007的八個字節。所以sizeof(struct C) = 8。
注意,結構體對齊到的字節數并非完全取決于當前指定的pack值,如下:
1 #pragma pack(8)
2 struct D{
3 char b;
4 short a;
5 char c;
6 };
7 #pragma pack()
雖然#pragma pack(8),但依然按照兩字節對齊,所以sizeof(struct D)的值為6。因為:對齊到的字節數 = min{當前指定的pack值,最大成員大小}。
另外,GNU GCC編譯器中按1字節對齊可寫為以下形式:
1 #define GNUC_PACKED __attribute__((packed))
2 struct C{
3 char b;
4 int a;
5 short c;
6 }GNUC_PACKED;
此時sizeof(struct C)的值為7。
3.2 棧內存對齊
在VC/C++中,棧的對齊方式不受結構體成員對齊選項的影響。總是保持對齊且對齊在4字節邊界上。
【例4】
1 #pragma pack(push, 1) //后面可改為1, 2, 4, 8
2 struct StrtE{
3 char m1;
4 long m2;
5 };
6 #pragma pack(pop)
7
8 int main(void){
9 char a;
10 short b;
11 int c;
12 double d[2];
13 struct StrtE s;
14
15 printf("a address: %p\n", &a);
16 printf("b address: %p\n", &b);
17 printf("c address: %p\n", &c);
18 printf("d[0] address: %p\n", &(d[0]));
19 printf("d[1] address: %p\n", &(d[1]));
20 printf("s address: %p\n", &s);
21 printf("s.m2 address: %p\n", &(s.m2));
22 return 0;
23 }
結果如下:
1 a address: 0xbfc4cfff
2 b address: 0xbfc4cffc
3 c address: 0xbfc4cff8
4 d[0] address: 0xbfc4cfe8
5 d[1] address: 0xbfc4cff0
6 s address: 0xbfc4cfe3
7 s.m2 address: 0xbfc4cfe4
可以看出都是對齊到4字節。并且前面的char和short并沒有被湊在一起(成4字節),這和結構體內的處理是不同的。
至于為什么輸出的地址值是變小的,這是因為該平臺下的棧是倒著“生長”的。
3.3 位域對齊
3.3.1 位域定義
有些信息在存儲時,并不需要占用一個完整的字節,而只需占幾個或一個二進制位。例如在存放一個開關量時,只有0和1兩種狀態,用一位二進位即可。為了節省存儲空間和處理簡便,C語言提供了一種數據結構,稱為“位域”或“位段”。
位域是一種特殊的結構成員或聯合成員(即只能用在結構或聯合中),用于指定該成員在內存存儲時所占用的位數,從而在機器內更緊湊地表示數據。每個位域有一個域名,允許在程序中按域名操作對應的位。這樣就可用一個字節的二進制位域來表示幾個不同的對象。
位域定義與結構定義類似,其形式為:
其中位域列表的形式為:
位域的使用和結構成員的使用相同,其一般形式為:
位域允許用各種格式輸出。
位域在本質上就是一種結構類型,不過其成員是按二進位分配的。位域變量的說明與結構變量說明的方式相同,可先定義后說明、同時定義說明或直接說明。
位域的使用主要為下面兩種情況:
1) 當機器可用內存空間較少而使用位域可大量節省內存時。如把結構作為大數組的元素時。
2) 當需要把一結構體或聯合映射成某預定的組織結構時。如需要訪問字節內的特定位時。
3.3.2 對齊準則
位域成員不能單獨被取sizeof值。下面主要討論含有位域的結構體的sizeof。
C99規定int、unsigned int和bool可以作為位域類型,但編譯器幾乎都對此作了擴展,允許其它類型的存在。位域作為嵌入式系統中非常常見的一種編程工具,優點在于壓縮程序的存儲空間。
其對齊規則大致為:
1) 如果相鄰位域字段的類型相同,且其位寬之和小于類型的sizeof大小,則后面的字段將緊鄰前一個字段存儲,直到不能容納為止;
2) 如果相鄰位域字段的類型相同,但其位寬之和大于類型的sizeof大小,則后面的字段將從新的存儲單元開始,其偏移量為其類型大小的整數倍;
3) 如果相鄰的位域字段的類型不同,則各編譯器的具體實現有差異,VC6采取不壓縮方式,Dev-C++和GCC采取壓縮方式;
4) 如果位域字段之間穿插著非位域字段,則不進行壓縮;
5) 整個結構體的總大小為最寬基本類型成員大小的整數倍,而位域則按照其最寬類型字節數對齊。
【例5】
1 struct BitField{
2 char element1 : 1;
3 char element2 : 4;
4 char element3 : 5;
5 };
位域類型為char,第1個字節僅能容納下element1和element2,所以element1和element2被壓縮到第1個字節中,而element3只能從下一個字節開始。因此sizeof(BitField)的結果為2。
【例6】
1 struct BitField1{
2 char element1 : 1;
3 short element2 : 5;
4 char element3 : 7;
5 };
由于相鄰位域類型不同,在VC6中其sizeof為6,在Dev-C++中為2。
【例7】
1 struct BitField2{
2 char element1 : 3;
3 char element2 ;
4 char element3 : 5;
5 };
非位域字段穿插在其中,不會產生壓縮,在VC6和Dev-C++中得到的大小均為3。
【例8】
1 struct StructBitField{
2 int element1 : 1;
3 int element2 : 5;
4 int element3 : 29;
5 int element4 : 6;
6 char element5 :2;
7 char stelement; //在含位域的結構或聯合中也可同時說明普通成員
8 };
位域中最寬類型int的字節數為4,因此結構體按4字節對齊,在VC6中其sizeof為16。
3.3.3 注意事項
關于位域操作有幾點需要注意:
1) 位域的地址不能訪問,因此不允許將&運算符用于位域。不能使用指向位域的指針也不能使用位域的數組(數組是種特殊指針)。
例如,scanf函數無法直接向位域中存儲數據:
1 int main(void){
2 struct BitField1 tBit;
3 scanf("%d", &tBit.element2); //error: cannot take address of bit-field 'element2'
4 return 0;
5 }
可用scanf函數將輸入讀入到一個普通的整型變量中,然后再賦值給tBit.element2。
2) 位域不能作為函數返回的結果。
3) 位域以定義的類型為單位,且位域的長度不能夠超過所定義類型的長度。例如定義int a:33是不允許的。
4) 位域可以不指定位域名,但不能訪問無名的位域。
位域可以無位域名,只用作填充或調整位置,占位大小取決于該類型。例如,char :0表示整個位域向后推一個字節,即該無名位域后的下一個位域從下一個字節開始存放,同理short :0和int :0分別表示整個位域向后推兩個和四個字節。
當空位域的長度為具體數值N時(如int :2),該變量僅用來占位N位。
【例9】
1 struct BitField3{
2 char element1 : 3;
3 char :6;
4 char element3 : 5;
5 };
結構體大小為3。因為element1占3位,后面要保留6位而char為8位,所以保留的6位只能放到第2個字節。同樣element3只能放到第3字節。
1 struct BitField4{
2 char element1 : 3;
3 char :0;
4 char element3 : 5;
5 };
長度為0的位域告訴編譯器將下一個位域放在一個存儲單元的起始位置。如上,編譯器會給成員element1分配3位,接著跳過余下的4位到下一個存儲單元,然后給成員element3分配5位。故上面的結構體大小為2。
5) 位域的表示范圍。
- 位域的賦值不能超過其可以表示的范圍;
- 位域的類型決定該編碼能表示的值的結果。
對于第二點,若位域為unsigned類型,則直接轉化為正數;若非unsigned類型,則先判斷最高位是否為1,若為1表示補碼,則對其除符號位外的所有位取反再加一得到最后的結果數據(原碼)。如:
1 unsigned int p:3 = 111; //p表示7
2 int p:3 = 111; //p 表示-1,對除符號位之外的所有位取反再加一
6) 帶位域的結構在內存中各個位域的存儲方式取決于編譯器,既可從左到右也可從右到左存儲。
【例10】在VC6下執行下面的代碼:
int main(void){
union{
int i;
struct{
char a : 1;
char b : 1;
char c : 2;
}bits;
}num;
printf("Input an integer for i(0~15): ");
scanf("%d", &num.i);
printf("i = %d, cba = %d %d %d\n", num.i, num.bits.c, num.bits.b, num.bits.a);
return 0;
}
輸入i值為11,則輸出為i = 11, cba = -2 -1 -1。
Intel x86處理器按小字節序存儲數據,所以bits中的位域在內存中放置順序為ccba。當num.i置為11時,bits的最低有效位(即位域a)的值為1,a、b、c按低地址到高地址分別存儲為10、1、1(二進制)。
但為什么最后的打印結果是a=-1而不是1?
因為位域a定義的類型signed char是有符號數,所以盡管a只有1位,仍要進行符號擴展。1做為補碼存在,對應原碼-1。
如果將a、b、c的類型定義為unsigned char,即可得到cba = 2 1 1。1011即為11的二進制數。
注:C語言中,不同的成員使用共同的存儲區域的數據構造類型稱為聯合(或共用體)。聯合占用空間的大小取決于類型長度最大的成員。聯合在定義、說明和使用形式上與結構體相似。
7) 位域的實現會因編譯器的不同而不同,使用位域會影響程序可移植性。因此除非必要否則最好不要使用位域。
8) 盡管使用位域可以節省內存空間,但卻增加了處理時間。當訪問各個位域成員時,需要把位域從它所在的字中分解出來或反過來把一值壓縮存到位域所在的字位中。
四 總結
讓我們回到引言部分的問題。
缺省情況下,C/C++編譯器默認將結構、棧中的成員數據進行內存對齊。因此,引言程序輸出就變成"c1 -> 0, s -> 2, c2 -> 4, i -> 8"。
編譯器將未對齊的成員向后移,將每一個都成員對齊到自然邊界上,從而也導致整個結構的尺寸變大。盡管會犧牲一點空間(成員之間有空洞),但提高了性能。
也正是這個原因,引言例子中sizeof(T_ FOO)為12,而不是8。
總結說來,就是
在結構體中,綜合考慮變量本身和指定的對齊值;
在棧上,不考慮變量本身的大小,統一對齊到4字節。 |
五 附錄
5.1 字節序與網絡序
5.1.1 字節序
字節序,顧名思義就是字節的高低位存放順序。
對于單字節,大部分處理器以相同的順序處理比特位,因此單字節的存放和傳輸方式一般相同。
對于多字節數據,如整型(32位機中一般占4字節),在不同的處理器的存放方式主要有兩種(以內存中0x0A0B0C0D的存放方式為例)。
1) 大字節序(Big-Endian,又稱大端序或大尾序)
在計算機中,存儲介質以下面方式存儲整數0x0A0B0C0D則稱為大字節序:
數據以8bit為單位 |
低地址方向 |
0x0A |
0x0B |
0x0C |
0x0D |
高地址方向 |
數據以16bit為單位 |
低地址方向 |
0x0A0B |
0x0C0D |
高地址方向 |
其中,最高有效位(MSB,Most Significant Byte)0x0A存儲在最低的內存地址處。下個字節0x0B存在后面的地址處。同時,最高的16bit單元0x0A0B存儲在低位。
簡而言之,大字節序就是“高字節存入低地址,低字節存入高地址”。
這里講個詞源典故:“endian”一詞來源于喬納森·斯威夫特的小說《格列佛游記》。小說中,小人國為水煮蛋該從大的一端(Big-End)剝開還是小的一端(Little-End)剝開而爭論,爭論的雙方分別被稱為Big-endians和Little-endians。
1980年,Danny Cohen在其著名的論文"On Holy Wars and a Plea for Peace"中為平息一場關于字節該以什么樣的順序傳送的爭論而引用了該詞。
借用上面的典故,想象一下要把熟雞蛋旋轉著穩立起來,大頭(高字節)肯定在下面(低地址)^_^
2) 小字節序(Little-Endian,又稱小端序或小尾序)
在計算機中,存儲介質以下面方式存儲整數0x0A0B0C0D則稱為小字節序:
數據以8bit為單位 |
高地址方向 |
0x0A |
0x0B |
0x0C |
0x0D |
低地址方向 |
數據以16bit為單位 |
高地址方向 |
0x0A0B |
0x0C0D |
低地址方向 |
其中,最低有效位(LSB,Least Significant Byte)0x0D存儲在最低的內存地址處。后面字節依次存在后面的地址處。同時,最低的16bit單元0x0A0B存儲在低位。
可見,小字節序就是“高字節存入高地址,低字節存入低地址”。
C語言中的位域結構也要遵循比特序(類似字節序)。例如:
1 struct bitfield{
2 unsigned char a: 2;
3 unsigned char b: 6;
4 }
該位域結構占1個字節,假設賦值a = 0x01和b=0x02,則大字節機器上該字節為(01)(000010),小字節機器上該字節為(000010)(01)。因此在編寫可移植代碼時,需要加條件編譯。
注意,在包含位域的C結構中,若位域A在位域B之前定義,則位域A所占用的內存空間地址低于位域B所占用的內存空間。
對上述問題,詳細的講解可參考http://www.linuxjournal.com/article/6788。
另見以下聯合體,在小字節機器上若low=0x01,high=0x02,則hex=0x21:
1 int main(void){
2 union{
3 unsigned char hex;
4 struct{
5 unsigned char low : 4;
6 unsigned char high : 4;
7 };
8 }convert;
9 convert.low = 0x01;
10 convert.high = 0x02;
11 printf("hex = 0x%0x\n", convert.hex);
12 return 0;
13 }
5.1.2 網絡序
網絡傳輸一般采用大字節序,也稱為網絡字節序或網絡序。IP協議中定義大字節序為網絡字節序。
對于可移植的代碼來說,將接收的網絡數據轉換成主機的字節序是必須的,一般會有成對的函數用于把網絡數據轉換成相應的主機字節序或反之(若主機字節序與網絡字節序相同,通常將函數定義為空宏)。
伯克利socket API定義了一組轉換函數,用于16和32位整數在網絡序和主機字節序之間的轉換。Htonl、htons用于主機序轉換到網絡序;ntohl、ntohs用于網絡序轉換到本機序。
注意:在大小字節序轉換時,必須考慮待轉換數據的長度(如5.1.1節的數據單元)。另外對于單字符或小于單字符的幾個bit數據,是不必轉換的,因為在機器存儲和網絡發送的一個字符內的bit位存儲順序是一致的。
5.1.3 位序
用于描述串行設備的傳輸順序。一般硬件傳輸采用小字節序(先傳低位),但I2C協議采用大字節序。網絡協議中只有數據鏈路層的底端會涉及到。
5.1.4 處理器字節序
不同處理器體系的字節序如下所示:
- X86、MOS Technology 6502、Z80、VAX、PDP-11等處理器為Little endian;
- Motorola 6800、Motorola 68000、PowerPC 970、System/370、SPARC(除V9外)等處理器為Big endian;
- ARM、PowerPC (除PowerPC 970外)、DEC Alpha,SPARC V9,MIPS,PA-RISC and IA64等的字節序是可配置的。
5.1.5 字節序編程
請看下面的語句:
1 printf("%c\n", *((short*)"AB") >> 8);
在大字節序下輸出為'A',小字節序下輸出為'B'。
下面的代碼可用來判斷本地機器字節序:
1 //字節序枚舉類型
2 typedef enum{
3 ENDIAN_LITTLE = (INT8U)0X00,
4 ENDIAN_BIG = (INT8U)0X01
5 }E_ENDIAN_TYPE;
6
7 E_ENDIAN_TYPE GetEndianType(VOID)
8 {
9 INT32U dwData = 0x12345678;
10
11 if(0x78 == *((INT8U*)&dwData))
12 return ENDIAN_LITTLE;
13 else
14 return ENDIAN_BIG;
15 }
16
17 //Start of GetEndianTypeTest//
18 #include <endian.h>
19 VOID GetEndianTypeTest(VOID)
20 {
21 #if _BYTE_ORDER == _LITTLE_ENDIAN
22 printf("[%s]<Test Case> Result: %s, EndianType = %s!\n", __FUNCTION__,
23 (ENDIAN_LITTLE != GetEndianType()) ? "ERROR" : "OK", "Little");
24 #elif _BYTE_ORDER == _BIG_ENDIAN
25 printf("[%s]<Test Case> Result: %s, EndianType = %s!\n", __FUNCTION__,
26 (ENDIAN_BIG != GetEndianType()) ? "ERROR" : "OK", "Big");
27 #endif
28 }
29 //End of GetEndianTypeTest//
在字節序不同的平臺間的交換數據時,必須進行轉換。比如對于int類型,大字節序寫入文件:
1 int i = 100;
2 write(fd, &i, sizeof(int));
小字節序讀出后:
1 int i;
2 read(fd, &i, sizeof(int));
3 char buf[sizeof(int)];
4 memcpy(buf, &i, sizeof(int));
5 for(i = 0; i < sizeof(int); i++)
6 {
7 int v = buf[sizeof(int) - i - 1];
8 buf[sizeof(int) - 1] = buf[i];
9 buf[i] = v;
10 }
11 memcpy(&i, buf, sizeof(int));
上面僅僅是個例子。在不同平臺間即使不存在字節序的問題,也盡量不要直接傳遞二進制數據。作為可選的方式就是使用文本來交換數據,這樣至少可以避免字節序的問題。
很多的加密算法為了追求速度,都會采取字符串和數字之間的轉換,在計算完畢后,必須注意字節序的問題,在某些實現中可以見到使用預編譯的方式來完成,這樣很不方便,如果使用前面的語句來判斷,就可以自動適應。
字節序問題不僅影響異種平臺間傳遞數據,還影響諸如讀寫一些特殊格式文件之類程序的可移植性。此時使用預編譯的方式來完成也是一個好辦法。
5.2 對齊時的填充字節
代碼如下:
1 struct A{
2 char c;
3 int i;
4 short s;
5 };
6 int main(void){
7 struct A a;
8 a.c = 1; a.i = 2; a.s = 3;
9 printf("sizeof(A)=%d\n", sizeof(struct A));
10 return 0;
11 }
執行后輸出為sizeof(A)=12。
VC6.0環境中,在main函數打印語句前設置斷點,執行到斷點處時根據結構體a的地址查看變量存儲如下:

可見填充字節為0xCC,即int3中斷。
5.3 pragma pack語法說明
#pragma pack(n)
#pragma pack(push, 1)
#pragma pack(pop) |
1) #pragma pack(n)
該指令指定結構和聯合成員的緊湊對齊。而一個完整的轉換單元的結構和聯合的緊湊對齊由/ Z p選項設置。緊湊對齊用pack編譯指示在數據說明層設置。該編譯指示在其出現后的第一個結構或者聯合說明處生效。該編譯指示對定義無效。
當使用#pragma pack (n) 時,n 為1、2、4、8 或1 6 。第一個結構成員后的每個結構成員都被存儲在更小的成員類型或n字節界限內。如果使用無參量的#pragma pack,結構成員被緊湊為以/ Z p指定的值。該缺省/ Z p緊湊值為/ Z p 8。
2. 編譯器也支持以下增強型語法:
#pragma pack( [ [ { push | pop } , ] [identifier, ] ] [ n] )
若不同的組件使用pack編譯指示指定不同的緊湊對齊, 這個語法允許你把程序組件組合為一個單獨的轉換單元。
帶push參量的pack編譯指示的每次出現將當前的緊湊對齊存儲到一個內部編譯器堆棧中。編譯指示的參量表從左到右讀取。如果使用push,則當前緊湊值被存儲起來;如果給出一個n值,該值將成為新的緊湊值。若指定一個標識符,即選定一個名稱,則該標識符將和這個新的的緊湊值聯系起來。
帶一個pop參量的pack編譯指示的每次出現都會檢索內部編譯器堆棧頂的值,并使該值為新的緊湊對齊值。如果使用pop參量且內部編譯器堆棧是空的,則緊湊值為命令行給定的值,并將產生一個警告信息。若使用pop且指定一個n值,該值將成為新的緊湊值。
若使用pop且指定一個標識符,所有存儲在堆棧中的值將從棧中刪除,直到找到一個匹配的標識符。這個與標識符相關的緊湊值也從棧中移出,并且這個僅在標識符入棧之前存在的緊湊值成為新的緊湊值。如果未找到匹配的標識符, 將使用命令行設置的緊湊值,并且將產生一個一級警告。缺省緊湊對齊為8。
pack編譯指示的新的增強功能讓你在編寫頭文件時,確保在遇到該頭文件的前后的緊湊值是一樣的。
5.4 Intel關于內存對齊的說明
以下內容節選自《Intel Architecture 32 Manual》。
字、雙字和四字在自然邊界上不需要在內存中對齊。(對于字、雙字和四字來說,自然邊界分別是偶數地址,可以被4整除的地址,和可以被8整除的地址。)
無論如何,為了提高程序的性能,數據結構(尤其是棧)應該盡可能地在自然邊界上對齊。原因在于,為了訪問未對齊的內存,處理器需要作兩次內存訪問;然而,對齊的內存訪問僅需要一次訪問。
一個字或雙字操作數跨越了4字節邊界,或者一個四字操作數跨越了8字節邊界,被認為是未對齊的,從而需要兩次總線周期來訪問內存。一個字起始地址是奇數但卻沒有跨越字邊界被認為是對齊的,能夠在一個總線周期中被訪問。
某些操作雙四字的指令需要內存操作數在自然邊界上對齊。如果操作數沒有對齊,這些指令將會產生一個通用保護異常(#GP)。雙四字的自然邊界是能夠被16 整除的地址。其他操作雙四字的指令允許未對齊的訪問(不會產生通用保護異常),然而,需要額外的內存總線周期來訪問內存中未對齊的數據。
5.5 不同架構處理器的對齊要求
RISC指令集處理器(MIPS/ARM):這種處理器的設計以效率為先,要求所訪問的多字節數據(short/int/ long)的地址必須是為此數據大小的倍數,如short數據地址應為2的倍數,long數據地址應為4的倍數,也就是說是對齊的。
CISC指令集處理器(X86):沒有上述限制。
對齊處理策略
訪問非對齊多字節數據時(pack數據),編譯器會將指令拆成多條(因為非對齊多字節數據可能跨越地址對齊邊界),保證每條指令都從正確的起始地址上獲取數據,但也因此效率比較低。
訪問對齊數據時則只用一條指令獲取數據,因此對齊數據必須確保其起始地址是在對齊邊界上。如果不是在對齊的邊界,對X86 CPU是安全的,但對MIPS/ARM這種RISC CPU會出現“總線訪問異常”。
為什么X86是安全的呢?
X86 CPU是如何進行數據對齊的。X86 CPU的EFLAGS寄存器中包含一個特殊的位標志,稱為AC(對齊檢查的英文縮寫)標志。按照默認設置,當CPU首次加電時,該標志被設置為0。當該標志是0時,CPU能夠自動執行它應該執行的操作,以便成功地訪問未對齊的數據值。然而,如果該標志被設置為1,每當系統試圖訪問未對齊的數據時,CPU就會發出一個INT 17H中斷。X86的Windows 2000和Windows 98版本從來不改變這個CPU標志位。因此,當應用程序在X86處理器上運行時,你根本看不到應用程序中出現數據未對齊的異常條件。
為什么MIPS/ARM不安全呢?
因為MIPS/ARM CPU不能自動處理對未對齊數據的訪問。當未對齊的數據訪問發生時,CPU就會將這一情況通知操作系統。這時,操作系統將會確定它是否應該引發一個數據未對齊異常條件,對vxworks是會觸發這個異常的。
5.6 ARM下的對齊處理
有部分摘自ARM編譯器文檔對齊部分。
對齊的使用:
1) __align(num)
用于修改最高級別對象的字節邊界。在匯編中使用LDRD或STRD時就要用到此命令__align(8)進行修飾限制。來保證數據對象是相應對齊。
這個修飾對象的命令最大是8個字節限制,可以讓2字節的對象進行4字節對齊,但不能讓4字節的對象2字節對齊。
__align是存儲類修改,只修飾最高級類型對象,不能用于結構或者函數對象。
2) __packed
進行一字節對齊。需注意:
- 不能對packed的對象進行對齊;
- 所有對象的讀寫訪問都進行非對齊訪問;
- float及包含float的結構聯合及未用__packed的對象將不能字節對齊;
- __packed對局部整型變量無影響。
- 強制由unpacked對象向packed對象轉化時未定義。整型指針可以合法定義為packed,如__packed int* p(__packed int 則沒有意義)
對齊或非對齊讀寫訪問可能存在的問題:
1 //定義如下結構,b的起始地址不對齊。在棧中訪問b可能有問題,因為棧上數據對齊訪問
2 __packed struct STRUCT_TEST{
3 char a;
4 int b;
5 char c;
6 };
7 //將下面的變量定義成全局靜態(不在棧上)
8 static char *p;
9 static struct STRUCT_TEST a;
10 void Main(){
11 __packed int *q; //定義成__packed來修飾當前q指向為非對齊的數據地址下面的訪問則可以
12
13 p = (char*)&a;
14 q = (int*)(p + 1);
15 *q = 0x87654321;
16 /* 得到賦值的匯編指令很清楚
17 ldr r5,0x20001590 ; = #0x12345678
18 [0xe1a00005] mov r0,r5
19 [0xeb0000b0] bl __rt_uwrite4 //在此處調用一個寫4字節的操作函數
20
21 [0xe5c10000] strb r0,[r1,#0] //函數進行4次strb操作然后返回,正確訪問數據
22 [0xe1a02420] mov r2,r0,lsr #8
23 [0xe5c12001] strb r2,[r1,#1]
24 [0xe1a02820] mov r2,r0,lsr #16
25 [0xe5c12002] strb r2,[r1,#2]
26 [0xe1a02c20] mov r2,r0,lsr #24
27 [0xe5c12003] strb r2,[r1,#3]
28 [0xe1a0f00e] mov pc,r14
29
30 若q未加__packed修飾則匯編出來指令如下(會導致奇地址處訪問失敗):
31 [0xe59f2018] ldr r2,0x20001594 ; = #0x87654321
32 [0xe5812000] str r2,[r1,#0]
33 */
34 //這樣很清楚地看到非對齊訪問如何產生錯誤,以及如何消除非對齊訪問帶來的問題
35 //也可看到非對齊訪問和對齊訪問的指令差異會導致效率問題
36 }
5.7 《The C Book》之位域篇
While we're on the subject of structures, we might as well look at bitfields. They can only be declared inside a structure or a union, and allow you to specify some very small objects of a given number of bits in length. Their usefulness is limited and they aren't seen in many programs, but we'll deal with them anyway. This example should help to make things clear:
1 struct{
2 unsigned field1 :4; //field 4 bits wide
3 unsigned :3; //unnamed 3 bit field(allow for padding)
4 signed field2 :1; //one-bit field(can only be 0 or -1 in two's complement)
5 unsigned :0; //align next field on a storage unit
6 unsigned field3 :6;
7 }full_of_fields;
Each field is accessed and manipulated as if it were an ordinary member of a structure. The keywords signed and unsigned mean what you would expect, except that it is interesting to note that a 1-bit signed field on a two's complement machine can only take the values 0 or -1. The declarations are permitted to include the const and volatile qualifiers.
The main use of bitfields is either to allow tight packing of data or to be able to specify the fields within some externally produced data files. C gives no guarantee of the ordering of fields within machine words, so if you do use them for the latter reason, you program will not only be non-portable, it will be compiler-dependent too. The Standard says that fields are packed into ‘storage units’, which are typically machine words. The packing order, and whether or not a bitfield may cross a storage unit boundary, are implementation defined. To force alignment to a storage unit boundary, a zero width field is used before the one that you want to have aligned.
Be careful using them. It can require a surprising amount of run-time code to manipulate these things and you can end up using more space than they save.
Bit fields do not have addresses—you can't have pointers to them or arrays of them.
5.8 C語言字節相關面試題
5.8.1 Intel/微軟C語言面試題
請看下面的問題:
1 #pragma pack(8)
2 struct s1{
3 short a;
4 long b;
5 };
6 struct s2{
7 char c;
8 s1 d;
9 long long e; //VC6.0下可能要用__int64代替雙long
10 };
11 #pragma pack()
問:1. sizeof(s2) = ? 2. s2的s1中的a后面空了幾個字節接著是b?
【分析】
成員對齊有一個重要的條件,即每個成員分別按自己的方式對齊。
也就是說上面雖然指定了按8字節對齊,但并不是所有的成員都是以8字節對齊。其對齊的規則是:每個成員按其類型的對齊參數(通常是這個類型的大小)和指定對齊參數(這里是8字節)中較小的一個對齊,并且結構的長度必須為所用過的所有對齊參數的整數倍,不夠就補空字節。
s1中成員a是1字節,默認按1字節對齊,而指定對齊參數為8,兩值中取1,即a按1字節對齊;成員b是4個字節,默認按4字節對齊,這時就按4字節對齊,所以sizeof(s1)應該為8;
s2中c和s1中a一樣,按1字節對齊。而d 是個8字節結構體,其默認對齊方式就是所有成員使用的對齊參數中最大的一個,s1的就是4。所以,成員d按4字節對齊。成員e是8個字節,默認按8字節對齊,和指定的一樣,所以它對到8字節的邊界上。這時,已經使用了12個字節,所以又添加4個字節的空,從第16個字節開始放置成員e。此時長度為24,并可被8(成員e按8字節對齊)整除。這樣,一共使用了24個字節。
各個變量在內存中的布局為:
c***aa**
bbbb****
dddddddd ——這種“矩陣寫法”很方便看出結構體實際大小!
因此,sizeof(S2)結果為24,a后面空了2個字節接著是b。
這里有三點很重要:
1) 每個成員分別按自己的方式對齊,并能最小化長度;
2) 復雜類型(如結構)的默認對齊方式是其最長的成員的對齊方式,這樣在成員是復雜類型時可以最小化長度;
3) 對齊后的長度必須是成員中最大對齊參數的整數倍,這樣在處理數組時可保證每一項都邊界對齊。
還要注意,“空結構體”(不含數據成員)的大小為1,而不是0。試想如果不占空間的話,一個空結構體變量如何取地址、兩個不同的空結構體變量又如何得以區分呢?
5.8.2 上海網宿科技面試題
假設硬件平臺是intel x86(little endian),以下程序輸出什么:
1 //假設硬件平臺是intel x86(little endian)
2 typedef unsigned int uint32_t;
3 void inet_ntoa(uint32_t in){
4 char b[18];
5 register char *p;
6 p = (char *)∈
7 #define UC(b) (((int)b)&0xff) //byte轉換為無符號int型
8 sprintf(b, "%d.%d.%d.%d\n", UC(p[0]), UC(p[1]), UC(p[2]), UC(p[3]));
9 printf(b);
10 }
11 int main(void){
12 inet_ntoa(0x12345678);
13 inet_ntoa(0x87654321);
14 return 0;
15 }
先看如下程序:
1 int main(void){
2 int a = 0x12345678;
3 char *p = (char *)&a;
4 char str[20];
5 sprintf(str,"%d.%d.%d.%d\n", p[0], p[1], p[2], p[3]);
6 printf(str);
7 return 0;
8 }
按照小字節序的規則,變量a在計算機中存儲方式為:
高地址方向 |
0x12 |
0x34 |
0x56 |
0x78 |
低地址方向 |
p[3] |
p[2] |
p[1] |
p[0] |
注意,p并不是指向0x12345678的開頭0x12,而是指向0x78。p[0]到p[1]的操作是&p[0]+1,因此p[1]地址比p[0]地址大。輸出結果為120.86.52.18。
反過來的話,令int a = 0x87654321,則輸出結果為33.67.101.-121。
為什么有負值呢?因為系統默認的char是有符號的,本來是0x87也就是135,大于127因此就減去256得到-121。
想要得到正值的話只需將char *p = (char *)&a改為unsigned char *p = (unsigned char *)&a即可。
綜上不難得出,網宿面試題的答案為120.86.52.18和33.67.101.135。