開篇:預備知識-3

前言

我們在之前兩篇文章中詳細的介紹了一下 C語言的歷史和關於 GCC 編譯器的使用方法。這篇文章中我們來一起探討一下關於信息數據在計算機是如何儲存和表示的。有些小夥伴可能會問。數據就是儲存在計算機的硬盤和主存中的啊。還能存去哪?確實,計算機中的所有數據都儲存在有儲存功能的部件中,這些部件包括內存、硬盤、CPU(寄存器)等。但是在這裏我們要探討的是數據在計算機中的表示形式,比如一個整型數 1 在計算機中的編碼值,這是一個理論層面的東西,也可以理解爲計算機科學家定製的一個標準。瞭解這些標準可以幫助我們更好的理解計算機的工作方式,寫出更加健壯的程序。

信息表示的方法-編碼

任何信息都是按照某個規則進行編碼而得到的。我們日常見到的漢字、數字、英文等,當然也包括這篇文章。我們拿英語單詞來舉例子:

所有英語單詞都是由 a~z 26 個字母中選取一個或者多個字母通過一定的順序組合成的,這個過程就可以理解成編碼:選取一個或者多個英文字母並按照一定順序排列的過程。實際上,在這裏我們把 英文字母 換成符號可能會更合適,因爲從本質上來說,a~z 就是英語中 26 個用來進行信息表示的基本符號,至於爲什麼要採用 a~z 來作爲基本符號,就得問這個語言的發明者了。

同樣的,編碼這個動作也適用於英語句子:所有的英語句子都是由一個或者多個英語單詞按照一定的順序組成的。

對於漢字也是一樣的道理:中文中的一句話是由一個或者多個漢字組成的,而漢字又是由一個或者多個偏旁組成的。

對於不同層面的信息我們有不同的編碼規則。但是隻有經過了編碼之後的符號纔有意義。我們可以理解爲:信息就是一個或者多個符號經過某個編碼規則進行排列組合後得到的符號所表達的東西。

從古代的實物計數、結繩計數、籌碼計數等到現代的羅馬數字、阿拉伯數字,某個信息在不同的編碼規則下可能有不同的符號表現。比如 ”五“ 這個數量詞在羅馬數字中用 V 符號表示,而在阿拉伯數字中用 5 這個符號來表示,但是它們表示的信息本質都是 這個數量詞。

計算機中信息的編碼

讓我們的思緒回到現代社會。計算機幫我們 ”記住“ 和 ”做“ 了很多事情,這句話換一個描述方式是:計算機幫我們儲存和處理了很多信息。而計算機內部採用 二進制 的形式來儲存和處理信息。這意味着在計算機內部中,只有 01 兩個符號可選。好在這兩個符號數量是不受限制的。也就是說我們可以選取任意個 01 的符號進行排列組合,即編碼,來得到無數個可能的結果(因爲我們可以選取任意個 01)。通過這兩個不同的符號已經足夠描述這個世界了,只是在現實層面上我們缺少足夠多的能夠儲存信息的介質而已,而這種介質在計算機中的最直接體現就是硬盤(無論再大,一個硬盤的容量也是有限的,容量大小時硬盤的物理屬性之一)。

假設我們現在有 1 位的二進制數據,我們可以選取的二進制符號有 0 或者 1。這兩我們通過排列組合得到的結果有兩種可能:01

如果可以選擇 2 位的二進制數據呢?我們在第一位可以選擇 0 或者 1,在第二位也可以選擇 0 或者 1。這兩我們通過排列組合得到的結果有 4 種可能性:00011011

如果可以選擇 n 位的二進制數據呢?我們通過排列組合得到的結果就有 2^n 種可能。

我們上面說過,將一個或者多個符號通過排列組合的過程就是編碼。編碼後的符號代表了某些信息。我們在上面已經通過二進制的符號(01)編碼出了一些符號組合。但是並沒有賦予其具體的含義。也就是說缺少了符號到信息的映射關係。這裏的原因在於我們缺少一個實際場景,這裏的缺少實際場景指的是我們還未指定這些符號要用來表示哪種類型的信息。我們來看看在計算機中這些符號組合分別代表什麼信息。

信息的表示與處理

我們在上面已經知道了,編碼出來的符號需要有實際的場景纔可以表示對應的信息。而在計算機中這些符號表示的信息取決於這些符號被賦值給了哪種類型的變量。假設我們有一個編碼出來的 8 位二進制符號:01000001,它代表的信息根據它的變量數據類型決定,我們拿 C語言中的數據類型舉例子:

char

字符類型,每個變量佔用 1 個字節(8位二進制)的儲存空間。二進制符號範圍爲:00000000 ~ 11111111。一共可以有 256 個組合, 每一個值都被表示爲了一個字符,對於上面的 01000001 來說。其表示的是大寫字母 A,參見 [Ascii 字符對照表](#1、Ascii 碼字符對照表)。我們可以通過代碼驗證:

#include <stdio.h>

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  char c = 0b01000001;
  printf("%c\n", c);
  return 0;
} 

在這裏插入圖片描述

short

短整型類型,每個變量佔用 2 個字節(16位二進制)的儲存空間。二進制符號範圍爲 0000000000000000 ~ 1111111111111111。一共有 65536 種編碼組合。 這種類型的每一種符號組合都被映射成了一個整數值。數值範圍(10 進製爲):-32768 ~ 32767。至於爲什麼會是這個數值範圍參考 整數的補碼錶示 小節。我們可以看到這個數值範圍恰好把 short 類型的 65536 種組合用完了(-32768 ~ -1 一共 32768 用了種組合,0 ~ 32767 一共用了 32768,兩個部分一共用了 65536 種組合)。

對於上面的 01000001 二進制編碼符號來說,如果保存它的變量是 short 類型,那麼其表示的含義是數字 65。我們可以通過代碼驗證:

#include <stdio.h>

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  short c = 0b01000001;
  printf("%d\n", c);
  return 0;
} 

在這裏插入圖片描述

int

整型類型,在 64 位計算機上,每個變量佔用 4 個字節(32位二進制)的儲存空間,32 位計算機上(基本已經很少了),每個變量佔用 2 個字節的儲存空間(16 位二進制)。如果在 32 位機器上,這個類型就等價於 short 類型。我們這裏只討論 64 位計算機。其二進制符號範圍爲 00000000000000000000000000000000 ~ 11111111111111111111111111111111。一共有 4294967296 種編碼組合。

short 類型類似,int 類型的每一個符號組合也被映射成了一個整數值。數值範圍(10 進製爲):-2147483648 ~ 2147483647。所有數字的個數總和正好等於 4294967296,將 4294967296 種二進制編碼的總和用完了。

short 類型 一樣,對於上面的 01000001 二進制編碼符號來說,如果保存它的變量是 int 類型,那麼其表示的含義是數字 65。我們可以通過代碼驗證:

#include <stdio.h>

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  int c = 0b01000001;
  printf("%d\n", c);
  return 0;
} 

在這裏插入圖片描述

long

長整型,每個變量佔用 4 個字節(32位二進制)的儲存空間。既然它是佔用 4 個字節的儲存空間,同時表示的信息又是整型數值類型,那麼它的功能就和 int 一模一樣(二進制符號範圍一樣、每個符號代表的信息也一樣)。即可以理解爲 long 類型是 int 類型的另一個別名。那麼既然兩個類型功能一樣,還要新建一個重複功能但又名字不同的數據類型幹嘛呢?我們這裏討論的類型佔用儲存空間的大小全部是針對 64 位機器而言的。而對於 32 位機器而言,int 類型變量佔用的字節數爲 2 個字節(16位二進制)。因此在早期(32位)計算機中,long 類型是爲了描述 4 個字節的整型值而存在,而隨着計算機的發展,到 64 位機器之後,int 類型的變量佔用的字節數也變成了 4,因此這裏 intlong 兩種數據類型的功能就重合了。

long long

雙長整型,每個變量佔用 8 個字節(64位二進制)的儲存空間,其二進制符號範圍爲 0000000000000000000000000000000000000000000000000000000000000000 ~ 1111111111111111111111111111111111111111111111111111111111111111。 一共有 1.8446744073709552e+19 種編碼組合。

int 類型類似,long long 類型的每一個符號組合也被映射成了一個整數值。數值範圍(10 進製爲):-9.223372036854776e+18 ~ 9.223372036854776e+18 - 1。所有數字的個數總和正好等於 1.8446744073709552e+19,將 1.8446744073709552e+19 種二進制編碼的總和用完了。

int 類型 一樣,對於上面的 01000001 二進制編碼符號來說,如果保存它的變量是 long long 類型,那麼其表示的含義是數字 65。我們可以通過代碼驗證:

#include <stdio.h>

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  long long c = 0b01000001;
  printf("%d\n", c);
  return 0;
}

在這裏插入圖片描述

float

單精度浮點類型。每個變量佔用 4 個字節(32 位二進制)的儲存空間。二進制符號範圍爲:00000000000000000000000000000000 ~ 11111111111111111111111111111111 。你會發現和 int 類型的二進制符號範圍一致,其實這個很好理解,因爲都是用二進制來進行編碼,佔用的二進制位數(都是32)也一樣,自然最後編碼得到的符號範圍和總數也一樣。那麼它們的區別在哪?其實就是對每個二進制編碼出來的符號賦予的含義不同:在 int 類型中,每一個二進制符號都被表示成了一個整數,而在 float 類型中,每一個二進制符號都被表示成了一個浮點數。

對於上面的 01000001 二進制編碼符號來說,如果保存它的變量是 float 類型,那麼其表示的含義是一個小於 0 的浮點數。我們可以通過代碼驗證:

#include <stdio.h>

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  int c = 0b01000001;
  float *cp = (float *)&c;
  // %.128f 表示輸出一個浮點數,結果保留 128 位小數
  printf("%.128f\n", *cp);
  return 0;
} 

在這裏插入圖片描述
我們需要解釋一下上面的代碼:我們先將 01000001 二進制編碼數據賦值給了一個 int 類型的變量 c。此時變量 c 在內存中的二進制編碼表示爲:00000000000000000000000001000001 。即(01000001)前面補齊了 24 個 0 位(int 類型佔用 32 位二進制儲存空間,當給定的二進制符號位數不足 32 位時,會在左邊用 0 補齊剩下的位數)。然後,我們將 c 的地址強制轉換成了 float 類型的指針,最後以 float 類型的編碼解釋模式輸出了這個二進制編碼數據代表的值。關於最後打印出來的結果爲什麼是截圖上的小數值,可以參考 浮點數的表示 小節。

爲什麼不直接使用 float c = 0b01000001; 來給 float 類型變量賦值呢?因爲如果這樣寫,那麼這個數據就會先轉換爲 int 類型,也就是 10 進制的 65,然後再將 10進制的 65 這個值轉換爲對應的浮點數。而最終解碼出來的值還是 65 這個數字。換句話來說 float c = 0b01000001; 寫法和 float c = 65; 寫法是沒有區別的。採用這種寫法時,這個 float 變量在計算機內容實際儲存的二進制編碼數據就不是 0b(24 個0)01000001 了。我們可以通過下面這段代碼看一下當我們使用 float c = 0b01000001; 這種賦值方式時在內存中變量 c 的二進制編碼數據:

#include <stdio.h>

/**
 * 打印出浮點數 f 在內存中的二進制編碼數據 
 */
void showFloatBits(float f) {
  int *fp = (int *) &f;
  int size = sizeof(f) * 8;
  int i;
  for (i = 0; i < size; i++) {
    printf("%d", (*fp) >> (size - 1 - i) & 1);
  }
  printf("\n");
} 

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  float c = 0b01000001;
  showFloatBits(c); 
  return 0;
} 

結果:
在這裏插入圖片描述
至於結果爲什麼是這個,可以參考 浮點數的表示 小節。

其實從上面的一系列代碼實驗我們已經可以看出:在計算機內存中儲存的只是二進制符號數據,至於要將這個符號數據表示/“翻譯” 成什麼信息,那就取決於具體的場景,在這裏這個場景就是儲存這個二進制符號數據的變量類型。如果是字符類型數據(char),則按照 [Ascii 碼字符對照表](#1、Ascii 碼字符對照表) 來進行"翻譯"。如果是整數類型(包括 shortintlonglong long 等),則按照 整數的表示規則 來進行翻譯。如果是浮點數類型(floatdouble),則按照 浮點數的表示規則 來進行翻譯。對於我們最開始的 01000001 這個二進制符號來說,如果保存這個二進制符號的變量是一個 char 類型變量,那麼其表示的值爲字母 A。如果保存這個二進制符號的變量是一個 shortintlong 類型的整型變量,那麼其表示的是 10 進制數字 65。如果保存這個二進制符號的變量是一個單精度浮點數類型(float)的變量,那麼其表示的是一個小數值:0.00000000000000000000000000000000000000000009108440018111311。如果是 double 類型的變量,其表示的小數將會更小,參見:double 小節

只有絕對不變的符號,沒有絕對不變的信息。

double

雙精度浮點類型,每個類型佔用 8 個字節的儲存空間(64 位二進制)。和 float 類型類似,double 類型也是用來表示浮點數,不過每個 double 類型的變量佔用 8 個字節的儲存空間,在儲存浮點數的範圍和精度方面都有了很大的提升。因此其名爲 雙精度浮點類型

對於上面的 01000001 二進制編碼符號來說,如果保存它的變量是 double 類型,那麼其表示的含義是一個小於 0 的浮點數。我們可以通過代碼驗證:

#include <stdio.h>

int main() {
    // 0b 開頭的代表這是一個二進制編碼數據
  long long c = 0b01000001;
  double *dp = (double *) &c;
  printf("%.1024lf\n", dp); 
  return 0;
} 

在這裏插入圖片描述
因爲 double 類型變量佔用 8 個字節的存儲空間,所以這裏先需要使用一個 long long 類型變量來承接初始的值(保證和 double 類型所佔用的儲存空間一致)。這裏在內存中得到的二進制編碼數據爲:0000000000000000000000000000000000000000000000000000000001000001。即爲在 01000001 前面補齊了 56 個 0 位,這一點和 int 類型類似,給定的二進制編碼數據長度不滿足數據類型所佔用的位數,則會在左邊補齊 0 。

後面其實和 float 小節的代碼類似:將 long long 類型變量的指針強轉爲 double 類型的指針,然後將其在內存中實際儲存的二進制編碼翻譯成 double 類型的浮點數。

double 類型能表示的浮點數精度比 float 大得多,因此這裏爲了能夠展示出所有的非 0 小數,在打印時保留了 1024 位小數,從結果也可以看到對於同樣的二進制編碼:01000001float 類型翻譯出來的值和 double 類型翻譯出來的值相差甚遠。至於這個值爲什麼會是這個,請參考:浮點數的表示 小節。

到這裏我們可以很清楚的知道:二進制編碼符號只是做一個標識功能,至於這個符號要翻譯成什麼信息,取決於具體的數據類型是什麼。

上面我們討論的內存中二進制編碼符號的翻譯。這個規律類比到文件其實也是一樣的。所有的文件本質上儲存的都是二進制數據,這些二進制數據在被程序使用時要被 “翻譯” 成什麼樣的信息就取決於文件類型,比如在 Windowstxt 類型的文件會被當成文本文件打開;png 類型的文件會被當成圖片打開…。這就是我們上面的說的:信息的表示本質上是對二進制符號進行編碼,具體的編碼規則就取決於所處的場景,在編程語言中,其取決於保存二進制符號的數據類型;而在文件中,其取決於文件的後綴名(本質上還是取決於文件的解碼方式,某種文件類型只是對應了一種解碼方式)。

看到這裏,相信你已經知道我們平時遇到的打開某些文本文件亂碼的本質原因了。沒錯,就是因爲解碼文本文件中二進制符號的方式和保存這個文本文件時採用的編碼方式不一致導致的。比如你採用 GBK 規則編碼文本文件,卻使用 ASCII 規則進行解碼。這裏的 GBKASCII 兩種編碼爲兩種不同的文本信息的表示方法,即爲兩種不同的編碼規則。

整數的補碼錶示

我們已經在上面見過了 C語言中的表示整數的幾種類型(shortintlonglong long)。我們在上面已經知道它們除了佔用的內存空間不一樣之外,採用的編碼規則是一樣的:都是採用二進制補碼錶示整數。我們拿 int 類型來舉例,int 類型的二進制編碼範圍是:00000000000000000000000000000000 ~ 11111111111111111111111111111111。一共 2^32 種編碼方式。這裏的 2^32 種編碼方式可以表示 2^32 種信息,在這裏就是 2^32 個數字。

符號數

因爲 int 類型的整數有負數的概念,因此我們將編碼的第一位看成符號位,不計入值運算:0 代表正數、1 代表負數。

這樣一分我們就相當於將這 2^32 中編碼方式一分爲二,第一部分爲:10000000000000000000000000000000 ~ 11111111111111111111111111111111 ;第二部分爲:00000000000000000000000000000000 ~ 01111111111111111111111111111111。第一部分我們用來表示負數,第二部分我們用來表示正數。這樣表示之後我們會發現整數 0 有兩個二進制符號表示:1000000000000000000000000000000000000000000000000000000000000000。分別代表 -0+0。但是這在數學上並沒有意義,數學上數字 0 就是數字 0,沒有正負之分。因此我們只讓 00000000000000000000000000000000 這個二進制符號表示數字 0,將 10000000000000000000000000000000 這個二進制符號解放出來,讓它表示 int 類型的最小值(即爲負數中絕對值最大的數),這個值爲 -2^31

對於非負數(上文中的第二部分)。從小到大,我們用 00000000000000000000000000000000 (即32位全0的編碼方式表示整數0),則整數 1 則爲 00000000000000000000000000000001,整數 2 則爲 00000000000000000000000000000010…如果按這種表示方式繼續,整數的最大值則爲 2^31-1,二進制符號爲:01111111111111111111111111111111。這是正數部分的規律。

對於負數(上文中的第一部分),從小到大,我們從 10000000000000000000000000000000 開始,這個二進制編碼符號代表是 int 類型的最小值:-2^31。而值 (-2^31) + 1 的二進制編碼符號爲 10000000000000000000000000000001(即爲在最小值的基礎上 + 1)。值 (-2^31) + 2 的二進制編碼符號爲 10000000000000000000000000000010(-2^31) + 3 的二進制編碼符號爲 10000000000000000000000000000011…到最後,負數的部分的最大值爲 -1。對應的二進制編碼符號爲 11111111111111111111111111111111。如果在這個基礎上再加 1 的話,就發生溢出了,留下的 32 位二進制符號值爲 00000000000000000000000000000000 ,變成了非負數的最小值,即爲數字 0,再繼續往上加的話就變成正數了。同樣的,如果在正數的最大值 01111111111111111111111111111111 的基礎上再加 1 的話,最高位的0 進位爲了 1 ,二進制編碼符號爲:10000000000000000000000000000000 ,就變成了負數的最小值。從這裏我們可以看出:正數的最大值和負數的最小值在二進制編碼符號上是相鄰的,正數的最小值和負數的最大值在二進制編碼符號上也是相鄰的,我們可以用一幅圖來表述這個規律:
在這裏插入圖片描述
圖中的整個圓形代表了 32 位二進制符號可以表示的所有符號總數,在幾個特殊的位置,標註了這個二進制符號對應的 int 類型特殊值。同時通過箭頭表明了符號改變的規律對應着 int 的變化規律。我們可以發現整個圓形的值隨着逆時針方向,對應的 int 值是遞增的,到達了南北兩個極點之後,值發生對調(負數最大值->正數最小值,正數最大值->負數最小值)。於是整個部分形成了一個環,理解這個規律很重要,我們在 溢出 小節還會討論到這個問題。

我們在上面討論的這種整數的表示方式稱爲 整數的補碼錶示,對應的,還有整數的原碼、反碼錶示方式,但是由於補碼可以使得計算機可以以加法的形式來進行減法,因此最終計算機科學家們將二進制補碼作爲有符號整數最終的表示方式。

無符號數

我們在上文討論了 int 類型(帶符號數)的二進制編碼表示。對於無符號數(unsigned int)類型就更簡單了,因爲是無符號數,所以所有的二進制編碼符號都是用來表示非負整數。對於 unsigned int 來說,其共有 2^32 中二進制編碼符號,因此可以表示的整數的範圍爲: 0 ~ 2^32-1 。這部分規律可以用如下圖來表示:

在這裏插入圖片描述
對於無符號數來說,只有兩個極點值:非負數的最大值和非負數的最小值,並且這兩個極點值是相鄰的。在這個圓中,和上面的圖一樣,依然有 2^32 種二進制符號的編碼方式,但是因爲我們解釋這些符號的規則不一樣了(這裏不需要將最高位當成符號位來解釋了)。因此在這裏對於某些(最高位爲 1 的)二進制編碼符號來說,得到的數值和上面相同的二進制編碼符號不一樣。

浮點數的表示

C語言中提供了兩種浮點數類型(floatdouble),分別對應於單精度浮點和雙精度浮點數。它們佔用的內存字節數分別爲 4 個字節和 8 個字節。既然規定了佔用的字節數,那麼這兩種類型的二進制編碼數目也就確定了。分別是 2^32 種和 2^64 種。我們分別來看一下這兩種數據類型對於二進制編碼的解釋方式:

單精度浮點

單精度浮點類型把 4 個字節, 32 位 Bit 儲存空間劃分爲如下部分:

在這裏插入圖片描述

雙精度浮點

雙精度浮點類型把 8 個字節,64 位 Bit 儲存空間劃分爲如下部分:

在這裏插入圖片描述
兩種浮點類型的區別在於佔用的儲存空間不同,因此能表示的浮點數的範圍和精度也不一樣。但是解釋二進制編碼的規則是一樣的:

浮點數的解釋規範

不管是單精度浮點數還是雙精度浮點數,都是將對應的內存 Bit 位分成了 3 個部分:sexpfrac

1、s(sign) : 符號位,和整數類型類似,浮點數也要有正負標識,這就是我們熟知的符號位,佔用一個 bit 位,值爲 0 代表正數、1 代表負數。

2、exp(exponent): 階碼,這個值會被解釋成一個無符號整數,我們先標記爲 e

3、frac: 尾數,這個值會被解釋成一個二進制小數,我們先標記爲 f

浮點數最終的值解釋公式爲:V = (-1)^s * M * 2^E

其中 M 和上面的尾數部分(frac)相關聯, E 和上面的階碼部分(exp)相關聯,根據 exp 部分的值,最終得到的值會有三種解釋方式,這三種解釋下 ME 的值又不盡相同,我們來看一下:

1). 規格化的值

當浮點數中 exp 部分的值不全爲 0(00000000) 並且不爲 1(11111111)。會採用該方式來對浮點數進行解釋。此時上面浮點數解釋公式中的 M 值公式爲 M = 1 + ff 即爲上面的尾數部分解釋成了二進制小數後的值。 E 的值公式爲 E = e - Bias。其中 Bias 是一個常量值,在單精度浮點中爲 1272^(7) - 1)。在雙精度浮點中爲 10232^(10) - 1)。Bias 值的規律爲 2^(階碼的位數-1) - 1

浮點數中絕大多數值都是規範化的值,我們來舉個例子,假設在計算機內存中有一個二進制編碼爲 00111111001100000000000000000000float 類型浮點數,它的 10 進制值是多少呢?我們按照上面的浮點數 Bit 劃分來分別取出 sexpfrac 部分的數據

1、s: 左邊第一位符號爲 0,因此這個浮點數是一個正數。

2、exp: 從左邊第二位開始,向右數 8 位,值爲 01111110,因此這是一個規範化的浮點數,得到的 E 值爲 e - Bias = 126 - 127 = -1

3、frac: 從右邊第一位開始,向左數 23 位,值爲 01100000000000000000000。即爲 0.01100000000000000000000。轉換爲小數值爲 2^(-2) + 2^(-3) = 3/8。此時得到的 M 值爲 1 + f = 11/8

最後,根據浮點數的計算公式:V = (-1)^s + M * 2^E。得到的最終10 進制小數值爲:(-1)^0 + 11/8 * 2^(-1) = 11/16。我們還是用計算機幫我們驗證吧:

#include <math.h>
#include <stdio.h>

int main() {
  int i = 0b00111111001100000000000000000000;
  float *p = &i;
  printf("%f\n", *p);
  printf("%f", 11/16.0f);
  return 0;
}

結果:
在這裏插入圖片描述
可以看到 11/16.0f 的值和我們手動解析 float 類型的二進制編碼得到的值相同。

2). 非規格化的值

當浮點數中 exp 部分的值全爲 0(00000000) 時,代表該浮點數是非規範化的。會採用該方式對浮點數進行解釋。此時上面浮點數解釋公式中的 M 值公式爲 M = ff 即爲上面的尾數部分解釋成了二進制小數後的值。E 值公式爲 E = 1 - BiasBias 和上面規格化的規則一樣,單精度浮點中值爲 127。雙精度浮點值爲 1023

浮點數中也有很大一部分數值是非規範化的, 我們拿 float 小節中遺留的數據,類型爲 float,值爲 00000000000000000000000001000001 的單精度浮點數據來舉例

1、s: 左邊第一位符號位爲 0,因此這個浮點數是一個正數。

2、exp: 從左邊第二位開始,向右數 8 位,都是 0,因此這是一個非規範化的浮點數,得到的 E 值爲 1 - Bias = -126

3、frac: 從右邊第一位開始,往左邊數 23 位,值爲 00000000000000001000001,實際值即爲 0.00000000000000001000001。轉換爲 10 進制小數爲:M * 2^E = (2^(-17) + 2^(-23)) * 2^(-126) = ...

最後,根據浮點數的計算公式:V = (-1)^s + M * 2^E。得到的最終10 進制小數值爲:(-1)^0 + M * 2^(-126)。因爲數字過於複雜,我們還是用計算機幫我們驗證吧:

#include <math.h>
#include <stdio.h>

int main() {
  float a = (float) ((pow(2, -17) + pow(2, -23)) * pow(2, -126));
  printf("%.128f\n", a);
  return 0;
}

結果:
在這裏插入圖片描述
這個結果和 float 小節中得到的運算結果是一致的!

3). 特殊值

當浮點數中 exp 部分的值全爲 1(11111111) 時。此時的值有以下 2 種情況:

[1]. 小數部分(frac)全爲 0 時,得到的值表示無窮,此時 s0 時表示正無窮,爲 1 表示負無窮。當我們把兩個非常大的浮點數相乘,或者除以 0.0f 時,會得到這個值。

[2]. 小數部分(frac) 不全爲 0 時,得到的稱爲 NaNnot a numer),即 “不是一個數字”。NaN 和任何數運算結果都是 NaN

浮點數的範圍
單精度浮點數(float)

從上面規律可以知道,對於單精度浮點數來說(float)類型,其能表示數據的最小值對應的二進制編碼爲:11111111011111111111111111111111。此時的 sexpfrac 的值分別爲:

s: 1。意味着這是一個負數。

exp: 11111110,既不爲全 0 也不爲全 1,意味着這是一個規格化的浮點數,e = 254

frac: 11111111111111111111111,即爲 0.11111111111111111111111 = 1 - 1/(2^(23))f = 1 - 1/(2^(23))

此時對應的 E 爲:e - Bias = 254 - 127 = 127M 爲:M = 1 + f = 2 - 1/(2^(23))。此時得到的浮點數值爲:V = -1^(s) * M * 2^E = -3.4028234663852886e+38。負數最小值是這個,那麼對應的正數最大值自然是將 s 符號位值改爲 0 的時候了,對應的正數最大值爲3.4028234663852886e+38

因此單精度浮點數(float) 能表達的數字範圍爲:-3.4028234663852886e+38 ~ 3.4028234663852886e+38

雙精度浮點數(double)

對於雙精度浮點數來說(double)類型,其能表示數據的最小值對應的二進制編碼爲:1111111111101111111111111111111111111111111111111111111111111111。此時的 sexpfrac 的值分別爲:

s: 1。意味着這是一個負數。

exp: 11111111110,既不爲全 0 也不爲全 1,意味着這是一個規格化的浮點數,e = 2046

frac: 1111111111111111111111111111111111111111111111111111,即爲 0.1111111111111111111111111111111111111111111111111111 = 1 - 1/(2^(52))f = 1 - 1/(2^(52))

此時對應的 E 爲:e - Bias = 2046- 1023 = 1023M 爲:M = 1 + f = 2 - 1/(2^(52))。此時得到的浮點數值爲:V = -1^(s) * M * 2^E = -1.7976931348623157e+308。負數最小值是這個,那麼對應的正數最大值自然是將 s 符號位值改爲 0 的時候了,對應的正數最大值爲 1.7976931348623157e+308

因此雙精度浮點數(double) 能表達的數字範圍爲:-1.7976931348623157e+308 ~ 1.7976931348623157e+308

不精確的浮點數

在數學中,0 ~ 1 之間的小數可以有無限多個,因爲我並沒有限制小數的位數。但是在計算機中就不存在 “無限多個” 這種說法,就如同計算機的儲存介質是有限的一樣。不管我們用 32 位的浮點數(float)還是 64 位的浮點數(double),因爲它們的二進制編碼總數是有限的。那麼它們能表示的浮點數的個數總就是有限的。因此對於某些特殊的小數,在計算機中就會出現沒辦法精確表示的情況。

舉個例子,假設我們要用一個 float 類型的變量保存 10 進制的浮點數 0.1。我們很容易就可以寫出這段代碼:

#include <math.h>
#include <stdio.h>

int main() {
  float a = 0.1f;
  return 0;
}

但是如果此時你把 a 打印出來:

#include <math.h>
#include <stdio.h>

int main() {
  float a = 0.1f;
    // 保留小數點後面 32 位小數
  printf("%.32f\n", a);
  return 0;
}

結果:

在這裏插入圖片描述
很奇怪對不對,賦值進去的明明是 0.1,怎麼打印出來的結果是略微比 0.1 大一點的值呢,我們不妨看一下變量 a 中在內存的二進制編碼:

#include <stdio.h>

/**
 * 打印出浮點數 f 在內存中的二進制編碼數據 
 */
void showFloatBits(float f) {
  int *fp = (int *) &f;
  int size = sizeof(f) * 8;
  int i;
  for (i = 0; i < size; i++) {
    printf("%d", (*fp) >> (size - 1 - i) & 1);
  }
  printf("\n");
} 

int main() {
  float c = 0.1f;
  showFloatBits(c); 
  return 0;
} 

在這裏插入圖片描述
得到的結果爲:00111101110011001100110011001101,我們將這個二進制編碼按照浮點數解釋規範來拆分,到的值如下

s: 0.

exp: 01111011 = 123.

frac: 10011001100110011001101 = 0.10011001100110011001101 = 2^(-1) +2^(-4) + 2^(-5) + 2^(-8) + 2^(-9) + 2^(-12) + 2^(-13) + 2^(-16) + 2^(-17) + 2^(-20) + 2^(-21) + 2^(-23)

根據 exp 的值,我們知道這是一個規範化的浮點數,因此

E= e - Bias = 123 - 127 = -4

M = 1 + f

最後得到:V = 2^(-4) * M。用計算機幫我們算:

#include <math.h>
#include <stdio.h>

int main() {
  float M = (float) (1 + pow(2, -1) + pow(2, -4) + 
    pow(2, -5) + pow(2, -8) + pow(2, -9) + pow(2, -12) + pow(2, -13) + 
    pow(2, -16) + pow(2, -17) + pow(2, -20) + pow(2, -21) + pow(2, -23));
  float E = (float) pow(2, -4);
  
  float a = (float) (M * E);
  printf("%.32f\n", a);
  return 0;
}

結果:
在這裏插入圖片描述
可以看到,和之前直接打印出 a 的結果一樣。這說明浮點數編碼規則是沒錯的,那爲什麼不精確呢?其實從上面的 a 的內存二進制編碼打印結果中就可以知道了,frac 部分的值爲 10011001100110011001101。 23 位表示小數部分的 bit 已經用完了,依然沒有辦法精確的表示所需要的小數。那麼只能取這個值了(float0.1 說:我最多隻能表示到這個精度了,還是沒有辦法精確的表示你,那我也沒辦法了,我盡力了)。但是我們在打印的時候會按正常的浮點數解釋規則對這個二進制編碼進行解釋,因此得到的浮點數就是不精確的。

這個問題我們可以通過提高浮點數的精度來改善,比如把 a 換成 double 類型的,這樣就有 52 位 bit 可以用來表示小數部分了。但是這也僅僅是改善,不能解決這個問題,小夥伴們仔細觀察一下就會知道:在數據類型爲 float 時, 0.1frac 部分是以 1001 的無限循環,這就和我們 10 進制中的無限循環小數一樣。而在有限的計算機內存中,顯然是無法精確表示的。我們還是用 double 類型來驗證一下:

#include <math.h>
#include <stdio.h>

int main() {
  double a = 0.1;
  printf("%.128f\n", a);
  return 0;
}

結果:
在這裏插入圖片描述
確實有了很大改善,但還是偏大一點,我們再打印出此時的變量 a 在內存中而進制編碼數據:

#include <stdio.h>

/**
 * 打印出浮點數 f 在內存中的二進制編碼數據 
 */
void showFloatBits(double f) {
  int *fp = (int *) &f;
  int *fpNext = fp++;
  int size = sizeof(int) * 8;
  int i;
  // 打印前 4 字節數據 
  for (i = 0; i < size; i++) {
    printf("%d", ((*fp) >> (size - 1 - i)) & 1);
  }
  // 打印後 4 字節數據 
  for (i = 0; i < size; i++) {
    printf("%d", ((*fpNext) >> (size - 1 - i)) & 1);
  }
  printf("\n");
} 

int main() {
  double c = 0.1;
  showFloatBits(c); 
  return 0;
} 

結果:
在這裏插入圖片描述
同樣的,按照浮點數的解釋規則進行分隔:

s: 0

exp: 01111111011

frac: 1001100110011001100110011001100110011001100110011010

這裏我們不再重新計算浮點數據了,可以觀察到,此時的 frac 還是以 1001 重複的無限循環,相對 float ,這個循環的位數提高了(從 23 到 52),這意味着值會更加精確,但是依然無法完全精確的表示 0.1 。如果對精度要求很高,需要使用高精度的浮點數類型,C語言中本身沒有提供,需要自己實現,Java 中有 BigDecimal 類可供使用。

溢出

數學上沒有溢出的概念,只有進位的概念,因爲在數學上默認可用的數字空間是無限的,是一種理論模型。但是到了計算機上面就不是這樣的了,我們拿兩數相加來舉例子,如果我要計算 10 進制數 516 + 728。我們會在草稿紙上寫下如下步驟:

在這裏插入圖片描述
結果等於 1244。是一個四位數,而我們參與運算的兩個數字都是三位數,也就是說我們如果要以 10 進制數儲存 516 + 728 的運算結果的話需要 4 個單位的儲存空間,這裏說的單位指的是能儲存數字 0~9 的硬件。

但是我們的計算機用的是存儲 0~1 的硬件,所以只能儲存二進制儲存數據,如果我們要在計算機中模擬出上述計算過程,必須先將運算數轉換成二進制數字:516 -> 1000000100728 -> 1011011000。於是這個運算過程就變成了:

在這裏插入圖片描述
可以看到,兩個運算數都是佔用 10 位(bit)儲存空間的數字,我們可以用 short 類型來保存着兩個運算數(short 類型有用兩個字節,16 bit 的存儲空間,最左邊的 1 位用來標識符號,有 15 位 bit 可以用來儲存值)。運算結果佔用 11 位(bit)的儲存空間。這本是一個再正常不過的運算式,但是如果我們把兩個運算數再擴大一點的話,情況可能就不是這樣了,現在我們把算式改成:100000010000000 + 101101100000000,這個運算過程就變成了:
在這裏插入圖片描述
此時的出來的結果爲 1001101110000000。如果用 short 類型保存這個數據的話,得到的值是:-25728。我們用代碼來驗證:

#include <stdio.h>

int main() {
  short valueA = 0b100000010000000;
  short valueB = 0b101101100000000;
  short s = valueA + valueB;
  printf("a = %d\n", valueA);
  printf("b = %d\n", valueB);
  printf("a + b = %d\n", s);
  return 0;
}

結果:

在這裏插入圖片描述
很顯然,這個結果並不是我們想要的。爲什麼兩個正數做加法得到的結果會是負數?其實答案已經在上面的二進制運算過程圖中:我們的兩個運算數相加得到的結果已經超過了 short 類型能表示的整數的最大值(65535),對應的二進制補碼是 0111111111111111,而我們運算得到的結果是 1001101110000000 。很明顯,運算過程中最左邊的 0 由於進位被置爲 1 ,而作爲一個有符號整型,最左邊的 1 位代表符號位。此時,根據有符號整數的補碼錶示規則。得到的值就是一個負數。具體的值爲:-2^15 + 0 + 0 + 2^12 + 2^11 + 0 + 2^9 + 2^8 + 2^7 + 0 + 0 + 0 + 0 + 0 + 0 + 0 = -25728

其實上面的運算已經產生了溢出,運算的結果超過了該類型能表示的最大值。還記得我們在 符號數 小節中的那張圓形圖嗎?圓形左邊的值代表負數,右邊代表正數。如果你的運算結果超過了其數據類型能表示的正數的最大值,那麼就會反轉到圓形的左邊,即變成負數。同樣的,對於負數運算也是如此。

對於上面的問題,我們有很多種解決方法,這裏舉兩種:

1、將 valueAvalueBs 的類型變成 unsigned short,即變成無符號短整類型,這個時候 16 位 Bit 中最左邊的就不算符號位了,成了實際的數值存儲位。對於結果不大於 16 的運算自然是可以儲存的。

2、將 valueAvalueBs 的類型變成 int,即將數據類型佔用的內存空間變大(16 bit -> 32 bit),這樣儲存 16 位 Bit 的數據就綽綽有餘了。

需要注意的是,在進行整數之間的運算時,不管你設置的數據類型佔用的內存空間有多大,得到的結果都會有發生溢出的風險。溢出是一個異常的事件,我們在進行程序設計時,應當對輸入數據的最大值和最小值有一個充分的預估,以避免溢出這種異常情況的產生。

這是計算機中運算和數學中一個很大的不同的地方,數學是理論,我們在草稿紙上計算時,如果有進位,我們就在左邊加一位,這是沒有空間限制的(只要你的草稿紙足夠大)。

而計算機是應用。在包括 C語言在內的絕大多數編譯語言中,每一個個變量能儲存大多的數值在這個變量定義時就已經決定了。計算機中每一個信息都需要用儲存介質(內存、磁盤)來進行儲存,而這個介質的容量一定是有限的,當數據超過了這個容量後(在這裏體現的就是數據類型佔用的字節數),多餘的數據就會丟失,而在沒有其他處理的前提下,計算機就會按照原有既定的規則來處理這些不完整的數據,進而發生意想不到的結果。

好了,這篇文章到這裏就結束了,我們詳細介紹了 C語言中的數據類型,以及信息在計算機中的表示方式、編碼方式,最後我們討論了一下爲什麼計算機無法精確的表示某些小數。如果覺得本文對你有幫助,請不要吝嗇你的贊,如果文章中有哪裏不對的地方,請多多指點。

謝謝觀看。。。

附錄

1、Ascii 碼字符對照表

Bin(二進制) Oct(八進制) Dec(十進制) Hex(十六進制) 縮寫/字符 解釋
0000 0000 00 0 0x00 NUL(null) 空字符
0000 0001 01 1 0x01 SOH(start of headline) 標題開始
0000 0010 02 2 0x02 STX (start of text) 正文開始
0000 0011 03 3 0x03 ETX (end of text) 正文結束
0000 0100 04 4 0x04 EOT (end of transmission) 傳輸結束
0000 0101 05 5 0x05 ENQ (enquiry) 請求
0000 0110 06 6 0x06 ACK (acknowledge) 收到通知
0000 0111 07 7 0x07 BEL (bell) 響鈴
0000 1000 010 8 0x08 BS (backspace) 退格
0000 1001 011 9 0x09 HT (horizontal tab) 水平製表符
0000 1010 012 10 0x0A LF (NL line feed, new line) 換行鍵
0000 1011 013 11 0x0B VT (vertical tab) 垂直製表符
0000 1100 014 12 0x0C FF (NP form feed, new page) 換頁鍵
0000 1101 015 13 0x0D CR (carriage return) 回車鍵
0000 1110 016 14 0x0E SO (shift out) 不用切換
0000 1111 017 15 0x0F SI (shift in) 啓用切換
0001 0000 020 16 0x10 DLE (data link escape) 數據鏈路轉義
0001 0001 021 17 0x11 DC1 (device control 1) 設備控制1
0001 0010 022 18 0x12 DC2 (device control 2) 設備控制2
0001 0011 023 19 0x13 DC3 (device control 3) 設備控制3
0001 0100 024 20 0x14 DC4 (device control 4) 設備控制4
0001 0101 025 21 0x15 NAK (negative acknowledge) 拒絕接收
0001 0110 026 22 0x16 SYN (synchronous idle) 同步空閒
0001 0111 027 23 0x17 ETB (end of trans. block) 結束傳輸塊
0001 1000 030 24 0x18 CAN (cancel) 取消
0001 1001 031 25 0x19 EM (end of medium) 媒介結束
0001 1010 032 26 0x1A SUB (substitute) 代替
0001 1011 033 27 0x1B ESC (escape) 換碼(溢出)
0001 1100 034 28 0x1C FS (file separator) 文件分隔符
0001 1101 035 29 0x1D GS (group separator) 分組符
0001 1110 036 30 0x1E RS (record separator) 記錄分隔符
0001 1111 037 31 0x1F US (unit separator) 單元分隔符
0010 0000 040 32 0x20 (space) 空格
0010 0001 041 33 0x21 ! 歎號
0010 0010 042 34 0x22 " 雙引號
0010 0011 043 35 0x23 # 井號
0010 0100 044 36 0x24 $ 美元符
0010 0101 045 37 0x25 % 百分號
0010 0110 046 38 0x26 & 和號
0010 0111 047 39 0x27 閉單引號
0010 1000 050 40 0x28 ( 開括號
0010 1001 051 41 0x29 ) 閉括號
0010 1010 052 42 0x2A * 星號
0010 1011 053 43 0x2B + 加號
0010 1100 054 44 0x2C , 逗號
0010 1101 055 45 0x2D - 減號/破折號
0010 1110 056 46 0x2E . 句號
0010 1111 057 47 0x2F / 斜槓
0011 0000 060 48 0x30 0 字符0
0011 0001 061 49 0x31 1 字符1
0011 0010 062 50 0x32 2 字符2
0011 0011 063 51 0x33 3 字符3
0011 0100 064 52 0x34 4 字符4
0011 0101 065 53 0x35 5 字符5
0011 0110 066 54 0x36 6 字符6
0011 0111 067 55 0x37 7 字符7
0011 1000 070 56 0x38 8 字符8
0011 1001 071 57 0x39 9 字符9
0011 1010 072 58 0x3A : 冒號
0011 1011 073 59 0x3B ; 分號
0011 1100 074 60 0x3C < 小於
0011 1101 075 61 0x3D = 等號
0011 1110 076 62 0x3E > 大於
0011 1111 077 63 0x3F ? 問號
0100 0000 0100 64 0x40 @ 電子郵件符號
0100 0001 0101 65 0x41 A 大寫字母A
0100 0010 0102 66 0x42 B 大寫字母B
0100 0011 0103 67 0x43 C 大寫字母C
0100 0100 0104 68 0x44 D 大寫字母D
0100 0101 0105 69 0x45 E 大寫字母E
0100 0110 0106 70 0x46 F 大寫字母F
0100 0111 0107 71 0x47 G 大寫字母G
0100 1000 0110 72 0x48 H 大寫字母H
0100 1001 0111 73 0x49 I 大寫字母I
01001010 0112 74 0x4A J 大寫字母J
0100 1011 0113 75 0x4B K 大寫字母K
0100 1100 0114 76 0x4C L 大寫字母L
0100 1101 0115 77 0x4D M 大寫字母M
0100 1110 0116 78 0x4E N 大寫字母N
0100 1111 0117 79 0x4F O 大寫字母O
0101 0000 0120 80 0x50 P 大寫字母P
0101 0001 0121 81 0x51 Q 大寫字母Q
0101 0010 0122 82 0x52 R 大寫字母R
0101 0011 0123 83 0x53 S 大寫字母S
0101 0100 0124 84 0x54 T 大寫字母T
0101 0101 0125 85 0x55 U 大寫字母U
0101 0110 0126 86 0x56 V 大寫字母V
0101 0111 0127 87 0x57 W 大寫字母W
0101 1000 0130 88 0x58 X 大寫字母X
0101 1001 0131 89 0x59 Y 大寫字母Y
0101 1010 0132 90 0x5A Z 大寫字母Z
0101 1011 0133 91 0x5B [ 開方括號
0101 1100 0134 92 0x5C \ 反斜槓
0101 1101 0135 93 0x5D ] 閉方括號
0101 1110 0136 94 0x5E ^ 脫字符
0101 1111 0137 95 0x5F _ 下劃線
0110 0000 0140 96 0x60 ` 開單引號
0110 0001 0141 97 0x61 a 小寫字母a
0110 0010 0142 98 0x62 b 小寫字母b
0110 0011 0143 99 0x63 c 小寫字母c
0110 0100 0144 100 0x64 d 小寫字母d
0110 0101 0145 101 0x65 e 小寫字母e
0110 0110 0146 102 0x66 f 小寫字母f
0110 0111 0147 103 0x67 g 小寫字母g
0110 1000 0150 104 0x68 h 小寫字母h
0110 1001 0151 105 0x69 i 小寫字母i
0110 1010 0152 106 0x6A j 小寫字母j
0110 1011 0153 107 0x6B k 小寫字母k
0110 1100 0154 108 0x6C l 小寫字母l
0110 1101 0155 109 0x6D m 小寫字母m
0110 1110 0156 110 0x6E n 小寫字母n
0110 1111 0157 111 0x6F o 小寫字母o
0111 0000 0160 112 0x70 p 小寫字母p
0111 0001 0161 113 0x71 q 小寫字母q
0111 0010 0162 114 0x72 r 小寫字母r
0111 0011 0163 115 0x73 s 小寫字母s
0111 0100 0164 116 0x74 t 小寫字母t
0111 0101 0165 117 0x75 u 小寫字母u
0111 0110 0166 118 0x76 v 小寫字母v
0111 0111 0167 119 0x77 w 小寫字母w
0111 1000 0170 120 0x78 x 小寫字母x
0111 1001 0171 121 0x79 y 小寫字母y
0111 1010 0172 122 0x7A z 小寫字母z
0111 1011 0173 123 0x7B { 開花括號
0111 1100 0174 124 0x7C | 垂線
0111 1101 0175 125 0x7D } 閉花括號
0111 1110 0176 126 0x7E ~ 波浪號
0111 1111 0177 127 0x7F DEL (delete) 刪除
發佈了115 篇原創文章 · 獲贊 283 · 訪問量 16萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章