小數在內存中是以浮點數的形式存儲的。浮點數並不是一種數值分類,它和整數、小數、實數等不是一個層面的概念。浮點數是數字(或者說數值)在內存中的一種存儲格式,它和定點數是相對的。
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 標准其實還規定了浮點數的加減乘除運算,但不是本文的內容就不加以討論了