深入 CPU 談程序的運行機制

概述

計算機的屬性反映的是人類創造者的本性。
其內部複雜的系統,依賴於底層原理來驅動,很多計算機的原理是相同的,不同的編程語言,複雜的業務邏輯等等,都是在講述同一個故事。
本篇從物理硬件上的 CPU 如何支撐源代碼運行的角度來窺探計算機硬件和軟件的合作關係。

首先明確程序的概念。程序是指令和數據的組合體。例如,C語言“printf ("你好"); ”這個簡單的程序中,printf是指令,"你好"是數據。
你好 和 printf從人的視角是很容易理解的,但計算機只能理解0101 的二進制機器碼。
所以編程代碼是如何運行的呢。

本文分兩層講述:

第一層:概述高級語言如何變成機器碼.

第二層:解釋機器碼如何驅動 CPU 運行。

兩者結合就解釋了程序運行的原理。

第一層: 概述高級語言如何變成機器碼

計算機作爲被造者,必然會朝向對人類越來越友好的軌跡發展,所謂的高級語言是從人類的視角出發,編程更友好。

從最開始通過紙帶有孔無孔來表示 0 和 1。
到後來,彙編語言採用助記符(memonic)來編寫程序,每一個原本是0101的機器語言指令都會有一個與其相應的助記符,助記符通常爲指令功能的英語單詞的簡寫。
而彙編指令與原有的二進制指令幾乎保持着一一對應的關係。
所以彙編語言的產生,但依賴於 0101 機器碼的年代,是一個很大的生產力提升。
但縱使這樣,彙編編程在大型程序中,晦澀難懂,很爲開發維護。因此後續衍生了 C 語言,再後來從面向程序的語言進化到更高級的語言。必然從這種趨勢上看,未來肯定會有更好的語言,將編程的門檻降到更低。
但以上是建立在人類的視角,在計算機的視角,尤其是 CPU 和內存,縱使你千遍萬換,CPU 能直接識別並使用的語言,對應的指令信息只有二進制的機器碼。
所以要想人類編寫的語言能讓 CPU 真正執行,就需要層層剝離對人類友好的抽象,還原本真,變成機器碼。

目前的高級語言可以分爲兩類: 解釋型語言編譯型語言,這兩種分別需要通過解釋器和編譯器變成彙編語言。然後藉助彙編語言與機器碼的映射關係,變爲 CPU 可識別的程序。

所以總結下來,第一層就是從高級語言的源碼層變成 CPU 能識別的機器碼。

第二層: 解釋機器碼如何驅動 CPU 運行

前面講到,程序中包含指令和數據。以上兩個概念,在 CPU 中是怎麼表示的呢。

這裏先對 CPU 的物理結構簡單展開以便於後續原理解釋。

CPU 的物理結構和運行過程

物理結構上看,CPU 和內存都是由一堆具有 ON/OFF 開關功能的晶體管組成的電子器件。
從功能的角度,CPU的內部由寄存器、控制器、運算器、時鐘四個部分構成,各部分之間由電流信號相互連通。

  • 寄存器可用來暫存指令、數據等處理對象,可以將其看作是內存的一種。根據種類的不同,一個CPU內部會有20~100個寄存器。
  • 控制器負責把內存上的指令、數據等讀入寄存器,並根據指令的執行結果來控制整個計算機。
  • 運算器負責運算從內存讀入寄存器的數據。
  • 時鐘負責發出CPU開始計時的時鐘信號。不過,也有些計算機的時鐘位於CPU的外部。
  • CPU 的運行過程大致可以解釋爲:
    指令和數據存儲在內存中,程序啓動後,根據時鐘信號,控制器會從內存中讀取指令和數據。通過對這些指令加以解釋和運行,運算器就會對數據進行運算,控制器根據該運算結果來控制計算機。

接下來就深入到寄存器內部,層層剝開機器碼驅動 CPU 的運行的神祕面紗。

考慮到寫一堆由 01 組成的機器碼對於閱讀和理解過於晦澀。
而彙編語言採用助記符(memonic)來編寫程序,每一個原本是01的機器語言指令都會有一個與其相應的助記符,助記符通常爲指令功能的英語單詞的簡寫。例如,mov和add分別是數據的存儲(move)和相加(addition)的簡寫。彙編語言和機器語言基本上是一一對應的。
我們利用匯編語言與機器碼一一對應的關係來說明機器碼驅動 CPU 運行的原理。

CPU 中的寄存器

寄存器根據功能的不同,分類也不同。
寄存器就是程序運行時,指令和數據的真實物理載荷。

這裏的數據,可以分爲用於運算的數值表示內存地址的數值兩種。
當然,數據類型不同,存儲該數值的計數器也不同。
用於運算的數值放在累加寄存器中,表示內存地址的數值則放在基址寄存器和變址寄存器中。(所以值類型和引用類型在寄存器的存儲方式就有區別)

所以綜上能看出,CPU是具有各種功能的寄存器的集合體。其中,程序計數器、累加寄存器、標誌寄存器、指令寄存器和棧寄存器都只有一個,其他的寄存器一般有多個。

寄存器的運作原理

程序計數器

程序運行啓動後,操作系統會將硬盤中保存的程序複製到內存中,讓 CPU 根據指令和數據信息來執行。
程序計數器也叫做指令計數器,是用於存放下一條指令所在單元的地址的地方。
當執行一條指令時,首先需要根據程序計數器中存放的指令地址,將指令由內存取到指令寄存器中,此過程稱爲取指令。與此同時,PC中的地址或自動加1或由轉移指針給出下一條指令的地址。此後經過分析指令,執行指令。完成第一條指令的執行,而後根據程序計數器取出第二條指令的地址,如此循環,執行每一條指令。

爲保證程序能連續自動執行下去,CPU 必須具有某些手段來確定下一條指令的地址,程序計數器就提供了物理基礎。在程序開始執行前,必須將它的起始地址,即程序的第一條指令所在的內存單元地址送入程序計數器,因此程序計數器的內容即是從內存提取的一條指令的地址。當執行指令時,CPU 將自動修改程序計數器的內容,即每執行一條指令程序計數器增加一個量,這個量等於指令所含的字節數,以便使其保持的總是將要執行的下一條指令的地址。由於大多數指令都是按順序來執行的,所以修改的過程通常只是簡單的對程序計數器加1。
但是,當遇到轉移指令如JMP(跳轉、外語全稱:JUMP)指令時,後繼指令的地址(即PC的內容)必須從指令寄存器中的地址字段取得。在這種情況下,下一條從內存取出的指令將由轉移指令來規定,而不像通常一樣按順序來取得。因此程序計數器的結構應當是具有寄存信息和計數兩種功能的結構。

根據順序,選擇和循環不同方式,往程序計數器中送入不同的指令。

函數調用機制和函數調用堆棧

函數調用處理也是通過把程序計數器的值設定成函數的存儲地址來實現的。不過,這和條件分支、循環的機制有所不同,函數的調用過程更爲複雜,尤其對於嵌套函數的調用,單純的跳轉指令無法實現函數的調用。 函數的調用需要在完成函數內部的處理後,處理流程再返回到函數調用點(函數調用指令的下一個地址)。因此,如果只是跳轉到函數的入口地址,處理流程就不知道應該返回至哪裏了.

具體用一段程序講解

其流程是:

int a = 123;
int b = 456;

c = MyFunc(a, b);

d = Nextfunc(a, b);

int Myfunc(int a, int b) {
    if a > b {
        return a - b;
    } else {
        return a + b
    }
}

int Nextfunc(int a, int b) {
    return a - b;
}

除了程序計數器來保證指令的調用之外,對於函數調用內部的函數,需要通過棧寄存器來記錄並保存返回值。

機器語言的call指令和return指令能夠解決這個問題。建議大家把二者結合起來來記憶。函數調用使用的是call指令,而不是跳轉指令。在將函數的入口地址設定到程序計數器之前,call指令會把調用函數後要執行的指令地址存儲在名爲棧的主存內(此代碼中代碼的是 Nextfunc的指令)。函數處理完畢後,再通過函數的出口來執行return命令。return命令的功能是把保存在棧中的地址(Nextfunc的指令)設定到程序計數器中,繼續執行。

簡單來說,函數的調用會轉化爲 call 指令,將該函數的調用地址設定到程序計數器中;
函數結束會轉換成 return 指令,將返回目的地的地址(下一條指令的地址)設定在程序計數器上,這樣程序就可以流暢運行了。

至於棧寄存器的運行原理這裏不作展開。

彙編語言和機器語言的種類

通過 CPU 的描述不難懂得,其實 CPU 依賴的硬件是有限的。所以任何複雜的邏輯,翻譯成對應的機器之類,也就幾種。

類型 功能
數據轉送指令 寄存器和內存,內存和內存,寄存器和外圍設備之間的讀寫操作
運算指令 用累加寄存器執行算術運算,邏輯運算,比較運算和移位運算
跳轉指令 實現條件分治,循環,強制跳轉等
call/return 指令 函數的調用/返回函數的地址

總結

本文主要希望通過對程序的運行機制有一個整體宏觀的描述能讓大家更充分的理解編程,讓抽象的世界不再那麼晦澀難懂,給你恍然大悟的感覺。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章