細說:程序運行的環境和運行過程,再看不懂請自行面壁

本文已收錄GitHub,更有互聯網大廠面試真題,面試攻略,高效學習資料等git

編譯器的任務,是要生成可以在計算機上運行的代碼,但要生成代碼,咱們必須對程序的運行環境和運行機制有比較透徹的瞭解。github

你要知道,大型的、複雜一點兒的系統,好比像淘寶同樣的電商系統、搜索引擎系統等等,都存在一些技術任務,是須要你深刻了解底層機制才能解決的。好比淘寶的基礎技術團隊就曾經貢獻過,Java 虛擬機即時編譯功能中的一個補丁。面試

這反映出掌握底層技術能力的重要性,因此,若是你想進階成爲這個層次的工程師,不能只學學上層的語法,而是要把計算機語言從上層的語法到底層的運行機制都瞭解透徹。緩存

文本我會對計算機程序如何運行,作一個解密,話題分紅兩個部分:網絡

  1. 瞭解程序運行的環境,包括 CPU、內存和操做系統,探知它們跟程序到底有什麼關係。
  2. 瞭解程序運行的過程。好比,一個程序是怎麼跑起來的,代碼是怎樣執行和跳轉的,又是如何管理內存的。

首先,咱們先來了解一下程序運行的環境。ide

程序運行的環境

程序運行的過程當中,主要是跟兩個硬件(CPU 和內存)以及一個軟件(操做系統)打交道。函數

細說:程序運行的環境和運行過程,再看不懂請自行面壁

本質上,咱們的程序只關心 CPU 和內存這兩個硬件。你可能說:「不對啊,計算機還有其餘硬件,好比顯示器和硬盤啊。」但對咱們的程序來講,操做這些硬件,也只是執行某些特定的驅動代碼,跟執行其餘代碼並無什麼差別。性能

1. 關注CPU和內存

CPU 的內部有不少組成部分,對於本文來講,咱們重點關注的是寄存器以及高速緩存,它們跟程序的執行機制和優化密切相關。學習

寄存器是 CPU 指令在進行計算的時候,臨時數據存儲的地方。CPU 指令通常都會用到寄存器,好比,典型的一個加法計算(c=a+b)的過程是這樣的:優化

  • 指令 1(mov):從內取 a 的值放到寄存器中;
  • 指令 2(add):再把內存中 b 的值取出來與這個寄存器中的值相加,仍然保存在寄存器中;
  • 指令 3(mov):最後再把寄存器中的數據寫回內存中 c 的地址。

寄存器的速度也很快,因此能用寄存器就別用內存。儘可能充分利用寄存器,是編譯器作優化的內容之一。

而高速緩存能夠彌補 CPU 的處理速度和內存訪問速度之間的差距。因此,咱們的指令在內存讀一個數據的時候,它不是老老實實地只讀進當前指令所須要的數據,而是把跟這個數據相鄰的一組數據都讀進高速緩存了。這就至關於外賣小哥送餐的時候,不會爲每一單來回跑一趟,而是一次取一批,若是這一批外賣剛好都是同一個寫字樓裏的,那小哥的送餐效率就會很高。

內存和高速緩存的速度差別差很少是兩個數量級,也就是一百倍。好比,高速緩存的讀取時間多是 0.5ns,而內存的訪問時間多是 50ns。不一樣硬件的參數可能有差別,但整體來講是幾十倍到上百倍的差別。

你寫程序時,儘可能把某個操做所需的數據都放在內存中的連續區域中,不要零零散散地處處放,這樣有利於充分利用高速緩存。這種優化思路,叫作數據的局部性

這裏提一句,在寫系統級的程序時,你要對各類 IO 的時間有基本的概念,好比高速緩存、內存、磁盤、網絡的 IO 大體都是什麼數量級的。由於這都影響到系統的總體性能,也影響到你如何作程序優化。若是你須要對程序作更多的優化,還須要瞭解更多的 CPU 運行機制,包括流水線機制、並行機制等等,這裏就不展開了。

講完 CPU 以後,還有內存這個硬件。

程序在運行時,操做系統會給它分配一塊虛擬的內存空間,讓它在運行期可使用。咱們目前使用的都是 64 位的機器,你能夠用一個 64 位的長整型來表示內存地址,它可以表示的全部地址,咱們叫作尋址空間。

64 位機器的尋址空間就有 2 的 64 次方那麼大,也就是有不少不少個 T(Terabyte),大到你的程序根本用不完。不過,操做系統通常會給予必定的限制,不會給你這麼大的尋址空間,好比給到 100 來個 G,這對通常的程序,也足夠用了。

在存在操做系統的狀況下,程序邏輯上可以使用的內存通常大於實際的物理內存。程序在使用內存的時候,操做系統會把程序使用的邏輯地址映射到真實的物理內存地址。有的物理內存區域會映射進多個進程的地址空間。

細說:程序運行的環境和運行過程,再看不懂請自行面壁

對於不太經常使用的內存數據,操做系統會寫到磁盤上,以便騰出更多可用的物理內存。

固然,也存在沒有操做系統的狀況,這個時候你的程序所使用的內存就是物理內存,咱們必須本身作好內存的管理。

對於這個內存,該怎麼用呢

本質上來講,你想怎麼用就怎麼用,並無什麼特別的限制。一個編譯器的做者,能夠決定在哪兒放代碼,在哪兒放數據,固然了,別的做者也可能採用其餘的策略。實際上,C 語言和 Java 虛擬機對內存的管理和使用策略就是不一樣的。

儘管如此,大多數語言仍是會採用一些通用的內存管理模式。以 C 語言爲例,會把內存劃分爲代碼區、靜態數據區、棧和堆。

細說:程序運行的環境和運行過程,再看不懂請自行面壁

通常來說,代碼區是在最低的地址區域,而後是靜態數據區,而後是堆。而棧傳統上是從高地址向低地址延伸,棧的最頂部有一塊區域,用來保存環境變量。

代碼區(也叫文本段)存放編譯完成之後的機器碼。這個內存區域是隻讀的,不會再修改,但也不絕對。現代語言的運行時已經愈來愈動態化,除了保存機器碼,還能夠存放中間代碼,而且還能夠在運行時把中間代碼編譯成機器碼,寫入代碼區。

靜態數據區保存程序中全局的變量和常量。它的地址在編譯期就是肯定的,在生成的代碼裏直接使用這個地址就能夠訪問它們,它們的生存期是從程序啓動一直到程序結束。它又能夠細分爲 Data 和 BSS 兩個段。Data 段中的變量是在編譯期就初始化好的,直接從程序裝在進內存。BSS 段中是那些沒有聲明初始化值的變量,都會被初始化成 0。

堆適合管理生存期較長的一些數據,這些數據在退出做用域之後也不會消失。好比,咱們在某個方法裏建立了一個對象並返回,並但願表明這個對象的數據在退出函數後仍然能夠訪問。

而棧適合保存生存期比較短的數據,好比函數和方法裏的本地變量。它們在進入某個做用域的時候申請內存,退出這個做用域的時候就能夠釋放掉。

講完了 CPU 和內存以後,咱們再來看看跟程序打交道的操做系統。

2. 程序和操做系統的關係

程序跟操做系統的關係比較微妙:

一方面咱們的程序能夠編譯成不須要操做系統也能運行,就像一些物聯網應用那樣,徹底跑在裸設備上。
另外一方面,有了操做系統的幫助,能夠爲程序提供便利,好比可使用超過物理內存的存儲空間,操做系統負責進行虛擬內存的管理。

在存在操做系統的狀況下,由於不少進程共享計算機資源,因此就要遵循一些約定。這就彷彿辦公室是全部同事共享的,那麼你們就都要遵照一些約定,若是一我的大聲喧譁,就會影響到其餘人。

程序須要遵照的約定包括:程序文件的二進制格式約定,這樣操做系統才能程序正確地加載進來,併爲同一個程序的多個進程共享代碼區。在使用寄存器和棧的時候也要遵照一些約定,便於操做系統在不一樣的進程之間切換的時候、在作系統調用的時候,作好上下文的保護。

因此,咱們編譯程序的時候,要知道須要遵照哪些約定。由於就算是使用一樣的 CPU,針對不一樣的操做系統,編譯的結果也是很是不一樣的。

好了,咱們瞭解了程序運行時的硬件和操做系統環境。接下來,咱們看看程序運行時,是怎麼跟它們互動的。

程序運行的過程

你每天運行程序,可對於程序運行的細節,真的清楚嗎?

1. 程序運行的細節

首先,可運行的程序通常是由操做系統加載到內存的,而且定位到代碼區里程序的入口開始執行。好比,C 語言的 main 函數的第一行代碼。

每次加載一條代碼,程序都會順序執行,碰到跳轉語句,纔會跳到另外一個地址執行。CPU裏有一個指令寄存器,裏面保存了下一條指令的地址。

細說:程序運行的環境和運行過程,再看不懂請自行面壁

假設咱們運行這樣一段代碼編譯後造成的程序:

int main(){
    int a = 1;
    foo(3);
    bar();
}
int foo(int c){
    int b = 2;
    return b+c;
}
int bar(){
    return foo(4) + 1;
}

咱們首先激活(Activate)main() 函數,main() 函數又激活 foo() 函數,而後又激活 bar()函數,bar() 函數還會激活 foo() 函數,其中 foo() 函數被兩次以不一樣的路徑激活。

細說:程序運行的環境和運行過程,再看不懂請自行面壁

咱們把每次調用一個函數的過程,叫作一次活動(Activation)。每一個活動都對應一個活動記錄(Activation Record),這個活動記錄裏有這個函數運行所須要的信息,好比參數、返回值、本地變量等。

目前咱們用棧來管理內存,因此能夠把活動記錄等價於棧楨。棧楨是活動記錄的實現方式,咱們能夠自由設計活動記錄或棧楨的結構,下圖是一個常見的設計:

細說:程序運行的環境和運行過程,再看不懂請自行面壁

  • 返回值:通常放在最頂上,這樣它的地址是固定的。foo() 函數返回之後,它的調用者能夠到這裏來取到返回值。在實際狀況中,咱們會優先經過寄存器來傳遞返回值,比經過內存傳遞性能更高。
  • 參數:在調用 foo 函數時,把參數寫到這個地址裏。一樣,咱們也能夠經過寄存器來傳遞,而不是內存。
  • 控制連接:就是上一級棧楨的地址。若是用到了上一級做用域中的變量,就能夠順着這個連接找到上一級棧楨,並找到變量的值。
  • 返回地址:foo 函數執行完畢之後,繼續執行哪條指令。一樣,咱們能夠用寄存器來保存這個信息。
  • 本地變量:foo 函數的本地變量 b 的存儲空間。
  • 寄存器信息:咱們還常常在棧楨裏保存寄存器的數據。若是在 foo 函數裏要使用某個寄存器,可能須要先把它的值保存下來,防止破壞了別的代碼保存在這裏的數據。這種約定叫作被調用者責任,也就是使用寄存器的人要保護好寄存器裏原有的信息。某個函數若是使用了某個寄存器,但它又要調用別的函數,爲了防止別的函數把本身放在寄存器中的數據覆蓋掉,要本身保存在棧楨中。這種約定叫作調用者責任

細說:程序運行的環境和運行過程,再看不懂請自行面壁

你能夠看到,每一個棧楨的長度是不同的。

用到的參數和本地變量多,棧楨就要長一點。可是,棧楨的長度和結構是在編譯期就能徹底肯定的。這樣就便於咱們計算地址的偏移量,獲取棧楨裏某個數據。

總的來講,棧楨的設計很自由。可是,你要考慮不一樣語言編譯造成的模塊要可以連接在一塊兒,因此仍是要遵照一些公共的約定的,不然,你寫的函數,別人就沒辦法調用了。

在以前的文章中我提到過棧楨,此次咱們用了更加貼近具體實現的描述:棧楨就是一塊肯定的內存,變量就是這塊內存裏的地址。在下一講,我會帶你動手實現咱們的棧楨。

2.從全局角度看整個運行過程

瞭解了棧楨的實現以後,咱們再來看一個更大的場景,從全局的角度看看整個運行過程當中都發生了什麼。

細說:程序運行的環境和運行過程,再看不懂請自行面壁

代碼區裏存儲了一些代碼,main 函數、bar 函數和 foo 函數各自有一段連續的區域來存儲代碼,我用了一些彙編指令來表示這些代碼(實際運行時這裏實際上是機器碼)。

假設咱們執行到 foo 函數中的一段指令,來計算「b+c」的值,並返回。這裏用到了mov、add、jmp 這三個指令。mov 是把某個值從一個地方拷貝到另外一個地方,add 是往

某個地方加一個值,jmp 是改變代碼執行的順序,跳轉到另外一個地方去執行(彙編命令的細節,咱們下節再講,你如今簡單瞭解一下就好了)。

mov b的地址寄存器1
add c的地址寄存器1
mov寄存器1 foo的返回值地址
jmp返回地址//或ret指令

執行完這幾個指令之後,foo 的返回值位置就寫入了 6,並跳轉到 bar 函數中執行 foo 以後的代碼。

這時,foo 的棧楨就沒用了,新的棧頂是 bar 的棧楨的頂部。理論上講,操做系統這時能夠把 foo 的棧楨所佔的內存收回了。好比,能夠映射到另外一個程序的尋址空間,讓另外一個程序使用。可是在這個例子中你會看到,即便返回了 bar 函數,咱們仍要訪問棧頂以外的一個內存地址,也就是返回值的地址。

因此,目前的調用約定都規定,程序的棧頂以外,仍然會有一小塊內存(好比 128K)是能夠由程序訪問的,好比咱們能夠拿來存儲返回值。這一小段內存操做系統並不會回收。

咱們目前只講了棧,堆的使用也相似,只不過是要手工進行申請和釋放,比棧要多一些維護工做。

總結

本文帶你瞭解了程序運行的環境和過程,咱們的程序主要跟 CPU、內存,以及操做系統打交道。你須要瞭解的重點以下:

  • CPU 上運行程序的指令,運行過程當中要用到寄存器、高速緩存來提升指令和數據的存取效率。
  • 內存能夠劃分紅不一樣的區域保存代碼、靜態數據,並用棧和堆來存放運行時產生的動態數據。
  • 操做系統會把物理的內存映射成進程的尋址空間,同一份代碼會被映射進多個進程的內存空間,操做系統的公共庫也會被映射進進程的內存空間,操做系統還會自動維護棧。
  • 程序在運行時順序執行代碼,能夠根據跳轉指令來跳轉;棧被劃分紅棧楨,棧楨的設計有必定的自由度,但一般也要遵照一些約定;棧楨的大小和結構在編譯時就能決定;在運行時,棧楨做爲活動記錄,不停地被動態建立和釋放。

以上這些內容就是一個程序運行時的祕密。你再面對代碼時,腦海裏就會想象出它是怎樣跟CPU、內存和操做系統打交道的了。並且有了這些背景知識,你也可讓編譯器生成代碼,按照本文所說的模式運行了!

相關文章
相關標籤/搜索