本文已收錄GitHub,更有互聯網大廠面試真題,面試攻略,高效學習資料等git
編譯器的任務,是要生成可以在計算機上運行的代碼,但要生成代碼,咱們必須對程序的運行環境和運行機制有比較透徹的瞭解。github
你要知道,大型的、複雜一點兒的系統,好比像淘寶同樣的電商系統、搜索引擎系統等等,都存在一些技術任務,是須要你深刻了解底層機制才能解決的。好比淘寶的基礎技術團隊就曾經貢獻過,Java 虛擬機即時編譯功能中的一個補丁。面試
這反映出掌握底層技術能力的重要性,因此,若是你想進階成爲這個層次的工程師,不能只學學上層的語法,而是要把計算機語言從上層的語法到底層的運行機制都瞭解透徹。緩存
文本我會對計算機程序如何運行,作一個解密,話題分紅兩個部分:網絡
首先,咱們先來了解一下程序運行的環境。ide
程序運行的過程當中,主要是跟兩個硬件(CPU 和內存)以及一個軟件(操做系統)打交道。函數
本質上,咱們的程序只關心 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 和內存以後,咱們再來看看跟程序打交道的操做系統。
程序跟操做系統的關係比較微妙:
一方面咱們的程序能夠編譯成不須要操做系統也能運行,就像一些物聯網應用那樣,徹底跑在裸設備上。
另外一方面,有了操做系統的幫助,能夠爲程序提供便利,好比可使用超過物理內存的存儲空間,操做系統負責進行虛擬內存的管理。
在存在操做系統的狀況下,由於不少進程共享計算機資源,因此就要遵循一些約定。這就彷彿辦公室是全部同事共享的,那麼你們就都要遵照一些約定,若是一我的大聲喧譁,就會影響到其餘人。
程序須要遵照的約定包括:程序文件的二進制格式約定,這樣操做系統才能程序正確地加載進來,併爲同一個程序的多個進程共享代碼區。在使用寄存器和棧的時候也要遵照一些約定,便於操做系統在不一樣的進程之間切換的時候、在作系統調用的時候,作好上下文的保護。
因此,咱們編譯程序的時候,要知道須要遵照哪些約定。由於就算是使用一樣的 CPU,針對不一樣的操做系統,編譯的結果也是很是不一樣的。
好了,咱們瞭解了程序運行時的硬件和操做系統環境。接下來,咱們看看程序運行時,是怎麼跟它們互動的。
你每天運行程序,可對於程序運行的細節,真的清楚嗎?
首先,可運行的程序通常是由操做系統加載到內存的,而且定位到代碼區里程序的入口開始執行。好比,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),這個活動記錄裏有這個函數運行所須要的信息,好比參數、返回值、本地變量等。
目前咱們用棧來管理內存,因此能夠把活動記錄等價於棧楨。棧楨是活動記錄的實現方式,咱們能夠自由設計活動記錄或棧楨的結構,下圖是一個常見的設計:
你能夠看到,每一個棧楨的長度是不同的。
用到的參數和本地變量多,棧楨就要長一點。可是,棧楨的長度和結構是在編譯期就能徹底肯定的。這樣就便於咱們計算地址的偏移量,獲取棧楨裏某個數據。
總的來講,棧楨的設計很自由。可是,你要考慮不一樣語言編譯造成的模塊要可以連接在一塊兒,因此仍是要遵照一些公共的約定的,不然,你寫的函數,別人就沒辦法調用了。
在以前的文章中我提到過棧楨,此次咱們用了更加貼近具體實現的描述:棧楨就是一塊肯定的內存,變量就是這塊內存裏的地址。在下一講,我會帶你動手實現咱們的棧楨。
瞭解了棧楨的實現以後,咱們再來看一個更大的場景,從全局的角度看看整個運行過程當中都發生了什麼。
代碼區裏存儲了一些代碼,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、內存和操做系統打交道的了。並且有了這些背景知識,你也可讓編譯器生成代碼,按照本文所說的模式運行了!