數值資訊的機器級儲存

YangAM發表於2018-03-14

計算機中使用八位的塊,或者說是「位元組」,作為最小的定址單元。你可以將整個儲存器視作一個超大的「位元組陣列」,每個位元組都有一個唯一的數字編號,這個編號就是所謂的地址,通過這個地址,我們可以唯一的確定一塊資料。但是我們程式碼中定義的各種數值又是如何轉換為二進位制串儲存在這些「位元組」裡面的呢?為什麼兩個整數相加之後的結果會變成負數?

等等這些類似問題,其實都歸咎於 計算機中是如何儲存各種型別的數值的。只有理解好這個問題,你才能對你程式中定義的各種數值型變數的範圍以及相互運算後的結果『盡在掌握』,才不至於程式動不動就因為變數的相互運算而資料溢位,系統崩潰。

基本的位運算操作

首先,掃個盲,簡單描述一下計算機中有關二進位制位的幾種基本的運算操作。

&(與) 運算:兩個二進位制位都為一結果才為一,其餘情況都為零

例如:0110 & 1100 = 0100,1101 & 0110 = 0100 。

|(或)運算:兩個二進位制位至少有一個位一,結果即可為一

例如:0110 | 1100 = 1110 ,1101 | 0110 = 1110 。

~(非)運算:二進位制串中的所有位顛倒,0 變成 1,1 變成 0

例如:~ 0110 = 1001 ,~ 1101 = 0010 。

^(異或)運算:兩個二進位制位有一個為一,但不全為一的時候,結果為一

例如:0110 ^ 1100 = 1010,1101 ^ 0110 = 1011。

位運算中還有一種操作很常見,各種原始碼中都很常見,那就是「移位運算」。

x << k(左移):x 向左移 k 個位,,也就是說丟棄高 k 位,右邊補 k 個 零。它等價於 x * 2^k

例如:0101 << 1 = 101==0==(等於 0101 * 2^1),0101 << 2 = 01==00== (這種情況會產生溢位,關於溢位後文再詳細說明)

位的右移一般分為兩種形式,邏輯右移和算數右移

x >> k(邏輯右移):x 向右移 k 個位,丟棄右邊 k 位,左邊補 k 個 0 。它等價於 x / 2^k

例如:0110 >> 1 = ==0==011(等於 0110 / 2^1),0111 >> 1 = ==0==011 。

x >>> k(算數右移):算數右移的基本規則和邏輯右移一樣,只是左邊補位的時候,補的是最高有效位

例如:0111 >>> 2 = ==00==01(7/4),1100 >>> 2 = ==11==11(-4/4) 。

算數右移和邏輯右移的唯一不同點在於,對於缺失位的補齊方式不同,邏輯右移統一補零,而算數右移則補的是原二進位制串的最高有效位(對於補碼來說就是符號位)。

整數的表示

計算機中,整數可以有兩種表述方式,無符號和有符號整數

C/C++ 中預設資料型別都是有符號的,但也可以通過申明 unsigned 來標識一個資料型別為無符號資料。但是,Java 中只支援有符號整數,所以本文對於無符號型別的描述只會「一帶而過」,感興趣的同學可以自行搜尋比較兩者之間的區別與聯絡。

下面我們主要來看看計算機中是如何儲存有符號整數的,以及它們之間的基本運算又是如何進行的?

① 原碼、反碼、補碼的基本概念

有符號整數的編碼標準要求,二進位制串的最高有效位為符號位,剩餘的二進位制位為該整數的「真值」。例如:

//這裡統一使用八位一個位元組來表述一個整數

5 :==0==000 0101

-10:==1==000 1010

最高有效位表述的是這個整數的符號,1 表示負數,0 表示正數。這就是計算機中整數的「原碼」表示。

但是,在進行基本的加減運算的時候,發現問題了。

    5 :0000 0101
+  -10:1000 1010
-----------------------
   -5 :1000 1111(-15)
複製程式碼

顯然,雖然原碼可以很好表示正負數,但是這個表述方式並不能正確的進行基本的運算操作。於是人們想出了「反碼」。

反碼:正數的反碼是其原碼本身,負數的反碼為原碼中除符號位不動其餘位取反的結果。

    5 :0000 0101         -》 0000 0101
+  -10:1000 1010         -》 1111 0101
----------------------------------
   -5 :1000 1111(-15)     1111 1010(反碼轉原碼得到結果:1000 0101 [-5])
複製程式碼

貌似反碼好像能夠解決我們的基本位運算了,但是看下面這個例子:

    1: 0000 0001        -》 0000 0001      
+  -1:1000 0001        -》 1111 1110
----------------------------------------
    0:                     1111 1111(反碼轉原碼得到結果:1000 0000 [-0] )
複製程式碼

顯然,0 本身無正負之分,而一個整數是不允許有兩個二進位制數值的。所以反碼的一個問題就是對於零這個整數數值來說,產生了兩種編碼結果。於是,人們又發明了「補碼」用於解決這個問題,而事實證明,「補碼」最終成為計算機中編碼數值的最後方案。

補碼:正數的補碼依然是其原碼本身,負數的補碼即原碼中符號位不變,其餘真值為取反再加一的結果。

    5 :0000 0101         -》 0000 0101
+  -10:1000 1010         -》 1111 0110
----------------------------------
   -5 :1000 1111(-15)     1111 1011(補碼轉原碼得到結果:1000 0101 [-5])
複製程式碼
    1: 0000 0001        -》 0000 0001      
+  -1:1000 0001        -》 1111 1111
----------------------------------------
    0:                     1 0000 0000(最高位溢位,所以最後的補碼值為 0000 0000 )
複製程式碼

事實上,大部分計算機都採用的補碼來表述有符號的整數。

② 擴充套件與截斷數字

這是一類在型別轉換時會遇到的問題,我們在程式設計中常常會將「小範圍」型別的變數轉換為「大範圍」型別的變數,或者將「大範圍」型別的變數強制轉換成「小範圍」型別的變數。

例如:Java 中 int 型別的變數佔 32 bits,long 型別的變數佔 64 bits,那麼我一個 int 型別的變數 x,如果被賦值給了一個 long 型別的變數 y,那麼 y 的高 32 位將是什麼?

對於採用補碼編碼的整數而言,擴充套件的 32 位將全部為原最高有效位的值。

這是小範圍擴充套件到大範圍所代表的一類問題,那麼大範圍縮排為小範圍,該怎麼辦呢?

大範圍縮排至小範圍的這一類問題,我們叫做「截斷數字」。截斷數字的最終結果是丟失最高的 k 位,以上面的例子來說,如果 64 位的數值被強轉成 32 位的數值,將直接導致丟失最高的 32 位。

③ 補碼編碼整數的四則運算

這是本篇文章的重點內容之一,理解了補碼的四則運算之後,對於程式中的數值運算的溢位將得到很好的控制。

補碼的加法運算

對於加法,我們要分幾種情況進行討論。

  • 正數加正數
  • 負數加負數
  • 正數加負數

首先,對於正數加負數的情況,沒什麼好說的,不可能產生溢位問題。

對於正數加正數的情況而言,可能會產生「負溢位」。例如:

\\我們以四位二進位制的格式來表述一個數值,最高位符號位
    0101                    0110
+   0010                +   0010
--------------         ---------------
    0111(5+2=7)             1000(結果溢位)
複製程式碼

對於第一個例子而言,並沒有什麼問題,但是第二個例子就有問題了,兩個正數相加,結果卻是個負數,這就是我們說的「負溢位」。

因為對於四位二進位制表示的數值來說,除去最高位用於表示符號,它能表述的範圍在:-8 ~ 7 之間。

而我們上述的例子中,6 + 2 = 8,顯然超出所能表示的最大數值,於是溢位為 -8 。

對於負數加負數的情況中,則可能發生「正溢位」。

    1110                            1001
+   1101                        +   1101    
-------------                   --------------
    1011(-2 + (-3)= -5)         0110(-7 + (-3))
複製程式碼

第二個例子,我們用 -7 + (-3) 得到結果為 6,出現了兩個負數相加結果為正數的情況,其實就是 -10 小於 -8 ,不能表示,於是產生了溢位。這就是所謂的「正溢位」。

在計算機的世界裡,只有加法,沒有減法。並不是我們設計不出來減法的數位電路,只是加法已經可以完全取代減法,而沒有必要專門再設計一個減法電路來增加底層電路的複雜程度了。

a - b 等價於 a + (-b)。

對於乘法操作而言,大多數計算機都有自己的乘法指令,只不過我們一般不用。原因就是乘法指令非常的慢,耗時。而相對於比較快的移位操作而言,編譯器通常會將程式中數值的乘法操作優化為多次的移位操作的組合。

例如:x * 20 = x << 4 + x << 2 。

除法操作也是一個道理,只不過除法是右移。

對於除法來說,還存在一個舍入的問題,就是說,-7/2 的結果應該得到的是 -3 而不是 -4。具體是怎麼做到讓結果「向零舍入」的,可以參見「深入理解計算機系統第二章」相關內容,此處不再贅述。

浮點數的表示

我們知道,計算機中的數值並不總是整型型別的,還有所謂的「小數」。那麼二進位制的小數都長啥樣?

100.10 = 1*2^2 + 1*2^(-1) = 4 + 1/2
10.010 = 1*2^1 + 1*2^(-2) = 2 + 1/4
010.11 = 1*2^1 + 1*2^(-1) + 1*2^(-2) = 2 + 1/2 + 1/4
複製程式碼

顯然,同一串二進位制字元可能由於小數點的位置不同而整個數值字面量不同。這個「小數點」對於浮點數而言是相當重要的,不僅在於它決定了整個數值的字面量大小以及規格化後的二進位制儲存,還在於它能影響到後面的浮點數運算操作。

浮點數的儲存遵循「IEEE 標準」,「IEEE 標準」使用下面的公式來表示一個浮點數。

V = (-1)^s * M * 2^E

其中,

  • s:符號位,1 表示負數,0 表示正數
  • M:尾數
  • E:階碼

例如:0100.10 可以表述為 (-1)^0 * 1.0010 * 2^2

但是實際上,IEEE 標準在實際轉儲成二進位制的時候,會有更加嚴格的要求,這裡只是簡單的描述。下圖是浮點數儲存的標準格式,當然單雙精度在各自的模組使用的位數不盡相同。

image

IEEE 標準規定,單精度和雙精度浮點數的儲存格式如下:

image

我們分幾種情況來討論這個浮點數的二進位制儲存。

  • 規格化儲存
  • 非規格化儲存
  • 特殊值儲存

首先,我們看看規格化的浮點數儲存有哪些要求。

這裡的 s 用於標識當前的浮點數的正負性,1 和 0 分別代表負數和正數,這沒什麼說的。

這裡的 exponent 表示的是階碼,階碼 ==E = e - Bias==,這個 e 的二進位制將填充在 exponent 裡面。有人可能會好奇,為什麼不直接儲存 E 呢,而是選擇加上一個 Bias 再存呢?

因為計算機在進行加法運算的時候,如果兩個浮點數的階碼不同,會首先統一一下兩者的階碼,然後將他們的尾數部分相加。那麼就必然需要比較兩者階碼的大小了,如果兩者的階碼都是正數,那麼計算機可以「無腦」得比較了,如果一個正數一個負數,就得另外設計數位電路用於比較正負數之間誰大誰小,本著讓底層數位電路越簡單越好的原則,肯定是選擇一種方案讓同一套數位電路可以處理這兩種不同的情況了。

於是人們想到了讓階碼加上一個很大的正數以保證加完後的結果是正數,這樣階碼之間的大小比較就完全變成了兩個正數之間的數值比較。但是這個「很大的正數」該如何取才能保證,無論原來的階碼有多小都能被轉換成一個正數呢?

IEEE 標準規定,單精度浮點數的這個 Bias 為 127,雙精度的 Bias 為 1023 。(2^(k-1) -1)

由於單精度的階碼佔八個位元位,也就是說 e 的取值在 0 - 255,==其中規格化的數值在階碼部分不允許全部為 0 或全部為 1== 。所以 e 的實際範圍在 1 - 254 ,因此,我們的 E = e - Bias 取值範圍在 ==-126 - 127== 之間。

同理,雙精度的階碼 E 的實際取值範圍為,==-1022 - 1023== 之間。

對於符號位和階碼的部分上述已經介紹了,下面我們看看,規格化的數對於尾數有沒有什麼特殊的要求。

規格化的尾數被定義為 M = 1 + f 。 而我們只儲存 f,例如:

010111.001 :1.0111001 * 2^4 -> 我們只儲存 f = 0.0111001

這樣會很方便我們讀取,因為我們知道尾數一定位於 0 - 1 之間,所以當我們讀取的時候,取出儲存的尾數統統加一就得到了實際的尾數值,而不用擔心,到底尾數是位於 0 到 1,或是 1 到 2 等範圍。

接著,我們看看非規格化的浮點數的表述有哪些要求

當階碼部分全為 0 的時候,所表示的浮點數就是非規格化格式的。此時我們的階碼值 E = 1 - Bias 。對於單精度(八個零)來說,E = 1 - (2^7 -1) = -126 ,對於雙精度(十六個零)來說,E = 1 - (2^15 - 1) = -1022 。

非規格化的尾數 M = f。

最後,我們看看幾種特殊值的浮點數表示

當階碼部分全為 1,並且尾數部分全為 0 的時候,表示無窮。其中如果,s 等於 0,則表示正無窮,如果 s 等於 1 則表示負無窮。

除此之外,如果尾數部分不是全 0,那麼當前的浮點數 「NaN」,不是一個數字。

下面,我們看一個簡單的例子:

float num = 9.0;

那麼 num 的二進位制儲存是什麼樣的呢?

9 的二進位制表述為:0000 0000 0000 0000 0000 0000 0000 1001

9.0 = (-1)^0 * 1.001 * 2^3

s = 0, M = 1 + 0.001 , E = 3

所以,該浮點數的規格化儲存為:0 ==1000 0001== 001 0000 0000 0000 0000 0000

反過來,當計算機讀到這麼一長串的二進位制,又會如何還原該二進位制串所表示的浮點數的值呢?

首先,第一位符號位表示該浮點數是正數。

然後接著讀取八個位元位(無符號),減去偏置值 127 得到實際的階碼值。

最後的 23 個位元位表示該浮點數的尾數部分,加上一就能得到實際的尾數值。

最終,計算機通過計算就能得到我們的浮點數的十進位制表述。

至此,關於計算機中整型和浮點型的數值是如何儲存的,我們已經詳盡介紹了,可能有些人會疑問,這些有什麼用??

就目前而言,我也不能保證,懂得了計算機是如何儲存數值的就一定能夠提高你的程式設計能力,但是等到你程式中出現數值運算錯誤而無法解決的時候,這一點點基礎知識一定能幫上忙。


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章