引言 考慮下麵的結構體定義: 假設這個結構體的成員在記憶體中是緊湊排列的,且c1的起始地址是0,則s的地址就是1,c2的地址是3,i的地址是4。 現在,我們編寫一個簡單的程式: 運行後輸出: 為什麼會這樣?這就是位元組對齊導致的問題。 本文在參考諸多資料的基礎上,詳細介紹常見的位元組對齊問題。因成文較早, ...
引言
考慮下麵的結構體定義:
假設這個結構體的成員在記憶體中是緊湊排列的,且c1的起始地址是0,則s的地址就是1,c2的地址是3,i的地址是4。
現在,我們編寫一個簡單的程式:
運行後輸出:
為什麼會這樣?這就是位元組對齊導致的問題。
本文在參考諸多資料的基礎上,詳細介紹常見的位元組對齊問題。因成文較早,資料來源大多已不可考,敬請諒解。
一,什麼是位元組對齊
現代電腦中,記憶體空間按照位元組劃分,理論上可以從任何起始地址訪問任意類型的變數。但實際中在訪問特定類型變數時經常在特定的記憶體地址訪問,這就需要各種類型數據按照一定的規則在空間上排列,而不是順序一個接一個地存放,這就是對齊。
二,對齊的原因和作用
不同硬體平臺對存儲空間的處理上存在很大的不同。某些平臺對特定類型的數據只能從特定地址開始存取,而不允許其在記憶體中任意存放。例如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】設結構體如下定義:
已知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}。
對於以上規則的說明如下:
(1)編譯器在給結構體開闢空間時,首先找到結構體中最寬的基本數據類型,然後尋找記憶體地址能被該基本數據類型所整除的位置,作為結構體的首地址。將這個最寬的基本數據類型的大小作為上面介紹的對齊模數。
(2)為結構體的一個成員開闢空間之前,編譯器首先檢查預開闢空間的首地址相對於結構體首地址的偏移是否是本成員大小的整數倍,若是,則存放本成員,反之,則在本成員和上一個成員之間填充一定的位元組,以達到整數倍的要求,也就是將預開闢空間的首地址後移幾個位元組。
(3)結構體總大小是包括填充位元組,最後一個成員滿足上面兩條以外,還必須滿足第三條,否則就必須在最後填充幾個位元組以達到本條要求。
【例2】假設4位元組對齊,以下程式的輸出結果是多少?
執行後輸出如下:
下麵來具體分析:
首先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 數據類型轉換
代碼中關於對齊的隱患,很多是隱式的。例如,在強制類型轉換的時候:
最後兩句代碼,從奇數邊界去訪問unsigned short型變數,顯然不符合對齊的規定。在X86上,類似的操作只會影響效率;但在MIPS或者SPARC上可能導致error,因為它們要求必須位元組對齊。
又如對於3.1.1節的結構體struct B,定義如下函數:
在函數體內如果直接訪問p->a,則很可能會異常。因為MIPS認為a是int,其地址應該是4的倍數,但p->a的地址很可能不是4的倍數。
如果p的地址不在對齊邊界上就可能出問題,比如p來自一個跨CPU的數據包(多種數據類型的數據被按順序放置在一個數據包中傳輸),或p是經過指針移位算出來的。因此要特別註意跨CPU數據的介面函數對介面輸入數據的處理,以及指針移位再強制轉換為結構指針進行訪問時的安全性。
解決方式如下:
定義一個此結構的局部變數,用memmove方式將數據拷貝進來。
註意:如果能確定p的起始地址沒問題,則不需要這麼處理;如果不能確定(比如跨CPU輸入數據、或指針移位運算出來的數據要特別小心),則需要這樣處理。
用#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位處理器為例,提出一種記憶體對齊方法以解決上述問題。
對於本地使用的數據結構,為提高記憶體訪問效率,採用四位元組對齊方式;同時為了減少記憶體的開銷,合理安排結構體成員的位置,減少四位元組對齊導致的成員之間的空隙,降低記憶體開銷。
對於處理器之間的數據結構,需要保證消息長度不會因不同編譯平臺或處理器而導致消息結構體長度發生變化,使用一位元組對齊方式對消息結構進行緊縮;為保證處理器之間的消息數據結構的記憶體訪問效率,採用位元組填充的方式自己對消息中成員進行四位元組對齊。
數據結構的成員位置要兼顧成員之間的關係、數據訪問效率和空間利用率。順序安排原則是:四位元組的放在最前面,兩位元組的緊接最後一個四位元組成員,一位元組緊接最後一個兩位元組成員,填充位元組放在最後。
舉例如下:
3.1.3.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)。
下麵具體針對MS VC/C++ 6.0編譯器介紹下如何修改編譯器預設對齊值。
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中可選擇不同的對齊值來改變編譯選項。
在編碼時,可用#pragma pack動態修改對齊值。具體語法說明見附錄5.3節。
自定義對齊值後要用#pragma pack()來還原,否則會對後面的結構造成影響。
【例3】分析如下結構體C:
變數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值,如下:
另外,GNU GCC編譯器中按1位元組對齊可寫為以下形式:
此時sizeof(struct C)的值為7。
3.2 棧記憶體對齊
在VC/C++中,棧的對齊方式不受結構體成員對齊選項的影響。總是保持對齊且對齊在4位元組邊界上。
【例4】
結果如下:
可以看出都是對齊到4位元組。並且前面的char和short並沒有被湊在一起(成4位元組),這和結構體內的處理是不同的。
至於為什麼輸出的地址值是變小的,這是因為該平臺下的棧是倒著“生長”的。
3.3 位域對齊
3.3.1 位域定義
有些信息在存儲時,並不需要占用一個完整的位元組,而只需占幾個或一個二進位位。例如在存放一個開關量時,只有0和1兩種狀態,用一位二進位即可。為了節省存儲空間和處理簡便,C語言提供了一種數據結構,稱為“位域”或“位段”。
位域是一種特殊的結構成員或聯合成員(即只能用在結構或聯合中),用於指定該成員在記憶體存儲時所占用的位數,從而在機器內更緊湊地表示數據。每個位域有一個功能變數名稱,允許在程式中按功能變數名稱操作對應的位。這樣就可用一個位元組的二進位位域來表示幾個不同的對象。
位域定義與結構定義類似,其形式為:
其中位域列表的形式為:
位域的使用和結構成員的使用相同,其一般形式為:
位域允許用各種格式輸出。
位域在本質上就是一種結構類型,不過其成員是按二進位分配的。位域變數的說明與結構變數說明的方式相同,可先定義後說明、同時定義說明或直接說明。
位域的使用主要為下麵兩種情況:
①當機器可用記憶體空間較少而使用位域可大量節省記憶體時。如把結構作為大數組的元素時。
②當需要把一結構體或聯合映射成某預定的組織結構時。如需要訪問位元組內的特定位時。
3.3.2 對齊準則
位域成員不能單獨被取sizeof值。下麵主要討論含有位域的結構體的sizeof。
C99規定int、unsigned int和bool可以作為位域類型,但編譯器幾乎都對此作了擴展,允許其它類型的存在。位域作為嵌入式系統中非常常見的一種編程工具,優點在於壓縮程式的存儲空間。
其對齊規則大致為:
(1)如果相鄰位域欄位的類型相同,且其位寬之和小於類型的sizeof大小,則後面的欄位將緊鄰前一個欄位存儲,直到不能容納為止;
(2)如果相鄰位域欄位的類型相同,但其位寬之和大於類型的sizeof大小,則後面的欄位將從新的存儲單元開始,其偏移量為其類型大小的整數倍;
(3)如果相鄰的位域欄位的類型不同,則各編譯器的具體實現有差異,VC6採取不壓縮方式,Dev-C++和GCC採取壓縮方式;
(4)如果位域欄位之間穿插著非位域欄位,則不進行壓縮;
(5)整個結構體的總大小為最寬基本類型成員大小的整數倍,而位域則按照其最寬類型位元組數對齊。
【例5】
位域類型為char,第1個位元組僅能容納下element1和element2,所以element1和element2被壓縮到第1個位元組中,而element3只能從下一個位元組開始。因此sizeof(BitField)的結果為2。
【例6】
由於相鄰位域類型不同,在VC6中其sizeof為6,在Dev-C++中為2。
【例7】
非位域欄位穿插在其中,不會產生壓縮,在VC6和Dev-C++中得到的大小均為3。
【例8】
位域中最寬類型int的位元組數為4,因此結構體按4位元組對齊,在VC6中其sizeof為16。
3.3.3 註意事項
關於位域操作有幾點需要註意:
(1)位域的地址不能訪問,因此不允許將&運算符用於位域。不能使用指向位域的指針也不能使用位域的數組(數組是種特殊指針)。
例如,scanf函數無法直接向位域中存儲數據:
intmain(void){structBitField1tBit;scanf("%d", &tBit.element2);//error: cannot take address of bit-field 'element2'return0;}
可用scanf函數將輸入讀入到一個普通的整型變數中,然後再賦值給tBit.element2。
(2)位域不能作為函數返回的結果。
(3)位域以定義的類型為單位,且位域的長度不能夠超過所定義類型的長度。例如定義int a:33是不允許的。
(4)位域可以不指定位功能變數名稱,但不能訪問無名的位域。
位域可以無位功能變數名稱,只用作填充或調整位置,占位大小取決於該類型。例如,char :0表示整個位域向後推一個位元組,即該無名位域後的下一個位域從下一個位元組開始存放,同理short :0和int :0分別表示整個位域向後推兩個和四個位元組。
當空位域的長度為具體數值N時(如int :2),該變數僅用來占位N位。
【例9】
結構體大小為3。因為element1占3位,後面要保留6位而char為8位,所以保留的6位只能放到第2個位元組。同樣element3只能放到第3位元組。
長度為0的位域告訴編譯器將下一個位域放在一個存儲單元的起始位置。如上,編譯器會給成員element1分配3位,接著跳過餘下的4位到下一個存儲單元,然後給成員element3分配5位。故上面的結構體大小為2。
(5)位域的表示範圍。
位域的賦值不能超過其可以表示的範圍;
位域的類型決定該編碼能表示的值的結果。
對於第二點,若位域為unsigned類型,則直接轉化為正數;若非unsigned類型,則先判斷最高位是否為1,若為1表示補碼,則對其除符號位外的所有位取反再加一得到最後的結果數據(原碼)。如:
(6)帶位域的結構在記憶體中各個位域的存儲方式取決於編譯器,既可從左到右也可從右到左存儲。
【例10】在VC6下執行下麵的代碼:
輸入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。
總結說來,就是:
(1)在結構體中,綜合考慮變數本身和指定的對齊值;
(2)在棧上,不考慮變數本身的大小,統一對齊到4位元組。
其實做為一個編程學習者,有一個學習的氛圍跟一個交流圈子特別重要這裡我推薦一個C語言C++交流QQ群1108152000,不管你是小白還是轉行人士歡迎入駐,大家一起交流成長。
微信公眾號:C語言編程學習基地