問題所在:位元上沒有減號
在上一篇你認識了無號整數:用 n 個位元,你可以為 0 到 2^n - 1 之間的每一個整數命名,就這麼多。沒有多出來的符號可以拿來當減號——一個位元組不過就是八個小小的開關。於是問題變得相當棘手:如果每一種位元組合都已經被某個非負整數佔走了,那 -5 該擺在哪裡?我們發明的任何方案,都必須設法把同樣的 2^n 種組合切分給負數與非負數,而且最好還能跟我們既有的硬體和睦相處。
最直覺的第一個點子是符號-數值表示法:偷走最高位元當作正負號(0 代表正、1 代表負),其餘位元放數值的大小,就跟我們在紙上寫數字一模一樣。對人類來說很好讀,卻有兩個難看的毛病。第一,它給了你兩個零——正的 0000 與負的 1000——白白浪費一種組合,還逼硬體得把這兩者視為相等。第二,相加的規則充滿特例:CPU 必須先檢查正負號,再決定要把數值相加還是相減。符號-數值表示法雖然誠實,代價卻很高。
第二種嘗試是一補數,把每個位元反轉來取負。它離我們想要的更近了,但仍受兩個零之苦(全 0 與全 1 都代表零),而且相加時得處理一個彆扭的「環繞進位」。這兩種方案在老機器上都真的被使用過,但世界最終匯流到了更好的東西。勝出者解決了雙零的問題,而且幾乎像變魔術般地讓減法變成免費的。
訣竅:把最高位元的權重設為負
二補數是每一顆現代 CPU 都採用的方案,整個構想可以用一句話講完:保留所有平常的位值,但把最高位元的位值設成負的。在 4 位元的無號數裡,各位元的值是 8、4、2、1;在二補數裡,它們變成 -8、4、2、1。於是同樣一組位元,可以代表正數或負數,差別只在這一條規則——位元本身其他什麼都沒變。
4-bit two's complement (top bit worth -8) place values: -8 4 2 1 ----------------------------------------- 0101 = 0 + 4 + 0 + 1 = +5 1011 = -8 + 0 + 2 + 1 = -5 1111 = -8 + 4 + 2 + 1 = -1 1000 = -8 + 0 + 0 + 0 = -8 (most negative) 0111 = 0 + 4 + 2 + 1 = +7 (most positive) 0000 = 0 + 0 + 0 + 0 = 0 (the ONLY zero) range of n bits: -2^(n-1) .. +2^(n-1) - 1
把後果讀出來。光看最高位元就知道正負:0 代表非負、1 代表負,就像一個符號位元——但它並非獨立的旗標,而是一個實實在在、貢獻權重的位值。現在零只有一個(0000),既不浪費組合,也不必做彆扭的相等判斷。而值域是刻意不對稱的:n 位元可表示 -2^(n-1) 到 2^(n-1) - 1。對 32 位元的整數來說,就是 -2,147,483,648 到 2,147,483,647——負數比正數多一個,因為零被算在非負的那一側。
如何取負:反轉所有位元,再加一
要對一個二補數取負,有個著名的機械式口訣:把每個位元反轉,然後加 1。要在 4 位元中得到 -5,從 +5(0101)出發,反轉成 1010,加一得到 1011——驗算:-8 + 2 + 1 = -5。這個口訣雙向都成立,所以對 1011 再做一次就會變回 0101。為什麼「反轉再加一」會有效?把全部 n 個位元反轉,會把 x 變成 (2^n - 1) - x;再加 1 就得到 2^n - x,而這正是一旦你讓最高位元帶著負權重後,行為就跟 -x 一模一樣的那組位元。
- 先用普通的二進位寫出數值大小,例如 +5 在 4 位元中是 0101。
- 反轉每一個位元(這就是一補數那一步):0101 變成 1010。
- 把結果加 1:1010 + 1 = 1011,這就是 -5。
- 用位值驗算一下:1011 = -8 + 2 + 1 = -5。完成。
回報:一個加法器同時做加法與減法
這就是二補數勝出的原因。因為負值是以 2^n - x 的形式儲存,普通的二進位加法——也就是你會在數位邏輯那一階建造的、由全加器串成的漣波進位加法器——就這麼直接管用,完全不必特別處理正負號。把 0101(+5)和 1011(-5)相加會得到 10000;在 n 位元運算裡,從最高位溢出的進位會直接被丟掉,剩下 0000 = 0。計算 5 + (-5) 的那組電線,跟計算 200 + 37 的是同一組。CPU 甚至不必知道你把這些位元當成有號還是無號:兩種情況下,總和的位元組合完全相同。
減法也從同一套電路免費附贈。要算 a - b,硬體就把 b 取負(反轉位元、加一)後相加。實作上,ALU會讓 b 穿過一排反相器,並把進位輸入強制設為 1——這單一的進位輸入正好提供了取負所需的「+1」。於是減法只需兩樣便宜的小添加,就能重用加法器,根本不需要第二台機器。這就是為什麼大家說二補數讓硬體更簡單:兩種運算共用一條資料路徑、一組邏輯閘。
追蹤一下 7 - 5 來體會。取 +5 = 0101,反轉成 1010,再把它與 0111(+7)相加,並把進位輸入強制設為 1:0111 + 1010 + 1 = 10010。丟掉溢出的進位,就剩下 0010 = +2,正好是 7 - 5。加法器從頭到尾都不知道自己在「減」——它永遠只會加;取負是反相器與進位輸入完成的。整個魔法就濃縮在這一行:a - b = a + (~b) + 1。
兩個誠實的陷阱:符號擴展與溢位
當你把一個較窄的有號值複製到較寬的暫存器時——比方說把 8 位元的 char 放進 32 位元的 int——你不能單純地補零,否則每個負數都會突然變成正數。你必須把符號位元複製到所有新的高位元裡。這就是符號擴展:4 位元裡的 -5(1011)擴展成 8 位元就是 1111_1011,仍然是 -5。(無號值則改用補零來加寬——CPU 之所以有各自獨立的有號與無號載入指令,正是為了這件事。)符號擴展也說明了為什麼同一個常數可以當作一個小小的立即運算元,在被撐寬到暫存器位寬後依然代表一個負數。
第二個陷阱是整數溢位。位寬固定為 n 時,只有 2^n 種組合,所以任何落在 -2^(n-1)..2^(n-1)-1 範圍之外的結果都會悄悄地繞回。在 4 位元裡,7 + 1 = 1000 = -8:對最大的正數加一,你就從懸崖跌落,掉進最負的那個數。硬體並不會阻止你,它只是在條件旗標裡設定一個溢位旗標,然後繼續執行。有號溢位有個一眼可辨的徵兆:當兩個輸入同號、結果卻變成相反的號時(兩正得負,或兩負得正),就正好發生了溢位。