小數在記憶體中是如何儲存的?

RioTian發表於2020-04-28

小數在記憶體中是以浮點數的形式儲存的。浮點數並不是一種數值分類,它和整數、小數、實數等不是一個層面的概念。浮點數是數字(或者說數值)在記憶體中的一種儲存格式,它和定點數是相對的。

C語言使用定點數格式來儲存 short、int、long 型別的整數,使用浮點數格式來儲存 float、double 型別的小數。整數和小數在記憶體中的儲存格式不一樣。

我們在學習C語言時,通常認為浮點數和小數是等價的,並沒有嚴格區分它們的概念,這也並沒有影響到我們的學習,原因就是浮點數和小數是繫結在一起的,只有小數才使用浮點格式來儲存。

其實,整數和小數可以都使用定點格式來儲存,也可以都使用浮點格式來儲存,但實際情況卻是,C語言使用定點格式儲存整數,使用浮點格式儲存小數,這是在“數值範圍”和“數值精度”兩項重要指標之間追求平衡的結果,稍後我會給大家帶來深入的剖析。

計算機的設計是一門藝術,很多實用技術都是權衡和妥協的結果。

浮點數和定點數中的“點”指的就是小數點!對於整數,可以認為小數點後面都是零,小數部分是否存在並不影響整個數字的值,所以乾脆將小數部分省略,只保留整數部分。

定點數

所謂定點數,就是指小數點的位置是固定的,不會向前或者向後移動。

假設我們用4個位元組(32位)來儲存無符號的定點數,並且約定,前16位表示整數部分,後16位表示小數部分,如下圖所示:

img

如此一來,小數點就永遠在第16位之後,整數部分和小數部分一目瞭然,不管什麼時候,整數部分始終佔用16位(不足16位前置補0),小數部分也始終佔用16位(不足16位後置補0)。例如,在記憶體中儲存了 10101111 00110001 01011100 11000011,那麼對應的小數就是 10101111 00110001 . 01011100 11000011,非常直觀。

精度

小數部分的最後一位可能是精確數字,也可能是近似數字(由四捨五入、向零舍入等不同方式得到);除此以外,剩餘的31位都是精確數字。從二進位制的角度看,這種定點格式的小數,最多有 32 位有效數字,但是能保證的是 31 位;也就是說,整體的精度為 31~32 位。

數值範圍

將記憶體中的所有位(Bit)都置為 1,小數的值最大,為 216 - 2-16,極其接近 216,換算成十進位制為 65 536。將記憶體中最後一位(第32位)置1,其它位都置0,小數的值最小,為2-16。

這裡所說的最小值不是 0 值,而是最接近 0 的那個值。

綜述

用定點格式來儲存小數,優點是精度高,因為所有的位都用來儲存有效數字了,缺點是取值範圍太小,不能表示很大或者很小的數字。

反面例子

在科學計算中,小數的取值範圍很大,最大值和最小值的差距有上百個數量級,使用定點數來儲存將變得非常困難。

例如,電子的質量為:

0.0000000000000000000000000009 克 = 9 × 10-28 克

太陽的質量為:

2000000000000000000000000000000000 克 = 2 × 1033 克

如果使用定點數,那麼只能按照=前面的格式來儲存,這將需要很大的一塊記憶體,大到需要幾十個位元組。

更加科學的方案是按照=後面的指數形式來儲存,這樣不但節省記憶體,也非常直觀。這種以指數的形式來儲存小數的解決方案就叫做浮點數。浮點數是對定點數的升級和優化,克服了定點數取值範圍太小的缺點。

浮點數

C語言標準規定,小數在記憶體中以科學計數法的形式來儲存,具體形式為:

flt = (-1)sign × mantissa × baseexponent

對各個部分的說明:

  • flt 是要表示的小數。
  • sign 用來表示 flt 的正負號,它的取值只能是 0 或 1:取值為 0 表示 flt 是正數,取值為 1 表示 flt 是負數。
  • base 是基數,或者說進位制,它的取值大於等於 2(例如,2 表示二進位制、10 表示十進位制、16 表示十六進位制……)。數學中常見的科學計數法是基於十進位制的,例如 6.93 × 1013;計算機中的科學計數法可以基於其它進位制,例如 1.001 × 27 就是基於二進位制的,它等價於 1001 0000。
  • mantissa 為尾數,或者說精度,是 base 進位制的小數,並且 1 ≤ mantissa < base,這意味著,小數點前面只能有一位數字;
  • exponent 為指數,是一個整數,可正可負,並且為了直觀一般採用十進位制表示。

下面我們以 19.625 為例來演示如何將小數轉換為浮點格式。

當 base 取值為 10 時,19.625 的浮點形式為:

19.625 = 1.9625 × 101

當 base 取值為 2 時,將 19.625 轉換成二進位制為 10011.101,用浮點形式來表示為:

19.625 = 10011.101 = 1.0011101×24

19.625 整數部分的二進位制形式為:
19 = 1×24 + 0×23 + 0×22 + 1×21 + 1×20 = 10011
小數部分的二進位制形式為:
0.625 = 1×2-1 + 0×2-2 + 1×2-3 = 101
將整數部分和小數部分合並在一起:
19.625 = 10011.101

可以看出,當基數(進位制)base 確定以後,指數 exponent 實際上就成了小數點的移動位數:

  • exponent 大於零,mantissa 中的小數點右移 exponent 位即可還原小數的值;
  • exponent 小於零,mantissa 中的小數點左移 exponent 位即可還原小數的值。

換句話說,將小數轉換成浮點格式後,小數點的位置發生了浮動(移動),並且浮動的位數和方向由 exponent 決定,所以我們將這種表示小數的方式稱為浮點數。

二進位制形式的浮點數的儲存

雖然C語言標準沒有規定 base 使用哪種進位制,但是在實際應用中,各種編譯器都將 base 實現為二進位制,這樣不僅貼近計算機硬體(任何資料在計算機底層都以二進位制形式表示),還能減少轉換次數。

接下來我們就討論一下如何將二進位制形式的浮點數放入記憶體中。

原則上講,上面的科學計數法公式中,符號 sign、尾數 mantissa、基數 base 和指數 exponent 都是不確定因素,都需要在記憶體中體現出來。但是現在基數 base 已經確定是二進位制了,就不用在記憶體中體現出來了,這樣只需要在記憶體中儲存符號 sign、尾數 mantissa、指數 exponent 這三個不確定的元素就可以了。

仍然以 19.625 為例,將它轉換成二進位制形式的浮點數格式:

19.625 = 1.0011101×24

此時符號 sign 為 0,尾數 mantissa 為 1.0011101,指數 exponent 為 4。

1) 符號的儲存

符號的儲存很容易,就像儲存 short、int 等普通整數一樣,單獨分配出一個位(Bit)來,用 0 表示正數,用 1 表示負數。對於 19.625,這一位的值是 0。

2) 尾數的儲存

當採用二進位制形式後,尾數部分的取值範圍為 1 ≤ mantissa < 2,這意味著:尾數的整數部分一定為 1,是一個恆定的值,這樣就無需在記憶體中提現出來,可以將其直接截掉,只要把小數點後面的二進位制數字放入記憶體中即可。對於 1.0011101,就是把 0011101 放入記憶體。

我們不妨將真實的尾數命名為 mantissa,將記憶體中儲存的尾數命名為 mant,那麼它們之間的關係為:

mantissa = 1.mant

如果 base 採用其它進位制,那麼尾數的整數部分就不是固定的,它有多種取值的可能,以十進位制為例,尾數的整數部分可能是 1~9 之間的任何一個值,這樣一來尾數的整數部分就不能省略了,必須在記憶體中體現出來。而將 base 設定為二進位制就可以節省掉一個位(Bit)的記憶體,這也算是採用二進位制的一點點優勢。

3) 指數的儲存

指數是一個整數,並且有正負之分,不但需要儲存它的值,還得能區分出正負號來。

short、int、long 等型別的整數在記憶體中的儲存採用的是補碼加符號位的形式,數值在寫入記憶體之前必須先進行轉換,讀取以後還要再轉換一次。但是為了提高效率,避免繁瑣的轉換,指數的儲存並沒有採用補碼加符號位的形式,而是設計了一套巧妙的解決方案,稍等我會為您解開謎團。

為二進位制浮點數分配記憶體

C語言中常用的浮點數型別為 float 和 double;float 始終佔用 4 個位元組,double 始終佔用 8 個位元組。

下圖演示了 float 和 double 的儲存格式:

img

浮點數的記憶體被分成了三部分,分別用來儲存符號 sign、尾數 mantissa 和指數 exponent ,當浮點數的型別確定後,每一部分的位數就是固定的。

符號 sign 可以不加修改直接放入記憶體中,尾數 mantissa 只需要將小數部分放入記憶體中,最讓人疑惑的是指數 exponent 如何放入記憶體中,這也是我們在前面留下的一個謎團,下面我們以 float 為例來揭開謎底。

float 的指數部分佔用 8 Bits,能表示從 0~255 的值,取其中間值 127,指數在寫入記憶體前先加上127,讀取時再減去127,正數負數就顯而易見了。19.625 轉換後的指數為 4,4+127 = 131,131 換算成二進位制為 1000 0011,這就是 19.626 的指數部分在 float 中的最終儲存形式。

先確定記憶體中指數部分的取值範圍,得到一箇中間值,寫入指數時加上這個中間值,讀取指數時減去這個中間值,這樣符號和值就都能確定下來了。

中間值的求取有固定的公式。設中間值為 median,指數部分佔用的記憶體為 n 位,那麼中間值為:

median = 2n-1 - 1

對於 float,中間值為 28-1 - 1 = 127;對於 double,中間值為 211-1 -1 = 1023。

我們不妨將真實的指數命名為 exponent,將記憶體中儲存的指數命名為 exp,那麼它們之間的關係為:

exponent = exp - median

也可以寫作:

exp = exponent + median

為了方便後續文章的編寫,這裡我強調一下命名:

  • mantissa 表示真實的尾數,包括整數部分和小數部分;mant 表示記憶體中儲存的尾數,只有小數部分,省略了整數部分。
  • exponent 表示真實的指數,exp 表示記憶體中儲存的指數,exponent 和 exp 並不相等,exponent 加上中間數 median 才等於 exp。

用程式碼驗證 float 的儲存

19.625 轉換成二進位制的指數形式為:

19.625 = 1.0011101×24

此時符號為 0;尾數為 1.0011101,截掉整數部分後為 0011101,補齊到 23 Bits 後為 001 1101 0000 0000 0000 0000;指數為 4,4+127 = 131,131 換算成二進位制為 1000 0011。

綜上所述,float 型別的 19.625 在記憶體中的值為:0 - 10000011 - 001 1101 0000 0000 0000 0000。

下面我們通過程式碼來驗證一下:

#include <stdio.h>
#include <stdlib.h>

//浮點數結構體
typedef struct {
    unsigned int nMant : 23;  //尾數部分
    unsigned int nExp : 8;  //指數部分
    unsigned int nSign : 1;  //符號位
} FP_SINGLE;

int main()
{
    char strBin[33] = { 0 };
    float f = 19.625;
    FP_SINGLE *p = (FP_SINGLE*)&f;
   
    itoa(p->nSign, strBin, 2);
    printf("sign: %s\n", strBin);
    itoa(p->nExp, strBin, 2);
    printf("exp: %s\n", strBin);
    itoa(p->nMant, strBin, 2);
    printf("mant: %s\n", strBin);
   
    return 0;
}

執行結果:
sign: 0
exp: 10000011
mant: 111010000000000000000

mant 的位數不足,在前面補齊兩個 0 即可。

printf() 不能直接輸出二進位制形式,這裡我們藉助 itoa() 函式將十進位制數轉換成二進位制的字串,再使用%s輸出。itoa() 雖然不是標準函式,但是大部分編譯器都支援。不過 itoa() 在 C99 標準中已經被指定為不可用函式,在一些嚴格遵循 C99 標準的編譯器下會失效,甚至會引發錯誤,例如在 Xcode(使用 LLVM 編譯器)下就會編譯失敗。如果 itoa() 無效,請使用%X輸出十六進位制形式,十六進位制能夠很方便地轉換成二進位制。

精度問題

對於十進位制小數,整數部分轉換成二進位制使用“展除法”(就是不斷除以 2,直到餘數為 0),一個有限位數的整數一定能轉換成有限位數的二進位制。但是小數部分就不一定了,小數部分轉換成二進位制使用“乘二取整法”(就是不斷乘以 2,直到小數部分為 0),一個有限位數的小數並不一定能轉換成有限位數的二進位制,只有末位是 5 的小數才有可能轉換成有限位數的二進位制,其它的小數都不行。

float 和 double 的尾數部分是有限的,固然不能容納無限的二進位制;即使小數能夠轉換成有限的二進位制,也有可能會超出尾數部分的長度,此時也不能容納。這樣就必須“四捨五入”,將多餘的二進位制“處理掉”,只保留有效長度的二進位制,這就涉及到了精度的問題。也就是說,浮點數不一定能儲存真實的小數,很有可能儲存的是一個近似值。

對於 float,尾數部分有 23 位,再加上一個隱含的整數 1,一共是 24 位。最後一位可能是精確數字,也可能是近似數字(由四捨五入、向零舍入等不同方式得到);除此以外,剩餘的23位都是精確數字。從二進位制的角度看,這種浮點格式的小數,最多有 24 位有效數字,但是能保證的是 23 位;也就是說,整體的精度為 23~24 位。如果轉換成十進位制,224 = 16 777 216,一共8位;也就是說,最多有 8 位有效數字,但是能保證的是 7 位,從而得出整體精度為 7~8 位。

對於 double,同理可得,二進位制形式的精度為 52~53 位,十進位制形式的精度為 15~16 位。

IEEE 754 標準

浮點數的儲存以及加減乘除運算是一個比較複雜的問題,很多小的處理器在硬體指令方面甚至不支援浮點運算,其他的則需要一個獨立的協處理器來處理這種運算,只有最複雜的處理器才會在硬體指令集中支援浮點運算。省略浮點運算,可以將處理器的複雜度減半!如果硬體不支援浮點運算,那麼只能通過軟體來實現,代價就是需要容忍不良的效能。

PC 和智慧手機上的處理器就是最複雜的處理器了,它們都能很好地支援浮點運算。

在六七十年代,計算機界對浮點數的處理比較混亂,各家廠商都有自己的一套規則,缺少統一的業界標準,這給資料交換、計算機協同工作帶來了很大不便。

作為處理器行業的老大,Intel 早就意識到了這個問題,並打算一統浮點數的世界。Intel 在研發 8087 浮點數協處理器時,聘請到加州大學伯克利分校的 William Kahan 教授(最優秀的數值分析專家之一)以及他的兩個夥伴,來為 8087 協處理器設計浮點數格式,他們的工作完成地如此出色,設計的浮點數格式具有足夠的合理性和先進性,被 IEEE 組織採用為浮點數的業界標準,並於 1985 年正式釋出,這就是 IEEE 754 標準,它等同於國際標準 ISO/IEC/IEEE 60559。

IEEE 是 Institute of Electrical and Electronics Engineers 的簡寫,中文意思是“電氣和電子工程師協會”。

IEEE 754 簡直是天才一般的設計,William Kahan 教授也因此獲得了 1987 年的圖靈獎。圖靈獎是計算機界的“諾貝爾獎”。

目前,幾乎所有的計算機都支援 IEEE 754 標準,大大改善了科學應用程式的可移植性,C語言編譯器在實現浮點數時也採用了該標準。

不過,IEEE 754 標準的出現晚於C語言標準(最早的 ANSI C 標準於 1983 年釋出),C語言標準並沒有強制編譯器採用 IEEE 754 格式,只是說要使用科學計數法的形式來表示浮點數,但是編譯器在實現浮點數時,都採用了 IEEE 754 格式,這既符合C語言標準,又符合 IEEE 標準,何樂而不為。

特殊值

IEEE 754 標準規定,當指數 exp 的所有位都為 1 時,不再作為“正常”的浮點數對待,而是作為特殊值處理:

  • 如果此時尾數 mant 的二進位制位都為 0,則表示無窮大:
    • 如果符號 sign 為 1,則表示負無窮大;
    • 如果符號 sign 為 0,則表示正無窮大。
  • 如果此時尾數 mant 的二進位制位不全為 0,則表示 NaN(Not a Number),也即這是一個無效的數字,或者該數字未經初始化。

非規格化浮點數

當指數 exp 的所有二進位制位都為 0 時,情況也比較特殊。

對於“正常”的浮點數,尾數 mant 隱含的整數部分為 1,並且在讀取浮點數時,記憶體中的指數 exp 要減去中間值 median 才能還原真實的指數 exponent,也即:

mantissa = 1.mant
exponent = exp - median

但是當指數 exp 的所有二進位制位都為 0 時,一切都變了!尾數 mant 隱含的整數部分變成了 0,並且用 1 減去中間值 median 才能還原真實的指數 exponent,也即:

mantissa = 0.mant
exponent = 1 - median

對於 float,exponent = 1 - 127 = -126,指數 exponent 的值恆為 -126;對於 double,exponent = 1 - 1023 = -1022,指數 exponent 的值恆為 -1022。

當指數 exp 的所有二進位制位都是 0 時,我們將這樣的浮點數稱為“非規格化浮點數”;當指數 exp 的所有二進位制位既不全為 0 也不全為 1 時,我們稱之為“規格化浮點數”;當指數 exp 的所有二進位制位都是 1 時,作為特殊值對待。 也就是說,究竟是規格化浮點數,還是非規格化浮點數,還是特殊值,完全看指數 exp。

+0 和 -0 的表示

對於非規格化浮點數,當尾數 mant 的所有二進位制位都為 0 時,整個浮點數的值就為 0:

  • 如果符號 sign 為 0,則表示 +0;
  • 如果符號 sign 為 1,則表示 -0。

IEEE 754 為什麼增加非規格化浮點數

我們以 float 型別為例來說明。

對於規格化浮點數,當尾數 mant 的所有位都為 0、指數 exp 的最低位為 1 時,浮點數的絕對值最小(符號 sign 的取值不影響絕對值),為 1.0 × 2-126,也即 2-126。

對於一般的計算,這個值已經很小了,非常接近 0 值了,但是對於科學計算,它或許還不夠小,距離 0 值還不夠近,非規格化浮點數就是來彌補這一缺點的:非規格化浮點數可以讓最小值更小,更加接近 0 值。

對於非規格化浮點數,當尾數的最低位為 1 時,浮點數的絕對值最小,為 2-23 × 2-126 = 2-149,這個值比 2-126 小了 23 個數量級,更加即接近 0 值。

讓我更加驚訝的是,規格化浮點數能夠很平滑地過度到非規格化浮點數,它們之間不存在“斷層”,下表能夠讓讀者看得更加直觀。

說明 float 記憶體 exp exponent mant mantissa 浮點數的值 flt
0值 最小非規格化數 最大非規格化數 0 - 00...00 - 00...00 0 - 00...00 - 00...01 0 - 00...00 - 00...10 0 - 00...00 - 00...11 …… 0 - 00...00 - 11...10 0 - 00...00 - 11...11 0 0 0 0 …… 0 0 -126 -126 -126 -126 …… -126 -126 0 2^-23 2^-22 1.1 × 2^-22 …… 0.11...10 0.11...11 0 2^-23 2^-22 1.1 × 2^-22 …… 0.11...10 0.11...11 +0 2^-149 2^-148 1.1 × 2^-148 …… 1.11...10 × 2^-127 1.11...11 × 2^-127
最小規格化數 最大規格化數 0 - 00...01 - 00...00 0 - 00...01 - 00...01 …… 0 - 00...10 - 00...00 0 - 00...10 - 00...01 …… 0 - 11...10 - 11...10 0 - 11...10 - 11...11 1 1 …… 2 2 …… 254 254 -126 -126 …… -125 -125 127 127 0.0 0.00...01 …… 0.0 0.00...01 …… 0.11...10 0.11...11 1.0 1.00...01 …… 1.0 1.00...01 …… 1.11...10 1.11...11 1.0 × 2^-126 1.00...01 × 2^-126 …… 1.0 × 2^-125 1.00...01 × 2^-125 …… 1.11...10 × 2^127 1.11...11 × 2^127
0 - 11...11 - 00...00 - - - - +∞
0 - 11...11 - 00...01 …… 0 - 11...11 - 11...11 - - - - NaN

^ 表示次方,例如 2^10 表示 2 的 10 次方。

上表演示了正數時的情形,負數與此類似。請讀者注意觀察最大非規格化數和最小規格化數,它們是連在一起的,是平滑過渡的。

舍入模式

浮點數的尾數部分 mant 所包含的二進位制位有限,不可能表示太長的數字,如果尾數部分過長,在放入記憶體時就必須將多餘的位丟掉,取一個近似值。究竟該如何來取這個近似值,IEEE 754 列出了四種不同的舍入模式。

1) 舍入到最接近的值

就是將結果舍入為最接近且可以表示的值,這是預設的舍入模式。最近舍入模式和我們平時所見的“四捨五入”非常類似,但有一個細節不同。

對於最近舍入模式,IEEE 754 規定,當有兩個最接近的可表示的值時首選“偶數”值;而對於四捨五入模式,當有兩個最接近的可表示的值時要選較大的值。以十進位制為例,就是對.5的舍入上採用偶數的方式,請看下面的例子。

最近舍入模式:Round(0.5) = 0、Round(1.5) = 2、Round(2.5) = 2
四捨五入模式:Round(0.5) = 1、Round(1.5) = 2、Round(2.5) = 3

2) 向 +∞ 方向舍入(向上舍入)

會將結果朝正無窮大的方向舍入。標準庫函式 ceil() 使用的就是這種舍入模式,例如,ceil(1.324) = 2,Ceil(-1.324) = -1。

3) 向 -∞ 方向舍入(向下舍入)

會將結果朝負無窮大的方向舍入。標準庫函式 floor() 使用的就是這種舍入模式,例如,floor(1.324) = 1,floor(-1.324) = -2。

4) 向 0 舍入(直接截斷)

會將結果朝接近 0 的方向舍入,也就是將多餘的位數直接丟掉。C語言中的型別轉換使用的就是這種舍入模式,例如,(int)1.324 = 1,(int) -1.324 = -1。

總結

與定點數相比,浮點數在精度方面損失不小,但是在取值範圍方面增大很多。犧牲精度,換來取值範圍,這就是浮點數的整體思想。

IEEE 754 標準其實還規定了浮點數的加減乘除運算,但不是本文的內容就不加以討論了

相關文章