
1、 前言
作為一名 C/C++ 程序員,字節是我們天天都要與之打交道的一個東西。我們和
它熟稔到幾乎已經忘記了它的存在。可是,它自己是不甘寂寞的,或遲或早地,
總會在某些時候探出頭 來張望,然后給你一個腿兒絆。其實,只要你真正了解
了它的底細,你就會暢行無阻。在本文中,我們將首先簡要了解一下字節的概念,
然后著重了解一下字節序問 題和字節對齊問題。
2、 什么是字節
我們知道,二進制計算機(也就是我們目前接觸到的幾乎所有的計算機)的最小
數據單位是位( bit )。一位數據只能夠表示兩種含義(需要說明,盡管我們
通常把單個位表示的兩種含義選擇為相互對立的含義,但這并不是必然的,例如
你可以認為 1 代表 5 個人, 0 代表 8 個人),對于絕大多數的計算要求,單
個位顯然不能滿足。因此,我們通常都會使用一連串的位,我們可以稱之為位串
( bit string ,請愛好質疑的的朋友注意,此術語非我杜撰)。由于種種原因,
計算機系統都不會讓你使用任意長度的位串,而是使用某個特定長度的位串。一
些常見的位串長度 形式具有約定好的名稱,如,半字節( nibble ,貌似用的
不多)代表四個位的組合,字節( byte ,主角出場!)代表 8 個位的組合。
再多的還有,字( word )、雙字( Double word ,通常簡寫為 Dword )、四
字( Quad word ,經常簡寫為 Qword )、十字節( Ten byte ,也簡寫為 Tbyte )。
在這些里面,字( word )有時表示不同的含義。在 Intel 體系里, word 表
示一個 16 位的數值,它是固定大小的。而在另外一些場合, word 表示了 CPU
一次可處理的數據的位數,表示一個符合 CPU 字長( word-length )的數目的
位串。事實上我們接觸較多的 ARM 體系中, word 就有不同的含義,它表示一
個 32 位的數據(與機器字長相同),對于 16 位大小的數據, ARM 使用了另
外的一個術語,叫作半字( half-word ),請大家在文檔閱讀時加以注意。另
外, Qword 也是 Intel 體系中的術語,其他的體系中可能并不使用。在本文中,
我們按照 Intel 的慣例來使用字或者 word 這一術語。
一個字節中共有 8 個數據位,有時需要用圖表逐位表述各個位。習慣上,我們
按照下面的圖來排列各個位的順序,即,按照從右到左的順序,依次為最低位(從
第 0 位開始)到最高位(對于字節,則是第 7 位):
字節是大多數現代計算機的最小存儲單元,但這并不代表它是計算機可以最高效
地處理的數據單位。一般的來說,計算機可以最高效地處理的數據大小,應該 與
其字長相同。在目前來講,桌面平臺的處理器字長正處于從 32 位向 64 位過渡
的時期,嵌入式設備的基本穩定在 32 位,而在某些專業領域(如高端顯卡),
處理器字長早已經達到了 64 位乃至更多的 128 位。
3、 字節序問題的由來
對于字、雙字這些多于一個字節的數據,如果把它們放置到內存中的某個位置上,
可以看出,我們還可以將之看作是字節的序列。一個字是兩個字節,雙字則 是
四個字節。假設有以下數據: 0x12345678 、 0x9abcdef0 。在此處,我使用了
我們最習慣的十六進制表示法,并給出了兩個雙字的值。按照慣例,我把雙字的
左側視為高端,而把右側視為低端。把它們順序放置在起始地址 為 0 的內存中,
如下圖所示:
由圖示可知, 0x9abcdef 的相應地址為 0x04 。現在,問題來了,如果有一個
內存操作,要從地址 0x06 處讀取一個字,得到的結果是多少呢?答案是:不一
定。
這里的本質問題在于,如何把多字節的對象存儲到內存中去呢?即使使用最正常
的思維去考慮這個問題,你也會發現有兩種方法。第一種方法是,把最低端的 字
節放到指定的起始位置(即基地址處),然后按照從低到高的字節順序把其余字
節依次放入,如下圖 a ;另一種方法非常類似,但是對高端字節和低端字節的
處理順序正好相反,如下圖 b (我確信你還可以想出其他的方法,但是除二字
節的情況外,必然會打破字節排列順序的一致性,我視之為反常規思維的產物,
此處暫不考慮)。
圖 a
圖 b
在很久之前,哪一種存儲方式更為合理曾經有過爭論。到今天,爭論的結果已經
無關緊要了,緊要的是以下事實:這兩種存儲方式都被應用到了現實的計算機 系
統中。上圖 a 中的排列方式為 Intel 所采用并大行其道,而圖 b 的排列方式
則被大多數的其他平臺采用(如最近被蘋果公司徹底拋棄的 PowerPC ),因此
上,我們不能稱之為罕見的用法。之所以造成事實上的不經常見到,其原因正如
我今天中午所得到的消息: Intel 的 CPU 占整個市場份額的 80% 以上。
這兩種排列方式通常用小端( little endian )和大端( big endian )來稱
謂。這兩個奇怪的名字據說來源于童話《格列佛游記》,其中小人國里的公民為
了雞蛋到底是應該從小的一頭打開還是大的一頭打開而大起爭執。 Intel 的方
式對應于“小端”,順便說一句,大端的方式也有一個大公司的名字作為其代表,
即最近開始沒落的 Motorola 。如果有誰了解過 TIFF 圖像文件格式,就會發現
其文件頭中用以標識文件數據字節序的標志就是“ II ”和“ MM ”,分別對應
于 Intel 和 Motorola 的首字母。值得提醒一下,小端方式的排列與位的排列
順序相一致,看上去似乎更協調一些。
現在我們可以回答上面的問題了。對于小端字節序,我們取到的字,其值為
0x9abc ,而如果是大端字節序的話,就會取到 0xdef0 。
4、 何時會出現字節序問題
字節序問題主要出現在數據在不同平臺之間進行交換時,交換的途徑可能是網絡
傳輸,也可能是文件復制。例如,如果你設計了一種可能會應用于不同平臺的 文
件格式,其中存儲了某些數據結構,則對于大小大于一個字節的數據就要明確地
規定其遵循的字節序,以便各平臺上的處理程序可以在使用數據時實現做必要的
轉 換。
舉一個實際的例子。 Java 是一個跨平臺的編程語言,其可執行文件(擴展名
為 .class ,使用的是一種機器無關的字節碼指令集)在理論上可以運行于所有
的實現了 Java 運行時的平臺(包含有與特定平臺相關特性的除外)。編譯后
的 .class 中一定保存有諸如 Integer 這樣類型的數據,這就涉及到了字節序
的確定,否則 .class 必然不能被采用了不同字節序的平臺同時正確加載并運行。
事實上, Java 語言采用的為大端字節序,這個一點都不奇怪,因為當初 SUN 公
司自己的 SPARC 架構就是采用的大端字節序。同樣的問題和解決問題的方式,
也存在于操作系統新貴 android 系統上。
網絡傳輸則是另一個典型場景。 TCP/IP 所采用的網絡傳輸字節序標準也是大端
字節序,這個也不必奇怪,因為 TCP/IP 是從 UNIX 系統發展起來的,而絕大部
分的 UNIX 系統在很長的一段時間內都沒有運行于 Intel 體系架構上的版本。
處理字節序問題的手段非常簡單,也就是對數據進行必要的轉換:將十六進制的
數字從兩端開始交換,直至移動到數據的中心,交換完成為止。交換的結果就好
像物體與鏡面之內的成像互換了位置,因此也被稱為鏡像交換( mirror-image
swap )。請參看下圖:
5、 如何在程序中判斷字節序
在實際的工作中,有時需要對字節序進行判斷,然后予以不同的處理。一般的來
說,編譯后的程序通常只能運行在特定的平臺之上,其所采用的字節序方式在 編
譯時即可確定,在這種情況下,程序源代碼中通常是把字節序的判別作為條件編
譯的判斷語句,而不會判斷代碼放在真正的可執行代碼中。
在這里,需要使用我們的老朋友 —— 宏。以下是一個真實的跨平臺工程中代碼,
清晰起見,我稍做了修改:
#define SGE_LITTLE_ENDIAN 1234
#define SGE_BIG_ENDIAN 4321
#ifndef SGE_BYTEORDER
#if defined(__hppa__) ||
defined(__m68k__) || defined(mc68000) || defined(_M_M68K) ||
(defined(__MIPS__) && defined(__MISPEB__)) ||
defined(__ppc__) || defined(__POWERPC__) || defined(_M_PPC) ||
defined(__sparc__)
#define SGE_BYTEORDER SGE_BIG_ENDIAN
#el
#define SGE_BYTEORDER SGE_LITTLE_ENDIAN
#endif
#endif
以上為根據平臺的預定義宏所作的前期工作,將之存入一個頭文件中,然后包含
到源代碼文件中使用。
在需要進行判斷的時候,則像以下代碼這樣使用:
#if SGE_BYTEORDER == SGE_BIG_ENDIAN
#define SwapWordLe(w) SwapWord(w)
#el
#define SwapWordLe(w) (w)
#endif
由于這兩個宏實際上被定義成了常量數值,因此也可以被用到可執行代碼中,進
行執行期的動態判斷:
if(SGE_BYTEORDER == SGE_BIG_ENDIAN)
return r << 16 | g << 8 | b;
el
return r | g << 8 | b << 16;
追根尋源,上面的這種判斷需要依賴編譯器及其所在平臺的預定義宏。下面介紹
一種執行期動態判斷的方法,則不需要有宏的參與,而是巧妙地利用了字節序的
本質。代碼如下:
int IsLittleEndian()
{
const static union
{
unsigned int i;
unsigned char c[4];
} u = { 0x00000001 };
return u.c[0];
}
動手畫一下內存布局即可了解其原理。還有更簡練的寫法,作為練習,請大家自
行去尋找。
在結束對字節序的討論之前,特別提醒一下, ARM 體系的 CPU 在字節序上與
Intel 的體系結構是一致的。
6、 字節對齊問題的產生
馮諾依曼體系的計算機,通過地址總線來尋址內存(假設 n 為地址總線的位數,
則最多可以尋址 2n 個內存位置)。根據地址總線的位數,我們可以知道 CPU 與
內存的一次交互(也即一次內存訪問)能夠讀寫的數據的大小。顯然地,對于 8
位的 CPU ,是一個字節,對于 16 位 CPU 則是一個字, 32 位 CPU 則是一個
雙字,依此類推。這是 CPU 與生俱來的最本質、最快捷的訪問方式。在實際的
計算需求中,如果訪問的數據量超過了一次訪問的限度,則很顯然需要進行多次
訪問,如果是少于的話,則需要對 從內存中取回的數據進行適當的裁剪。裁剪
操作有可能是 CPU 自身支持的,也有可能是需要用軟件來實現的。
有的系統是支持尋址到單個字節所在的位置的(稱為可字節尋址),而有的則不
可以,只能尋址到符合某些條件的地址上。對于 Intel/ARM 體系結構的 CPU ,
我們在宏觀上可以認為它們都支持字節尋址(但是 ARM 家族的 CPU 在內存訪問
時有其他約束,下文有詳細敘述)。
出現這樣的限制是有原因的,終極因素就在于內存訪問的粒度與字長的關聯上。
用 32 位 CPU 來說,它對于地址為 4 的倍數處的內存訪問是最自然的,其余的
地址就要做一些額外的工作。例如,我們要訪問地址為 0x03 處的一個雙字,對
于 80x86 體系,事實上將會導致 CPU 的兩次內存訪問,取回 0x00 以及 0x04
處的兩個雙字,分別進行適當的截取之后再拼裝為一個雙字返回。對于其他的體
系,設計者可能認為 CPU 不應該承擔數據拼裝的工作,因而就選擇產生一個硬
件異常。
在硬件和 / 或操作系統的約束下,進行數據訪問時對數據所在的起始位置以及
數據的大小都需要遵循一定的規則 ,與這些規則相關的問題,都可以稱之為字
節對齊問題。
舉例來說。在 HP-UX (惠普公司的一個服務器產品平臺, UNIX 的一種)平臺
中,系統嚴禁對奇地址直接進行訪問,假設你視這一原則于不顧:
int i = 0; // 編譯器保證 i 的起始地址不是奇地址
char c = *((char*)&i + 1); // 強制在奇地址處訪問
其執行結果就是內核轉儲( core dump ),為應用程序最嚴重的錯誤。(特別
注明:此處代碼為記憶中的情形,目前筆者已經沒有驗證環境了)
在不同的硬件體系架構下,字節對齊關系到三方面的問題,一是數據訪問的可行
性問題,二是數據訪問的效率問題,三是數據訪問的正確性問題。
字節對齊問題給程序員在編碼時帶來了額外的注意點,并且對最終程序執行的正
確性也帶來了一定的不確定因素。相同的代碼在不同的平臺上,甚至在相同的平
臺上采用不同的編譯選項,都可能有不同的執行結果。
如果所有的系統都和 HP-UX 的表現一樣的話,事情要簡單一些,問題通常會在
比較早的時間內就可以暴露出來。遺憾的是,我們目前所面對的平臺不是這樣,
這些平臺的設計者為最大程度地減 少對開發人員的干擾而作了辛苦的努力,使
得我們在很多時候都感覺不到字節對齊問題的存在。但另一方面,也制造出了把
問題隱藏得更深的機會。
效果最好的努力是 Intel 的體系架構。 80x86 允許你對整個內存進行字節尋址,
在不超過機器字長的情況下可以訪問任意數目的字節(很顯然,大多數情況下就
是 1 字節、 2 字節、 3 字節、 4 字節這四種情況)。
ARM 體系的 CPU 似乎做了一定的努力,但是其結果和其他體系相比呈現一種很
奇怪的狀態。由于筆者沒有對 ARM 整個系列的 CPU 進行過完整的了解,因此此
處的論述可能并不完整。 ARM CPU 允許對內存進行字節尋址,但在訪問時有額
外的要求。即:如果你要訪問一個字(注意本文慣例,此處的字是兩字節大小,
與 ARM 平臺的標準術語不同),那么起始地址必須在一個字的邊界上,如果訪
問一個雙字,則起始地址必須位于一個雙字的邊界上(其余數據類型請參考 ARM
的知識庫文檔)。這意味著,你不能在 0x03 這樣的地址處訪問一個字或者一個
雙字。但是,令人痛苦的事情到來了,如果你非要這么訪問,大多數的 CPU 不
會有顯式的異常,而是返回錯誤的數據,其余的一些 CPU 則會造成程序崩潰。
7、 如何控制字節對齊
控制程序的字節對齊行為是一個與編譯器相關的工作。以下編譯指示
( directive )被許多編譯器認可:
#pragma pack(n)
#pragma pack()
任何處于這兩個編譯指示語句之間的數據結構,將采用 n 字節的數據對齊方式。
n 是一個可以指定的數字,取值范圍請參閱所使用編譯器的文檔,通常都會取值
為 2 的冪。現代編譯器在對程序進行編譯時,處于效率方面的考慮,會對數據
結構的內存布局使用一個默認的字節對齊值,這個值一般都可以在命令行上顯式
指定。如果 要在一個頭文件 / 源文件中對特定的部分指定對齊屬性,則需要上
述的編譯指示。結束指示的寫法在某些編譯器或者平臺下需要寫成:
#pragma pack(pop)
我們用一個例子來看一下這兩個指示的實際效用,看它究竟是如何影響數據的內
存排列的。假定我們有如下的數據結構定義:
struct S1
{
int i;
char c;
short s;
};
struct S2
{
char c;
int i;
short s;
};
這兩個結構的成員看起來是一樣的,只不過換了一下順序而已。我們使用
sizeof() 操作符來測量各自占用多少字節(除非特別指出,均在 32 位平臺上,
并認為 int 占用 4 字節, char 占用 1 字節, short 占用 2 字節)。答案
似乎不可思議, sizeof(S1) 的結果是 8 ,而 sizeof(S2) 卻是 12 。差異是
怎么來的呢?原因就在于編譯器缺省的字節對齊設定在發生作用。
這里需要引入以下概念和規則:
概念及規則一,原生數據類型自身對齊值。原生數據類型即是 C/C++ 直接支持
的數據類型,也可以稱為內建( built in )數據類型。它們的自身對齊值分別
為: char 為 1 , short int 為 2 , int 、 float 、 double 等為 4 ,不
受符號位(即正負)的影響。
概念及規則二,用戶數據類型自身對齊值。用戶數據類型即由程序員定義的類、
結構、聯合等,也叫抽象數據類型( ADT )。它們的自身對齊值等同于為其成
員的對齊值中的最大值。
概念及規則三,用戶指定對齊值。程序員在編譯器命令行上的指定值,或者在
pragma pack 編譯指示中指定的值,對最終數據的影響取就近原則(顯然 pragma
pack 指示會覆蓋命令行的指定)。
概念及規則四,有效對齊值。取數據類型的自身對齊值與用戶指定對齊值中的較
小值。此值一旦決出,則會影響到數據在內存中的布局。一個有效對齊值為 n ,
表示以下事實:相關數據在內存中存放時,其起始地址的值必須可以被 n 整除 。
根據以上四條,可以很圓滿地解釋 S1 和 S2 的大小不同這一現狀。由于沒有使
用 pragma pack 指示,那么編譯器(在我的測試環境下)會采用缺省的對齊值 4 。
假設 S1 或者 S2 的實例將從地址 0x0000 處開始。
在 S1 中,第一個成員 i 的自身對齊值為 4 ,指定對齊值(盡管是缺省的)也
是 4 ,同時 0x0000 這一地址符合被 4 整除的要求,因此, i 將占據 0x0000
到 0x0003 的四個字節,下一個可用地址值為 0x0004 ;接下來的成員 c 的數
據類型為 char ,自身對齊值為 1 ,指定對齊值為 4 ,取較小者仍然是 1 ,
0x0004 符合被 1 整除的要求,因此 c 將占據 0x0004 處的一個字節,下一個
可用地址值為 0x0005 ;最后的一個成員 s 數據類型為 short ,自身對齊值為
2 ,指定對齊值為 4 ,有效對齊值取 2 ,但是地址 0x0005 不能符合被 2 整
除的要求,因此編譯器作相應調整,向后移動到最近的滿足要求的地址處,即
0x0006 , s 將占用 0x0006 和 0x0007 處的兩個字節,由此導致 S1 的大小為
8 。
在地址 0x0005 處的一個字節,習慣上稱之為填充數據( padding )。
同理可以輕易推出 S2 結構的大小確實是 12 。是這樣嗎?不是的。實際動手的
結果應該是 10 。那么 12 應該作何解釋?
我們來設想一個場景,程序員用 new 或者 malloc 分配一個 S2 的數組。不用
多,假定有兩個元素,而地址 0x0000 處正好有空閑的內存可以滿足這一內存分
配請求。我們都知道,在 C/C++ 語言中,數組的元素是緊鄰排放的。也就是說,
后一個元素的起始地址應該正好等于前一個元素的起始地址,并加上元素的大小。
我們來檢視一下 S2 的情況,它的元素大小為 10 ,它的有效對齊值是 4 (請
參閱概念及規則二),這表示任何一個 S2 結構的起始地址都應該位于 4 的整
數倍處。現實的情況是,第一個元素的起始地址是 0x0000 ,第二個元素的起始
地址變成了 0x000A ,而后者的數值不能滿足被 4 整除的要求。正是為了解決
這一情況,編譯器為 S2 結構在結尾處也增加了兩個字節的填充,從而滿足各個
條件的限定。
pragma pack 指示非常有效,使用也比較普遍,但是對于 ARM 平臺,它有一些
力所不及的地方,我們再來看一個例子。仍然用 S2 ,這一次,我們強制把它的
字節對齊設定為 1 ,并同時定義了 S2 的一個全局變量 s2 。也即:
#pragma pack(1)
struct S2
{
char c;
int i;
short s;
} s2;
#pragma pop()
然后,在某處具有如下的數據訪問:
int i = s2.i;
這條看上去稀松平常的語句很可能不能如所希望的那樣執行。因為對于 i 的訪
問其前提應該是 i 的起始地址是 4 的倍數(注意,這個不是對齊規則的約束結
果,而是 ARM CPU 的數據訪問規則的約束結果),但強行指定的 1 字節對齊則
導致 i 的起始地址是一個奇數。
RVCT 編譯器為此做了特別的努力,引入了 __packed 關鍵字。此關鍵字應用到
用戶定義數據結構上會導致該結構的內存布局取得與 pragma pack(1) 等同的
效果,但是,更進一步地,編譯器會把對該結構中成員的訪問作適當的處理,發
現不對齊的訪問則會翻譯為調用適當的保證數據正確性的函數。此關鍵字也可
以應用到指針上,以保證經由指針對目標對象的訪問也采用保守方式。可以預料
到的是,此關鍵字的使用會降低代碼執行的效率,所以需要慎用,一個很典型的
使用 場景是移植其他平臺的代碼時。以下是一些使用了此關鍵字的定義示例:
typedef __packed struct
{
char x; // 所有成員都會被 __packed 修飾
int y;
} X; // 5 字節的結構,自身對齊值 = 1
int f(X* p)
{
return p->y; // 執行一個非對齊的讀取操作
}
typedef struct
{
short x;
char y;
__packed int z; // 僅 __pack 本成員,此用法僅適用于整型
char a;
} Y; // 8 字節結構,自身對齊值 = 2(請思考原因)
int g(Y* p)
{
return p->z + p->x; // 僅對 z 執行非對齊讀取操作
}
需要注意的是, GCCE 編譯器沒有實現類似的努力,它有一個和對齊有關的關鍵
字: __attribute__ (packed)) ,該關鍵字的功效與 pragma pack(1) 類似。

本文發布于:2023-05-28 08:16:16,感謝您對本站的認可!
本文鏈接:http://www.newhan.cn/zhishi/a/1685232977182514.html
版權聲明:本站內容均來自互聯網,僅供演示用,請勿用于商業和其他非法用途。如果侵犯了您的權益請與我們聯系,我們將在24小時內刪除。
本文word下載地址:字節序、字節對齊的理解.doc
本文 PDF 下載地址:字節序、字節對齊的理解.pdf
| 留言與評論(共有 0 條評論) |