字節那些事兒
1、 前言
本文引用地址:http://dyxdggzs.com/article/201607/294782.htm作為一名 C/C++ 程序員,字節是我們天天都要與之打交道的一個(gè)東西。我們和它熟稔到幾乎已經(jīng)忘記了它的存在??墒?,它自己是不甘寂寞的,或遲或早地,總會(huì )在某些時(shí)候探出頭來(lái)張望,然后給你一個(gè)腿兒絆。其實(shí),只要你真正了解了它的底細,你就會(huì )暢行無(wú)阻。在本文中,我們將首先簡(jiǎn)要了解一下字節的概念,然后著(zhù)重了解一下字節序問(wèn)題和字節對齊問(wèn)題。
注:筆者已經(jīng)盡最大努力保證本文信息的正確性,但確實(shí)無(wú)法提供百分之百的擔保。
2、 什么是字節
我們知道,二進(jìn)制計算機(也就是我們目前接觸到的幾乎所有的計算機)的最小數據單位是位( bit )。一位數據只能夠表示兩種含義(需要說(shuō)明,盡管我們通常把單個(gè)位表示的兩種含義選擇為相互對立的含義,但這并不是必然的,例如你可以認為 1 代表 5 個(gè)人, 0 代表 8 個(gè)人),對于絕大多數的計算要求,單個(gè)位顯然不能滿(mǎn)足。因此,我們通常都會(huì )使用一連串的位,我們可以稱(chēng)之為位串( bit string ,請愛(ài)好質(zhì)疑的的朋友注意,此術(shù)語(yǔ)非我杜撰)。由于種種原因,計算機系統都不會(huì )讓你使用任意長(cháng)度的位串,而是使用某個(gè)特定長(cháng)度的位串。一些常見(jiàn)的位串長(cháng)度形式具有約定好的名稱(chēng),如,半字節( nibble ,貌似用的不多)代表四個(gè)位的組合,字節( byte ,主角出場(chǎng)!)代表 8 個(gè)位的組合。再多的還有,字( word )、雙字( Double word ,通常簡(jiǎn)寫(xiě)為 Dword )、四字(Quad word ,經(jīng)常簡(jiǎn)寫(xiě)為 Qword )、十字節( Ten byte ,也簡(jiǎn)寫(xiě)為 Tbyte )。
在這些里面,字( word )有時(shí)表示不同的含義。在 Intel 體系里, word 表示一個(gè) 16 位的數值,它是固定大小的。而在另外一些場(chǎng)合, word 表示了 CPU 一次可處理的數據的位數,表示一個(gè)符合 CPU 字長(cháng)( word-length )的數目的位串。事實(shí)上我們接觸較多的 ARM 體系中, word 就有不同的含義,它表示一個(gè) 32 位的數據(與機器字長(cháng)相同),對于 16 位大小的數據, ARM 使用了另外的一個(gè)術(shù)語(yǔ),叫作半字( half-word ),請大家在文檔閱讀時(shí)加以注意。另外, Qword 也是 Intel 體系中的術(shù)語(yǔ),其他的體系中可能并不使用。在本文中,我們按照 Intel 的慣例來(lái)使用字或者 word 這一術(shù)語(yǔ)。
一個(gè)字節中共有 8 個(gè)數據位,有時(shí)需要用圖表逐位表述各個(gè)位。習慣上,我們按照下面的圖來(lái)排列各個(gè)位的順序,即,按照從右到左的順序,依次為最低位(從第 0 位開(kāi)始)到最高位(對于字節,則是第 7 位):

字節是大多數現代計算機的最小存儲單元,但這并不代表它是計算機可以最高效地處理的數據單位。一般的來(lái)說(shuō),計算機可以最高效地處理的數據大小,應該與其字長(cháng)相同。在目前來(lái)講,桌面平臺的處理器字長(cháng)正處于從 32位向 64 位過(guò)渡的時(shí)期,嵌入式設備的基本穩定在 32 位,而在某些專(zhuān)業(yè)領(lǐng)域(如高端顯卡),處理器字長(cháng)早已經(jīng)達到了 64 位乃至更多的 128 位。
3、 字節序問(wèn)題的由來(lái)
對于字、雙字這些多于一個(gè)字節的數據,如果把它們放置到內存中的某個(gè)位置上,可以看出,我們還可以將之看作是字節的序列。一個(gè)字是兩個(gè)字節,雙字則是四個(gè)字節。假設有以下數據: 0x12345678 、 0x9abcdef0 。在此處,我使用了我們最習慣的十六進(jìn)制表示法,并給出了兩個(gè)雙字的值。按照慣例,我把雙字的左側視為高端,而把右側視為低端。把它們順序放置在起始地址為 0 的內存中,如下圖所示:

由圖示可知, 0x9abcdef 的相應地址為 0x04 ?,F在,問(wèn)題來(lái)了,如果有一個(gè)內存操作,要從地址 0x06 處讀取一個(gè)字,得到的結果是多少呢?答案是:不一定。
這里的本質(zhì)問(wèn)題在于,如何把多字節的對象存儲到內存中去呢?即使使用最正常的思維去考慮這個(gè)問(wèn)題,你也會(huì )發(fā)現有兩種方法。第一種方法是,把最低端的字節放到指定的起始位置(即基地址處),然后按照從低到高的字節順序把其余字節依次放入,如下圖 a ;另一種方法非常類(lèi)似,但是對高端字節和低端字節的處理順序正好相反,如下圖 b (我確信你還可以想出其他的方法,但是除二字節的情況外,必然會(huì )打破字節排列順序的一致性,我視之為反常規思維的產(chǎn)物,此處暫不考慮)。

圖 a

圖 b
在很久之前,哪一種存儲方式更為合理曾經(jīng)有過(guò)爭論。到今天,爭論的結果已經(jīng)無(wú)關(guān)緊要了,緊要的是以下事實(shí):這兩種存儲方式都被應用到了現實(shí)的計算機系統中。上圖 a 中的排列方式為 Intel 所采用并大行其道,而圖 b的排列方式則被大多數的其他平臺采用(如最近被蘋(píng)果公司徹底拋棄的 PowerPC ),因此上,我們不能稱(chēng)之為罕見(jiàn)的用法。之所以造成事實(shí)上的不經(jīng)常見(jiàn)到,其原因正如我今天中午所得到的消息: Intel 的 CPU 占整個(gè)市場(chǎng)份額的 80% 以上。
這兩種排列方式通常用小端( little endian )和大端( big endian )來(lái)稱(chēng)謂。這兩個(gè)奇怪的名字據說(shuō)來(lái)源于童話(huà)《格列佛游記》,其中小人國里的公民為了雞蛋到底是應該從小的一頭打開(kāi)還是大的一頭打開(kāi)而大起爭執。 Intel的方式對應于“小端”,順便說(shuō)一句,大端的方式也有一個(gè)大公司的名字作為其代表,即最近開(kāi)始沒(méi)落的 Motorola。如果有誰(shuí)了解過(guò) TIFF 圖像文件格式,就會(huì )發(fā)現其文件頭中用以標識文件數據字節序的標志就是“ II ”和“ MM”,分別對應于 Intel 和 Motorola 的首字母。值得提醒一下,小端方式的排列與位的排列順序相一致,看上去似乎更協(xié)調一些。
現在我們可以回答上面的問(wèn)題了。對于小端字節序,我們取到的字,其值為 0x9abc ,而如果是大端字節序的話(huà),就會(huì )取到 0xdef0 。
4、 何時(shí)會(huì )出現字節序問(wèn)題
字節序問(wèn)題主要出現在數據在不同平臺之間進(jìn)行交換時(shí),交換的途徑可能是網(wǎng)絡(luò )傳輸,也可能是文件復制。例如,如果你設計了一種可能會(huì )應用于不同平臺的文件格式,其中存儲了某些數據結構,則對于大小大于一個(gè)字節的數據就要明確地規定其遵循的字節序,以便各平臺上的處理程序可以在使用數據時(shí)實(shí)現做必要的轉換。
舉一個(gè)實(shí)際的例子。 Java 是一個(gè)跨平臺的編程語(yǔ)言,其可執行文件(擴展名為 .class ,使用的是一種機器無(wú)關(guān)的字節碼指令集)在理論上可以運行于所有的實(shí)現了 Java 運行時(shí)的平臺(包含有與特定平臺相關(guān)特性的除外)。編譯后的 .class 中一定保存有諸如 Integer 這樣類(lèi)型的數據,這就涉及到了字節序的確定,否則 .class 必然不能被采用了不同字節序的平臺同時(shí)正確加載并運行。事實(shí)上, Java 語(yǔ)言采用的為大端字節序,這個(gè)一點(diǎn)都不奇怪,因為當初 SUN 公司自己的 SPARC 架構就是采用的大端字節序。同樣的問(wèn)題和解決問(wèn)題的方式,也存在于操作系統新貴 android 系統上。
網(wǎng)絡(luò )傳輸則是另一個(gè)典型場(chǎng)景。 TCP/IP 所采用的網(wǎng)絡(luò )傳輸字節序標準也是大端字節序,這個(gè)也不必奇怪,因為 TCP/IP 是從 UNIX 系統發(fā)展起來(lái)的,而絕大部分的 UNIX 系統在很長(cháng)的一段時(shí)間內都沒(méi)有運行于 Intel 體系架構上的版本。
處理字節序問(wèn)題的手段非常簡(jiǎn)單,也就是對數據進(jìn)行必要的轉換:將十六進(jìn)制的數字從兩端開(kāi)始交換,直至移動(dòng)到數據的中心,交換完成為止。交換的結果就好像物體與鏡面之內的成像互換了位置,因此也被稱(chēng)為鏡像交換(mirror-image swap )。請參看下圖:

5、 如何在程序中判斷字節序
在實(shí)際的工作中,有時(shí)需要對字節序進(jìn)行判斷,然后予以不同的處理。一般的來(lái)說(shuō),編譯后的程序通常只能運行在特定的平臺之上,其所采用的字節序方式在編譯時(shí)即可確定,在這種情況下,程序源代碼中通常是把字節序的判別作為條件編譯的判斷語(yǔ)句,而不會(huì )判斷代碼放在真正的可執行代碼中。
在這里,需要使用我們的老朋友 —— 宏。以下是一個(gè)真實(shí)的跨平臺工程中代碼,清晰起見(jiàn),我稍做了修改:
#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
#else
#define SGE_BYTEORDER SGE_LITTLE_ENDIAN
#endif
#endif
以上為根據平臺的預定義宏所作的前期工作,將之存入一個(gè)頭文件中,然后包含到源代碼文件中使用。
在需要進(jìn)行判斷的時(shí)候,則像以下代碼這樣使用:
#if SGE_BYTEORDER == SGE_BIG_ENDIAN
#define SwapWordLe(w) SwapWord(w)
#else
#define SwapWordLe(w) (w)
#endif
由于這兩個(gè)宏實(shí)際上被定義成了常量數值,因此也可以被用到可執行代碼中,進(jìn)行執行期的動(dòng)態(tài)判斷:
if(SGE_BYTEORDER == SGE_BIG_ENDIAN)
return r << 16 | g << 8 | b;
else
return r | g << 8 | b << 16;
追根尋源,上面的這種判斷需要依賴(lài)編譯器及其所在平臺的預定義宏。下面介紹一種執行期動(dòng)態(tài)判斷的方法,則不需要有宏的參與,而是巧妙地利用了字節序的本質(zhì)。代碼如下:
int IsLittleEndian()
{
const static union
{
unsigned int i;
unsigned char c[4];
} u = { 0x00000001 };
return u.c[0];
}
動(dòng)手畫(huà)一下內存布局即可了解其原理。還有更簡(jiǎn)練的寫(xiě)法,作為練習,請大家自行去尋找。
在結束對字節序的討論之前,特別提醒一下, ARM 體系的 CPU 在字節序上與 Intel 的體系結構是一致的。
6、 字節對齊問(wèn)題的產(chǎn)生
馮諾依曼體系的計算機,通過(guò)地址總線(xiàn)來(lái)尋址內存(假設 n 為地址總線(xiàn)的位數,則最多可以尋址 2n 個(gè)內存位置)。根據地址總線(xiàn)的位數,我們可以知道 CPU 與內存的一次交互(也即一次內存訪(fǎng)問(wèn))能夠讀寫(xiě)的數據的大小。顯然地,對于 8 位的 CPU ,是一個(gè)字節,對于 16 位 CPU 則是一個(gè)字, 32 位 CPU 則是一個(gè)雙字,依此類(lèi)推。這是 CPU 與生俱來(lái)的最本質(zhì)、最快捷的訪(fǎng)問(wèn)方式。在實(shí)際的計算需求中,如果訪(fǎng)問(wèn)的數據量超過(guò)了一次訪(fǎng)問(wèn)的限度,則很顯然需要進(jìn)行多次訪(fǎng)問(wèn),如果是少于的話(huà),則需要對從內存中取回的數據進(jìn)行適當的裁剪。裁剪操作有可能是CPU 自身支持的,也有可能是需要用軟件來(lái)實(shí)現的。
有的系統是支持尋址到單個(gè)字節所在的位置的(稱(chēng)為可字節尋址),而有的則不可以,只能尋址到符合某些條件的地址上。對于 Intel/ARM 體系結構的 CPU ,我們在宏觀(guān)上可以認為它們都支持字節尋址(但是 ARM 家族的CPU 在內存訪(fǎng)問(wèn)時(shí)有其他約束,下文有詳細敘述)。
出現這樣的限制是有原因的,終極因素就在于內存訪(fǎng)問(wèn)的粒度與字長(cháng)的關(guān)聯(lián)上。用 32 位 CPU 來(lái)說(shuō),它對于地址為 4 的倍數處的內存訪(fǎng)問(wèn)是最自然的,其余的地址就要做一些額外的工作。例如,我們要訪(fǎng)問(wèn)地址為 0x03 處的一個(gè)雙字,對于 80x86 體系,事實(shí)上將會(huì )導致 CPU 的兩次內存訪(fǎng)問(wèn),取回 0x00 以及 0x04 處的兩個(gè)雙字,分別進(jìn)行適當的截取之后再拼裝為一個(gè)雙字返回。對于其他的體系,設計者可能認為 CPU 不應該承擔數據拼裝的工作,因而就選擇產(chǎn)生一個(gè)硬件異常。
在硬件和 / 或操作系統的約束下,進(jìn)行數據訪(fǎng)問(wèn)時(shí)對數據所在的起始位置以及數據的大小都需要遵循一定的規則 ,與這些規則相關(guān)的問(wèn)題,都可以稱(chēng)之為字節對齊問(wèn)題。
舉例來(lái)說(shuō)。在 HP-UX (惠普公司的一個(gè)服務(wù)器產(chǎn)品平臺, UNIX 的一種)平臺中,系統嚴禁對奇地址直接進(jìn)行訪(fǎng)問(wèn),假設你視這一原則于不顧:
int i = 0; // 編譯器保證 i 的起始地址不是奇地址
char c = *((char*)&i + 1); // 強制在奇地址處訪(fǎng)問(wèn)
其執行結果就是內核轉儲( core dump ),為應用程序最嚴重的錯誤。(特別注明:此處代碼為記憶中的情形,目前筆者已經(jīng)沒(méi)有驗證環(huán)境了)
在不同的硬件體系架構下,字節對齊關(guān)系到三方面的問(wèn)題,一是數據訪(fǎng)問(wèn)的可行性問(wèn)題,二是數據訪(fǎng)問(wèn)的效率問(wèn)題,三是數據訪(fǎng)問(wèn)的正確性問(wèn)題。
字節對齊問(wèn)題給程序員在編碼時(shí)帶來(lái)了額外的注意點(diǎn),并且對最終程序執行的正確性也帶來(lái)了一定的不確定因素。相同的代碼在不同的平臺上,甚至在相同的平臺上采用不同的編譯選項,都可能有不同的執行結果。
如果所有的系統都和 HP-UX 的表現一樣的話(huà),事情要簡(jiǎn)單一些,問(wèn)題通常會(huì )在比較早的時(shí)間內就可以暴露出來(lái)。遺憾的是,我們目前所面對的平臺不是這樣,這些平臺的設計者為最大程度地減少對開(kāi)發(fā)人員的干擾而作了辛苦的努力,使得我們在很多時(shí)候都感覺(jué)不到字節對齊問(wèn)題的存在。但另一方面,也制造出了把問(wèn)題隱藏得更深的機會(huì )。
效果最好的努力是 Intel 的體系架構。 80x86 允許你對整個(gè)內存進(jìn)行字節尋址,在不超過(guò)機器字長(cháng)的情況下可以訪(fǎng)問(wèn)任意數目的字節(很顯然,大多數情況下就是 1 字節、 2 字節、 3 字節、 4 字節這四種情況)。
ARM 體系的 CPU 似乎做了一定的努力,但是其結果和其他體系相比呈現一種很奇怪的狀態(tài)。由于筆者沒(méi)有對ARM 整個(gè)系列的 CPU 進(jìn)行過(guò)完整的了解,因此此處的論述可能并不完整。 ARM CPU 允許對內存進(jìn)行字節尋址,但在訪(fǎng)問(wèn)時(shí)有額外的要求。即:如果你要訪(fǎng)問(wèn)一個(gè)字(注意本文慣例,此處的字是兩字節大小,與 ARM 平臺的標準術(shù)語(yǔ)不同),那么起始地址必須在一個(gè)字的邊界上,如果訪(fǎng)問(wèn)一個(gè)雙字,則起始地址必須位于一個(gè)雙字的邊界上(其余數據類(lèi)型請參考 ARM 的知識庫文檔)。這意味著(zhù),你不能在 0x03 這樣的地址處訪(fǎng)問(wèn)一個(gè)字或者一個(gè)雙字。但是,令人痛苦的事情到來(lái)了,如果你非要這么訪(fǎng)問(wèn),大多數的 CPU 不會(huì )有顯式的異常,而是返回錯誤的數據,其余的一些 CPU 則會(huì )造成程序崩潰。
評論