【CSAPP筆記】3. 浮點數


回想起剛學C語言時,我對浮點數的印象大概是“能夠表示小數”的數據類型。還死記硬背過例如什么“小數用double存,用%f輸出”這類的話。實際上呢,浮點數可以用這么一個公式來概括:

\[\sum_{k=-j}^i b_k×2^k \]

除了我們所熟知的,表示小數之外,它對執行涉及非常大的數字、非常接近於0的數字,以及更普遍地作為對實數運算的結果的近似值,是非常有用的。

以下摘取一些書本中的內容:

20世界80年代,每個計算機制造商都指定了自己的表示浮點數的規則,以及對浮點數執行運算的細節。因此,這對不同的計算機之間的協同工作帶來了很大的不便。這一切都隨着IEEE 754標准的推出而改變了。這是一項由Intel公司贊助的計划,詳細地制定了浮點數的表示方法和運算標准。因為其具有足夠的合理性和先進性,被IEEE采納為浮點數的標准,在1985年發布。目前,實際上所有的計算機都支持這個后來被稱為IEEE浮點的標准。大大提高了各種程序在不同機器上的可移植性。

旁注:IEEE(讀作“Eye-Triple-Eee”),指電器和電子工程師協會,是一個包括所有電子和計算機技術的專業團體。它出版刊物、舉辦會議、建立委員會來定義各種標准,涉及范圍從電力傳輸到軟件工程。

這一節中,我們將看到IEEE 754 浮點格式是如何表示數字的,以及有關的其他細節。許多程序員認為浮點數沒甚意思(往壞了說,深奧難懂)。但我們將看到,IEEE標准是相當優雅和容易理解的。

二進制小數

理解浮點數的第一步是理解含有小數部分的二進制數字。對於我們熟悉的十進制來說,一個小數可以表示為

\[d_md_{m-1}...d_1d_0.d_{-1}d_{-2}...d_{-n} \]

每個數位上面的數字的范圍是0~9。上面的一長串表示法所表示的數值d為:

\[d = \sum_{i=-n}^m = 10^i×d_i \]

十進制,意味着數字權為10。小數點左邊的數字的權是10的正冪或0次冪,乘起來之后累加會得到整數部分的值。小數點右邊的數字的權是10的負冪,乘起來之后累加會得到小數部分的值。下面舉一個 \(12.34_{(10)}\) 的運算過程作為例子。

\[1×10^1+2×10^0+3×10^{-1}+4×10^{-2} = 12\frac{34}{100} \]

其實二進制小數不會很難理解,跟10進制不同的是,把10的冪次改成2的冪次,僅此而已。還有就是,二進制只有0和1組成。下面舉一個\(101.11_{(2)}\)的運算過程作為例子。

\[1×2^2+0×2^1+1×2^0+1×2^{-1}+1×2^{-2} = 4+0+1+\frac{1}{2}+\frac{1}{4}=5\frac{3}{4} \]

對於十進制小數的一些性質,完全能夠在二進制小數中發現類似的。例如,對於十進制小數來說,把小數點右移一位相當於乘以10。那么,對於二進制小數來說,把小數點往右移一位相當於乘以2。

由於\(0.111111....\) 非常接近於1,因此我們用 \(1.0−ϵ\)來表示。

很顯然,任何編碼長度都是有限的。十進制表示法是無法准確地表達像三分之一這樣的無限循環小數。其究極原因是在於,如果把十進制小數想象成一種編碼的話,那么這種編碼方案的精度不夠,所以無法准確地把三分之一表示出來(這個例子有點牽強,因為三分之一是無限循環的)。由此,我們可以想象,二進制數表示法只能表示那些能夠被寫成\(x×2^y\)的小數。例如,數字\(\frac{1}{5}\)可以被精確地表示成十進制小數0.2,但沒辦法用一個二進制小數准確地表示它,只能通過提高二進制表示位的長度來提高精度,從而近似地去表示。這是一個很重要的理念。

IEEE 754 浮點標准

在正式進入對754標准的講解之前,我想先回到我之前在博客中提到過的一句話:

0和1這種離散值在用機器來表示時是非常方便的……雖然0和1是離散的,但大量的0和1,足夠讓我們認為是“連續”的。

什么叫做“大量的離散,足夠讓我們認為是連續的”呢?對於整數編碼了解后,我們知道其實計算機能夠表示的整數的范圍很有限。但如果用比較長的字長,例如32位,那么對於普通人來說,他們日常需要用到的加減法是絕對夠用了。但是,整數編碼是表示不了特別大的數的,也不能表示特別小的負數、以及小數。例如,如果你用一個32位的int整型去存儲計算階乘!n的結果,那么計算到13的階乘時就已經溢出了。即使你用long long去存,算到20的階乘也就溢出了。

下面你將能夠看到,浮點數不僅僅能表示小數,還具有表示特別大/特別小的數的能力。但通過二進制小數我們也了解到,不是每個數都能夠被“精確”地表示,有的時候只能用另一個近似數通過“舍入”去表示,因此也增加了對於編碼設計、數值分析方面的挑戰性。綜上所訴,我們可以這樣認為:整型只能表示范圍較小的數,但它能表示的每個數都是准確的。浮點數雖然能編碼一個很大的范圍,但這種表示只是近似的。

好了,廢話不多說,下面介紹IEEE 754標准的內容。

IEEE浮點標准使用 $ V=(-1)^s×M×2^E$ 的形式來表示一個數。

  • 符號位(sign),與補碼類似,s為1代表這個數為負,s為0代表這個數為正

  • 階碼(exponent)E的作用是對浮點數加權,權重是2的E次冪。

  • 尾數(significand)M是一個二進制小數,范圍是\(1到2−ϵ\)(規格化)或者\(0到1−ϵ\)(非規格化)

標准浮點格式把位划分成三個字段,分別對上面這三種值進行編碼

  • 開頭第一位代表符號位

  • 接下來是k位的字段exp,編碼的是階碼,但是結果要減去偏置值。(需要重點理解)

  • 在接下來n位的小數字段frac,編碼尾數M,但是編碼出來的值依賴於這個數是規格化數or非規格化數(后面會解釋,需要重點理解)

比較常見的是32位和64位的浮點數編碼。在C語言中,32位對於的就是數據類型float,稱為單精度浮點數(single precision)。64位的是double,稱為雙精度浮點數(double precision)。下面是他們的編碼格式,具體的就是k和n位數的不同,看下圖。

規格化和非規格化的值

規格化的值(Normalized Values)

在 exp≠000…0 和 exp≠111…1 時(階碼部分不全為0或全為1),表示的值就叫規格化值。到底規格化是什么意思呢?對於規格化數來說,用二進制數來表示時,原本連續的值會被規范到有限的定值上。如果把規格化的數放到數軸上表示,那他們之間的距離是不同的。(后面會舉例子解釋,現在不懂不用太擔心)

對於規格化值,階碼字段(exp這部分)的編碼區域的無符號值為e,但階碼E的值是\(E = e - Bias\),注意這里不要把大小寫e和E搞混。

Bias是指偏移量,值為$ 2^{k-1}-1 $,k是階碼字段的位數。

也就是說

  • 單精度規格化數的Bias是127,e的范圍是1 ~ 254,E的范圍是-126 ~ 127

  • 雙精度規格化數的Bias是1023,e的范圍是1 ~ 2046,E的范圍是-1022 ~ 1023

之所以需要采用一個偏移量,是為了保證 exp 編碼只需要以無符號數來處理。

還記得嗎?階碼值E,是用來做浮點數的權。可以看到,這個權在float最大可以達到127,最小可以達到-126,double的范圍就更大了,不知道比float高到哪里去了。想想看,用float可以表示的最大的數是表示為某個小數乘以2的127次方,這就是為什么浮點數可以表示很大的數與很小的數的原因,相比之下,int能存下的最大的數也就10的9次方啊。

對於小數字段frac,它就是描述小數值f,0≤f≤1。而尾數M定於為 M = 1 + f。你也可以這樣理解:M一定是一個以1開頭的小數,形如 M=1.xxxxx.xx,那些 xxx 就是 frac 的編碼部分。當 frac 全為0時 M 最小(M=1.0),當 frac 全為1時 M 最大(M=2-ϵ)。之所以采取這種方式,是因為如果階碼沒有溢出,我們總是可以調整階碼(想象一下,也就是移動小數點的位置)來把尾數控制在 1≤M<2 之間。這種方式就是輕松獲得一個額外精度位的技巧。也可以理解為:開頭的1是白送的,不需要額外的編碼位

舉個例子:

int x = 12345;
printf("%f",(float)x);

在強制類型轉換時,到底發生了什么呢?

首先,用二進制來表示十進制的12345:

\[12345_{10}=11000000111001 \]

將其小數點左移13位,創造一個規格化的形式:

\[12345 = 1.1000000111001_2 × 2^{13} \]

那么,構造float型的32位浮點數編碼:

  • 正數,所以符號位為0

  • 階碼的值E為13,E = e - Bias, e = E + Bias = E + 127 = 140。所以階碼字段就是140的八位二進制表示,即10001100,這就是exp字段。

  • 將上述規格化形式的小數部分,后續添0添到23位,也就是添10個0,構造frac字段,即10000001110010000000000

將其組合在一起,就形成了32位單精度浮點數12345的編碼

0  10001100  10000001110010000000000
s    exp            frac

非規格化的值(Denormalized Values)

當階碼域exp全為0時,所表示的數就是非規格化形式。這里的意思是,原本用二進制表示的連續值,它們之間的間距是一樣的。

在這種情況下有兩點不同。對於階碼字段,階碼值E改成了E = 1 - Bias。也就是說

  • 單精度非規格化數的E是-126

  • 雙精度非規格化數的E是-1022

對於尾數字段,尾數的值是 M = f。也就是說,M不再是一個以1開頭的小數,而是以0開頭的小數。這所以這樣設置是有原因的。首先,非規格化數定義了一種表示零的方法,如果使用規格化數,由於總有 M ≥ 1,那么我們就不能表示0。實際上,零的表示就是exp和frac字段全為0,因此,對於浮點數來說,還有正零和負零的區別。非規格化數的另一個用途就是表示非常接近零的數。這種機制實現了由最大非規格化數到最小規格化數的平滑轉換

特殊值

第一類是當exp全為1,frac全為0時,表示的值為無窮。當符號位 s = 1 代表負無窮,s = 0 代表正無窮。當小數域不是0,那就代表NaN(Not a Number),不是一個數,表示出錯。例如計算-1開根號的值就是NaN。

實例分析

我們采取一位符號位,4位exp位,3位frac位,bias = 2^3-1 = 7的編碼方式。

    s exp  frac   E   十進制值
------------------------------------------------------------------
    0 0000 000   -6   0   # 這部分是非規范化數值,下一部分是規范化值
    0 0000 001   -6   1/8 * 1/64 = 1/512 # 能表示的最接近零的值
    0 0000 010   -6   2/8 * 1/64 = 2/512 
    ...
    0 0000 110   -6   6/8 * 1/64 = 6/512
    0 0000 111   -6   7/8 * 1/64 = 7/512 # 能表示的最大非規范化值
------------------------------------------------------------------
    0 0001 000   -6   8/8 * 1/64 = 8/512 # 能表示的最小規范化值
    0 0001 001   -6   9/8 * 1/64 = 9/512
    ...
    0 0110 110   -1   14/8 * 1/2 = 14/16
    0 0110 111   -1   15/8 * 1/2 = 15/16 # 最接近且小於 1 的值
    0 0111 000    0   8/8 * 1 = 1
    0 0111 001    0   9/8 * 1 = 9/8      # 最接近且大於 1 的值
    0 0111 010    0   10/8 * 1 = 10/8
    ...
    0 1110 110    7   14/8 * 128 = 224
    0 1110 111    7   15/8 * 128 = 240   # 能表示的最大規范化值
------------------------------------------------------------------
    0 1111 000   n/a  無窮               # 特殊值

觀察表格,我們更好地可以理解之前的幾個細節:

  • 連續非規格化值之間的間距是一樣的,上面這個例子中,都是差了1/512

  • 連續的規格化值之間的間距不同。這是由於exp的不同而導致的。比方說最接近1的數字是15/16和9/8,分別相差1/16和1/8。

  • “浮點數能編碼一個很大的范圍,但這種表示只是近似的。”我們能想象,如果frac位的長度越長,那么對於小數表示的精度就能越高。如果exp位的長度越長,那么能夠表示的范圍就越大。

上面是一個數軸,表示的是這種編碼方式下的能表示的數在數軸上排列的情況。可以看到,浮點數表示的范圍是很大的,也能夠表示特別大和特別小的數。但是數的分布不是均勻的——在數軸兩端,數字比較稀疏;在越靠近原點的地方,數字越稠密。在最靠近原點的地方,是非規格化數。

舍入(rounding)

因為表示方法限定了浮點數不是精確的,因此浮點運算只能近似地表示實數運算。因此,對於某個運算結果x,我們需要想出一個系統的方法,找到一個能夠用浮點編碼表示的“最接近”的匹配值x1,用x1來表示運算結果。這就是舍入的任務。

浮點數采取的規則是舍入到偶數,即:將數字向上或向下舍入,使得結果的最低有效數字是偶數。舍入到偶數我認為可以這么理解:四舍六入,五到偶

例如,我們要把一個一位小數舍入到整數位,那么1.4會舍入成1,1.6會舍入成2。如果是按照四舍五入的話,2.5和1.5會舍入成3和2。如果是按照舍入到偶數,那么2.5和1.5都是舍入成2的。

為什么傾向於采取這種方法?我們很容易想到這樣的情景:用某種方法舍入一組數值,必然會在用這些數值做某種計算的時候產生誤差,誤差的類型依照舍入方法而不同。例如,如果都采取向上舍入,那么一組數據經舍入后,求這組數據的平均值,那么平均值肯定是偏大的。之所以采取舍入到偶數,可以認為舍入到偶數是一種比四舍五入更加“公平”的舍入方法,我們把中間值5,一半向上舍入,一半向下舍入,那么就在大多數的現實情況中避免了計算的統計偏差。

浮點運算

浮點加法

\[(-1)^{s1}M_1·2^{E_1} + (-1)^{s2}M_2·2^{E_2} \]

結果:(這里假設 E1 ≥ E2)

\[(-1)^{s}M·2^{E} \]

其中

\[s = s1 ∧s2,M = M1 + M2, E = E1 \]

  • 如果兩個浮點數的階碼不一樣,那么首先要對階。因為假設了E1 > E2,所以可以把第二個數的小數點往左移動,然后增加E2的值。

  • 尾數相加得結果M。

  • 規格化,把M通過左移or右移小數點來表示為1.xxx的形式,並更新E的值。

  • 如果不能完成規格化的操作,那么就是非規格化數。

  • 如果超過了可以表示的范圍,那么就是溢出。

  • 最后把M舍入到frac的精度。

浮點乘法

\[(-1)^{s1}M_1·2^{E_1} × (-1)^{s2}M_2·2^{E_2} \]

結果:

\[(-1)^{s}M·2^{E} \]

其中

\[s = s1 ∧ s2,M = M1 × M2, E = E1+E2 \]

仍然需要進行規格化操作、也需要舍入、也有可能溢出。

浮點運算性質

對於浮點值x和y,如果對它們進行某種運算,那么計算的結果實際上是將精確的計算結果舍入后的結果。因此,浮點運算有以下幾條重要的性質:

  • 滿足交換率,但不滿足結合律

例如表達式(3.14 + 1e10) - 1e10的結果會是0.0——因為舍入的緣故,3.14就丟失了。然而,表達式3.14 + (1e10 - 1e10)會得到正確結果3.14

  • 浮點加法滿足單調性原則。

如果 a ≥ b ,那么對於任何的x值,除了產生NaN,都有x + a ≥ x + b。

  • 浮點加法不具有可結合性。

(1e20 × 1e20)× 1e-20的值為正無窮,然而 1e20 × (1e20 × 1e-20)的值為1e20。

  • 不具備分配性。

1e20 ×(1e20-1e20) 的值為0.0,然而1e20 × 1e20 - 1e20 × 1e20會得到NaN。

int、float、double間的強制類型轉換

在沒有了解編碼方面的細節前,我們對這個問題只能說知其然而不知其所以然。

  • int轉換成float,數字不會溢出,但是可能被舍入。

  • int或float轉換成double,因為double有比這兩者都來得更大的范圍,和更高的精度,所以能夠保留精確的數值。

  • double轉成float,范圍會變小,有可能得到值正負無窮。由於精度也變小了,所以可能被舍入。

  • float或double轉成int,值將會像零舍入。例如:1.999會變成1,-1.999會變成-1。值有可能溢出。C語言標准沒有對這種情況制定固定的結果。

對於浮點數的一些總結,以及特殊的浮點數

第一章小結

好了,我們也看完了浮點數的全部內容,也就了解了信息在計算機內的存儲方式這一章的全部內容。

我們從最基本的元素——bit開始,了解了整型和浮點數兩個非常重要的數據類型的編碼方式。只有了解了位的知識、以及編碼方式,我們才可能對這些數據類型涉及的其他方方面面的概念,例如:掩碼運算、強制類型轉換、擴展、截斷、舍入、溢出等等。如果不了解位,那么對於上述這些概念只能是知其然而不知其所以然(好吧,原諒我的詞窮,這個詞我好像在本篇博客中用了好幾遍。。但我覺得知道深層的原理是很有幫助的)。

這些內容很重要,但我覺得比較好的學習方法是舉例子來理解。這本書每個小節后面配有很好的題目,可以幫助大家理解這些知識點。

See Also


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM