對不起,學會這些知識後我飄了

  • 2020 年 2 月 13 日
  • 筆記

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

CPU

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

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

CPU 內部處理過程

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

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

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

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

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

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

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

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

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

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

種類

功能

累加暫存器

存儲運行的數據和運算後的數據。

標誌暫存器

用於反應處理器的狀態和運算結果的某些特徵以及控制指令的執行。

程式計數器

程式計數器是用於存放下一條指令所在單元的地址的地方。

基址暫存器

存儲數據記憶體的起始位置

變址暫存器

存儲基址暫存器的相對地址

通用暫存器

存儲任意數據

指令暫存器

儲存正在被運行的指令,CPU內部使用,程式設計師無法對該暫存器進行讀寫

棧暫存器

存儲棧區域的起始位置

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

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

程式計數器

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

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

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

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

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

條件分支和循環機制

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

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

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

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

標誌暫存器

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

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

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

函數調用機制

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

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

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

在一些高級語言的條件或者循環語句中,函數調用的處理會轉換成 call 指令,函數結束後的處理則會轉換成 return 指令。

通過地址和索引實現數組

接下來我們看一下基址暫存器和變址暫存器,通過這兩個暫存器,我們可以對主存上的特定區域進行劃分,來實現類似數組的操作,首先,我們用十六進位數將電腦記憶體上的 00000000 – FFFFFFFF 的地址劃分出來。那麼,凡是該範圍的記憶體地址,只要有一個 32 位的暫存器,便可查看全部地址。但如果想要想數組那樣分割特定的記憶體區域以達到連續查看的目的的話,使用兩個暫存器會更加方便。

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

這種表示方式很類似數組的構造,數組是指同樣長度的數據在記憶體中進行連續排列的數據構造。用數組名表示數組全部的值,通過索引來區分數組的各個數據元素,例如: 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 引腳示意圖

圖中 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 進行數據寫入和讀取的模型

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

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

記憶體的現實模型

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

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

二進位

我們都知道,電腦的底層都是使用二進位數據進行數據流傳輸的,那麼為什麼會使用二進位表示電腦呢?或者說,什麼是二進位數呢?在拓展一步,如何使用二進位進行加減乘除?下面就來看一下

什麼是二進位數

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

也就是說,二進位數代表的 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 進位即可。二進位數的運算,也是電腦程式所特有的運算,因此了解二進位的運算是必須要掌握的。

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

補數

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

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

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

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

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

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

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

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

我們可以看到 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 右移兩位,來各自看一下移位示意圖

如上圖所示,在邏輯右移的情況下, -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 是需要通過程式計數器來指定記憶體地址從而讀出程式指令的。

磁碟構造

磁碟快取

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

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

虛擬記憶體

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

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

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

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

虛擬記憶體的方法有分頁式分段式 兩種。Windows 採用的是分頁式。該方式是指在不考慮程式構造的情況下,把運行的程式按照一定大小的頁進行分割,並以為單位進行置換。在分頁式中,我們把磁碟的內容讀到記憶體中稱為 Page In,把記憶體的內容寫入磁碟稱為 Page Out。Windows 電腦的頁大小為 4KB ,也就是說,需要把應用程式按照 4KB 的頁來進行切分,以頁(page)為單位放到磁碟中,然後進行置換。

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

磁碟的物理結構

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

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

扇區是對磁碟進行物理讀寫的最小單位。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 。如果文件中存儲的數據是文字,那麼該文件就是文本文件。如果是圖形,那麼該文件就是影像文件。在任何情況下,文件中的位元組數都是連續存儲的。

壓縮演算法的定義

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

壓縮演算法(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 位元組。如圖

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

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

從圖中我們可以看出,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位為一個位元組來保存文件的。

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

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

莫爾斯編碼一般把文本中出現最高頻率的字元用短編碼 來表示。如表所示,假如表示短點的位是 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 這種文本來說並不是效率最高的。

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

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

字元

出現頻率

編碼(方案)

位數

A

6

0

1

E

5

1

1

B

2

10

2

D

2

11

2

C

1

100

3

F

1

101

3

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

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

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

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

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

接下來我們來看一下哈夫曼樹的壓縮比率,用上圖得到的數據表示 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 演算法和哈夫曼演算法,因為影像文件在多數情況下並不要求數據需要還原到和壓縮之前一摸一樣的狀態,允許丟失一部分數據。我們把能還原到壓縮前狀態的壓縮稱為 可逆壓縮,無法還原到壓縮前狀態的壓縮稱為非可逆壓縮

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

作業系統

作業系統環境

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

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

圖中的主要配置如下

  • 作業系統版本:說的就是應用程式運行在何種系統環境,現在市面上主要有三種作業系統環境,Windows 、Linux 和 Unix ,一般我們玩兒的大型遊戲幾乎都是在 Windows 上運行,可以說 Windows 是遊戲的天堂。Windows 作業系統也會有區分,分為32位作業系統和64位作業系統,互不兼容。
  • 處理器:處理器指的就是 CPU,你的電腦的計算能力,通俗來講就是每秒鐘能處理的指令數,如果你的電腦覺得卡帶不起來的話,很可能就是 CPU 的計算能力不足導致的。想要加深理解,請閱讀部落客的另一篇文章:程式設計師需要了解的硬核知識之CPU
  • 顯示卡:顯示卡承擔圖形的輸出任務,因此又被稱為圖形處理器(Graphic Processing Unit,GPU),顯示卡也非常重要,比如我之前玩兒的劍靈開五檔(其實就是影像變得更清晰)會卡,其實就是顯示卡顯示不出來的原因。
  • 記憶體:記憶體即主存,就是你的應用程式在運行時能夠動態分析指令的這部分存儲空間,它的大小也能決定你電腦的運行速度,想要加深理解,請閱讀部落客的另一篇文章 程式設計師需要了解的硬核知識之記憶體
  • 存儲空間:存儲空間指的就是應用程式安裝所佔用的磁碟空間,由圖中可知,此遊戲的最低存儲空間必須要大於 5GB,其實我們都會遺留很大一部分用來安裝遊戲。

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

CPU 只能解釋其自身固有的語言。不同的 CPU 能解釋的機器語言的種類也是不同的。機器語言的程式稱為 本地程式碼(native code),程式設計師用 C 等高級語言編寫的程式,僅僅是文本文件。文本文件(排除文字編碼的問題)在任何環境下都能顯示和編輯。我們稱之為源程式碼。通過對源程式碼進行編譯,就可以得到本地程式碼。下圖反映了這個過程。

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

電腦的硬體並不僅僅是由 CPU 組成的,還包括用於存儲程式指令的數據和記憶體,以及通過 I/O 連接的鍵盤、顯示器、硬碟、印表機等外圍設備。

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

不同作業系統的 API 差異性

接下來我們看一下作業系統的種類。同樣機型的電腦,可安裝的作業系統類型也會有多種選擇。例如:AT 兼容機除了可以安裝 Windows 之外,還可以採用 Unix 系列的 Linux 以及 FreeBSD (也是一種Unix作業系統)等多個作業系統。當然,應用軟體則必須根據不同的作業系統類型來專門開發。CPU 的類型不同,所對應機器的語言也不同,同樣的道理,作業系統的類型不同,應用程式向作業系統傳遞指令的途徑也不同

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

鍵盤輸入、滑鼠輸入、顯示器輸出、文件輸入和輸出等同外圍設備進行交互的功能,都是通過 API 提供的。

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

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

作業系統功能的歷史

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

在電腦尚不存在作業系統的年代,完全沒有任何程式,人們通過各種按鈕來控制電腦,這一過程非常麻煩。於是,有人開發出了僅具有載入和運行功能的監控程式,這就是作業系統的原型。通過事先啟動監控程式,程式設計師可以根據需要將各種程式載入到記憶體中運行。雖然仍舊比較麻煩,但比起在沒有任何程式的狀態下進行開發,工作量得到了很大的緩解。

隨著時代的發展,人們在利用監控程式編寫程式的過程中發現很多程式都有公共的部分。例如,通過鍵盤進行文字輸入,顯示器進行數據展示等,如果每編寫一個新的應用程式都需要相同的處理的話,那真是太浪費時間了。因此,基本的輸入輸出部分的程式就被追加到了監控程式中。初期的作業系統就是這樣誕生了。

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

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 時間切片,這也是多執行緒多任務的核心。

CPU分片,也是時鐘分割

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

Windows 中,網路功能是作為標準功能提供的。資料庫(資料庫伺服器)功能有時也會在後面追加。網路功能和資料庫功能雖然並不是作業系統不可或缺的,但因為它們和作業系統很接近,所以被統稱為中間件而不是應用。意思是處於作業系統和應用的中間層,作業系統和中間件組合在一起,稱為系統軟體。應用不僅可以利用作業系統,也可以利用中間件的功能。

應用可以使用作業系統和中間件

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

通過即插即用實現設備驅動的自動設定

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

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

有時 DLL 文件也會同設備驅動文件一起安裝。這些 DLL 文件中存儲著用來利用該新追加的硬體API,通過 API ,可以製作出運行該硬體的心應用。

彙編語言和本地程式碼

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

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

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

用彙編語言編寫的源程式碼和本地程式碼是一一對應的。因而,本地程式碼也可以反過來轉換成彙編語言編寫的程式碼。把本地程式碼轉換為彙編程式碼的這一過程稱為反彙編,執行反彙編的程式稱為反彙編程式

本地程式碼和彙編語言一對一的轉換

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

通過編譯器輸出彙編語言的源程式碼

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

首先需要先做一些準備,需要先下載 Borland C++ 5.5 編譯器,為了方便,我這邊直接下載好了讀者直接從我的百度網盤提取即可 (鏈接:https://pan.baidu.com/s/19LqVICpn5GcV88thD2AnlA 密碼:hz1u)

下載完畢,需要進行配置,下面是配置說明 (https://wenku.baidu.com/view/22e2f418650e52ea551898ad.html),教程很完整跟著配置就可以,下面開始我們的編譯過程

首先用 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  

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

上面程式碼的開始位置,定義了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 的種類決定的,下面對操作碼的功能進行了整理。

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

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

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

指令解析

下面就對 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)的特性是後入先出,數據在存儲時是從記憶體的下層(大的地址編號)逐漸往上層(小的地址編號)累積,讀出時則是按照從上往下進行讀取的。

棧是存儲臨時數據的區域,它的特點是通過 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 文件時,出現了下圖的這條消息

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

下圖是調用 AddNum 這一函數前後棧記憶體的變化

函數的內部處理

上面我們用彙編程式碼分析了一下 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 函數入口和出口處棧的狀態變化,就如下圖所示

全局變數和局部變數

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

下面定義的 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  

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

操作碼

操作數

功能

add

A,B

把A和B的值相加,並把結果賦值給A

call

A

調用函數A

cmp

A,B

對A和B進行比較,比較結果會自動存入標誌暫存器中

inc

A

對A的值 + 1

ige

標籤名

和 cmp 命令組合使用。跳轉到標籤行

jl

標籤名

和 cmp 命令組合使用。跳轉到標籤行

jle

標籤名

和 cmp 命令組合使用。跳轉到標籤行

jmp

標籤名

和 cmp 命令組合使用。跳轉到標籤行

mov

A,B

把 B 的值賦給 A

pop

A

從棧中讀取數值並存入A

push

A

把A的值存入棧中

ret

將處理返回到調用源

xor

A,B

A和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 暫存器的值還原到原始狀態,從而對申請分配的棧空間進行釋放,這時棧中用到的局部變數就消失了。這也是棧的清理處理。在使用暫存器的情況下,局部變數則會在暫存器被用於其他用途時自動消失,如下圖所示。

 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位元組數據。

循環控制語句的處理

上面說的都是順序流程,那麼現在就讓我們分析一下循環流程的處理,看一下 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 的原因也是有幫助的。

上面我們了解到的編程方式都是串列處理的,那麼串列處理有什麼特點呢?

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

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

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

// 定義全局變數  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 。

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

現在基本上沒有人用彙編語言來編寫程式了,因為 C、Java等高級語言的效率要比彙編語言快很多。不過,彙編語言的經驗還是很重要的,通過藉助彙編語言,我們可以更好的了解電腦運行機制。

文章參考

https://www.computerhope.com/jargon/m/memory.htm

https://baike.baidu.com/item/隊列/14580481?fr=aladdin

https://baike.baidu.com/item/棧/12808149?fr=aladdin

https://baike.baidu.com/item/環形緩衝器/22701730?fr=aladdin

《程式是怎樣跑起來的》

https://baike.baidu.com/item/彙編語言/61826?fr=aladdin

https://baike.baidu.com/item/Windows作業系統/852149?fr=aladdin

磁碟

磁碟快取

虛擬記憶體

https://baike.baidu.com/item/壓縮演算法/2762648

https://en.wikipedia.org/wiki/Central_processing_unit

https://www.digitaltrends.com/computing/what-is-a-cpu/

https://baike.baidu.com/item/暫存器/187682?fr=aladdin

https://baike.baidu.com/item/記憶體/103614?fr=aladdin

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

https://baike.baidu.com/item/程式計數器/3219536?fr=aladdin

https://zhidao.baidu.com/question/124425422.html