一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

石杉的架構筆記發表於2022-12-08

本文來自公眾號讀者cxuan的投稿

我們每個程式設計師或許都有一個夢,那就是成為大牛,我們或許都沉浸在各種框架中,以為框架就是一切,以為應用層才是最重要的,你錯了。在當今計算機行業中,會應用是基本素質,如果你懂其原理才能讓你在行業中走的更遠,而計算機基礎知識又是重中之重。下面,跟隨我的腳步,為你介紹一下計算機底層知識。

CPU

還不瞭解 CPU 嗎?現在就帶你瞭解一下 CPU 是什麼

CPU 的全稱是 Central Processing Unit,它是你的電腦中最硬核的元件,這種說法一點不為過。CPU 是能夠讓你的計算機叫計算機的核心元件,但是它卻不能代表你的電腦,CPU 與計算機的關係就相當於大腦和人的關係。CPU 的核心是從程式或應用程式獲取指令並執行計算。此過程可以分為三個關鍵階段:提取,解碼和執行。CPU從系統的主存中提取指令,然後解碼該指令的實際內容,然後再由 CPU 的相關部分執行該指令。

CPU 內部處理過程

下圖展示了一般程式的執行流程(以 C 語言為例),可以說了解程式的執行流程是掌握程式執行機制的基礎和前提。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

在這個流程中,CPU 負責的就是解釋和執行最終轉換成機器語言的內容。

CPU 主要由兩部分構成:控制單元 和 算術邏輯單元(ALU)

  • 控制單元:從記憶體中提取指令並解碼執行
  • 算數邏輯單元(ALU):處理算數和邏輯運算

CPU 是計算機的心臟和大腦,它和記憶體都是由許多電晶體組成的電子部件。它接收資料輸入,執行指令並處理資訊。它與輸入/輸出(I / O)裝置進行通訊,這些裝置向 CPU 傳送資料和從 CPU 接收資料。

從功能來看,CPU 的內部由暫存器、控制器、運算器和時鐘四部分組成,各部分之間透過電訊號連通。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

  • 暫存器是中央處理器內的組成部分。它們可以用來暫存指令、資料和地址。可以將其看作是記憶體的一種。根據種類的不同,一個 CPU 內部會有 20 - 100個暫存器。
  • 控制器負責把記憶體上的指令、資料讀入暫存器,並根據指令的結果控制計算機
  • 運算器負責運算從記憶體中讀入暫存器的資料
  • 時鐘 負責發出 CPU 開始計時的時鐘訊號

CPU 是一系列暫存器的集合體

在 CPU 的四個結構中,我們程式設計師只需要瞭解暫存器就可以了,其餘三個不用過多關注,為什麼這麼說?因為程式是把暫存器作為物件來描述的。

不同型別的 CPU ,其內部暫存器的種類,數量以及暫存器儲存的數值範圍都是不同的。不過,根據功能的不同,可以將暫存器劃分為下面這幾類

種類功能
累加暫存器儲存執行的資料和運算後的資料。
標誌暫存器用於反應處理器的狀態和運算結果的某些特徵以及控制指令的執行。
程式計數器程式計數器是用於存放下一條指令所在單元的地址的地方。
基址暫存器儲存資料記憶體的起始位置
變址暫存器儲存基址暫存器的相對地址
通用暫存器儲存任意資料
指令暫存器儲存正在被執行的指令,CPU內部使用,程式設計師無法對該暫存器進行讀寫
棧暫存器儲存棧區域的起始位置

其中程式計數器、累加暫存器、標誌暫存器、指令暫存器和棧暫存器都只有一個,其他暫存器一般有多個。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

下面就對各個暫存器進行說明

程式計數器

程式計數器(Program Counter)是用來儲存下一條指令所在單元的地址。

程式執行時,PC的初值為程式第一條指令的地址,在順序執行程式時,控制器首先按程式計數器所指出的指令地址從記憶體中取出一條指令,然後分析和執行該指令,同時將PC的值加1指向下一條要執行的指令。

我們還是以一個事例為準來詳細的看一下程式計數器的執行過程

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

這是一段進行相加的操作,程式啟動,在經過編譯解析後會由作業系統把硬碟中的程式複製到記憶體中,示例中的程式是將 123 和 456 執行相加操作,並將結果輸出到顯示器上。

地址 0100 是程式執行的起始位置。Windows 等作業系統把程式從硬碟複製到記憶體後,會將程式計數器作為設定為起始位置 0100,然後執行程式,每執行一條指令後,程式計數器的數值會增加1(或者直接指向下一條指令的地址),然後,CPU 就會根據程式計數器的數值,從記憶體中讀取命令並執行,也就是說,程式計數器控制著程式的流程

條件分支和迴圈機制

高階語言中的條件控制流程主要分為三種:順序執行、條件分支、迴圈判斷三種,順序執行是按照地址的內容順序的執行指令。條件分支是根據條件執行任意地址的指令。迴圈是重複執行同一地址的指令。

  • 順序執行的情況比較簡單,每執行一條指令程式計數器的值就是+ 1
  • 條件和迴圈分支會使程式計數器的值指向任意的地址,這樣一來,程式便可以返回到上一個地址來重複執行同一個指令,或者跳轉到任意指令。

下面以條件分支為例來說明程式的執行過程(迴圈也很相似)

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

程式的開始過程和順序流程是一樣的,CPU 從0100處開始執行命令,在0100和0101都是順序執行,PC 的值順序+1,執行到0102地址的指令時,判斷0106暫存器的數值大於0,跳轉(jump)到0104地址的指令,將數值輸出到顯示器中,然後結束程式,0103 的指令被跳過了,這就和我們程式中的 if() 判斷是一樣的,在不滿足條件的情況下,指令會直接跳過。所以 PC 的執行過程也就沒有直接+1,而是下一條指令的地址。

標誌暫存器

條件和迴圈分支會使用到 jump(跳轉指令),會根據當前的指令來判斷是否跳轉,上面我們提到了標誌暫存器,無論當前累加暫存器的運算結果是正數、負數還是零,標誌暫存器都會將其儲存

CPU 在進行運算時,標誌暫存器的數值會根據當前運算的結果自動設定,運算結果的正、負和零三種狀態由標誌暫存器的三個位表示。標誌暫存器的第一個位元組位、第二個位元組位、第三個位元組位各自的結果都為1時,分別代表著正數、零和負數。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

CPU 的執行機制比較有意思,假設累加暫存器中儲存的 XXX 和通用暫存器中儲存的 YYY 做比較,執行比較的背後,CPU 的運算機制就會做減法運算。而無論減法運算的結果是正數、零還是負數,都會儲存到標誌暫存器中。結果為正表示 XXX 比 YYY 大,結果為零表示 XXX 和 YYY 相等,結果為負表示 XXX 比 YYY 小。程式比較的指令,實際上是在 CPU 內部做減法運算。

函式呼叫機制

接下來,我們繼續介紹函式呼叫機制,哪怕是高階語言編寫的程式,函式呼叫處理也是透過把程式計數器的值設定成函式的儲存地址來實現的。函式執行跳轉指令後,必須進行返回處理,單純的指令跳轉沒有意義,下面是一個實現函式跳轉的例子

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

圖中將變數 a 和 b 分別賦值為 123 和 456 ,呼叫 MyFun(a,b) 方法,進行指令跳轉。圖中的地址是將 C 語言編譯成機器語言後執行時的地址,由於1行 C 程式在編譯後通常會變為多行機器語言,所以圖中的地址是分散的。在執行完 MyFun(a,b)指令後,程式會返回到 MyFun(a,b) 的下一條指令,CPU 繼續執行下面的指令。

函式的呼叫和返回很重要的兩個指令是 call 和 return 指令,再將函式的入口地址設定到程式計數器之前,call 指令會把呼叫函式後要執行的指令地址儲存在名為棧的主存內。函式處理完畢後,再透過函式的出口來執行 return 指令。return 指令的功能是把儲存在棧中的地址設定到程式計數器。MyFun 函式在被呼叫之前,0154 地址儲存在棧中,MyFun 函式處理完成後,會把 0154 的地址儲存在程式計數器中。

這個呼叫過程如下

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

在一些高階語言的條件或者迴圈語句中,函式呼叫的處理會轉換成 call 指令,函式結束後的處理則會轉換成 return 指令。

透過地址和索引實現陣列

接下來我們看一下基址暫存器和變址暫存器,透過這兩個暫存器,我們可以對主存上的特定區域進行劃分,來實現類似陣列的操作

首先,我們用十六進位制數將計算機記憶體上的 00000000 - FFFFFFFF 的地址劃分出來。那麼,凡是該範圍的記憶體地址,只要有一個 32 位的暫存器,便可檢視全部地址。但如果想要想陣列那樣分割特定的記憶體區域以達到連續檢視的目的的話,使用兩個暫存器會更加方便。

例如,我們用兩個暫存器(基址暫存器和變址暫存器)來表示記憶體的值

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

這種表示方式很類似陣列的構造,陣列是指同樣長度的資料在記憶體中進行連續排列的資料構造。用陣列名錶示陣列全部的值,透過索引來區分陣列的各個資料元素,例如: a[0] - a[4],[]內的 0 - 4 就是陣列的下標。

CPU 指令執行過程

幾乎所有的馮·諾伊曼型計算機的CPU,其工作都可以分為5個階段:取指令、指令譯碼、執行指令、訪存取數、結果寫回

  • 取指令階段是將記憶體中的指令讀取到 CPU 中暫存器的過程,程式暫存器用於儲存下一條指令所在的地址
  • 指令譯碼階段,在取指令完成後,立馬進入指令譯碼階段,在指令譯碼階段,指令譯碼器按照預定的指令格式,對取回的指令進行拆分和解釋,識別區分出不同的指令類別以及各種獲取運算元的方法。
  • 執行指令階段,譯碼完成後,就需要執行這一條指令了,此階段的任務是完成指令所規定的各種操作,具體實現指令的功能。
  • 訪問取數階段,根據指令的需要,有可能需要從記憶體中提取資料,此階段的任務是:根據指令地址碼,得到運算元在主存中的地址,並從主存中讀取該運算元用於運算。
  • 結果寫回階段,作為最後一個階段,結果寫回(Write Back,WB)階段把執行指令階段的執行結果資料“寫回”到某種儲存形式:結果資料經常被寫到CPU的內部暫存器中,以便被後續的指令快速地存取;

記憶體

CPU 和 記憶體就像是一堆不可分割的戀人一樣,是無法拆散的一對兒,沒有記憶體,CPU 無法執行程式指令,那麼計算機也就失去了意義;只有記憶體,無法執行指令,那麼計算機照樣無法執行。

那麼什麼是記憶體呢?記憶體和 CPU 如何進行互動?下面就來介紹一下

什麼是記憶體

記憶體(Memory)是計算機中最重要的部件之一,它是程式與CPU進行溝通的橋樑。

計算機中所有程式的執行都是在記憶體中進行的,因此記憶體對計算機的影響非常大,記憶體又被稱為主存,其作用是存放 CPU 中的運算資料,以及與硬碟等外部儲存裝置交換的資料。只要計算機在執行中,CPU 就會把需要運算的資料調到主存中進行運算,當運算完成後CPU再將結果傳送出來,主存的執行也決定了計算機的穩定執行。

記憶體的物理結構

記憶體的內部是由各種 IC 電路組成的,它的種類很龐大,但是其主要分為三種儲存器

  • 隨機儲存器(RAM):記憶體中最重要的一種,表示既可以從中讀取資料,也可以寫入資料。當機器關閉時,記憶體中的資訊會 丟失
  • 只讀儲存器(ROM):ROM 一般只能用於資料的讀取,不能寫入資料,但是當機器停電時,這些資料不會丟失。
  • 快取記憶體(Cache):Cache 也是我們經常見到的,它分為一級快取(L1 Cache)、二級快取(L2 Cache)、三級快取(L3 Cache)這些資料,它位於記憶體和 CPU 之間,是一個讀寫速度比記憶體更快的儲存器。當 CPU 向記憶體寫入資料時,這些資料也會被寫入快取記憶體中。當 CPU 需要讀取資料時,會直接從快取記憶體中直接讀取,當然,如需要的資料在Cache中沒有,CPU會再去讀取記憶體中的資料。

記憶體 IC 是一個完整的結構,它內部也有電源、地址訊號、資料訊號、控制訊號和用於定址的 IC 引腳來進行資料的讀寫。下面是一個虛擬的 IC 引腳示意圖

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

圖中 VCC 和 GND 表示電源,A0 - A9 是地址訊號的引腳,D0 - D7 表示的是控制訊號、RD 和 WR 都是好控制訊號,我用不同的顏色進行了區分,將電源連線到 VCC 和 GND 後,就可以對其他引腳傳遞 0 和 1 的訊號,大多數情況下,+5V 表示1,0V 表示 0

我們都知道記憶體是用來儲存資料,那麼這個記憶體 IC 中能儲存多少資料呢?D0 - D7 表示的是資料訊號,也就是說,一次可以輸入輸出 8 bit = 1 byte 的資料。A0 - A9 是地址訊號共十個,表示可以指定 00000 00000 - 11111 11111 共 2 的 10次方 = 1024個地址。每個地址都會存放 1 byte 的資料,因此我們可以得出記憶體 IC 的容量就是 1 KB。

記憶體的讀寫過程

讓我們把關注點放在記憶體 IC 對資料的讀寫過程上來吧!我們來看一個對記憶體IC 進行資料寫入和讀取的模型

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

來詳細描述一下這個過程,假設我們要向記憶體 IC 中寫入 1byte 的資料的話,它的過程是這樣的:

  • 首先給 VCC 接通 +5V 的電源,給 GND 接通 0V 的電源,使用 A0 - A9 來指定資料的儲存場所,然後再把資料的值輸入給 D0 - D7 的資料訊號,並把 WR(write)的值置為 1,執行完這些操作後,即可以向記憶體 IC 寫入資料
  • 讀出資料時,只需要透過 A0 - A9 的地址訊號指定資料的儲存場所,然後再將 RD 的值置為 1 即可。
  • 圖中的 RD 和 WR 又被稱為控制訊號。其中當WR 和 RD 都為 0 時,無法進行寫入和讀取操作。

記憶體的現實模型

為了便於記憶,我們把記憶體模型對映成為我們現實世界的模型,在現實世界中,記憶體的模型很想我們生活的樓房。在這個樓房中,1層可以儲存一個位元組的資料,樓層號就是地址,下面是記憶體和樓層整合的模型圖

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

我們知道,程式中的資料不僅只有數值,還有資料型別的概念,從記憶體上來看,就是佔用記憶體大小(佔用樓層數)的意思。即使物理上強制以 1 個位元組為單位來逐一讀寫資料的記憶體,在程式中,透過指定其資料型別,也能實現以特定位元組數為單位來進行讀寫。

二進位制

我們都知道,計算機的底層都是使用二進位制資料進行資料流傳輸的,那麼為什麼會使用二進位制表示計算機呢?或者說,什麼是二進位制數呢?在擴充一步,如何使用二進位制進行加減乘除?下面就來看一下

什麼是二進位制數

那麼什麼是二進位制數呢?為了說明這個問題,我們先把 00100111 這個數轉換為十進位制數看一下,二進位制數轉換為十進位制數,直接將各位置上的值 * 位權即可,那麼我們將上面的數值進行轉換

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

也就是說,二進位制數代表的 00100111 轉換成十進位制就是 39,這個 39 並不是 3 和 9 兩個數字連著寫,而是 3 * 10 + 9 * 1,這裡面的 10 , 1 就是位權,以此類推,上述例子中的位權從高位到低位依次就是 7 6 5 4 3 2 1 0。這個位權也叫做次冪,那麼最高位就是2的7次冪,2的6次冪 等等。二進位制數的運算每次都會以2為底,這個2 指得就是基數,那麼十進位制數的基數也就是 10 。在任何情況下位權的值都是 數的位數 - 1,那麼第一位的位權就是 1 - 1 = 0, 第二位的位權就睡 2 - 1 = 1,以此類推。

那麼我們所說的二進位制數其實就是 用0和1兩個數字來表示的數,它的基數為2,它的數值就是每個數的位數 * 位權再求和得到的結果,我們一般來說數值指的就是十進位制數,那麼它的數值就是 3 * 10 + 9 * 1 = 39。

移位運算和乘除的關係

在瞭解過二進位制之後,下面我們來看一下二進位制的運算,和十進位制數一樣,加減乘除也適用於二進位制數,只要注意逢 2 進位即可。二進位制數的運算,也是計算機程式所特有的運算,因此瞭解二進位制的運算是必須要掌握的。

首先我們來介紹移位 運算,移位運算是指將二進位制的數值的各個位置上的元素坐左移和右移操作,見下圖

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

補數

剛才我們沒有介紹右移的情況,是因為右移之後空出來的高位數值,有 0 和 1 兩種形式。要想區分什麼時候補0什麼時候補1,首先就需要掌握二進位制數表示負數的方法。

二進位制數中表示負數值時,一般會把最高位作為符號來使用,因此我們把這個最高位當作符號位。 符號位是 0 時表示正數,是 1 時表示 負數。那麼 -1 用二進位制數該如何表示呢?可能很多人會這麼認為:因為 1 的二進位制數是 0000 0001,最高位是符號位,所以正確的表示 -1 應該是 1000 0001,但是這個答案真的對嗎?

計算機世界中是沒有減法的,計算機在做減法的時候其實就是在做加法,也就是用加法來實現的減法運算。比如 100 - 50 ,其實計算機來看的時候應該是 100 +  (-50),為此,在表示負數的時候就要用到二進位制補數,補數就是用正數來表示的負數。

為了獲得補數,我們需要將二進位制的各數位的數值全部取反,然後再將結果 + 1 即可,先記住這個結論,下面我們來演示一下。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

具體來說,就是需要先獲取某個數值的二進位制數,然後對二進位制數的每一位做取反操作(0 ---> 1 , 1 ---> 0),最後再對取反後的數 +1 ,這樣就完成了補數的獲取。

補數的獲取,雖然直觀上不易理解,但是邏輯上卻非常嚴謹,比如我們來看一下 1 - 1 的這個過程,我們先用上面的這個 1000 0001(它是1的補數,不知道的請看上文,正確性先不管,只是用來做一下計算)來表示一下

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

奇怪,1 - 1 會變成 130 ,而不是0,所以可以得出結論 1000 0001 表示 -1 是完全錯誤的。

那麼正確的該如何表示呢?其實我們上面已經給出結果了,那就是 1111 1111,來論證一下它的正確性

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

我們可以看到 1 - 1 其實實際上就是 1 + (-1),對 -1 進行上面的取反 + 1 後變為 1111 1111, 然後與 1 進行加法運算,得到的結果是九位的 1 0000 0000,結果發生了溢位,計算機會直接忽略掉溢位位,也就是直接拋掉 最高位 1 ,變為 0000 0000。也就是 0,結果正確,所以 1111 1111 表示的就是 -1 。

所以負數的二進位制表示就是先求其補數,補數的求解過程就是對原始數值的二進位制數各位取反,然後將結果 + 1

算數右移和邏輯右移的區別

在瞭解完補數後,我們重新考慮一下右移這個議題,右移在移位後空出來的最高位有兩種情況0 和 1

將二進位制數作為帶符號的數值進行右移運算時,移位後需要在最高位填充移位前符號位的值( 0 或 1)。這就被稱為算數右移。如果數值使用補數表示的負數值,那麼右移後在空出來的最高位補 1,就可以正確的表示 1/2,1/4,1/8等的數值運算。如果是正數,那麼直接在空出來的位置補 0 即可。

下面來看一個右移的例子。將 -4 右移兩位,來各自看一下移位示意圖

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

如上圖所示,在邏輯右移的情況下, -4 右移兩位會變成 63, 顯然不是它的 1/4,所以不能使用邏輯右移,那麼算數右移的情況下,右移兩位會變為 -1,顯然是它的 1/4,故而採用算數右移。

那麼我們可以得出來一個結論:左移時,無論是圖形還是數值,移位後,只需要將低位補 0 即可;右移時,需要根據情況判斷是邏輯右移還是算數右移。

下面介紹一下符號擴充套件:將資料進行符號擴充套件是為了產生一個位數加倍、但數值大小不變的結果,以滿足有些指令對運算元位數的要求,例如倍長於除數的被除數,再如將資料位數加長以減少計算過程中的誤差。

以8位二進位制為例,符號擴充套件就是指在保持值不變的前提下將其轉換成為16位和32位的二進位制數。將0111 1111這個正的 8位二進位制數轉換成為 16位二進位制數時,很容易就能夠得出0000 0000 0111 1111這個正確的結果,但是像 1111 1111這樣的補數來表示的數值,該如何處理?直接將其表示成為1111 1111 1111 1111就可以了。

也就是說,不管正數還是補數表示的負數,只需要將 0 和 1 填充高位即可。

記憶體和磁碟的關係

我們大家知道,計算機的五大基礎部件是 儲存器控制器運算器輸入和輸出裝置,其中從儲存功能的角度來看,可以把儲存器分為記憶體和 磁碟,我們上面介紹過記憶體,下面就來介紹一下磁碟以及磁碟和記憶體的關係

程式不讀入記憶體就無法執行

計算機最主要的儲存部件是記憶體和磁碟。磁碟中儲存的程式必須載入到記憶體中才能執行,在磁碟中儲存的程式是無法直接執行的,這是因為負責解析和執行程式內容的 CPU 是需要透過程式計數器來指定記憶體地址從而讀出程式指令的。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

磁碟構造

磁碟快取

我們上面提到,磁碟往往和記憶體是互利共生的關係,相互協作,彼此持有良好的合作關係。每次記憶體都需要從磁碟中讀取資料,必然會讀到相同的內容,所以一定會有一個角色負責儲存我們經常需要讀到的內容。我們大家做軟體的時候經常會用到快取技術,那麼硬體層面也不例外,磁碟也有快取,磁碟的快取叫做磁碟快取

磁碟快取指的是把從磁碟中讀出的資料儲存到記憶體的方式,這樣一來,當接下來需要讀取相同的內容時,就不會再透過實際的磁碟,而是透過磁碟快取來讀取。某一種技術或者框架的出現勢必要解決某種問題的,那麼磁碟快取就大大改善了磁碟訪問的速度

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

虛擬記憶體

虛擬記憶體是記憶體和磁碟互動的第二個媒介。虛擬記憶體是指把磁碟的一部分作為假想記憶體來使用。這與磁碟快取是假想的磁碟(實際上是記憶體)相對,虛擬記憶體是假想的記憶體(實際上是磁碟)。

虛擬記憶體是計算機系統記憶體管理的一種技術。它使得應用程式認為它擁有連續可用的記憶體(一個完整的地址空間),但是實際上,它通常被分割成多個物理碎片,還有部分儲存在外部磁碟管理器上,必要時進行資料交換。

透過藉助虛擬記憶體,在記憶體不足時仍然可以執行程式。例如,在只剩 5MB 記憶體空間的情況下仍然可以執行 10MB 的程式。由於 CPU 只能執行載入到記憶體中的程式,因此,虛擬記憶體的空間就需要和記憶體中的空間進行置換(swap),然後執行程式。

虛擬記憶體與記憶體的交換方式

虛擬記憶體的方法有分頁式 和 分段式 兩種。Windows 採用的是分頁式。該方式是指在不考慮程式構造的情況下,把執行的程式按照一定大小的頁進行分割,並以為單位進行置換。在分頁式中,我們把磁碟的內容讀到記憶體中稱為 Page In,把記憶體的內容寫入磁碟稱為 Page Out

Windows 計算機的頁大小為 4KB ,也就是說,需要把應用程式按照 4KB 的頁來進行切分,以頁(page)為單位放到磁碟中,然後進行置換。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

為了實現記憶體功能,Windows 在磁碟上提供了虛擬記憶體使用的檔案(page file,頁檔案)。該檔案由 Windows 生成和管理,檔案的大小和虛擬記憶體大小相同,通常大小是記憶體的 1 - 2 倍。

磁碟的物理結構

之前我們介紹了CPU、記憶體的物理結構,現在我們來介紹一下磁碟的物理結構。磁碟的物理結構指的是磁碟儲存資料的形式

磁碟是透過其物理表面劃分成多個空間來使用的。劃分的方式有兩種:可變長方式 和 扇區方式。前者是將物理結構劃分成長度可變的空間,後者是將磁碟結構劃分為固定長度的空間。一般 Windows 所使用的硬碟和軟盤都是使用扇區這種方式。扇區中,把磁碟表面分成若干個同心圓的空間就是 磁軌,把磁軌按照固定大小的儲存空間劃分而成的就是 扇區

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

扇區是對磁碟進行物理讀寫的最小單位。Windows 中使用的磁碟,一般是一個扇區 512 個位元組。不過,Windows 在邏輯方面對磁碟進行讀寫的單位是扇區整數倍簇。根據磁碟容量不同功能,1簇可以是 512 位元組(1 簇 = 1扇區)、1KB(1簇 = 2扇區)、2KB、4KB、8KB、16KB、32KB( 1 簇 = 64 扇區)。簇和扇區的大小是相等的。

壓縮演算法

我們想必都有過壓縮和 解壓縮檔案的經歷,當檔案太大時,我們會使用檔案壓縮來降低檔案的佔用空間。比如微信上傳檔案的限制是100 MB,我這裡有個資料夾無法上傳,但是我解壓完成後的檔案一定會小於 100 MB,那麼我的檔案就可以上傳了。

此外,我們把相機拍完的照片儲存到計算機上的時候,也會使用壓縮演算法進行檔案壓縮,檔案壓縮的格式一般是JPEG

那麼什麼是壓縮演算法呢?壓縮演算法又是怎麼定義的呢?在認識演算法之前我們需要先了解一下檔案是如何儲存的

檔案儲存

檔案是將資料儲存在磁碟等儲存媒介的一種形式。程式檔案中最基本的儲存資料單位是位元組。檔案的大小不管是 xxxKB、xxxMB等來表示,就是因為檔案是以位元組 B = Byte 為單位來儲存的。

檔案就是位元組資料的集合。用 1 位元組(8 位)表示的位元組資料有 256 種,用二進位制表示的話就是 0000 0000 - 1111 1111 。如果檔案中儲存的資料是文字,那麼該檔案就是文字檔案。如果是圖形,那麼該檔案就是影像檔案。在任何情況下,檔案中的位元組數都是連續儲存的。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

壓縮演算法的定義

上面介紹了檔案的集合體其實就是一堆位元組資料的集合,那麼我們就可以來給壓縮演算法下一個定義。

壓縮演算法(compaction algorithm)指的就是資料壓縮的演算法,主要包括壓縮和還原(解壓縮)的兩個步驟。

其實就是在不改變原有檔案屬性的前提下,降低檔案位元組空間和佔用空間的一種演算法。

根據壓縮演算法的定義,我們可將其分成不同的型別:

有損和無損

無失真壓縮:能夠無失真地從壓縮後的資料重構,準確地還原原始資料。可用於對資料的準確性要求嚴格的場合,如可執行檔案和普通檔案的壓縮、磁碟的壓縮,也可用於多媒體資料的壓縮。該方法的壓縮比較小。如差分編碼、RLE、Huffman編碼、LZW編碼、算術編碼。

有失真壓縮:有失真,不能完全準確地恢復原始資料,重構的資料只是原始資料的一個近似。可用於對資料的準確性要求不高的場合,如多媒體資料的壓縮。該方法的壓縮比較大。例如預測編碼、音感編碼、分形壓縮、小波壓縮、JPEG/MPEG。

對稱性

如果編解碼演算法的複雜性和所需時間差不多,則為對稱的編碼方法,多數壓縮演算法都是對稱的。但也有不對稱的,一般是編碼難而解碼容易,如 Huffman 編碼和分形編碼。但用於密碼學的編碼方法則相反,是編碼容易,而解碼則非常難。

幀間與幀內

在影片編碼中會同時用到幀內與幀間的編碼方法,幀內編碼是指在一幀影像內獨立完成的編碼方法,同靜態影像的編碼,如 JPEG;而幀間編碼則需要參照前後幀才能進行編解碼,並在編碼過程中考慮對幀之間的時間冗餘的壓縮,如 MPEG。

實時性

在有些多媒體的應用場合,需要實時處理或傳輸資料(如現場的數字錄音和錄影、播放MP3/RM/VCD/DVD、影片/音訊點播、網路現場直播、可視電話、視訊會議),編解碼一般要求延時 ≤50 ms。這就需要簡單/快速/高效的演算法和高速/複雜的CPU/DSP晶片。

分級處理

有些壓縮演算法可以同時處理不同解析度、不同傳輸速率、不同質量水平的多媒體資料,如JPEG2000、MPEG-2/4。

這些概念有些抽象,主要是為了讓大家瞭解一下壓縮演算法的分類,下面我們就對具體的幾種常用的壓縮演算法來分析一下它的特點和優劣

幾種常用壓縮演算法的理解

RLE 演算法的機制

接下來就讓我們正式看一下檔案的壓縮機制。首先讓我們來嘗試對 AAAAAABBCDDEEEEEF這 17 個半形字元的檔案(文字檔案)進行壓縮。雖然這些文字沒有什麼實際意義,但是很適合用來描述 RLE 的壓縮機制。

由於半形字元(其實就是英文字元)是作為 1 個位元組儲存在檔案中的,所以上述的檔案的大小就是 17 位元組。如圖

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

那麼,如何才能壓縮該檔案呢?大家不妨也考慮一下,只要是能夠使檔案小於 17 位元組,我們可以使用任何壓縮演算法。

最顯而易見的一種壓縮方式我覺得你已經想到了,就是把相同的字元去重化,也就是 字元 * 重複次數 的方式進行壓縮。所以上面檔案壓縮後就會變成下面這樣

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!


從圖中我們可以看出,AAAAAABBCDDEEEEEF 的17個字元成功被壓縮成了A6B2C1D2E5F1 的12個字元,也就是 12 / 17  = 70%,壓縮比為 70%,壓縮成功了。

像這樣,把檔案內容用 資料 * 重複次數 的形式來表示的壓縮方法成為 RLE(Run Length Encoding, 行程長度編碼)  演算法。RLE 演算法是一種很好的壓縮方法,經常用於壓縮傳真的影像等。因為影像檔案的本質也是位元組資料的集合體,所以可以用 RLE 演算法進行壓縮

哈夫曼演算法和莫爾斯編碼

下面我們來介紹另外一種壓縮演算法,即哈夫曼演算法。在瞭解哈夫曼演算法之前,你必須捨棄半形英文數字的1個字元是1個位元組(8位)的資料。下面我們就來認識一下哈夫曼演算法的基本思想。

文字檔案是由不同型別的字元組合而成的,而且不同字元出現的次數也是不一樣的。例如,在某個文字檔案中,A 出現了 100次左右,Q僅僅用到了 3 次,類似這樣的情況很常見。

哈夫曼演算法的關鍵就在於 多次出現的資料用小於 8 位的位元組數表示,不常用的資料則可以使用超過 8 位的位元組數表示。A 和 Q 都用 8 位來表示時,原檔案的大小就是 100次 * 8 位 + 3次 * 8 位 = 824位,假設 A 用 2 位,Q 用 10 位來表示就是 2 * 100 + 3 * 10 = 230 位。

不過要注意一點,最終磁碟的儲存都是以8位為一個位元組來儲存檔案的。

哈夫曼演算法比較複雜,在深入瞭解之前我們先吃點甜品,瞭解一下 莫爾斯編碼,你一定看過美劇或者戰爭片的電影,在戰爭中的通訊經常採用莫爾斯編碼來傳遞資訊,例如下面

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

接下來我們來講解一下莫爾斯編碼,下面是莫爾斯編碼的示例,大家把 1 看作是短點(嘀),把 11 看作是長點(嗒)即可。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

莫爾斯編碼一般把文字中出現最高頻率的字元用短編碼 來表示。如表所示,假如表示短點的位是 1,表示長點的位是 11 的話,那麼 E(嘀)這一資料的字元就可以用 1 來表示,C(滴答滴答)就可以用 9 位的 110101101來表示。

在實際的莫爾斯編碼中,如果短點的長度是 1 ,長點的長度就是 3,短點和長點的間隔就是1。這裡的長度指的就是聲音的長度。比如我們想用上面的 AAAAAABBCDDEEEEEF 例子來用莫爾斯編碼重寫,在莫爾斯曼編碼中,各個字元之間需要加入表示時間間隔的符號。這裡我們用 00 加以區分。

所以,AAAAAABBCDDEEEEEF 這個文字就變為了 A * 6 次 + B * 2次 + C * 1次 + D * 2次 + E * 5次 + F * 1次 + 字元間隔 * 16 = 4 位 * 6次 + 8 位 * 2次 + 9 位 * 1 次 + 6位 * 2次 + 1位 * 5次 + 8 位 * 1次 + 2位 * 16次 = 106位  = 14位元組。

所以使用莫爾斯電碼的壓縮比為 14 / 17 = 82%。效率並不太突出。

用二叉樹實現哈夫曼演算法

剛才已經提到,莫爾斯編碼是根據日常文字中各字元的出現頻率來決定表示各字元的編碼資料長度的。不過,在該編碼體系中,對 AAAAAABBCDDEEEEEF 這種文字來說並不是效率最高的。

下面我們來看一下哈夫曼演算法。哈夫曼演算法是指,為各壓縮物件檔案分別構造最佳的編碼體系,並以該編碼體系為基礎來進行壓縮。因此,用什麼樣的編碼(哈夫曼編碼)對資料進行分割,就要由各個檔案而定。用哈夫曼演算法壓縮過的檔案中,儲存著哈夫曼編碼資訊和壓縮過的資料。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

接下來,我們在對 AAAAAABBCDDEEEEEF 中的 A - F 這些字元,按照出現頻率高的字元用盡量少的位數編碼來表示這一原則進行整理。按照出現頻率從高到低的順序整理後,結果如下,同時也列出了編碼方案。

字元出現頻率編碼(方案)位數
A601
E511
B2102
D2112
C11003
F11013

在上表的編碼方案中,隨著出現頻率的降低,字元編碼資訊的資料位數也在逐漸增加,從最開始的 1位、2位依次增加到3位。不過這個編碼體系是存在問題的,你不知道100這個3位的編碼,它的意思是用 1、0、0這三個編碼來表示 E、A、A 呢?還是用10、0來表示 B、A 呢?還是用100來表示 C 呢。

而在哈夫曼演算法中,透過藉助哈夫曼樹的構造編碼體系,即使在不使用字元區分符號的情況下,也可以構建能夠明確進行區分的編碼體系。不過哈夫曼樹的演算法要比較複雜,下面是一個哈夫曼樹的構造過程。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

自然界樹的從根開始生葉的,而哈夫曼樹則是葉生枝

哈夫曼樹能夠提升壓縮比率

使用哈夫曼樹之後,出現頻率越高的資料所佔用的位數越少,這也是哈夫曼樹的核心思想。透過上圖的步驟二可以看出,枝條連線資料時,我們是從出現頻率較低的資料開始的。這就意味著出現頻率低的資料到達根部的枝條也越多。而枝條越多則意味著編碼的位數隨之增加。

接下來我們來看一下哈夫曼樹的壓縮比率,用上圖得到的資料表示 AAAAAABBCDDEEEEEF 為 000000000000 100100 110 101101 0101010101 111,40位 = 5 位元組。壓縮前的資料是 17 位元組,壓縮後的資料竟然達到了驚人的5 位元組,也就是壓縮比率 = 5 / 17 = 29% 如此高的壓縮率,簡直是太驚豔了。

大家可以參考一下,無論哪種型別的資料,都可以用哈夫曼樹作為壓縮演算法

檔案型別壓縮前壓縮後壓縮比率
文字檔案14862位元組4119位元組28%
影像檔案96062位元組9456位元組10%
EXE檔案24576位元組4652位元組19%

可逆壓縮和非可逆壓縮

最後,我們來看一下影像檔案的資料形式。影像檔案的使用目的通常是把影像資料輸出到顯示器、印表機等裝置上。常用的影像格式有 : BMPJPEGTIFFGIF 格式等。

  • BMP :是使用 Windows 自帶的畫筆來做成的一種影像形式
  • JPEG:是數位相機等常用的一種影像資料形式
  • TIFF: 是一種透過在檔案中包含"標籤"就能夠快速顯示出資料性質的影像形式
  • GIF:是由美國開發的一種資料形式,要求色數不超過 256個

影像檔案可以使用前面介紹的 RLE 演算法和哈夫曼演算法,因為影像檔案在多數情況下並不要求資料需要還原到和壓縮之前一摸一樣的狀態,允許丟失一部分資料。我們把能還原到壓縮前狀態的壓縮稱為 可逆壓縮,無法還原到壓縮前狀態的壓縮稱為非可逆壓縮 。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

一般來說,JPEG格式的檔案是非可逆壓縮,因此還原後有部分影像資訊比較模糊。GIF 是可逆壓縮

作業系統

作業系統環境

程式中包含著執行環境這一內容,可以說 執行環境 = 作業系統 + 硬體 ,作業系統又可以被稱為軟體,它是由一系列的指令組成的。我們不介紹作業系統,我們主要來介紹一下硬體的識別。

我們肯定都玩兒過遊戲,你玩兒遊戲前需要幹什麼?是不是需要先看一下自己的筆記本或者電腦是不是能肝的起遊戲?下面是一個遊戲的配置(懷念一下 wow)

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

圖中的主要配置如下

  • 作業系統版本:說的就是應用程式執行在何種系統環境,現在市面上主要有三種作業系統環境,Windows 、Linux 和 Unix ,一般我們玩兒的大型遊戲幾乎都是在 Windows 上執行,可以說 Windows 是遊戲的天堂。Windows 作業系統也會有區分,分為32位作業系統和64位作業系統,互不相容。

  • 處理器:處理器指的就是 CPU,你的電腦的計算能力,通俗來講就是每秒鐘能處理的指令數,如果你的電腦覺得卡帶不起來的話,很可能就是 CPU 的計算能力不足導致的。想要加深理解,請閱讀博主的另一篇文章:程式設計師需要了解的硬核知識之CPU

  • 顯示卡:顯示卡承擔圖形的輸出任務,因此又被稱為圖形處理器(Graphic Processing Unit,GPU),顯示卡也非常重要,比如我之前玩兒的劍靈開五檔(其實就是影像變得更清晰)會卡,其實就是顯示卡顯示不出來的原因。

  • 記憶體:記憶體即主存,就是你的應用程式在執行時能夠動態分析指令的這部分儲存空間,它的大小也能決定你電腦的執行速度,想要加深理解,請閱讀博主的另一篇文章 程式設計師需要了解的硬核知識之記憶體

  • 儲存空間:儲存空間指的就是應用程式安裝所佔用的磁碟空間,由圖中可知,此遊戲的最低儲存空間必須要大於 5GB,其實我們都會遺留很大一部分用來安裝遊戲。

從程式的執行環境這一角度來考量的話,CPU 的種類是特別重要的引數,為了使程式能夠正常執行,必須滿足 CPU 所需的最低配置。

CPU 只能解釋其自身固有的語言。不同的 CPU 能解釋的機器語言的種類也是不同的。機器語言的程式稱為 原生程式碼(native code),程式設計師用 C 等高階語言編寫的程式,僅僅是文字檔案。文字檔案(排除文字編碼的問題)在任何環境下都能顯示和編輯。我們稱之為原始碼。透過對原始碼進行編譯,就可以得到原生程式碼

下圖反映了這個過程

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

Windows 作業系統克服了CPU以外的硬體差異

計算機的硬體並不僅僅是由 CPU 組成的,還包括用於儲存程式指令的資料和記憶體,以及透過 I/O 連線的鍵盤、顯示器、硬碟、印表機等外圍裝置。

在 WIndows 軟體中,鍵盤輸入、顯示器輸出等並不是直接向硬體傳送指令。而是透過向 Windows 傳送指令實現的。因此,程式設計師就不用注意記憶體和 I/O 地址的不同構成了。Windows 操作的是硬體而不是軟體,軟體透過操作 Windows 系統可以達到控制硬體的目的。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

不同作業系統的 API 差異性

接下來我們看一下作業系統的種類。同樣機型的計算機,可安裝的作業系統型別也會有多種選擇。

例如:AT 相容機除了可以安裝 Windows 之外,還可以採用 Unix 系列的 Linux 以及 FreeBSD (也是一種Unix作業系統)等多個作業系統。

當然,應用軟體則必須根據不同的作業系統型別來專門開發。CPU 的型別不同,所對應機器的語言也不同,同樣的道理,作業系統的型別不同,應用程式向作業系統傳遞指令的途徑也不同

應用程式向系統傳遞指令的途徑稱為 API(Application Programming Interface)。Windows 以及 Linux 作業系統的 API,提供了任何應用程式都可以利用的函式組合。因為不同作業系統的 API 是有差異的。所以,如何要將同樣的應用程式移植到另外的作業系統,就必須要覆蓋應用所用到的 API 部分。

鍵盤輸入、滑鼠輸入、顯示器輸出、檔案輸入和輸出等同外圍裝置進行互動的功能,都是透過 API 提供的。

這也就是為什麼 Windows 應用程式不能直接移植到 Linux 作業系統上的原因,API 差異太大了。

在同型別的作業系統下,不論硬體如何,API 幾乎相同。但是,由於不同種類 CPU 的機器語言不同,因此原生程式碼也不盡相同。

作業系統功能的歷史

作業系統其實也是一種軟體,任何新事物的出現肯定都有它的歷史背景,那麼作業系統也不是憑空出現的,肯定有它的歷史背景。

在計算機尚不存在作業系統的年代,完全沒有任何程式,人們透過各種按鈕來控制計算機,這一過程非常麻煩。

於是,有人開發出了僅具有載入和執行功能的監控程式,這就是作業系統的原型。透過事先啟動監控程式,程式設計師可以根據需要將各種程式載入到記憶體中執行。雖然仍舊比較麻煩,但比起在沒有任何程式的狀態下進行開發,工作量得到了很大的緩解。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

隨著時代的發展,人們在利用監控程式編寫程式的過程中發現很多程式都有公共的部分。例如,透過鍵盤進行文字輸入,顯示器進行資料展示等,如果每編寫一個新的應用程式都需要相同的處理的話,那真是太浪費時間了。

因此,基本的輸入輸出部分的程式就被追加到了監控程式中。初期的作業系統就是這樣誕生了。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

類似的想法可以共用,人們又發現有更多的應用程式可以追加到監控程式中,比如硬體控制程式程式語言處理器(彙編、編譯、解析)以及各種應用程式等,結果就形成了和現在差異不大的作業系統,也就是說,其實作業系統是多個程式的集合體。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

Windows 作業系統的特徵

Windows 作業系統是世界上使用者數量最龐大的群體,作為 Windows 作業系統的資深使用者,你都知道 Windows 作業系統有哪些特徵嗎?下面列舉了一些 Windows 作業系統的特性

  • Windows 作業系統有兩個版本:32位和64位
  • 透過 API 函式整合來提供系統呼叫
  • 提供了採用圖形使用者介面的使用者介面
  • 透過 WYSIWYG 實現列印輸出,WYSIWYG 其實就是 What You See Is What You Get ,值得是顯示器上顯示的圖形和文字都是可以原樣輸出到印表機列印的。
  • 提供多工功能,即能夠同時開啟多個任務
  • 提供網路功能和資料庫功能
  • 透過即插即用實現裝置驅動的自設定

這些是對程式設計師來講比較有意義的一些特徵,下面針對這些特徵來進行分別的介紹

32位作業系統

這裡表示的32位作業系統表示的是處理效率最高的資料大小。Windows 處理資料的基本單位是 32 位。這與最一開始在 MS-DOS 等16位作業系統不同,因為在16位作業系統中處理32位資料需要兩次,而32位作業系統只需要一次就能夠處理32位的資料,所以一般在 windows 上的應用,它們的最高能夠處理的資料都是 32 位的。

比如,用 C 語言來處理整數資料時,有8位的 char 型別,16位的short型別,以及32位的long型別三個選項,使用位數較大的 long 型別進行處理的話,增加的只是記憶體以及磁碟的開銷,對效能影響不大。

現在市面上大部分都是64位作業系統了,64位作業系統也是如此。

透過 API 函式集來提供系統呼叫

Windows 是透過名為 API 的函式集來提供系統呼叫的。API是聯絡應用程式和作業系統之間的介面,全稱叫做 Application Programming Interface,應用程式介面。

當前主流的32位版 Windows API 也稱為 Win32 API,之所以這樣命名,是需要和不同的作業系統進行區分,比如最一開始的 16 位版的 Win16 API,和後來流行的 Win64 API 。

API 透過多個 DLL 檔案來提供,各個 API 的實體都是用 C 語言編寫的函式。所以,在 C 語言環境下,使用 API 更加容易,比如 API 所用到的 MessageBox() 函式,就被儲存在了 Windows 提供的 user32.dll 這個 DLL 檔案中。

提供採用了 GUI 的使用者介面

GUI(Graphical User Interface) 指得就是圖形使用者介面,透過點選顯示器中的視窗以及圖示等視覺化的使用者介面,舉個例子:Linux 作業系統就有兩個版本,一種是簡潔版,直接透過命令列控制硬體,還有一種是視覺化版,透過游標點選圖形介面來控制硬體。

透過 WYSIWYG 實現列印輸出

WYSIWYG 指的是顯示器上輸出的內容可以直接透過印表機列印輸出。在 Windows 中,顯示器和印表機被認作同等的圖形輸出裝置處理的,該功能也為 WYSIWYG 提供了條件。

藉助 WYSIWYG 功能,程式設計師可以輕鬆不少。最初,為了是現在顯示器中顯示和在印表機中列印,就必須分別編寫各自的程式,而在 Windows 中,可以藉助 WYSIWYG 基本上在一個程式中就可以做到顯示和列印這兩個功能了。

提供多工功能

多工指的就是同時能夠執行多個應用程式的功能,Windows 是透過時鐘分割技術來實現多工功能的。時鐘分割指的是短時間間隔內,多個程式切換執行的方式。在使用者看來,就好像是多個程式在同時執行,其底層是 CPU 時間切片,這也是多執行緒多工的核心。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

CPU分片,也是時鐘分割

提供網路功能和資料庫功能

Windows 中,網路功能是作為標準功能提供的。資料庫(資料庫伺服器)功能有時也會在後面追加。

網路功能和資料庫功能雖然並不是作業系統不可或缺的,但因為它們和作業系統很接近,所以被統稱為中介軟體而不是應用。意思是處於作業系統和應用的中間層,作業系統和中介軟體組合在一起,稱為系統軟體。應用不僅可以利用作業系統,也可以利用中介軟體的功能。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

應用可以使用作業系統和中介軟體

相對於作業系統一旦安裝就不能輕易更換,中介軟體可以根據需要進行更換,不過,對於大部分應用來說,更換中介軟體的話,會造成應用也隨之更換,從這個角度來說,更å換中介軟體也不是那麼容易。

透過即插即用實現裝置驅動的自動設定

即插即用(Plug-and-Play)指的是新的裝置連線(plug) 後就可以直接使用的機制,新裝置連線計算機後,計算機就會自動安裝和設定用來控制該裝置的驅動程式

裝置驅動是作業系統的一部分,提供了同硬體進行基本的輸入輸出的功能。鍵盤、滑鼠、顯示器、磁碟裝置等,這些計算機中必備的硬體的裝置驅動,一般都是隨作業系統一起安裝的。

有時 DLL 檔案也會同裝置驅動檔案一起安裝。這些 DLL 檔案中儲存著用來利用該新追加的硬體API,透過 API ,可以製作出執行該硬體的心應用。

組合語言和原生程式碼

我們在之前的文章中探討過,計算機 CPU 只能執行原生程式碼(機器語言)程式,用 C 語言等高階語言編寫的程式碼,需要經過編譯器編譯後,轉換為原生程式碼才能夠被 CPU 解釋執行。

但是原生程式碼的可讀性非常差,所以需要使用一種能夠直接讀懂的語言來替換原生程式碼,那就是在各原生程式碼中,附帶上表示其功能的英文縮寫,比如在加法運算的原生程式碼加上add(addition) 的縮寫、在比較運算子的原生程式碼中加上cmp(compare)的縮寫等,這些透過縮寫來表示具體原生程式碼指令的標誌稱為 助記符,使用助記符的語言稱為組合語言。這樣,透過閱讀組合語言,也能夠了解原生程式碼的含義了。

不過,即使是使用匯編語言編寫的原始碼,最終也必須要轉換為原生程式碼才能夠執行,負責做這項工作的程式稱為編譯器,轉換的這個過程稱為彙編。在將原始碼轉換為原生程式碼這個功能方面,彙編器和編譯器是同樣的。

用匯編語言編寫的原始碼和原生程式碼是一一對應的。因而,原生程式碼也可以反過來轉換成組合語言編寫的程式碼。把原生程式碼轉換為彙編程式碼的這一過程稱為反彙編,執行反彙編的程式稱為反彙編程式

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

原生程式碼和組合語言一對一的轉換

哪怕是 C 語言編寫的原始碼,編譯後也會轉換成特定 CPU 用的原生程式碼。而將其反彙編的話,就可以得到組合語言的原始碼,並對其內容進行調查。不過,原生程式碼變成 C 語言原始碼的反編譯,要比原生程式碼轉換成彙編程式碼的反彙編要困難,這是因為,C 語言程式碼和原生程式碼不是一一對應的關係。

透過編譯器輸出組合語言的原始碼

我們上面提到原生程式碼可以經過反彙編轉換成為彙編程式碼,但是隻有這一種轉換方式嗎?顯然不是,C 語言編寫的原始碼也能夠透過編譯器編譯稱為彙編程式碼,下面就來嘗試一下。

首先需要先做一些準備,需要先下載 Borland C++ 5.5 編譯器,為了方便,我這邊直接下載好了讀者直接從我的百度網盤提取即可 (連結:  密碼:hz1u)

下載完畢,需要進行配置,下面是配置說明 (),教程很完整跟著配置就可以,下面開始我們的編譯過程

首先用 Windows 記事本等文字編輯器編寫如下程式碼

// 返回兩個引數值之和的函式
int AddNum(int a,int b){
 return a + b;
}

// 呼叫 AddNum 函式的函式
void MyFunc(){
 int c;
 c = AddNum(123,456);
}

編寫完成後將其檔名儲存為 Sample4.c ,C 語言原始檔的副檔名,通常用.c 來表示,上面程式是提供兩個輸入引數並返回它們之和。

在 Windows 作業系統下開啟 命令提示符,切換到儲存 Sample4.c 的資料夾下,然後在命令提示符中輸入

bcc32 -c -S Sample4.c

bcc32 是啟動 Borland C++ 的命令,-c 的選項是指僅進行編譯而不進行連結,-S 選項被用來指定生成組合語言的原始碼

作為編譯的結果,當前目錄下會生成一個名為Sample4.asm 的組合語言原始碼。

組合語言原始檔的副檔名,通常用.asm 來表示,下面就讓我們用編輯器開啟看一下 Sample4.asm 中的內容

	.386p
ifdef ??version
if    ??version GT 500H
.mmx
endif
endif
model flat
ifndef ??version
?debug macro
endm
endif
?debug S "Sample4.c"
?debug T "Sample4.c"
_TEXT segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
DGROUP group _BSS,_DATA
_TEXT segment dword public use32 'CODE'
_AddNum proc near
?live1@0:
  ;
  ; int AddNum(int a,int b){
  ;
push      ebp
mov       ebp,esp
  ;
  ;
  ;    return a + b;
  ;
@1:
mov       eax,dword ptr [ebp+8]
add       eax,dword ptr [ebp+12]
  ;
  ; }
  ;
@3:
@2:
pop       ebp
ret
_AddNum endp
_MyFunc proc near
?live1@48:
  ;
  ; void MyFunc(){
  ;
push      ebp
mov       ebp,esp
  ;
  ;    int c;
  ;    c = AddNum(123,456);
  ;
@4:
push      456
push      123
call      _AddNum
add       esp,8
  ;
  ; }
  ;
@5:
pop       ebp
ret
_MyFunc endp
_TEXT ends
public _AddNum
public _MyFunc
?debug D "Sample4.c" 20343 45835
end

這樣,編譯器就成功的把 C 語言轉換成為了彙編程式碼了。

不會轉換成原生程式碼的偽指令

第一次看到彙編程式碼的讀者可能感覺起來比較難,不過實際上其實比較簡單,而且可能比 C 語言還要簡單,為了便於閱讀彙編程式碼的原始碼,需要注意幾個要點

組合語言的原始碼,是由轉換成原生程式碼的指令(後面講述的操作碼)和針對彙編器的偽指令構成的。偽指令負責把程式的構造以及彙編的方法指示給彙編器(轉換程式)。不過偽指令是無法彙編轉換成為原生程式碼的。下面是上面程式擷取的偽指令

_TEXT	segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
DGROUP group _BSS,_DATA

_AddNum proc near
_AddNum endp

_MyFunc proc near
_MyFunc endp

_TEXT ends
end

由偽指令 segment 和 ends 圍起來的部分,是給構成程式的命令和資料的集合體上加一個名字而得到的,稱為段定義。段定義的英文表達具有區域的意思,在這個程式中,段定義指的是命令和資料等程式的集合體的意思,一個程式由多個段定義構成。

上面程式碼的開始位置,定義了3個名稱分別為 _TEXT、_DATA、_BSS 的段定義,_TEXT 是指定的段定義,_DATA 是被初始化(有初始值)的資料的段定義,_BSS 是尚未初始化的資料的段定義。這種定義的名稱是由 Borland C++ 定義的,是由 Borland C++ 編譯器自動分配的,所以程式段定義的順序就成為了 _TEXT、_DATA、_BSS ,這樣也確保了記憶體的連續性

_TEXT	segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends

段定義( segment ) 是用來區分或者劃分範圍區域的意思。組合語言的 segment 偽指令表示段定義的起始,ends 偽指令表示段定義的結束。段定義是一段連續的記憶體空間

group 這個偽指令表示的是將 _BSS和_DATA 這兩個段定義彙總名為 DGROUP 的組

DGROUP	group	_BSS,_DATA

圍起 _AddNum 和 _MyFun 的 _TEXT segment 和 _TEXT  ends ,表示_AddNum 和 _MyFun 是屬於 _TEXT 這一段定義的。

_TEXT	segment dword public use32 'CODE'
_TEXT ends

因此,即使在原始碼中指令和資料是混雜編寫的,經過編譯和彙編後,也會轉換成為規整的原生程式碼。

_AddNum proc 和 _AddNum endp 圍起來的部分,以及_MyFunc proc 和 _MyFunc endp 圍起來的部分,分別表示 AddNum 函式和 MyFunc 函式的範圍。

_AddNum	proc	near
_AddNum endp

_MyFunc proc near
_MyFunc endp

編譯後在函式名前附帶上下劃線_ ,是 Borland C++ 的規定。在 C 語言中編寫的 AddNum 函式,在內部是以 _AddNum 這個名稱處理的。偽指令 proc 和 endp 圍起來的部分,表示的是 過程(procedure) 的範圍。在組合語言中,這種相當於 C 語言的函式的形式稱為過程。

末尾的 end 偽指令,表示的是原始碼的結束。

組合語言的語法是 操作碼 + 運算元

在組合語言中,一行表示一對 CPU 的一個指令。組合語言指令的語法結構是 操作碼 + 運算元,也存在只有操作碼沒有運算元的指令。

操作碼錶示的是指令動作,運算元表示的是指令物件。操作碼和運算元一起使用就是一個英文指令。比如從英語語法來分析的話,操作碼是動詞,運算元是賓語。比如這個句子 Give me money這個英文指令的話,Give 就是操作碼,me 和 money 就是運算元。組合語言中存在多個運算元的情況,要用逗號把它們分割,就像是 Give me,money 這樣。

能夠使用何種形式的操作碼,是由 CPU 的種類決定的,下面對操作碼的功能進行了整理。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

原生程式碼需要載入到記憶體後才能執行,記憶體中儲存著構成原生程式碼的指令和資料。程式執行時,CPU會從記憶體中把資料和指令讀出來,然後放在 CPU 內部的暫存器中進行處理。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

如果 CPU 和記憶體的關係你還不是很瞭解的話,請閱讀作者的另一篇文章 程式設計師需要了解的硬核知識之CPU 詳細瞭解。

暫存器是 CPU 中的儲存區域,暫存器除了具有臨時儲存和計算的功能之外,還具有運算功能,x86 系列的主要種類和角色如下圖所示


一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

指令解析

下面就對 CPU 中的指令進行分析

最常用的 mov 指令

指令中最常使用的是對暫存器和記憶體進行資料儲存的 mov 指令,mov 指令的兩個運算元,分別用來指定資料的儲存地和讀出源。

運算元中可以指定暫存器、常數、標籤(附加在地址前),以及用方括號([]) 圍起來的這些內容。如果指定了沒有用([]) 方括號圍起來的內容,就表示對該值進行處理;如果指定了用方括號圍起來的內容,方括號的值則會被解釋為記憶體地址,然後就會對該記憶體地址對應的值進行讀寫操作。讓我們對上面的程式碼片段進行說明

	mov       ebp,esp
mov       eax,dword ptr [ebp+8]

mov ebp,esp 中,esp 暫存器中的值被直接儲存在了 ebp 中,也就是說,如果 esp 暫存器的值是100的話那麼 ebp 暫存器的值也是 100。

而在 mov eax,dword ptr [ebp+8] 這條指令中,ebp 暫存器的值 + 8 後會被解析稱為記憶體地址。如果 ebp

暫存器的值是100的話,那麼 eax 暫存器的值就是 100 + 8 的地址的值。dword ptr 也叫做 double word pointer 簡單解釋一下就是從指定的記憶體地址中讀出4位元組的資料

對棧進行 push 和 pop

程式執行時,會在記憶體上申請分配一個稱為棧的資料空間。棧(stack)的特性是後入先出,資料在儲存時是從記憶體的下層(大的地址編號)逐漸往上層(小的地址編號)累積,讀出時則是按照從上往下進行讀取的。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

棧是儲存臨時資料的區域,它的特點是透過 push 指令和 pop 指令進行資料的儲存和讀出。向棧中儲存資料稱為 入棧 ,從棧中讀出資料稱為 出棧,32位 x86 系列的 CPU 中,進行1次 push 或者 pop,即可處理 32 位(4位元組)的資料。

函式的呼叫機制

下面我們一起來分析一下函式的呼叫機制,我們以上面的 C 語言編寫的程式碼為例。首先,讓我們從MyFunc 函式呼叫AddNum 函式的組合語言部分開始,來對函式的呼叫機制進行說明。棧在函式的呼叫中發揮了巨大的作用,下面是經過處理後的 MyFunc 函式的彙編處理內容

_MyFunc 	 proc 	 near
push ebp  ; 將 ebp 暫存器的值存入棧中                                      (1)
mov ebp,esp ; 將 esp 暫存器的值存入 ebp 暫存器中                      (2)
push 456 ; 將 456 入棧       (3)
push 123 ; 將 123 入棧       (4)
call _AddNum ; 呼叫 AddNum 函式       (5)
add esp,8 ; esp 暫存器的值 + 8       (6)
pop ebp ; 讀出棧中的數值存入 esp 暫存器中       (7)
ret ; 結束 MyFunc 函式,返回到呼叫源       (8)
_MyFunc endp
         

程式碼解釋中的(1)、(2)、(7)、(8)的處理適用於 C 語言中的所有函式,我們會在後面展示 AddNum 函式處理內容時進行說明。這裡希望大家先關注(3) - (6) 這一部分,這對了解函式呼叫機制至關重要。

(3) 和 (4) 表示的是將傳遞給 AddNum 函式的引數透過 push 入棧。在 C 語言原始碼中,雖然記述為函式 AddNum(123,456),但入棧時則會先按照 456,123 這樣的順序。也就是位於後面的數值先入棧。這是 C 語言的規定。

(5) 表示的 call 指令,會把程式流程跳轉到 AddNum 函式指令的地址處。在組合語言中,函式名表示的就是函式所在的記憶體地址。AddNum 函式處理完畢後,程式流程必須要返回到編號(6) 這一行。call 指令執行後,call 指令的下一行(也就指的是 (6) 這一行)的記憶體地址(呼叫函式完畢後要返回的記憶體地址)會自動的 push 入棧。該值會在 AddNum 函式處理的最後透過 ret 指令 pop 出棧,然後程式會返回到 (6) 這一行。

(6) 部分會把棧中儲存的兩個引數 (456 和 123) 進行銷燬處理。雖然透過兩次的 pop 指令也可以實現,不過採用 esp 暫存器 + 8 的方式會更有效率(處理 1 次即可)。對棧進行數值的輸入和輸出時,數值的單位是4位元組。因此,透過在負責棧地址管理的 esp 暫存器中加上4的2倍8,就可以達到和執行兩次 pop 命令同樣的效果。雖然記憶體中的資料實際上還殘留著,但只要把 esp 暫存器的值更新為資料儲存地址前面的資料位置,該資料也就相當於銷燬了。

我在編譯 Sample4.c 檔案時,出現了下圖的這條訊息

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

圖中的意思是指 c 的值在 MyFunc 定義了但是一直未被使用,這其實是一項編譯器最佳化的功能,由於儲存著 AddNum 函式返回值的變數 c 在後面沒有被用到,因此編譯器就認為 該變數沒有意義,進而也就沒有生成與之對應的組合語言程式碼

下圖是呼叫 AddNum 這一函式前後棧記憶體的變化


一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

函式的內部處理

上面我們用匯編程式碼分析了一下 Sample4.c 整個過程的程式碼,現在我們著重分析一下 AddNum 函式的原始碼部分,分析一下引數的接收、返回值和返回等機制

_AddNum 		proc		near
push ebp               (1)
mov ebp,esp                          (2)
mov eax,dword ptr[ebp+8]     (3)
add eax,dword ptr[ebp+12]   (4)
pop ebp        (5)
ret                                        (6)
_AddNum endp

ebp 暫存器的值在(1)中入棧,在(5)中出棧,這主要是為了把函式中用到的 ebp 暫存器的內容,恢復到函式呼叫前的狀態。

(2) 中把負責管理棧地址的 esp 暫存器的值賦值到了 ebp 暫存器中。這是因為,在 mov 指令中方括號內的引數,是不允許指定 esp 暫存器的。因此,這裡就採用了不直接透過 esp,而是用 ebp 暫存器來讀寫棧內容的方法。

(3) 使用[ebp + 8] 指定棧中儲存的第1個引數123,並將其讀出到 eax 暫存器中。像這樣,不使用 pop 指令,也可以參照棧的內容。而之所以從多個暫存器中選擇了 eax 暫存器,是因為 eax 是負責運算的累加暫存器。

透過(4) 的 add 指令,把當前 eax 暫存器的值同第2個引數相加後的結果儲存在 eax 暫存器中。[ebp + 12] 是用來指定第2個引數456的。在 C 語言中,函式的返回值必須透過 eax 暫存器返回,這也是規定。也就是 函式的引數是透過棧來傳遞,返回值是透過暫存器返回的

(6) 中 ret 指令執行後,函式返回目的地記憶體地址會自動出棧,據此,程式流程就會跳轉返回到(6) (Call _AddNum) 的下一行。這時,AddNum 函式入口和出口處棧的狀態變化,就如下圖所示


一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

全域性變數和區域性變數

在熟悉了組合語言後,接下來我們來了解一下全域性變數和區域性變數,在函式外部定義的變數稱為全域性變數,在函式內部定義的變數稱為區域性變數,全域性變數可以在任意函式中使用,區域性變數只能在函式定義區域性變數的內部使用。下面,我們就透過組合語言來看一下全域性變數和區域性變數的不同之處。

下面定義的 C 語言程式碼分別定義了區域性變數和全域性變數,並且給各變數進行了賦值,我們先看一下原始碼部分

// 定義被初始化的全域性變數
int a1 = 1;
int a2 = 2;
int a3 = 3;
int a4 = 4;
int a5 = 5;

// 定義沒有初始化的全域性變數
int b1,b2,b3,b4,b5;

// 定義函式
void MyFunc(){
 // 定義區域性變數
 int c1,c2,c3,c4,c5,c6,c7,c8,c9,c10;
 
 // 給區域性變數賦值
 c1 = 1;
 c2 = 2;
 c3 = 3;
 c4 = 4;
 c5 = 5;
 c6 = 6;
 c7 = 7;
 c8 = 8;
 c9 = 9;
 c10 = 10;
 
 // 把區域性變數賦值給全域性變數
 a1 = c1;
 a2 = c2;
 a3 = c3;
 a4 = c4;
 a5 = c5;
 b1 = c6;
 b2 = c7;
 b3 = c8;
 b4 = c9;
 b5 = c10;
}

上面的程式碼挺暴力的,不過沒關係,能夠便於我們分析其彙編原始碼就好,我們用 Borland C++ 編譯後的彙編程式碼如下,編譯完成後的原始碼比較長,這裡我們只拿出來一部分作為分析使用(我們改變了一下段定義順序,刪除了部分註釋)

_DATA segment dword public use32 'DATA'
  align 4
 _a1 label dword
  dd 1
  align 4
 _a2 label dword
  dd 2
  align 4
 _a3 label dword
  dd 3
  align 4
 _a4 label dword
  dd 4
  align 4
 _a5 label dword
  dd 5
_DATA ends

_BSS segment dword public use32 'BSS'
align 4
 _b1 label dword
  db 4 dup(?)
  align 4
 _b2 label dword
  db 4 dup(?)
  align 4
 _b3 label dword
  db 4 dup(?)
  align 4
 _b4 label dword
  db 4 dup(?)
  align 4
 _b5 label dword
  db 4 dup(?)
_BSS ends

_TEXT segment dword public use32 'CODE'
_MyFunc proc near

push      ebp
mov       ebp,esp
add       esp,-20
push      ebx
push      esi
mov       eax,1
mov       edx,2
mov       ecx,3
mov       ebx,4
mov       esi,5
mov       dword ptr [ebp-4],6
mov       dword ptr [ebp-8],7
mov       dword ptr [ebp-12],8
mov       dword ptr [ebp-16],9
mov       dword ptr [ebp-20],10
mov       dword ptr [_a1],eax
mov       dword ptr [_a2],edx
mov       dword ptr [_a3],ecx
mov       dword ptr [_a4],ebx
mov       dword ptr [_a5],esi
mov       eax,dword ptr [ebp-4]
mov       dword ptr [_b1],eax
mov       edx,dword ptr [ebp-8]
mov       dword ptr [_b2],edx
mov       ecx,dword ptr [ebp-12]
mov       dword ptr [_b3],ecx
mov       eax,dword ptr [ebp-16]
mov       dword ptr [_b4],eax
mov       edx,dword ptr [ebp-20]
mov       dword ptr [_b5],edx
pop       esi
pop       ebx
mov       esp,ebp
pop       ebp
ret

_MyFunc   endp
_TEXT ends

編譯後的程式,會被歸類到名為段定義的組。

  • 初始化的全域性變數,會彙總到名為 _DATA 的段定義中
_DATA segment dword public use32 'DATA'
...
_DATA ends
  • 沒有初始化的全域性變數,會彙總到名為 _BSS 的段定義中
_BSS segment dword public use32 'BSS'
...
_BSS ends
  • 被段定義 _TEXT 圍起來的彙編程式碼則是 Borland C++ 的定義
_TEXT segment dword public use32 'CODE'
_MyFunc proc near
...
_MyFunc   endp
_TEXT ends

我們在分析上面彙編程式碼之前,先來認識一下更多的彙編指令,此表是對上面部分操作碼及其功能的接續

操作碼運算元功能
addA,B把A和B的值相加,並把結果賦值給A
callA呼叫函式A
cmpA,B對A和B進行比較,比較結果會自動存入標誌暫存器中
incA對A的值 + 1
ige標籤名和 cmp 命令組合使用。跳轉到標籤行
jl標籤名和 cmp 命令組合使用。跳轉到標籤行
jle標籤名和 cmp 命令組合使用。跳轉到標籤行
jmp標籤名和 cmp 命令組合使用。跳轉到標籤行
movA,B把 B 的值賦給 A
popA從棧中讀取數值並存入A
pushA把A的值存入棧中
ret將處理返回到呼叫源
xorA,BA和B的位進行亦或比較,並將結果存入A中

我們首先來看一下 _DATA 段定義的內容。_a1 label dword 定義了 _a1 這個標籤。

標籤表示的是相對於段定義起始位置的位置。由於_a1 在 _DATA 段定義的開頭位置,所以相對位置是0。_a1 就相當於是全域性變數a1。編譯後的函式名和變數名前面會加一個(_),這也是 Borland C++ 的規定。dd 1 指的是,申請分配了4位元組的記憶體空間,儲存著1這個初始值。dd指的是 define double word表示有兩個長度為2的位元組領域(word),也就是4位元組的意思。

Borland C++ 中,由於int 型別的長度是4位元組,因此彙編器就把 int a1 = 1 變換成了 _a1 label dword 和 dd 1。同樣,這裡也定義了相當於全域性變數的 a2 - a5 的標籤 _a2 - _a5,它們各自的初始值 2 - 5 也被儲存在各自的4位元組中。

接下來,我們來說一說 _BSS 段定義的內容。這裡定義了相當於全域性變數 b1 - b5 的標籤 _b1 - _b5。其中的db 4dup(?) 表示的是申請分配了4位元組的領域,但值尚未確定(這裡用 ? 來表示)的意思。db(define byte) 表示有1個長度是1位元組的記憶體空間。因而,db 4 dup(?) 的情況下,就是4位元組的記憶體空間。

注意:db 4 dup(?) 不要和 dd 4 混淆了,前者表示的是4個長度是1位元組的記憶體空間。而 db 4 表示的則是雙位元組( = 4 位元組) 的記憶體空間中儲存的值是 4

臨時確保區域性變數使用的記憶體空間

我們知道,區域性變數是臨時儲存在暫存器和棧中的。函式內部利用棧進行區域性變數的儲存,函式呼叫完成後,區域性變數值被銷燬,但是暫存器可能用於其他目的。所以,區域性變數只是函式在處理期間臨時儲存在暫存器和棧中的

回想一下上述程式碼是不是定義了10個區域性變數?這是為了表示儲存區域性變數的不僅僅是棧,還有暫存器。為了確保 c1 - c10 所需的域,暫存器空閒的時候就會使用暫存器,暫存器空間不足的時候就會使用棧。

讓我們繼續來分析上面程式碼的內容。_TEXT段定義表示的是 MyFunc 函式的範圍。在 MyFunc 函式中定義的區域性變數所需要的記憶體領域。會被儘可能的分配在暫存器中。大家可能認為使用高效能的暫存器來替代普通的記憶體是一種資源浪費,但是編譯器不這麼認為,只要暫存器有空間,編譯器就會使用它。由於暫存器的訪問速度遠高於記憶體,所以直接訪問暫存器能夠高效的處理。區域性變數使用暫存器,是 Borland C++ 編譯器最最佳化的執行結果。

程式碼清單中的如下內容表示的是向暫存器中分配區域性變數的部分

mov       eax,1
mov       edx,2
mov       ecx,3
mov       ebx,4
mov       esi,5

僅僅對區域性變數進行定義是不夠的,只有在給區域性變數賦值時,才會被分配到暫存器的記憶體區域。上述程式碼相當於就是給5個區域性變數 c1 - c5 分別賦值為 1 - 5。eax、edx、ecx、ebx、esi 是 x86 系列32位 CPU 暫存器的名稱。至於使用哪個暫存器,是由編譯器來決定的 。

x86 系列 CPU 擁有的暫存器中,程式可以操作的是十幾,其中空閒的最多會有幾個。因而,區域性變數超過暫存器數量的時候,可分配的暫存器就不夠用了,這種情況下,編譯器就會把棧派上用場,用來儲存剩餘的區域性變數。

在上述程式碼這一部分,給區域性變數c1 - c5 分配完暫存器後,可用的暫存器數量就不足了。於是,剩下的5個區域性變數c6 - c10 就被分配給了棧的記憶體空間。如下面程式碼所示

mov       dword ptr [ebp-4],6
mov       dword ptr [ebp-8],7
mov       dword ptr [ebp-12],8
mov       dword ptr [ebp-16],9
mov       dword ptr [ebp-20],10

函式入口 add esp,-20 指的是,對棧資料儲存位置的 esp 暫存器(棧指標)的值做減20的處理。為了確保記憶體變數 c6 - c10 在棧中,就需要保留5個 int 型別的區域性變數(4位元組 * 5 = 20 位元組)所需的空間。

mov ebp,esp這行指令表示的意思是將 esp 暫存器的值賦值到 ebp 暫存器。之所以需要這麼處理,是為了透過在函式出口處 mov esp ebp 這一處理,把 esp 暫存器的值還原到原始狀態,從而對申請分配的棧空間進行釋放,這時棧中用到的區域性變數就消失了。這也是棧的清理處理。在使用暫存器的情況下,區域性變數則會在暫存器被用於其他用途時自動消失,如下圖所示。

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!


 mov       dword ptr [ebp-4],6
mov       dword ptr [ebp-8],7
mov       dword ptr [ebp-12],8
mov       dword ptr [ebp-16],9
mov       dword ptr [ebp-20],10

這五行程式碼是往棧空間代入數值的部分,由於在向棧申請記憶體空間前,藉助了 mov ebp, esp這個處理,esp 暫存器的值被儲存到了 esp 暫存器中,因此,透過使用[ebp - 4]、[ebp - 8]、[ebp - 12]、[ebp - 16]、[ebp - 20] 這樣的形式,就可以申請分配20位元組的棧記憶體空間切分成5個長度為4位元組的空間來使用。

例如,mov dword ptr [ebp-4],6 表示的就是,從申請分配的記憶體空間的下端(ebp暫存器指示的位置)開始向前4位元組的地址([ebp - 4]) 中,儲存著6這一4位元組資料。


一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

迴圈控制語句的處理

上面說的都是順序流程,那麼現在就讓我們分析一下迴圈流程的處理,看一下 for 迴圈以及 if 條件分支等 c 語言程式的 流程控制是如何實現的,我們還是以程式碼以及編譯後的結果為例,看一下程式控制流程的處理過程。

// 定義MySub 函式
void MySub(){
 // 不做任何處理
 
}

// 定義MyFunc 函式
void Myfunc(){
 int i;
 for(int i = 0;i < 10;i++){
   // 重複呼叫MySub十次
   MySub();
 }
}

上述程式碼將區域性變數 i 作為迴圈條件,迴圈呼叫十次MySub 函式,下面是它主要的彙編程式碼

		xor 		ebx, ebx 	; 將暫存器清0
@4  call _MySub ; 呼叫MySub函式
inc ebx ; ebx暫存器的值 + 1
cmp ebx,10 ; 將ebx暫存器的值和10進行比較
jl short @4 ; 如果小於10就跳轉到 @4

C 語言中的 for 語句是透過在括號中指定迴圈計數器的初始值(i = 0)、迴圈的繼續條件(i < 10)、迴圈計數器的更新(i++) 這三種形式來進行迴圈處理的。與此相對的彙編程式碼就是透過比較指令(cmp) 和 跳轉指令(jl)來實現的。

下面我們來對上述程式碼進行說明

MyFunc 函式中用到的區域性變數只有 i ,變數 i 申請分配了 ebx 暫存器的記憶體空間。for 語句括號中的 i = 0 被轉換為 xor ebx,ebx 這一處理,xor 指令會對左起第一個運算元和右起第二個運算元進行 XOR 運算,然後把結果儲存在第一個運算元中。

由於這裡把第一個運算元和第二個運算元都指定為了 ebx,因此就變成了對相同數值的  XOR 運算。也就是說不管當前暫存器的值是什麼,最終的結果都是0。類似的,我們使用 mov ebx,0 也能得到相同的結果,但是 xor 指令的處理速度更快,而且編譯器也會啟動最最佳化功能。

XOR 指的就是異或操作,它的運算規則是 如果a、b兩個值不相同,則異或結果為1。如果a、b兩個值相同,異或結果為0

相同數值進行 XOR 運算,運算結果為0。XOR 的運算規則是,值不同時結果為1,值相同時結果為0。例如 01010101 和 01010101 進行運算,就會分別對各個數字位進行 XOR 運算。因為每個數字位都相同,所以運算結果為0。

ebx 暫存器的值初始化後,會透過 call 指定呼叫 _MySub 函式,從 _MySub 函式返回後,會執行inc ebx 指令,對 ebx 的值進行 + 1 操作,這個操作就相當於 i++ 的意思,++ 表示的就是當前數值 + 1。

這裡需要知道 i++ 和 ++i 的區別

i++ 是先賦值,複製完成後再對 i執行 + 1 操作

++i 是先進行 +1 操作,完成後再進行賦值

inc 下一行的 cmp 是用來對第一個運算元和第二個運算元的數值進行比較的指令。cmp ebx,10 就相當於 C 語言中的 i < 10 這一處理,意思是把 ebx 暫存器的值與10進行比較。組合語言中比較指令的結果,會儲存在 CPU 的標誌暫存器中。不過,標誌暫存器的值,程式是無法直接參考的。那如何判斷比較結果呢?

組合語言中有多個跳轉指令,這些跳轉指令會根據標誌暫存器的值來判斷是否進行跳轉操作,例如最後一行的 jl,它會根據 cmp ebx,10 指令所儲存在標誌暫存器中的值來判斷是否跳轉,jl 這條指令表示的就是 jump on less than(小於的話就跳轉)。發現如果 i 比 10 小,就會跳轉到 @4 所在的指令處繼續執行。

那麼彙編程式碼的意思也可以用 C 語言來改寫一下,加深理解

		i ^= i;
L4: MySub();
i++;
if(i < 10) goto L4;

程式碼第一行 i ^= i 指的就是 i 和 i 進行異或運算,也就是 XOR 運算,MySub() 函式用 L4 標籤來替代,然後進行 i 自增操作,如果i 的值小於 10 的話,就會一直迴圈 MySub() 函式。

條件分支的處理方法

條件分支的處理方式和迴圈的處理方式很相似,使用的也是 cmp 指令和跳轉指令。下面是用 C 語言編寫的條件分支的程式碼

// 定義MySub1 函式
void MySub1(){

// 不做任何處理
}

// 定義MySub2 函式
void MySub2(){
 
// 不做任何處理
}

// 定義MySub3 函式
void MySub3(){

// 不做任何處理
}

// 定義MyFunc 函式
void MyFunc(){

int a = 123;
// 根據條件呼叫不同的函式
if(a > 100){
 MySub1();
}
else if(a < 50){
 MySub2();
}
else
{
 MySub3();
}

}

很簡單的一個實現了條件判斷的 C 語言程式碼,那麼我們把它用 Borland C++ 編譯之後的結果如下

_MyFunc proc near
push      ebp
mov       ebp,esp
mov       eax,123 ; 把123存入 eax 暫存器中
cmp       eax,100 ; 把 eax 暫存器的值同100進行比較
jle       short @8 ; 比100小時,跳轉到@8標籤
call      _MySub1 ; 呼叫MySub1函式
jmp short @11 ; 跳轉到@11標籤
@8:
cmp       eax,50 ; 把 eax 暫存器的值同50進行比較
jge       short @10 ; 比50大時,跳轉到@10標籤
call      _MySub2 ; 呼叫MySub2函式
jmp short @11 ; 跳轉到@11標籤
@10:
call      _MySub3 ; 呼叫MySub3函式
@11:
pop       ebp
ret
_MyFunc endp

上面程式碼用到了三種跳轉指令,分別是jle(jump on less or equal) 比較結果小時跳轉,jge(jump on greater or equal) 比較結果大時跳轉,還有不管結果怎樣都會進行跳轉的jmp,在這些跳轉指令之前還有用來比較的指令 cmp,構成了上述彙編程式碼的主要邏輯形式。

瞭解程式執行邏輯的必要性

透過對上述彙編程式碼和 C 語言原始碼進行比較,想必大家對程式的執行方式有了新的理解,而且,從彙編原始碼中獲取的知識,也有助於瞭解 Java 等高階語言的特性,比如 Java 中就有 native 關鍵字修飾的變數,那麼這個變數的底層就是使用 C 語言編寫的,還有一些 Java 中的語法糖只有透過彙編程式碼才能知道其執行邏輯。在某些情況下,對於查詢 bug 的原因也是有幫助的。

上面我們瞭解到的程式設計方式都是序列處理的,那麼序列處理有什麼特點呢?


一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

序列處理最大的一個特點就是專心只做一件事情,一件事情做完之後才會去做另外一件事情。

計算機是支援多執行緒的,多執行緒的核心就是 CPU切換,如下圖所示

一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

我們還是舉個實際的例子,讓我們來看一段程式碼

// 定義全域性變數
int counter = 100;

// 定義MyFunc1()
void MyFunc(){
 counter *= 2;
}

// 定義MyFunc2()
void MyFunc2(){
 counter *= 2;
}

上述程式碼是更新 counter 的值的 C 語言程式,MyFunc1() 和 MyFunc2() 的處理內容都是把 counter 的值擴大至原來的二倍,然後再把 counter 的值賦值給 counter 。這裡,我們假設使用多執行緒處理,同時呼叫了一次MyFunc1 和 MyFunc2 函式,這時,全域性變數 counter 的值,理應程式設計 100 * 2 * 2 = 400。如果你開啟了多個執行緒的話,你會發現 counter 的數值有時也是 200,對於為什麼出現這種情況,如果你不瞭解程式的執行方式,是很難找到原因的。

我們將上面的程式碼轉換成組合語言的程式碼如下

mov eax,dword ptr[_counter] 	; 將 counter 的值讀入 eax 暫存器
add eax,eax ; 將 eax 暫存器的值擴大2倍。
mov dword ptr[_counter],eax ; 將 eax 暫存器的值存入 counter 中。

在多執行緒程式中,用匯編語言表示的程式碼每執行一行,處理都有可能切換到其他執行緒中。因而,假設 MyFun1 函式在讀出 counter 數值100後,還未來得及將它的二倍值200寫入 counter 時,正巧 MyFun2 函式讀出了 counter 的值100,那麼結果就將變為 200 。


一網打盡:Java 程式設計師必須瞭解的計算機底層知識!

為了避免該bug,我們可以採用以函式或 C 語言程式碼的行為單位來禁止執行緒切換的鎖定方法,或者使用某種執行緒安全的方式來避免該問題的出現。

現在基本上沒有人用匯編語言來編寫程式了,因為 C、Java等高階語言的效率要比組合語言快很多。不過,組合語言的經驗還是很重要的,透過藉助組合語言,我們可以更好的瞭解計算機執行機制。

END

文章參考:

佇列/14580481?fr=aladdin

棧/12808149?fr=aladdin

環形緩衝器/22701730?fr=aladdin

《程式是怎樣跑起來的》

組合語言/61826?fr=aladdin

Windows作業系統/852149?fr=aladdin

磁碟

磁碟快取

虛擬記憶體

壓縮演算法/2762648

暫存器/187682?fr=aladdin

記憶體/103614?fr=aladdin

https://blog.csdn.net/mark_lq/article/details/44245423

程式計數器/3219536?fr=aladdin

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69901780/viewspace-2674042/,如需轉載,請註明出處,否則將追究法律責任。

相關文章