1. 程式人生 > 程式設計 >計算機組成原理

計算機組成原理

本文為極客時間徐文浩老師 - 深入淺出計算機組成原理學習筆記

0x01 計算機

現代計算機的基本組成部分其實主要由三部分組成:CPU,記憶體,主機板。

你撰寫的程式,開啟的任何PC端應用。都要載入到記憶體中才能執行,存放在記憶體中的程式及其資料需要被CPU讀取,CPU計算完之後還要把對應的資料寫回到記憶體。主機板的作用就是承載二者,因為他們不能互相嵌入到對方中。

CPU讀取記憶體中的二進位制指令,然後譯碼,通過控制訊號操作對應的運算原件以及儲存單元進行操作。

0x02 摩爾定律

英特爾創始人之一 戈登丶摩爾 曾說:當價格不變時,積體電路上可容納的元器件的數目每隔18-24個月便會增加一倍,效能也將提升一倍。

0x03 馮諾依曼體系結構

我們現在所使用的機器既叫圖靈機也叫馮諾依曼機,兩者是不同的計算機抽象。馮諾依曼側重於硬體抽象,圖靈機則側重於計算抽象。

起源於馮諾依曼發表的一片文章即“第一份草案”,文中描述了他心目中的一臺計算機應該長什麼樣。進而確立了當代計算機的體系結構,即:運算器,控制器,儲存器,輸入裝置,輸出裝置五部分。

他認為現代計算機應該是一個“可程式設計”的計算機,是一個“可儲存”的計算機。即也叫儲存程式計算機。因為過去的計算機電路是焊死在電路板的,如同現在的計算器,如果要做其他的操作,那麼就需要重新焊接電路,它是不可程式設計的計算機。再後來的計算機是“插拔式”的計算機,需要用什麼程式必須得插入該程式的元件才可。這樣程式無法儲存在計算機上,每次使用的時候還需要插拔的方式才可以。它是不可儲存的計算機。

0x04 圖靈機

圖靈是一位數學家,他並沒有考慮計算機的硬體基礎,而是隻考慮了計算機在數學計算模型上的可行性。即只思考了作為一個“計算機”,他應該做的工作,和怎麼工作。圖靈只思考了計算機的計算模型,及計算機所謂的“計算”的理論邏輯的實現方法。

圖靈機可以看做由一條兩端可無限延長的帶子,它有一個讀寫頭,以及一組控制讀寫頭工作的命令組成,是一種抽象的計算模型,即將人們使用紙筆進行的數學運算由一個虛擬的機器代替。

它證明瞭通用計算理論,肯定了計算機實現的可能性。同時它給出了計算機應有的主要架構,引入了讀寫,演演算法,程式語言的概念。極大的突破了過去的計算機的設計理念。

其實圖靈機本質上是狀態機,計算機理論模型,馮諾依曼體系則更像是圖靈機的具體物理實現。包括運算,控制,儲存,輸入,輸出五個部分。馮諾依曼體系相對之前的計算機最大的創新在於程式和資料的儲存。從此實現機器的內部程式設計。圖靈機的紙帶應對馮諾依曼體系中的儲存,讀寫頭對應輸入輸出規則及(讀取一個符號後,做了什麼)運算,紙帶怎麼移動則對應著控制。

理論上圖靈機可以模擬人類的所有計算過程,無論複雜與否。

0x05 晶片組,匯流排

晶片組

晶片組是主機板的核心組成部分,聯絡CPU及其他周邊裝置的運作。主機板上最重要的晶片組就是南北橋晶片組。

南北橋晶片組->主機板上的兩個主要晶片組,靠上方的叫北橋,下方的叫南橋。北橋負責與CPU通訊,並且連結告訴裝置(記憶體,顯示卡),並且與(I/O操作)南橋通訊,南橋負責與低俗裝置(硬碟/外部IO裝置,USB等裝置)通訊,並且與北橋通訊。

匯流排

主機板的晶片組和匯流排解決了CPU和記憶體通訊的問題(北橋),晶片組控制資料傳輸的流轉(從哪來,到哪兒去),匯流排則是實際資料傳輸的高速公路。

0x06 CPU

CPU的好壞決定 -> 主頻高,快取大,核心數多。CPU一般安裝在主機板的CPU插槽中。

資料通路:其實就是連線了整個運算器與控制器,方便我們程式的運轉和計算,並最終組成了CPU。

CPU一般被叫做超大規模積體電路,由一個個電晶體組合而成,CPU的計算過程其實就是讓電晶體中的“開關”訊號不斷的去“開啟”和“關閉”。來組合完成各種運算和功能。這裡的“開啟”及“關閉”操作的快慢就是由CPU主頻來影響。

控制器

一條條指令執行的控制過程,就是由計算機五大元件之一的控制器來控制的。

CPU與GPU

CPU即中央處理器,GPU即圖形處理器,現在的電腦,大部分GPU都整合在了CPU中也叫整合顯示卡,後來原本的GPU即屬於北橋的記憶體控制器等作為一支獨立的晶片封裝到了CPU基板上。所以後來的及其的主機板上沒有南北橋之分了,只剩下了PCH晶片即過去的南橋。

當然如果你的PC機要執行一些大型遊戲,或者有一些對GPU要求較高的工作的話,也可以配置獨立的GPU卡到主機板上。

過去:

  • CPU — 北橋 — 記憶體
  • CPU — 北橋 — 顯示卡
  • CPU — 北橋 — 南橋 - 硬碟
  • CPU — 北橋 — 南橋 - 網路卡
  • CPU — 北橋 — 南橋 - 外部IO裝置

CPU 的核數執行緒數

要看一臺PC機的具體CPU核數以及執行緒數可以通過工作管理員介面看到,也可以通過計算機右鍵屬性的裝置管理器中看到(僅能看到執行緒數)。或者通過如下命令看到

wmic
cpu get *
-----------對應屬性
NumberOfCores
NumberLogicProcessors
複製程式碼

CPU 主頻

CPU 的主頻即核心工作的時脈頻率,通常所說的***CPU是多少兆赫的,這裡所謂的兆赫就是描述的CPU主頻,CPU型號後面跟著的2.4 GHZ即主頻的數字描述。

主頻並不直接代表CPU的運算速度,所以也會有CPU主頻高但是CPU的運算速度慢的情況,主頻僅是CPU效能表現的一方面。

CPU 執行緒和 Java 執行緒的關係

Java 中的所有執行緒均在JVM程式中,CPU排程的是程式中的執行緒。

CPU執行緒數和Java執行緒數並沒有直接關係,CPU採用分片機制執行執行緒,給每個執行緒劃分很小的時間顆粒去執行,但是真正的專案中,一個程式要做很多的的操作,讀寫磁碟、資料邏輯處理、出於業務需求必要的休眠等等操作,當程式正在執行的執行緒進入到I/O操作的時候,執行緒隨之進入阻塞狀態,此時CPU會做上下文切換,以便處理其他執行緒的任務;當I/O操作完成後,CPU會收到一個來自硬碟的中斷訊號,並進入中斷處理例程,手頭正在執行的執行緒則可能因此被打斷,回到 ready 佇列。而先前因 I/O 而阻塞等待的執行緒隨著 I/O 的完成也再次回到 就緒佇列,這時 CPU 在進行執行緒排程的時候則可能會選擇它來執行。

參考:

0x07 程式與執行緒

執行緒是作業系統最小的排程單位,程式則是作業系統資源分配的對小單位。

程式:程式是作業系統分配資源的基本單位,每隔程式擁有虛擬後的獨立的記憶體空間,儲存空間,CPU資源。各種PC端應用均是一個獨立的程式。 執行緒:是CPU排程的基本單位,同意程式的各個執行緒共享程式內部的資源,執行緒間的通訊遠小於程式間的。因為(各個執行緒共享程式內部的資源)。所以在多執行緒併發的情況下,需要額外關注對於共享資源的保護問題,尤其是全域性變數。

0x08 超執行緒

Intel的超執行緒技術,目的是為了更充分地利用一個單核CPU的資源。CPU在執行一條機器指令時,並不會完全地利用所有的CPU資源,而且實際上,是有大量資源被閒置著的。 超執行緒技術允許兩個執行緒同時不衝突地使用CPU中的資源。比如一條整數運算指令只會用到整數運算單元,此時浮點運算單元就空閒了,若使用了超執行緒技術,且另一個執行緒剛好此時要執行一個浮點運算指令,CPU就允許屬於兩個不同執行緒的整數運算指令和浮點運算指令同時執行,這是真的並行。 我不瞭解其它的硬體多執行緒技術是怎麼樣的,但單就超執行緒技術而言,它是可以實現真正的並行的。但這也並不意味著兩個執行緒在同一個CPU中一直都可以並行執行,只是恰好碰到兩個執行緒當前要執行的指令不使用相同的CPU資源時才可以真正地並行執行。

本質上是一個物理核在跑一個線城時,同時利用閒置的電晶體跑其他指令,這樣就可以提升效能。

參考:

0x09 效能和功耗

計算機的兩個核心指標:效能,功耗。具體的體現則是響應時間和吞吐率。響應時間即單位任務執行運算的快慢,吞吐量即單位時間處理任務的多少。

程式執行時間: 程式在使用者態執行指令的時間+核心態執行指令的時間。

但受執行緒排程的影響,CPU在同一時間會有很多的Task在執行,不是隻執行特定程式的指令,並且同一臺計算機可能CPU滿載執行,也能會降頻執行。並且程式執行時間也會受到相應的主機板和記憶體的影響。

程式的CPU執行時間 = CPU時鐘週期數 * 時鐘週期時間 - 可以看成處理每個Task所需時間。

比如Intel Core - i7 - 7700HQ 2.8GHZ,這裡的2.8GHZ粗淺理解即CPU在一秒裡可以執行的簡單指令數是2.8G條。準確說即CPU的一個“鐘錶”能夠識別出來的最小時間間隔。

**時鐘週期時間:**在CPU內部,和我們戴的電子石英錶類似,有一個叫晶體振盪器的東西簡稱“晶振”,晶振的每一次“滴答”即電子石英錶的時鐘週期時間(晶振時間)。在2.8GHZ主頻的CPU上,這個時鐘週期時間就是1/2.8GHZ。CPU就是按照這個“時鐘”提示的時間來進行自己的操作,主頻越高意味著這個表走的越快,CPU也就“被逼”著走的也快,CPU越快散熱壓力當然也越大。

這裡可以得出,晶振時間與CPU執行固定指令耗時成正比,越小耗時越少。

CPU時鐘週期數 = 指令數 * 每條指令的平均時鐘週期數(CPI)。 - 可以看成共有多少個Task。

這裡說了每條指令的平均時鐘週期數,所以我們就知道不同的指令執行時間是不同的,即所花費的時鐘週期數是不同的,可能別人的Task簡單花1秒鐘就能做完,你的Task比較複雜需要5秒才行。具體到計算機,乘法的時鐘週期數就要多於加法。不過現代的CPU通過流水線技術可以使得單個命令的執行需要的CPU時鐘週期數更少了。

一個程式包含多條語句,一條語句可能對應多條指令,一條CPU指令可能需要多個CPU時鐘週期才能完成。

程式的CPU執行時間: 指令數 * 每條指令的平均時鐘週期數(CPI) * 時鐘週期時間

由上面的公式我們知道,如果想要減少程式的CPU執行時間的話那麼就要從以上三點著手。但是指令數是由不同編譯器所決定的,時鐘週期時間則是由CPU主頻的高低來決定的,而每條指令的平均時鐘週期數我們則可以通過流水線技術來優化。

CPU功耗 ~= 1/2 * 負載電容 * 電壓的平方 * 開關平率 * 電晶體數量

製程:

納米制程,以14nm為例,其製程是指在晶片中,線最小可以做到14奈米的尺寸,縮小電晶體可以減少耗電量(電晶體一定的單位面積中),同時可以提升訊號量在電路間的傳輸速度,縮小製程後,電晶體之間的電容也會更低,從而提升他們之間的開關頻率。可知功耗與電容成正比,所以傳輸速度更快,還更省電。

阿姆達爾定律:

並行優化,並不是所有的問題都可以通過並行去優化。

  • 條件1:需要進行的計算本身可以進行分解成幾個並行的任務,如乘法可以分解成多個加法。
  • 條件2:需要能夠分解的計算確保最後可以合併在一起。
  • 條件3:“彙總”階段無法再並行優化,只能單步執行。

優化後的執行時間 = 受優化影響的執行時間/加速倍數(並行處理數) + 不受影響的執行時間

0x0A 編譯器

彙編器是一種工具程式,用於將組合語言源程式轉換為機器語言。機器語言是一種數字語言, 專門設計成能被計算機處理器(CPU)理解。所有 x86 處理器都理解共同的機器語言。

組合語言包含用短助記符如 ADD、MOV、SUB 和 CALL 書寫的語句。組合語言與機器語言是一對一的關係:每一條組合語言指令對應一條機器語言指令。 這就意味著不同型號的處理器如果所使用的機器語言不同的話,那麼他們的組合語言也絕不相同。

高階語言如 Python、C++ 和 Java 與組合語言和機器語言的關係是一對多。比如,C++的一條語句就會擴充套件為多條彙編指令或機器指令。 一種語言,如果它的源程式能夠在各種各樣的計算機系統中進行編譯和執行,那麼這種語言被稱為是可移植的。

組合語言不是可移植的,因為它是為特定處理器系列設計的。目前廣泛使用的有多種不同的組合語言,每一種都基於一個處理器系列。 對於一些廣為人知的處理器系列如 Motorola 68×00、x86、SUN Sparc、Vax 和 IBM-370,組合語言指令會直接與該計算機體系結構相匹配,或者在執行時用一種被稱為微程式碼直譯器的處理器內建程式來進行轉換。

要讓一段C語言程式在一個 Linux 作業系統上跑起來,我們需要把整個程式翻譯成一個組合語言的程式,這個過程我們一般叫編譯成彙編程式碼。針對彙編程式碼,我們可以再用匯編器翻譯成 機器碼。這些機器碼由“0”和“1組成的機器語言表示。這一條條機器碼,就是一條條的計算機指令。這樣一串串的 16 進位制數字,就是我們 CPU 能夠真正認識的計算機指令。為了讀起來方便,我們一般把對應的二進位制數,用 16 進製表示

解釋型語言,是通過直譯器在程式執行的時候逐句翻譯,而 Java 這樣使用虛擬機器器的語言,則是由虛擬機器器對編譯出來的中間程式碼進行解釋,或者即時編譯(JIT)成為機器碼來最終執行。

我們日常用的 Intel CPU,有 2000 條左右的 CPU 指令。常見的指令可以分成五大類:

  • 第一類是算術類指令。我們的加減乘除,在 CPU 層面,都會變成一條條算術類指令。
  • 第二類是資料傳輸類指令。給變數賦值、在記憶體裡讀寫資料,用的都是資料傳輸類指令。
  • 第三類是邏輯類指令。邏輯上的與或非,都是這一類指令。
  • 第四類是條件分支類指令。日常我們寫的“if/else”,其實都是條件分支類指令。
  • 最後一類是無條件跳轉指令。寫一些大一點的程式,我們常常需要寫一些函式或者方法。在呼叫函式的時候,其實就是發起了一個無條件跳轉指令。

來看一段彙編程式碼:

#include <time.h>
#include <stdlib.h>


int main()
{
  srand(time(NULL));
  int r = rand() % 2;
  int a = 10;
  if (r == 0)
  {
    a = 1;
  } else {
    a = 2;
  } 
==================
    if (r == 0)
  3b:   83 7d fc 00             cmp    DWORD PTR [rbp-0x4],0x0
  3f:   75 09                   jne    4a <main+0x4a>
    {
        a = 1;
  41:   c7 45 f8 01 00 00 00    mov    DWORD PTR [rbp-0x8],0x1
  48:   eb 07                   jmp    51 <main+0x51>
    }
    else
    {
        a = 2;
  4a:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
  51:   b8 00 00 00 00          mov    eax,0x0
    } 
複製程式碼

該段程式碼的具體釋義可參考深入淺出計算機組成原理第六節。

機器語言,組合語言,編譯器:

過去編寫程式是通過紙帶打孔的方式,那麼就只能通過“0,1”機器碼來進行程式的編寫,後來進不出了組合語言,組合語言是一種更接近人類語言的語言,用匯編器可以將組合語言轉為機器語言。彙編器則相當於翻譯機的存在,可以根據具體的彙編指令轉為計算機能識別的二進位制碼,即各CPU開發商提供的機器碼。

我們知道當下所流行的各種組合語言都是與處理器所一對一的,即當初組合語言的設計人員在編寫組合語言的時候是通過CPU開發商提供的指令集手冊來對應開發定義的組合語言,而各種型號不同的CPU所獨有的指令集則是燒錄到了CPU中。

  • C語言:C語言 -> 編譯器編譯 -> 組合語言 -> 彙編器 -> 機器碼
  • Java:Java語言 -> 編譯器編譯 -> 位元組碼 -> JVM -> 機器碼

參考:

  1. c.biancheng.net/view/450.ht…
  2. www.zhihu.com/question/38…
  3. zhuanlan.zhihu.com/p/53336801
  4. www.zhihu.com/question/39…
  5. blog.csdn.net/zaassd/arti…
  6. blog.csdn.net/u013678930/…

0x0B 暫存器

可以先讀:

記憶體、暫存器和儲存器的區別

基本概念:

  • RAM(random access memory)即隨機儲存記憶體,這種儲存器在斷電時將丟失其儲存內容,故主要用於儲存短時間使用的程式。
  • ROM(Read-Only Memory)即只讀記憶體,是一種只能讀出事先所存資料的固態半導體儲存器。

暫存器 暫存器是中央處理器內的組成部分。暫存器是有限存貯容量的高速存貯部件,它們可用來暫存指令、資料和地址。在中央處理器的控制部件中,包含的暫存器有指令暫存器(IR)程式計數器(PC)

  1. 暫存器是和CPU一起的,只能存少量的資訊,但是存取速度特別快;
  2. 儲存器是指的是硬碟,U盤,軟盤,光碟之類的外儲存工具,速度最慢;
  3. 記憶體指的是記憶體條,由於一半的硬碟讀取速度很慢,所以用先將硬碟裡面的東西讀取到記憶體條裡面,然後在給CPU進行處理,這樣是為了加快系統的執行速度;

各類儲存器按照到cpu距離由近到遠(訪存速度由高到低)排列分別是暫存器,快取,主存,輔存。

其他優質解答:

暫存器的種類

邏輯上,我們可以認為,CPU 其實就是由一堆暫存器組成的。而暫存器就是 CPU 內部,由多個觸發器或者鎖存器組成的簡單電路。

N 個觸發器或者鎖存器,就可以組成一個 N 位(Bit)的暫存器,能夠儲存 N 位的資料。比方說,我們用的 64 位 Intel 伺服器,暫存器就是 64 位的。

一個 CPU 裡面會有很多種不同功能的暫存器。其中有三個比較特殊的

  • PC 暫存器,也叫指令地址暫存器。用來存放下一條需要執行的計算機指令的記憶體地址。
  • 指令暫存器,用來存放當前正在執行的指令。
  • 條件碼暫存器,用裡面的一個一個標記位(Flag),存放 CPU 進行算術或者邏輯計算的結果。

CPU 裡面還有更多用來儲存資料和記憶體地址的暫存器。這樣的暫存器通常一類裡面不止一個。我們通常根據存放的資料內容來給它們取名字,比如整數暫存器、浮點數暫存器、向量暫存器和地址暫存器等等。有些暫存器既可以存放資料,又能存放地址,我們就叫它通用暫存器。

一個程式執行的時候,CPU 會根據 PC 暫存器裡的地址,從記憶體裡面把需要執行的指令讀取到指令暫存器裡面執行,然後根據指令長度自增,開始順序讀取下一條指令。一個程式的一條條指令,在記憶體裡面是連續儲存的,也會一條條順序載入。

而有些特殊指令,比如 J 類指令,也就是跳轉指令,會修改 PC 暫存器裡面的地址值。這樣,下一條要執行的指令就不是從記憶體裡面順序載入的了。事實上,這些跳轉指令的存在,也是我們可以在寫程式的時候,使用 if…else 條件語句和 while/for 迴圈語句的原因。

CPU執行程式的過程

CPU從PC暫存器中取地址,找到地址對應的記憶體位子,取出其中指令送入指令暫存器執行,然後指令自增,重複操作。所以只要程式在記憶體中是連續儲存的,就會順序執行這也是馮諾依曼體系的理念。而實際上跳轉指令就是當前指令修改了當前PC暫存器中所儲存的下一條指令的地址,從而實現了跳轉。當然各個暫存器實際上是由數電中的一個一個閘電路組合出來的。

參考:

  • 深入淺出計算機組成原理第六講

0x0C 位運算

計算機中的數在記憶體中都是以二進位制形式進行儲存的,用位運算就是直接對整數在記憶體中的二進位制位進行操作,因此其執行效率非常高,在程式中儘量使用位運算進行操作,這會大大提高程式的效能。當然可讀性才是首要保證的目標。

位操作符

  • & 與運算 兩個位都是 1 時,結果才為 1,否則為 0
1 0 0 1 1 
&
1 1 0 0 1 
------------------------------
1 0 0 0 1
複製程式碼
  • |或運算 兩個位都是 0 時,結果才為 0,否則為 1
1 0 0 1 1 
| 
1 1 0 0 1 
------------------------------
1 1 0 1 1
複製程式碼
  • ^ 異或運算,兩個位相同則為 0,不同則為 1
1 0 0 1 1 
^
1 1 0 0 1 
-----------------------------
0 1 0 1 0
複製程式碼
  • ~ 取反運算,0 則變為 1,1 則變為 0
~ 1 0 0 1 1 
-----------------------------
  0 1 1 0 0
複製程式碼
  • << 左移運算,向左進行移位操作,高位丟棄,低位補 0
int a = 8;
a << 3;
移位前:0000 0000 0000 0000 0000 0000 0000 1000
移位後:0000 0000 0000 0000 0000 0000 0100 0000
複製程式碼
  • >> 右移運算,向右進行移位操作,對無符號數,高位補 0,對於有符號數,高位補符號位
unsigned int a = 8;
a >> 3;
移位前:0000 0000 0000 0000 0000 0000 0000 1000
移位後:0000 0000 0000 0000 0000 0000 0000 0001

int a = -8;
a >> 3;
移位前:1111 1111 1111 1111 1111 1111 1111 1000
移位前:1111 1111 1111 1111 1111 1111 1111 1111
複製程式碼

參考自:

0x0D 棧

在真實的程式裡,壓棧的不只有函式呼叫完成後的返回地址。比如函式 A 在呼叫 B 的時候,需要傳輸一些引數資料,這些引數資料在暫存器不夠用的時候也會被壓入棧中。整個函式 A 所佔用的所有記憶體空間,就是函式 A 的棧幀。

實際的程式棧佈局,頂和底與我們的乒乓球桶相比是倒過來的。底在最上面,頂在最下面,這樣的佈局是因為棧底的記憶體地址是在一開始就固定的(記憶體地址偏大的那一邊)。而一層層壓棧之後,棧頂的記憶體地址是在逐漸變小而不是變大。(這裡理解這句話,需要明白棧是固定大小的,想象一個乒乓球筒,反向往裡面填球)

觸發的StackOverFlow常見觸發方式:函式的遞迴呼叫,在棧中宣告一個非常佔記憶體的變數(巨大陣列)。

程式執行常見的優化方案:

把一個實際呼叫的函式產生的指令,直接插入到呼叫該函式的位置,來替換對應的函式呼叫指令。這種方案在如果被呼叫的函式中沒有呼叫其他函式的情況下,還是可行的。這是一個常見的編譯器進行自動優化的場景,叫函式內聯。

這裡編譯器優化的具體痛點並非簡單的少了一些指令的執行,而是函式頻繁進出棧所花費時間的開銷,因為相對於暫存器來說,記憶體是十分慢的。所以讓CPU反覆操作記憶體的話,開銷還是很大的。所以上述文字著重提示了被呼叫函式沒有呼叫其他函式的情況下,因為如果有呼叫的話,一是暫存器記憶體可能開銷不夠,二是還是有操作主存的瓶頸在。

0x0E 編譯、連結和裝載:拆解程式執行

C 語言的檔案在編譯後會生成以.o為尾綴的組合語言檔案,如 add_lib.o 以及 link_example.o 並不是一個可執行檔案而是目標檔案。只有通過連結器把多個目標檔案以及呼叫的各種函式庫連結起來,我們才能得到一個可執行檔案。

C 語言程式碼 - 彙編程式碼 - 機器碼 這個過程,在我們的計算機上進行的時候是由兩部分組成的。

  • 第一部分:由編譯、彙編以及連結三個階段組成。在這三個階段完成之後,我們就生成了一個可執行檔案。
  • 第二部分,我們通過裝載器把可執行檔案裝載到記憶體中。CPU 從記憶體中讀取指令和資料,來開始真正執行程式。

由上我們可以得知程式最終是通過裝載器載入程式及資料到記憶體然後變成指令和資料的,所以其實我們生成的可執行程式碼也並不僅僅是一條條的指令。

ELF 格式

可執行程式碼和目的碼長得差不多,但是長了很多。因為在Linux下,可執行檔案和目標檔案所使用的都是一種叫ELF的檔案格式,中文名字叫可執行與可連結檔案格式,這裡面不僅存放了編譯成的彙編指令,還保留了很多別的資料。

如函式名稱addmain 等,以及定義的全域性可以訪問的變數名稱,都存放在ELF格式檔案裡。這些名字和它們對應的地址,在 ELF 檔案裡面,儲存在一個叫作符號表的位置裡。符號表相當於一個地址簿,把名字和地址關聯了起來。

  • 重定位表:發生在連結前,該檔案中引用的多個函式的地址還不明確則記錄在這裡,連結後進行更改。

執行流程: 連結器會掃描所有輸入的目標檔案,然後把所有符號表裡的資訊收集起來,構成一個全域性的符號表。然後再根據重定位表,把所有不確定要跳轉地址的程式碼,根據符號表裡面儲存的地址,進行一次修正。最後,把所有的目標檔案的對應段進行一次合併,變成了最終的可執行程式碼。這也是為什麼,可執行檔案裡面的函式呼叫的地址都是正確的。

main 函式裡呼叫 add 的跳轉地址,不再是下一條指令的地址了,而是 add 函式的入口地址了,這就是 EFL 格式和連結器的功勞。

所以一些檔案即便是在同一計算機同一CPU上的不同作業系統上可能會出現一個可執行而一個不可執行的情況。根本原因在於不同OS的裝載器所對應的能解析的檔案格式也是不同的。Linux的裝載器只能裝載EFL的檔案格式,而Windows是PE的。

這裡Java實現跨平臺的機制則是:Java是通過實現不同平臺上的虛擬機器器,然後即時翻譯javac生成的中間程式碼來做到跨平臺的。跨平臺的工作被虛擬機器器開發人員來解決了(如同彙編)。

裝載器需要滿足兩個要求。

  • 第一,可執行程式載入後佔用的記憶體空間應該是連續的。因為處理器在執行指令的時候,程式計數器是順序地一條一條指令執行下去,所以就意味著這些指令應該連續的儲存在一起。
  • 第二,我們需要同時載入很多個程式,並且不能讓程式自己規定在記憶體中載入的位置。 雖然編譯出來的指令裡已經有了對應的各種各樣的記憶體地址,但是實際載入的時候,我們其實沒有辦法確保,這個程式一定載入在哪一段記憶體地址上。因為我們現在的計算機通常會同時執行很多個程式,可能你想要的記憶體地址已經被其他載入了的程式佔用了。

解決辦法:

分段: 在記憶體中劃分一段連續的記憶體空間,分配給裝載的程式,把連續的記憶體空間和指令指向的記憶體地址進行對映。

其中指令裡用到的記憶體地址叫作虛擬記憶體地址,實際記憶體硬體裡面的物理空間叫做實體記憶體地址。程式設計師只需要關心虛擬記憶體地址就行了。所以我們只需要維護虛擬記憶體到實體記憶體的對映關係的起始地址和對應的空間大小就可以了。

問題:記憶體碎片

解決辦法:

記憶體交換。即先把記憶體中某個程式所佔用的記憶體寫到硬碟上,然後再從硬碟上讀回記憶體中,只不過讀回來的時候要緊貼上一個應用所佔用記憶體空間的後面,形成連續的記憶體佔用。

問題:效能瓶頸,記憶體碎片和記憶體交換的空間太大,硬碟的讀寫速度太慢

解決辦法:

記憶體分頁。原理是少出現一些記憶體碎片。另外,當需要進行記憶體交換的時候,讓需要交換寫入或者從磁碟裝載的資料更少一點。和分段這樣分配一整段連續的空間給到程式相比,分頁是把整個實體記憶體空間切成一段段固定尺寸的大小。 。而對應的程式所需要佔用的虛擬記憶體空間,也會同樣切成一段段固定尺寸的大小。 這樣一個連續並且尺寸固定的記憶體空間,就是頁。一般頁遠小於程式大小隻有幾KB。

由於記憶體空間都是預先劃分好的,也就沒有了不能使用的碎片,而只有被釋放出來的很多 4KB 的頁。即使記憶體空間不夠,需要讓現有的、正在執行的其他程式,通過記憶體交換釋放出一些記憶體的頁出來,一次性寫入磁碟的也只有少數的一個頁或者幾個頁,不會花太多時間,讓整個機器被記憶體交換的過程給卡住。

分頁的方式使得我們在載入程式的時候,不再需要一次性都把程式載入到實體記憶體中。我們完全可以在進行虛擬記憶體和實體記憶體的頁之間的對映之後,並不真的把頁載入到實體記憶體裡,而是隻在程式執行中,需要用到對應虛擬記憶體頁裡面的指令和資料時,再載入到實體記憶體裡面去。

虛擬記憶體是指一段地址,但是沒有載入到實體記憶體裡的時候其實就是放在硬碟上。

虛擬記憶體,記憶體交換,記憶體分頁三者結合下,其實執行一個應用程式需要用的必要記憶體是很少的,也是為什麼我們優先的記憶體可以執行比我們記憶體大很多的應用的原因。

JVM也是一個可執行程式,同其他程式一樣依賴於作業系統的記憶體管理和裝載程式,它可以按自己的方式去規劃它自身的記憶體空間給就Java程式使用而無需考慮怎麼對映到實體記憶體這些。這是承載他的作業系統需要做的事情,每個應用程式都有固定使用的記憶體空間的限度。

連結可以分動、靜,共享執行省記憶體

上文提到在使用聯結器進行程式碼合併的時候,這裡的連結是指靜態連結,相應的,也有對應的動態連結。我們知道程式在進行裝載的時候同一份程式碼如果多個程式都靜態連線了一遍那麼記憶體中將會有多分同樣的程式碼佔用記憶體,這對記憶體耗費也是非常大的。

既然是共享程式碼,那麼記憶體中只要裝載一份即可。在程式連結的時候我們連結到該共享庫的記憶體地址即可,不同系統下,共享庫的檔案尾綴不同。Windows是.dll,Linux下是.so

共享庫檔案程式碼要求:

編譯出來的共享庫檔案的指令程式碼,是地址無關的。 原因是不同程式如果都用同一份共享程式碼庫的話,不同程式該程式碼的虛擬地址是不同的,雖然實體地址上是相同的,但是對於該共享程式碼庫的虛擬地址和實體地址的對映就無法維護了。

其中利用重定位表的程式碼就是與地址相關的程式碼。利用重定位表的程式碼在程式連結的時候,就把函式呼叫後要跳轉訪問的地址確定下來了,這意味著,如果這個函式載入到一個不同的記憶體地址,跳轉就會失敗。

相對地址: 動態程式碼庫中的資料和指令的虛擬地址都是通過相對地址的方式互相訪問的。各種指令中使用到的記憶體地址,給出的不是一個絕對的地址空間,而是一個相對於當前指令偏移量的記憶體地址。因為整個共享庫是放在一段連續的虛擬記憶體地址中的,無論裝載到哪一段地址,不同指令之間的相對地址都是不變的。

需要注意的是:雖然共享庫的程式碼部分的實體記憶體是共享的,但是資料部分是各個動態連結它的應用程式裡面各載入一份的。

全域性偏移表(GOT): GOT表位於共享庫的資料段裡。所以使用動態連結的各個程式在共享庫中生成各自的GOT,每個程式的GOT都不同。 而 GOT 表裡的資料,則是在載入一個個共享庫的時候寫進去的。所以如果當前執行程式的共享庫指令需要用到外部的變數和函式地址的話,都會查詢 GOT,來找到當前執行程式的虛擬記憶體地址。

不同的程式,呼叫同樣的共享庫,各自 GOT 裡面指向最終載入的動態連結庫裡面的虛擬記憶體地址是不同的(因為各應用程式呼叫該函式的虛擬記憶體地址是不同的)。

雖然不同的程式呼叫的同樣的動態庫,而各自的資料部分的記憶體地址是獨立的,呼叫的又都是同一個動態庫,但是不需要去修改動態庫裡面的程式碼所使用的地址,而是各個程式各自維護好自己的 GOT,能夠找到對應的動態庫就好了。

像動態連結這樣通過修改“地址資料”來進行間接跳轉,去呼叫一開始不能確定位置程式碼的思路,在Java中,類似多型的實現。

如下程式碼:

public class DynamicCode {// 動態程式碼庫
   
   private HashMap<String,Object> data; // 各類私有的資料部分 - 其中有一項是GOT
   
   public static void main(strs[] args) {// 公用程式碼部分
   
   }
}
複製程式碼

0x0F 二進位制編碼

二進位制 -> 十進位制: 把從右到左的第 N 位,乘上一個 2 的 N 次方,然後加起來。N 從 0 開始記位。

示例:

0011
=====
0×2^3 + 0×2^2 + 1×2^1 + 1×2^0
複製程式碼

十進位制 -> 二進位制: 短除法。也就是,把十進位制數除以 2 的餘數,作為最右邊的一位。然後用商繼續除以 2,把對應的餘數緊靠著剛才餘數的右側,這樣遞迴迭代,直到商為 0 就可以了。然後餘數序列從下到上組成的序列就是該整數的二進位製表示。

原碼,反碼,補碼

首先需要明白:在計算機中,數字都是用補碼來儲存的,而對於補碼的表示方式一個位元組(8bit)的數字,規定1000 0000就是-128。而且對於正數而言,反碼,補碼是其原碼本身。

原碼: 0001 在原碼中就表示為 +1。而 1001 最左側的第一位是 1,所以它就表示 -1。這個其實就是整數的原碼錶示法。

原碼錶示法的問題

  • 0 有兩種表示方法:-0(1000) 及 +0(0000) - 補碼解決
  • +1(0001) 和 -1(1001) 相加不為 0 的情況。(1010 為 -2) - 反碼解決

反碼: 為瞭解決“正負相加不等於0”的問題,在“原碼”的基礎上,人們發明瞭“反碼”。“反碼”表示方式是用來處理負數的,符號位置不變,其餘位置相反

這樣正負兩數相加不為0的情況就解決了

反碼錶示法的問題:

  • 但目前0還存在兩種表示方法。

補碼:同樣是針對"負數"做處理的,從原來"反碼"的基礎上 +1。在補一位1的時候,要丟掉最高位(比如1111)。

這樣就解決了+0和-0同時存在的問題,另外"正負數相加等於0"的問題,同樣得到滿足。同時還多了一位數 -8。

用原碼的話,一個位元組可以表示的範圍是:-127~127,用補碼的話表示的範圍是:-128~127.

二進位制負數的補碼,等於該負數取反碼再加1,也等於其正數按位取反再加1。

正數的反碼是其本身 負數的反碼是在其原碼的基礎上,符號位不變,其餘各個位取反。

重點:

說了那麼多,只是描述一下三者的區別及由來。因為我們從一開始就說了,計算機中是按補碼來儲存資料的,所以我們只要想辦法快速搞清楚一個計算機中的二進位制數的十進位制是多少。

我們仍然通過最左側第一位的0和1,來判斷這個數的正負。但是,我們不再把這一位當成單獨的符號位,在剩下幾位計算出的十進位制前加上正負號而是在計算整個二進位制值的時候,在左側最高位前面加個負號

比如,一個 4 位的二進位制補碼數值 1011,轉換成十進位制,就是 -1×2^3 + 0×2^2 + 1×2^1 + 1×2^0 = -5。如果最高位是 1,這個數必然是負數;最高位是 0,必然是正數。並且,只有 0000 表示 0,1000 在這樣的情況下表示 -8。一個 4 位的二進位制數,可以表示從 -8 到 7 這 16 個數,不會浪費一位。

參考:

字串的編碼

ASCII(American Standard Code for Interchange,美國資訊交換標準程式碼: 最早計算機只需要使用英文字元,加上數字和一些特殊符號,然後用8位的二進位制,就能表示我們日常需要的所有字元了,這個就是ASCII碼。

ASCII 碼就好比一個字典,用 8 位二進位制中的 128 個不同的數,對映到 128 個不同的字元裡。比如,小寫字母 a 在 ASCII 裡面,就是第 97 個,也就是二進位制的 0110 0001,對應的十六進位製表示就是 61。而大寫字母 A,就是第 65 個,也就是二進位制的 0100 0001,對應的十六進位製表示就是 41。

需要注意的是:

在 ASCII 碼裡面,數字 9 不再像整數表示法裡一樣,用 0000 1001 來表示,而是用 0011 1001 來表示。字串 “15” 也不是用 0000 1111 這 8 位來表示,而是變成兩個字元 1 和 5 連續放在一起,也就是 0011 0001 和 0011 0101,需要用兩個 8 位來表示。 兩個 8 位的原因是,因為 4 位最高只能表示到(-8 - 7)。

我們可以看到,最大的 32 位整數,就是 2147483647。如果用整數表示法,只需要 32 位就能表示了。但是如果用字串來表示,一共有 10 個字元,每個字元用 8 位的話,需要整整 80 位。比起整數表示法,要多佔很多空間。所以這也是為什麼我們在儲存資料的時候要通過二進位制序列化的方式來儲存。

Unicode: 其實就是一個字符集,包含了 150 種語言的 14 萬個不同的字元。

字元編碼則是對於字符集裡的這些字元,怎麼一一用二進位製表示出來的一個字典。我們上面說的 Unicode,就可以用 UTF-8、UTF-16,乃至 UTF-32 來進行編碼,儲存成二進位制。所以,有了 Unicode,其實我們可以用不止 UTF-8 一種編碼形式,只要別人知道這套編碼規則,就可以正常傳輸、顯示這段程式碼。

同樣的文字,採用不同的編碼儲存下來。如果另外一個程式,用一種不同的編碼方式來進行解碼和展示,就會出現亂碼。

需要注意的是,如果我們程式中使用了一些或者說儲存了一些不常用的古老字符集,那麼可能Unicode字符集中並不存在這樣的字元,那麼Unicode 會統一把這些字元記錄為 U+FFFD 這個編碼。如果用 UTF-8 的格式儲存下來,就是\xef\xbf\xbf。

參考:

0x10 電路

繼電器,閘電路

計算機不用十進位制而用二進位制原因如下:

電磁關係及繼電器的由來可參考繼電器

電訊號在傳遞的時候,由於電線過長會導致電阻過大此時對電壓要求會變大或者說用電器會出現無響應的狀態。所以在進行遠距離資訊傳遞的時候為了避免電路過長這種情況,發明瞭繼電器(電驛)。繼電器可以方便我們的電訊號進行傳導,或者根據需要組成我們想要的“與”,“或”,“非”等的邏輯電路。

“與”電路的話相當於我們在電路上串聯兩個開關,當兩個開關都開啟,電路才接通。“或”相當於我們在輸入端通兩條電路到輸出端,任意一條電路是開啟狀態,那麼到輸出端的電路都是聯通的。“非”相當於從開關預設關掉,只有通電有了磁場之後開啟,換成預設是開啟通電的,只有通電之後才關閉,我們就得到了一個計算機中的“非”操作。輸出端開和關正好和輸入端相反。

這三種基本邏輯電路實現起來都比較簡單,如果要做複雜的工作的話則需要更多的邏輯電路通過分層,組合的方式來實現。

結論:我們通過電路的“開”和“關”,來表示“1”和“0”。就像電晶體在不同的情況下,表現為導電的“1”和絕緣的“0”的狀態。

這些基本的邏輯電路,也叫閘電路。 一方面,我們可以通過繼電器或者中繼,進行長距離的訊號傳輸。另一方面,我們也可以通過設定不同的線路和開關狀態,實現更多不同的訊號表示和處理方式,這些線路的連線方式其實就是我們在數位電路中所說的閘電路。而這些閘電路,也是我們建立 CPU 和記憶體的基本邏輯單元。我們的各種對於計算機二進位制的“0”和“1”的操作,其實就是來自於閘電路,叫作組合邏輯電路。

所謂閘電路在數位電路中,所謂“門”就是隻能實現基本邏輯關係的電路。最基本的邏輯關係是與,或,非,最基本的邏輯閘是與門,或門和非門。如下是最基本的閘電路,其他複雜的閘電路都是由這些閘電路組合而成。他們是構成現代計算機硬體的“積木”。

加法器

半加器

可以看到基礎閘電路,輸入都是兩個單獨的 bit,輸出是一個單獨的 bit。如果我們要對 2 個 8 位的數,計算與、或、非這樣的簡單邏輯運算,其實很容易。只要連續擺放 8 個開關,來代表一個 8 位數。這樣的兩組開關,從左到右,上下單個的位開關之間,都統一用“與門”或者“或門”連起來,就是兩個 8 位數的 AND 或者 OR 的運算了。

要想實現一個加法器,各二進位制位的計算邏輯如下:

可以看到每位的輸入輸出關係對應著基本閘電路中的異或門的邏輯。所以,其實異或門就是一個最簡單的整數加法,所需要使用的基本閘電路。 但需要注意的是,如果當兩個輸入位都是1的話,我們還需要考慮進1位的情況。所以這就用到了基礎閘電路中的與門。

所以,通過一個異或門計算出個位,通過一個與門計算出是否進位,我們就通過電路算出了一個一位數的加法。於是,後來就把這兩個閘電路進行打包,叫他為半加器。

全加器

半加器只能解決個位的運算,二,四,八位的輸入情況與個位的並不一樣。因為二位除了一個加數和被加數之外,還需要加上來自個位的進位訊號,一共需要三個數進行相加,才能得到結果。但是基本的閘電路以及組合而成的半加器輸入內容都是兩位的。其實解決辦法很簡單,即通過兩個半加器和一個或門就能組合成一個全加器。

如圖W的輸出即為二位的值。有了全加器理論上兩個8位數的加法運算就可以實現了:

可以看到的是,個位和其他高位不同,個位只需要一個半加器即可。而最高位即最左側的一位表示的是我們的加法是否溢位了。整個電路中有這樣一個訊號來表示我們所做的加法運算是否溢位了,可以給到硬體層面的其它標誌位中,來讓計算機知曉這樣算溢位了,以便得到計算機硬體層面的支援。

算術邏輯單元(ALU):是中央處理器的執行單元,是所有中央處理器的核心組成部分,由與門和或門構成的算術邏輯單元,主要功能是進行二進位制的算術運算,如加減乘數(不包括整數除法)。

乘法器

13 * 9 = 117 的二進位制轉化表:

實際二進位制資料在進行乘法運算的時候,退化成了位移加法。因為是二進位制乘法,所以乘數的各位和被乘數的乘積不是全部為0就是把被乘數複製一份下來。需要注意的是乘數的每位進行一次乘積運算之後,下一次的運算結果就需要向高位移動一位。最後這些結果相加起來即可

二進位制的乘法運算具體放到電路中的話,也並不需要引入任何新的、更復雜的電路,仍然用最基礎的電路即可,只要用不同的接線方式,就能夠實現一個基本的乘法。最簡單的實現思路就是,我們只要根據乘數從個位一直到高位通過一個閘電路來控制每位的輸出訊號,來判斷和被乘數的結果是全部為0輸出還是把被乘數複製一份輸出,並將結果儲存並累加到某個暫存器上即可。

先拿乘數最右側的個位乘以被乘數,然後把結果存入到暫存器中,然後,把被乘數左移一位,把乘數右移一位,仍然用乘數的個位去乘以被乘數,然後把結果加到剛才的暫存器上。反覆重複這一步驟,直到兩者分別不能再左移和右移位置。這樣,乘數和被乘數其實僅僅需要簡單的加法器(結果的累加),一個可以支援其左移一位的電路和一個右移一位的電路,以及一個開關(判斷乘數的每位和被乘數乘積的結果是複製還是0)就能完成整個乘法。 如圖所示

這裡的控制測試,其實就是通過一個時鐘訊號,來控制左移、右移以及重新計算乘法和加法的時機。

13 * 9 的具體豎列圖

由上圖的分解示意圖,可以發現其實所謂的位移+加法。並不是完全獨立的,乘數的最高位在進行乘法運算之前任然需要低位的運算完才可以。所以我們用的是 4 位數,所以要進行 4 組“位移 + 加法”的操作。而且這 4 組操作還不能同時進行。因為 下一組的加法要依賴上一組的加法後的計算結果,下一組的位移也要依賴上一組的位移的結果。這樣,整個演演算法是“順序”的,每一組加法或者位移的運算都需要一定的時間,及一定的等待時間。

如果要優化整個乘法器的運算,可以看到影響執行速度的原因有如下幾點:

  1. 每組位移+加法運算都具有強關聯及先後關係。
  2. 控制測試進行每次進行一次位移及加法所需要等待的時脈頻率。
  3. 每組乘法結果通過加法器在暫存器上進行結果累加的時候受門延時影響。

解決辦法就是把我們的電路進行展開,首先針對第一點,我們上面所看到的豎列圖分析出所謂的每組位移+加法的強關聯關係及先後關係是因為我們人分析,但其實對於計算機的電路而言,當相加的兩個數是確定的,那高位是否會進位其實也是確定的。也就是說,對於計算機的電路而言,高位和地位可以同時出結果,電路是天然並行的,也就不存在所謂的強關聯關係。同時對應的第三點的門延時也就只有一組加法進行運算的門延時存在了,即3T的門延時。

可以看到其實乘法器的實現方式共有兩種:

  1. RISC:用更少更簡單的電路,但是需要更長的門延遲和時鐘週期;
  2. CISC:用更復雜的電路,但是更短的門延遲和時鐘週期來計算一個複雜的指令。

0x11

定點數

定點數的表示方法:用 4 個位元來表示 0~9 的整數,那麼 32 個位元就可以表示 8 個這樣的整數。然後我們把最右邊的 2 個 0~9 的整數,當成小數部分;把左邊 6 個 0~9 的整數,當成整數部分。這樣,我們就可以用 32 個位元,來表示從 0 到 999999.99 這樣 1 億個實數了。

用二進位制來表示十進位制的編碼方式,叫作BCD 編碼。

缺點:能表示數值太小,原本32位的數值表示方法,能表示的數值的最大值是42億。用BCD編碼的話最大隻能表示到100w。

浮點數

浮點數彌補了定點數表示方式在表達數值上缺陷。浮點數使用科學計數法的方式來進行數值的表示。 浮點數的科學計數法的表示有一個IEEE 754的標準,它定義了兩個基本的格式。一個是用 32 位元表示單精度的浮點數,也就是我們常常說的 float 或者 float32 型別。另外一個是用 64 位元表示雙精度的浮點數,也就是我們平時說的 double 或者 float64 型別。

根據國際標準IEEE 754,任意一個二進位制浮點數V可以表示成下面的形式:

  • 符號:(-1)^s表示符號位,當s=0,V為正數;當s=1,V為負數。
  • 尾數:M表示有效數字,大於等於1,小於2。
  • 階碼:2^E表示指數位。

例如:

  • 十進位制的6.0,寫成二進位制是110.0,相當於1.10×2^2。那麼,按照上面V的格式,可以得出s=0,M=1.10,E=2。
  • 十進位制的-5.0,寫成二進位制是-101.0,相當於-1.01×2^2。那麼,s=1,M=1.01,E=2。

IEEE 754規定,對於32位的浮點數,最高的1位是符號位s,接著的8位是指數E,剩下的23位為有效數字M。 對於64位的浮點數,最高的1位是符號位S,接著的11位是指數E,剩下的52位為有效數字M。

因為E為8位,所以它的取值範圍為0~255。由於科學計數法中的E是可以出現負數的,所以IEEE 754規定,E的真實值必須再加上一個中間數,對於8位的E,這個中間數是127;對於11位的E,這個中間數是1023。

比如,2^10的E是10,所以儲存成32位浮點數時,必須儲存成10+127=137,即10001001。

然後,指數E還可以再分成三種情況:

  1. E不全為0或不全為1。這時,浮點數就採用上面的規則表示,即指數E的計算值減去127(或1023),得到真實值,再將有效數字M前加上第一位的1。
  2. E全為0。這時,浮點數的指數E等於1-127(或者1-1023),有效數字M不再加上第一位的1,而是還原為0.xxxxxx的小數。這樣做是為了表示±0,以及接近於0的很小的數字。
  3. E全為1。這時,如果有效數字M全為0,表示±無窮大(正負取決於符號位s);如果有效數字M不全為0,表示這個數不是一個數(NaN)。

在這樣的浮點數表示下,不考慮符號的話,浮點數能夠表示的最小的數和最大的數,差不多是 1.17 * 10^-383.40 * 10^38,表示的數值範圍就大很多了。此時f為23個0,e為-126 和 f為23個1,e為127。

正是因為這個數對應的小數點的位置是“浮動”的,它才被稱為浮點數。隨著指數位 e 的值的不同,小數點的位置也在變動。對應的,前面的 BCD 編碼的實數,就是小數點固定在某一位的方式,我們也就把它稱為定點數。

0.1~0.9 這 9 個數,其中只有 0.5 能夠被精確地表示成二進位制的浮點數。而其他的都只是一個近似的表達。

參考: