小數在記憶體中是如何存儲的?
小數在記憶體中是以浮點數的形式存儲的。浮點數並不是一種數值分類,它和整數、小數、實數等不是一個層面的概念。浮點數是數字(或者說數值)在記憶體中的一種存儲格式,它和定點數是相對的。
C語言使用定點數格式來存儲 short、int、long 類型的整數,使用浮點數格式來存儲 float、double 類型的小數。整數和小數在記憶體中的存儲格式不一樣。
我們在學習C語言時,通常認為浮點數和小數是等價的,並沒有嚴格區分它們的概念,這也並沒有影響到我們的學習,原因就是浮點數和小數是綁定在一起的,只有小數才使用浮點格式來存儲。
其實,整數和小數可以都使用定點格式來存儲,也可以都使用浮點格式來存儲,但實際情況卻是,C語言使用定點格式存儲整數,使用浮點格式存儲小數,這是在「數值範圍」和「數值精度」兩項重要指標之間追求平衡的結果,稍後我會給大家帶來深入的剖析。
電腦的設計是一門藝術,很多實用技術都是權衡和妥協的結果。
浮點數和定點數中的「點」指的就是小數點!
對於整數,可以認為小數點後面都是零,小數部分是否存在並不影響整個數字的值,所以乾脆將小數部分省略,只保留整數部分。
定點數
所謂定點數,就是指小數點的位置是固定的,不會向前或者向後移動。
假設我們用4個位元組(32位)來存儲無符號的定點數,並且約定,前16位表示整數部分,後16位表示小數部分,如下圖所示:
如此一來,小數點就永遠在第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 的存儲格式:
浮點數的記憶體被分成了三部分,分別用來存儲符號 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 標準其實還規定了浮點數的加減乘除運算,但不是本文的內容就不加以討論了