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

位元、遮罩,與文字編碼

整級的功課在這裡兌現。你會學到怎麼用位元運算子與遮罩,伸手進去翻動、測試、打包一個個位元,接著看著同樣的那些位元組,透過 ASCII、Unicode 與 UTF-8 變成讀得懂的文字。

六個逐位元動作的運算子

到現在,你已經能用三種方式讀一個位元組,也知道它的位元可能代表一個無號計數、一個二的補數有號值,或一個浮點數的一部分。這最後一篇要給你的工具,會完全無視那些意義,直接在原始的位元上動手。位元運算子接受一或兩個運算元,逐位元、逐欄地產生結果,欄與欄之間沒有進位,也沒有符號的概念。值得記住的有六個:AND、OR、XOR、NOT,以及兩個位移。每一個在 C 裡都是一個字元或一對字元,而每一個都只做一件微小、可預測的事。

一個一個來看。AND,寫作 `x & y`,只有在兩個輸入在那一欄是 1 時,那一欄的結果才是 1;把任何東西和一個 0 配對,就強迫那一欄變成 0。OR,寫作 `x | y`,只要任一輸入在那欄是 1,結果就是 1;把任何東西和一個 1 配對,就強迫那一欄變成 1。XOR,寫作 `x ^ y`(互斥或),恰好在兩個輸入相異時給出 1;和一個 1 配對會翻轉一個位元,和一個 0 配對則維持原狀。NOT,寫作 `~x`,只取一個運算元,一口氣把每個位元都翻轉。注意你早就見過 `~x` 了:它正是二的補數那篇取負招式的前半,那裡用 `~x + 1` 算出了負數。

一組小小的實作範例就能把它說具體。設 x 是 1100 1010、y 是 0110 0110。那麼 `x & y` 是 0100 0010(只有兩者都是 1 處才是 1)、`x | y` 是 1110 1110(任一是 1 處就是 1)、`x ^ y` 是 1010 1100(只有在它們相異的欄才是 1)、而 `~x` 是 0011 0101(x 的每個位元都翻轉)。沒有進位、沒有符號——每一欄完全各自獨立地決定。別把這些和邏輯運算子 `&&`、`||`、`!` 搞混了,後者把整個運算元當成單一的真/假值來看、而且會短路求值:`5 & 2` 是 0101 和 0010 的位元 AND,結果是 0,而 `5 && 2` 問的是「兩者都非零嗎?」,結果是 1。它們長得幾乎一樣,意思卻南轅北轍——一個經典的初學者失誤,會安靜地編譯通過、產出一堆胡言亂語。

位移:把位元往左往右滑

兩個位移運算子把整個樣式往旁邊搬。`x << 3` 把每個位元往滑三格,右邊補進 0,左邊掉出去的就丟掉。因為每往左一步就讓每個位值加倍,往左移 n 位就是乘以 2^n——所以 `x << 3` 就是 `x * 8`,而且不用乘法器就算出來。`x >> 1` 把所有東西往滑一格,是它做除法的表親,大致是 `x / 2`。位移是中央處理器做得最便宜的算術,也是把多個值打包在一起的家常基本功。

右移藏著一個你絕不能含糊帶過的真正細節,而它直接接回二的補數。當高位空出來時,是什麼填進去——0,還是符號位元的複本?這個選擇正是算術位移與邏輯位移核心的差別。邏輯右移永遠補進 0,這對無號數是對的。算術右移補進符號位元的複本,於是一個負數仍然是負的,`>>` 就算對負數也維持「除以二」的意思。在 C 裡這條規則由型別決定:對 `unsigned int` 做位移是邏輯的,而把一個負的有號值右移則是由實作定義的——大多數編譯器做算術位移,但標準並沒有強制,所以在講求可攜的程式碼裡別倚賴它。

這裡有一道連老手都會忘記的銳利邊緣:位移量等於或大於型別寬度,在 C 裡是未定義行為。對一個 32 位元的 `unsigned int` 來說,`x << 32` 並不是「把所有東西移出去得到 0」——它是未定義行為,而正如基礎教材所警告的,最佳化器被允許假設它永遠不會發生,接著就可能做出令人意外的事;同一段程式碼在 -O0 與 -O2 下可以表現得不一樣。位元不會就這麼憑空消失;是整個程式失去了所有保證。把位移量嚴格保持在寬度以下。

遮罩:測試、設定、清除與切換

現在這些運算子變成一套工具了。遮罩不過是你精心挑選的一個位元樣式,用來和你的資料結合,只碰你打算碰的那些位元。這就是遮罩與旗標,也是一個整數如何能打包一整排各自獨立的是/否開關——一組權限、一個狀態字、一個硬體暫存器——讓每個開關住在一個位元裡的辦法。要為第 k 號位元造一個遮罩,你拿一個單獨的 1 把它移到定位:`1u << k`。於是第 0 位是 `0x01`、第 3 位是 `0x08`、第 7 位是 `0x80`。這些就是你伸手去碰某個特定位元的工具。

  1. 測試一個位元:`x & mask` 恰好在那個位元被設起時為非零。AND 只保留被遮的那一欄、把其餘清成零,所以結果只有在所選位元是 1 時才非零。
  2. 設定一個位元:`x | mask` 把被遮的那一欄強制成 1,其餘每一欄原封不動,因為和 0 做 OR 不會改變任何東西。
  3. 清除一個位元:`x & ~mask` 把被遮的那一欄強制成 0。`~mask` 除了目標那一欄之外全是 1,所以 AND 保住其他一切、只把那一個位元清成零。
  4. 切換一個位元:`x ^ mask` 翻轉被遮的那一欄、其餘維持不動,因為和 1 做 XOR 會反轉、和 0 做 XOR 會保留。

兩個樣式把這套工具補齊。要從一個打包好的值裡抽出一段相鄰的若干位元——比方一個 32 位元字的最低位元組——你把它往下移,再和一個剛好夠多 1 的遮罩做 AND:`(x >> 8) & 0xFF` 從最底端數來抽出第二個位元組。而 `0xFF` 本身就是「保留最低八個位元、其餘全丟掉」的日常遮罩,這正是你從一個較大的值裡分離出一個位元組的辦法。把用來對齊的位移和用來修邊的 AND 結合起來,你就能從任何一個字裡刻出任意一段位元。

打包旗標,以及關於位元欄位的一句話

當許多小事實必須一起搬運時,遮罩就回本了。假設有三種權限——讀、寫、執行——你給每一種一個位元:讀是 `1u << 0`、寫是 `1u << 1`、執行是 `1u << 2`。現在一個叫 perms 的 `unsigned int` 就同時帶著這三者。你用 `perms = (1u << 0) | (1u << 2)` 設定「讀加執行」,也就是 `0x05`;你用 `perms & (1u << 1)` 問「它能寫嗎?」;你用 `perms &= ~(1u << 1)` 收回寫權。你看過、長得像 0755 那種數字的 Unix 檔案模式,就是這樣鋪排的——每個八進位數字是一個小小的三位元 rwx 遮罩,全都騎在一個數裡頭。

C 為此提供了一個看起來更整潔的捷徑,位元欄位,它讓你宣告結構裡的具名成員,各佔指定數量的位元,於是你寫 `flags.write = 1`,而不用拋接遮罩。它讀起來比較清楚,但對它的限制要老實:標準把位元欄位的位元佈局大致交給編譯器決定——位元從哪一端起算、怎麼打包、填補落在哪裡——所以兩個編譯器可以把同一個結構排得不一樣。這使得位元欄位很不適合用來對應一個固定的硬體暫存器或一種傳輸格式,那些情況下確切的位元位置是由外界規定的。對那些情況,顯式的遮罩與位移仍然是可攜、可預測的選擇。

對任何跨越機器邊界的東西,遮罩勝過位元欄位還有一個更深的理由,你在第 3 篇見過它:位元組順序。當一個多位元組的值離開你的程式的那一刻——寫進檔案、經由通訊端送出——它的位元組坐落的順序就變得可見,而兩台機器可能各執一詞。遮罩與位移操作的是一個值在數值上的意義,而非它在記憶體裡的位元組順序,所以用 `(hi << 8) | lo` 造出一個欄位、再以雙方議定的順序寫出位元組,能給你一份編譯器自選的位元欄位佈局根本給不了的掌控。對於什麼會抵達另一端心存疑慮時,就伸手去拿顯式的位元運算。

從位元組到文字:ASCII、Unicode 與 UTF-8

文字是本級鋪在那些老位元組上的最後一種解讀,也是你會遇到最純粹的樣式對解讀的示範。一個裝著 0x41 的 `char`,對算術單元來說是數字 65,對任何同意把它讀成文字的東西來說則是字母 A。那份同意就是一種編碼——一張公布的、把數字對應到字元的表。最早的這種表是 ASCII,可追溯到 1963 年,它把 0 到 127 的值指派給英文字母、數字、常見標點、空格,以及少數幾個控制碼,像換行(0x0A)和定位(0x09)。七個位元、128 個格子——這正是為什麼一個純 ASCII 字元永遠裝得進一個位元組,且最高位是 0。

ASCII 涵蓋英文,其餘幾乎什麼都不管,所以世界需要更大的東西。Unicode 就是答案,但要把它是什麼說精確:Unicode 是一份龐大的目錄,它給每一種文字系統的每一個字元——拉丁、漢字、阿拉伯、表情符號——指派一個單一的數,叫做碼位,寫成像 U+0041 代表 A、U+4E2D 代表「中」。Unicode 本身對位元組隻字不提;它只固定了哪個數命名哪個字元。那些碼位的數究竟怎麼在記憶體或檔案裡變成位元組,是另一個分開的問題,由一種編碼來回答。把目錄(Unicode)和位元組編碼分開來想,是釐清大多數文字混淆的那一個關鍵觀念。

UTF-8,以及整級盡收一框

贏得網際網路的編碼是 UTF-8,而它的設計是本篇那些位元把戲的一個小小傑作。UTF-8 是變動寬度的:一個碼位變成一到四個位元組。關鍵的性質是,那 128 個 ASCII 字元維持它們確切的單位元組 ASCII 值不變,所以每一個 ASCII 檔案本來就已經是合法的 UTF-8——向後相容不花一毛錢。超過 127 的碼位用兩、三或四個位元組,而每個位元組的高位充當小小的路標:起始位元組宣告後面跟著幾個位元組,而每一個接續位元組都以位元 10 開頭。那些路標位元,正是用你剛學的遮罩來讀的。

code point range   bytes   bit layout (x = code-point bits)
  U+0000..U+007F     1     0xxxxxxx                     (plain ASCII)
  U+0080..U+07FF     2     110xxxxx 10xxxxxx
  U+0800..U+FFFF     3     1110xxxx 10xxxxxx 10xxxxxx

example:  the Han character at U+4E2D
          0x4E2D = 0100 1110 0010 1101   (16 code-point bits)
          3 bytes:  1110_0100  10_111000  10_101101
                  =  0xE4       0xB8       0xAD
UTF-8 的位元組形狀。起首的位元(0、110、1110、10)是你用 AND 遮罩與位移去測試、去剝除的路標。

退一步,把整級放進一個畫框裡看。一個位元組是一個本身不帶意義的位元樣式;二進位與十六進位讓你讀它,二的補數讓它能是負的,IEEE-754 讓它能是帶小數的,一種編碼讓它能是一個字母——而位元運算子讓你伸手進去,重排那一切解讀底下的位元。這就是系統程式設計賴以建立的識讀能力。從這裡起,這道梯子轉向這些位元組住在哪裡、以及程式怎麼為它們命名:指標、堆疊與堆積。你現在讀得懂、也塑形得了的這些位元,即將擁有位址。