固定的位數,就像會繞圈的時鐘
在上一篇你已經看到為什麼 二補數如此通用:它只有一個零,而且能讓同一個加法器同時做加法與減法。但這份優雅藏著一個我們現在必須正面面對的陷阱。一個暫存器只能容納固定的位數——比方說 8、32 或 64 位——就只有這麼多。最高位之外,沒有「下一欄」可以進位。所以數值不會一路跑到無限大;它們會繞回來,就像一個 12 小時制的時鐘,11 加 2 會落在 1,而不是 13。
對 n 位的無號整數來說,合法的數值是 0 到 2^n 減 1。8 位時就是 0 到 255。把 255 加 1,結果應該是 256——那需要第 9 個位元——但根本沒有第 9 個位元,於是答案繞回了 0。對一個有號的 8 位數而言,範圍是負 128 到正 127,把 127 加 1 會繞到負 128。位元完全照算術規則做了該做的事;突然看起來不對勁的,是我們對這些位元的詮釋。
溢位:當答案再也塞不下時
我們把這種繞回稱為 整數溢位:真正的數學結果超出了位元能表示的範圍。關鍵——而且常常令人意外——的事實是,有號數與無號數的溢位是用不同方式偵測的,即使二進位加法對兩者用的是完全相同的邏輯閘。硬體並不知道你把這些位元當成有號還是無號;它就只是相加。它順手算出幾個條件旗標,再由軟體決定哪一個旗標才重要。
對無號加法而言,溢位代表有進位逃出了最高位——進位旗標會被設起。對有號的二補數加法而言,最高位的進位輸出毫無意義;反之,當兩個同號的運算元產生了相反號的結果時(正加正卻得到負,或負加負卻得到正),就發生了溢位。有個漂亮的硬體捷徑:當進入最高位的進位與離開最高位的進位不同時,正好就是發生了有號溢位。那一個 XOR 就驅動了溢位旗標。
8-bit signed add: 127 + 1 0111 1111 (+127) + 0000 0001 ( +1) --------- 1000 0000 (-128) <- sign flipped! signed overflow carry into bit7 = 1, carry out of bit7 = 0 -> 1 XOR 0 = OVERFLOW 8-bit unsigned add: 255 + 1 1111 1111 ( 255) + 0000 0001 ( 1) --------- 1 0000 0000 wraps to 0, carry-out = 1 -> CARRY (unsigned overflow)
符號擴展:在不改變數值的前提下加寬一個數
程式不斷地把一個較窄的值複製到較寬的暫存器——把一個 8 位的位元組放進 32 位的暫存器,或把烘焙在指令裡的一個小型立即運算元放進完整的 64 位暫存器。如果你只是把新的高位用零補滿,無號數可以安然無恙。但有號數就會被毀掉:以 8 位 1111 1111 儲存的負 1,用零補滿後會變成 0000 0000 1111 1111——那是 255,不是負 1。
解法是 符號擴展:把符號位(最高位)複製到所有新的高位,而不是寫入零。以負 5 為例,它在 8 位裡是 1111 1011;符號擴展到 16 位會複製那個開頭的 1,得到 1111 1111 1111 1011,仍是負 5,而零補位則會給出 0000 0000 1111 1011,也就是 251——錯了。像正 5(0000 0101)這樣的正數,符號位是 0,所以符號擴展只是補上零,與零擴展一致。真實的指令集兩種口味都提供:載入位元組(有號)會做符號擴展,而載入位元組(無號)則做零擴展,你挑選的那條載入指令會告訴硬體該做哪一種。
位元運算:把一個字當成一排開關
有時候你根本不想做算術——你想戳的是個別的位元。一個位元運算會對兩個字裡每一對位元獨立地套用一個邏輯閘,欄與欄之間沒有進位。AND 會清除位元(任何位元與 0 做 AND 都變成 0),OR 會設起位元(任何位元與 1 做 OR 都變成 1),XOR 會翻轉位元(與 1 做 XOR 會反相,與 0 做 XOR 則不變),而 NOT 會反相每一個位元。這些都直接對應到你在這條學習階梯後面打造算術邏輯單元時會用到的邏輯閘。
其中少數幾招成了日常慣用語。要測試第 k 位是否被設起,就跟一個只在第 k 位有單一個 1 的遮罩做 AND,再看結果是否非零。要設起第 k 位,就跟那個遮罩做 OR;要清除它,就跟遮罩的 NOT 做 AND;要切換它,就跟遮罩做 XOR。一整組布林旗標可以搭在同一個整數裡,靠遮罩來打包與拆解——這正是硬體狀態暫存器與權限位元的儲存方式。
位移:廉價的乘除與欄位手術
一個位元位移會把每個位元向左或向右滑動若干個位置。向左位移 k 位會讓高位從頂端掉出去,並從底部餵入零,這會把一個無號數乘以 2^k——就像把一個十進位數字向左移會乘以十一樣。向右位移 k 位會除以 2^k,但右移有兩種,而其差別之重要,恰恰與符號擴展不相上下。
邏輯右移會把零餵進頂端,這對無號數是正確的。算術右移則改為把符號位的複本餵進頂端——就是同一套符號擴展的概念——這樣把負數除以 2 的冪次才會維持為負。挑錯了,就會把負 8 除以 2 變成一個很大的正數。(即便是算術右移也是朝負無限大方向捨入,而非朝零捨入,所以對負數而言它與整數除法並不完全相同——這又是一道誠實的小皺褶。)
位移與遮罩合在一起,就是打包欄位的外科手術器械。要從一個字中間拉出一個 4 位的欄位,先把它向下移到底部,再跟一個 4 位的遮罩做 AND,抹掉其他一切。要把一個值打包進那個欄位,先把它 AND 到合適大小,向上移到定位,再 OR 進去。這套確切的舞步——位移、遮罩、合併——正是指令被編碼與解碼的方式,也是稍後在這條階梯更上面,一個位址被切成片段供快取與分頁表使用的方式。
讓這些小技巧上工
這些零件能組合成出奇精簡的樣式。想知道一個數是不是偶數?用 AND 1 測試最低位。想要一個字的低位位元組?跟 0xFF 做 AND。想把一個位址向下捨入到 8 的倍數以對齊?用 NOT 7 做 AND 來清除低 3 位。想要一個快速的「這是不是 2 的冪次」檢查?2 的冪次正好只有一個位元被設起,所以對任何非零的 2 的冪次而言,x AND (x 減 1) 等於 0——因為減 1 會一路借位,把那單一個 1 連同它底下的一切都翻轉掉。
- 先決定你的值是有號還是無號——光看位元說不準,而接下來的每一個運算都取決於此。
- 加寬它時,對有號數做符號擴展、對無號數做零擴展,好讓這個數的意義在搬移中存活下來。
- 向右位移時,對有號數用算術位移、對無號數用邏輯位移。
- 做完算術後,先檢查進位旗標(無號溢位)或溢位旗標(有號溢位),再去信任那個結果。
這一切都不是魔法——它就是你已經認識的、固定寬度的二補數,只是換個視角來看:到底什麼塞得進這些位元,以及我們如何搬動位元。接下來我們將離開整數,轉向小數:一個二進位小數點、繼而是 IEEE 754 浮點數,如何讓一個有限的字去逼近實數,以及那些逼近並不精確的誠實陷阱。