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

浮點數與 IEEE 754

整數只能數整個的東西。要在一個固定大小的字裡容納小數,並橫跨從原子到星系的鴻溝,硬體用的是浮點數——二進位版的科學記號。本篇就來看 IEEE 754 如何把符號、指數與尾數打包進位元,以及一個誠實而無可迴避的真相:這些答案通常只是近似值。

先有二進位小數點:定點數,以及它為何不夠用

到目前為止你建立的每個值都是整數——一個無號整數,或一個二補數有號數。但這個世界充滿了小數:3.5 伏特、0.1 秒、半個像素。最簡單的第一個點子是:就想像一個二進位小數點固定坐落在字裡的某個位置,正如十進位小數點坐落在 12.75 裡一樣。這就叫定點數

正如小數點後的十進位數字代表十分位、百分位、千分位,小數點後的二進位位元就代表二分之一、四分之一、八分之一。所以二進位的 101.011 是 4 加 1 加四分之一加八分之一,也就是 5.375。定點數很快——它沿用你已認識的整數加法器,因為小數點永遠對齊。但它很僵硬:如果你保留比方說小數點前 8 位、小數點後 8 位,你就永遠無法在同一格式裡表示十億(太大)或十億分之一(太小)。範圍與精度被焊死在一起了。

浮點數:一個會滑動的二進位小數點

解法是讓小數點浮動起來。這就是科學記號,你早就在十進位裡用過:與其寫 0.0000000123,你寫 1.23 乘以 10^-8,把這個數拆成一個小數部分(1.23)與一個指數(負 8),由指數說明小數點真正該擺在哪。浮點數在二進位裡做的正是這件事:每個值都變成一個符號、一個叫尾數(significand 或 mantissa)的小數部分,以及一個指數,全部打包進一個固定大小的字裡。同樣的位數現在可以伸展到描述一個星系的質量或一顆原子的大小,因為伸展的活由指數來做。

這裡有個聰明的省空間步驟,叫正規化。正如十進位科學記號堅持小數點前剛好只有一個非零數字(1.23,絕不寫 0.123 或 12.3),二進位正規化會把尾數移位到讀作 1.某某。但在二進位裡那個領頭數字只可能是 1——既然如此,何必把它存起來?IEEE 754 乾脆就不存。那個領頭的 1 是一個隱含(隱藏)位元,白白多送你一個尾數精度位元。被儲存的小數部分只保留小數點後的位元。

拆開一個 IEEE 754 字:符號、指數、尾數

IEEE 754 是幾乎每顆處理器都遵守的標準,所以在一台機器上寫下的浮點數,在另一台上會讀出完全一樣的值。一個 32 位的單精度浮點數把位元分成三個欄位:1 個符號位、8 個指數位、23 個尾數位(連同那個隱藏的領頭 1 算進去則是 24 位)。雙精度用 64 位——1、11、52——換取大得多的範圍與精度。這些欄位由高到低排列,於是有個討喜的結果:把兩個同號浮點數當成整數來比較,得到的順序是對的。

指數裡藏著一個微妙之處。它必須能表示大的正冪次與小的負冪次,但為了讓那個整數比較的把戲成立,它不能用二補數(二補數的負數帶著領頭的 1,會排錯順序)。取而代之的是,指數帶著一個偏移量(bias)儲存:存入前加上一個常數(單精度是 127),讀取時再減掉。所以儲存的指數 127 代表實際指數 0,儲存的 130 代表實際的 3。帶偏移的指數是單純的無號數,所以會以自然順序排列。

32-bit single precision:  value = (-1)^sign x 1.fraction x 2^(exp - 127)

  +-----+-----------+-------------------------+
  | sign|  exponent |       significand       |
  | 1   |  8 bits   |        23 bits          |
  +-----+-----------+-------------------------+

Encode -6.0:
  6.0  = 110.0 (binary) = 1.10 x 2^2     (normalized: hidden leading 1)
  sign     = 1           (negative)
  exponent = 2 + 127 = 129 = 1000 0001   (biased)
  fraction = 100 0000 ... 0              (the bits after the point)
  bits = 1 1000 0001 100 0000 0000 0000 0000 0000
一個單精度浮點數:符號乘以 1.尾數乘以 2 的(帶偏移指數減 127)次方。

零、無限大與 NaN:保留的位元樣式

那個隱藏的領頭 1 有個麻煩:既然永遠有個 1 在,那要怎麼寫出?IEEE 754 把兩個極端的指數值劃出來作特殊用途。指數全為零、尾數也全為零,就是零本身(而因為符號位是分開的,技術上有正零與負零,兩者比較起來相等)。指數全為零、尾數非零,則編碼次正規(subnormal)數——低於正規範圍的微小值,它們捨棄那個隱藏的 1,好讓數值平滑地淡向零,而不是直接跳到零。

在另一端,全為 1 的指數也是保留的。配上全零的尾數,它代表無限大(正或負,由符號位決定)——除以零或超出最大可表示數時誠實的結果。配上非零的尾數,它代表 NaN,「非數」:真正未定義的運算的結果,例如零除以零、無限大減無限大、或負數的平方根。NaN 會傳染——幾乎任何碰到 NaN 的算術都會產生 NaN——而且眾所周知,一個 NaN 連自己都不等於自己,這正是偵測它的一個可靠方法。

誠實的部分:浮點數並不精確

這就是絆倒每個初學者的真相:大多數十進位小數無法被精確儲存。正如三分之一在十進位裡是 0.3333……永無止境,看似無害的 0.1 在二進位裡是個循環小數——它永遠不會結束。在只有 23 或 52 個尾數位的情況下,硬體儲存它能達到的最接近值,並把其餘的部分捨入掉。所以 0.1 加 0.2 不會剛好得到 0.3;它會得到類似 0.30000000000000004 的東西。位元正在對一些一開始就略有偏差的數,做著完美無瑕的二進位算術。

因為每一步都會發生捨入,你對實數所信任的規則便悄悄失效了。浮點加法不具結合律:(a + b) + c 可能不等於 a + (b + c),因為捨入落在哪取決於順序。把一個極小的數加到一個極大的數上,可能把那個極小的數整個弄丟——它掉到了相鄰可表示值之間的縫隙之下。IEEE 754 把捨入的方式釘得死死的(預設是「捨入到最接近、平手取偶」,由捨入模式掌管),好讓結果至少是可預測、可在不同機器間重現的,即使它們並非精確的數學答案。

一步一步讀出一個浮點數

把這三個欄位拆開,用的正是上一篇的位移與遮罩手術——暫存器裡的浮點數就只是位元,解碼它不過是欄位擷取再加上一點算術。下面是一個正規單精度值的完整食譜,也正是浮點運算單元在硬體裡跳的同一套舞步。

  1. 讀最高位作為符號:0 代表正,1 代表負。
  2. 擷取接下來的 8 位作為帶偏移的指數,再減去偏移量 127,得到真正的 2 的冪次。
  3. 取低 23 位作為小數部分,把隱藏的領頭 1 黏回最前面,重建完整的尾數 1.小數部分。
  4. 把它們合起來:值等於符號乘以尾數乘以 2 的真實指數次方——同時留意那些保留的全零與全 1 指數,它們代表零/次正規或無限大/NaN。

有了這個,你就能表示並解碼電腦所容納的任何實數。你現在已經走過了資料表示法的整條主幹:位元與進位制、無號與有號整數、搬動它們的位元小技巧,以及給小數用的浮點數。在離開這一階之前還剩一個問題——給定一個多位元組的數,它的各個位元組以什麼順序坐在記憶體裡,而字元與對齊的字又是怎麼擺放的?那就是位元組順序(endianness),最後一篇的主題。