JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

位元組順序、字元與對齊

你現在已經知道單一個數字如何活在位元裡。最後這一步要問的是實務問題:一個數字的位元組在記憶體裡是以什麼順序排放、字母與表情符號如何變成位元組,以及為什麼硬體偏好你的資料停在整齊的位址邊界上?

一個數字是一個值,卻是好幾個位元組

在這個梯級裡,你一直把一個整數或一個浮點數當成單一個東西——一排有著單一意義的位元。但記憶體不是以位元定址的;它是以位元組(8 個位元一組)定址的,而大多數的值都比一個位元組寬。一個 32 位整數是四個位元組;一個 64 位的值是八個。所以當你一儲存多位元組的數字,一個算術從未提過的新問題就冒出來了:哪一個位元組該放在最低的位址?

拿 32 位的值 0x1A2B3C4D 來說。它有一個最高有效位元組(0x1A,這個數的大端)和一個最低有效位元組(0x4D,小端)。這四個位元組必須鋪排在四個連續的位址上,而合理的做法有兩種。這個排序的抉擇叫做位元組順序,雖然聽起來像個註腳,它卻正是那種會在一台機器寫出檔案、另一台讀回卻讀錯時悄悄毀掉資料的細節。

大端與小端:哪一端先來

大端序把大端放在前面:最高有效位元組擺在最低的位址,就像我們由左到右、最大位數先寫的十進位數字一樣。一台大端機器的記憶體傾印,讀起來與十六進位字面值的順序相同,這對人類來說很順眼。網路協定很早就統一採用大端序,這也是它被稱為網路位元組順序的原因。

小端序把小端放在前面:最低有效位元組擺在最低的位址。它在傾印裡看起來是反的,卻有一種低調的優雅——任一位址上的位元組永遠是「個位」位元組,所以把一個值當成位元組、半字或整個字來讀,全都從同一個地方開始、只是多拿幾個位元組而已。你每天在用的 x86 與大多數 ARM 系統都是小端序,所以實務上,你的筆電幾乎肯定就是小端序。

store 32-bit  0x1A2B3C4D  starting at address 100:

  address:    100    101    102    103
  big-endian: 0x1A   0x2B   0x3C   0x4D     (big end first)
  little-end: 0x4D   0x3C   0x2B   0x1A     (little end first)

read back as a byte at address 100:
  big-endian -> 0x1A     little-endian -> 0x4D
同一個數、同一批位元組,在記憶體中的順序相反——差別只在排列方式,而唯有跨機器傳輸時它才會咬人。

字元:從 ASCII 到 Unicode 與 UTF-8

記憶體裡的東西不只有數字——文字也在裡頭,而文字不過是依某個約定好的字元編碼來解讀的數字罷了。老式的 ASCII 方案給每個英文字母、數字與標點符號一個 0 到 127 的數字,剛好塞進 7 位(也就是一個位元組,最高位空著)。「A」是 65(0x41),「a」是 97,數字「0」是 48。那個空著的位元與表中的空隙,正是為什麼簡單的小技巧管用,比如翻轉一個位元就能切換字母大小寫。

ASCII 涵蓋了英文,幾乎別的都不行。Unicode 補正了這點,它給每一種文字系統裡的每一個字元——拉丁文、中文、العربية、表情符號——各自一個數字,叫做碼位,容量超過一百萬個。但碼位是個抽象的數字;你仍得把它變成位元組。最主流的做法是 UTF-8,而它的設計確實巧妙。

UTF-8 是一種可變長度的編碼:一個字元依其碼位用上一到四個位元組。前 128 個碼位編成單一個與 ASCII 完全相同的位元組,所以每一個 ASCII 檔案本身就已經是合法的 UTF-8——一個漂亮的向後相容設計。較大的碼位用兩、三或四個位元組,其中開頭的位元組宣告長度,而後續位元組全部以位元 10 開頭,於是你一眼就能分辨一個位元組是某個字元的開頭、還是某個字元的延續。誠實的代價是:你再也不能靠跳五個位元組去找「第五個字元」,因為字元並非全都一樣寬。

對齊:為什麼硬體喜歡整齊的邊界

記憶體交給處理器的資料不是一次一個位元組,而是一塊一塊地給——一次一個,在快取層級則是一整條也許 64 位元組的快取線。正因如此,硬體強烈偏好每個值落在它自身大小整數倍的位址上:一個 4 位元組的整數放在能被 4 整除的位址、一個 8 位元組的值放在能被 8 整除的位址。如此擺放的值就是對齊的;這個限制就是資料對齊

為什麼這有差?一個對齊的 4 位元組值完整地落在一個記憶體字之內,所以硬體一次存取就能把它取來。一個未對齊的值可能橫跨兩個字——甚至兩條快取線——逼得硬體做兩次取用、外加一些位移來把兩半縫合起來。在某些處理器上,未對齊存取只是比較慢;在較嚴格的處理器上,它會引發例外而當機。這就是前幾個梯級的局部性故事拉近來看的樣子:尊重邊界,能讓每次存取搭上一趟乾淨的傳輸。

這就是為什麼編譯器會在結構內塞進看不見的填補位元組。若一個 1 位元組的欄位後面跟著一個 4 位元組的欄位,編譯器可能會在第一個欄位後留下 3 個沒用到的位元組,好讓第二個欄位落在 4 的倍數上。把一個結構的欄位從大到小重新排列,可以靠去掉填補來縮小它——一個小而真實的好處,當你手上有上百萬筆記錄時就有影響。那些填補不是編譯器忘了清的浪費;它是讓每個欄位都能以一次對齊存取被取出的代價。

從頭到尾讀懂一份佈局

把這三個概念合起來,你就能讀懂任何一塊記憶體。假設一筆小記錄裝著一個 1 位元組的旗標、接著一個小端序的 4 位元組整數、再接著一個 3 字元的 UTF-8(ASCII)標籤。解碼它是一道程序,不是猜測:你逐位元組走過去,心裡清楚每個欄位的大小、數字的位元組順序、文字的編碼,以及編譯器為了維持對齊而在哪裡塞進了填補。

  1. 讀位移 0 處的 1 個位元組當作旗標——單一位元組,所以位元組順序用不上。
  2. 跳過 3 個填補位元組(位移 1 到 3),好讓整數從位移 4 開始——4 的倍數——這就是對齊在運作。
  3. 讀位移 4 處的 4 個位元組,由於這台機器是小端序,把它們反轉以還原整數的值。
  4. 把接下來的 3 個位元組讀成 0 到 127 的碼位,各自解碼成一個 ASCII/UTF-8 字元。

至此,資料表示這個梯級就完整了。你已經從原始的位元與位元組,走過無號與二補數整數、讓它們變快的位元與位移小技巧、浮點數及其誠實的不精確,最後走到這些值連同文字如何在真實記憶體中有序且對齊地坐落。階梯上比這更高的一切——資料路徑、快取、虛擬記憶體——操弄的,正是這些你現在能親手讀懂的位元組樣式。