(掌握底層技術(shù)能力的重要性如果你想進階成為這個層次的工程師不能只學(xué)上層的語法而是要把計算機語言從上層的語法到底層的運行機制都了解透徹) https://m.toutiao.com/is/eRynJwa/ 編譯器的任務(wù),是要生成能夠在計算機上運行的代碼,但要生成代碼,我們必須對程序的運行環(huán)境和運行機制有比較透徹的了解。 你要知道,大型的、復(fù)雜一點兒的系統(tǒng),比如像淘寶一樣的電商系統(tǒng)、搜索引擎系統(tǒng)等等,都存在一些技術(shù)任務(wù),是需要你深入了解底層機制才能解決的。比如淘寶的基礎(chǔ)技術(shù)團隊就曾經(jīng)貢獻過,Java 虛擬機即時編譯功能中的一個補丁。 這反映出掌握底層技術(shù)能力的重要性,所以,如果你想進階成為這個層次的工程師,不能只學(xué)學(xué)上層的語法,而是要把計算機語言從上層的語法到底層的運行機制都了解透徹。 文本我會對計算機程序如何運行,做一個解密,話題分成兩個部分:
首先,我們先來了解一下程序運行的環(huán)境。 程序運行的環(huán)境程序運行的過程中,主要是跟兩個硬件(CPU 和內(nèi)存)以及一個軟件(操作系統(tǒng))打交道。 本質(zhì)上,我們的程序只關(guān)心 CPU 和內(nèi)存這兩個硬件。你可能說:“不對啊,計算機還有其他硬件,比如顯示器和硬盤啊?!钡珜ξ覀兊某绦騺碚f,操作這些硬件,也只是執(zhí)行某些特定的驅(qū)動代碼,跟執(zhí)行其他代碼并沒有什么差異。 1. 關(guān)注CPU和內(nèi)存CPU 的內(nèi)部有很多組成部分,對于本文來說,我們重點關(guān)注的是寄存器以及高速緩存,它們跟程序的執(zhí)行機制和優(yōu)化密切相關(guān)。 寄存器是 CPU 指令在進行計算的時候,臨時數(shù)據(jù)存儲的地方。CPU 指令一般都會用到寄存器,比如,典型的一個加法計算(c=a+b)的過程是這樣的:
寄存器的速度也很快,所以能用寄存器就別用內(nèi)存。盡量充分利用寄存器,是編譯器做優(yōu)化的內(nèi)容之一。 而高速緩存可以彌補 CPU 的處理速度和內(nèi)存訪問速度之間的差距。所以,我們的指令在內(nèi)存讀一個數(shù)據(jù)的時候,它不是老老實實地只讀進當(dāng)前指令所需要的數(shù)據(jù),而是把跟這個數(shù)據(jù)相鄰的一組數(shù)據(jù)都讀進高速緩存了。這就相當(dāng)于外賣小哥送餐的時候,不會為每一單來回跑一趟,而是一次取一批,如果這一批外賣恰好都是同一個寫字樓里的,那小哥的送餐效率就會很高。 內(nèi)存和高速緩存的速度差異差不多是兩個數(shù)量級,也就是一百倍。比如,高速緩存的讀取時間可能是 0.5ns,而內(nèi)存的訪問時間可能是 50ns。不同硬件的參數(shù)可能有差異,但總體來說是幾十倍到上百倍的差異。 你寫程序時,盡量把某個操作所需的數(shù)據(jù)都放在內(nèi)存中的連續(xù)區(qū)域中,不要零零散散地到處放,這樣有利于充分利用高速緩存。這種優(yōu)化思路,叫做數(shù)據(jù)的局部性。 這里提一句,在寫系統(tǒng)級的程序時,你要對各種 IO 的時間有基本的概念,比如高速緩存、內(nèi)存、磁盤、網(wǎng)絡(luò)的 IO 大致都是什么數(shù)量級的。因為這都影響到系統(tǒng)的整體性能,也影響到你如何做程序優(yōu)化。如果你需要對程序做更多的優(yōu)化,還需要了解更多的 CPU 運行機制,包括流水線機制、并行機制等等,這里就不展開了。 講完 CPU 之后,還有內(nèi)存這個硬件。 程序在運行時,操作系統(tǒng)會給它分配一塊虛擬的內(nèi)存空間,讓它在運行期可以使用。我們目前使用的都是 64 位的機器,你可以用一個 64 位的長整型來表示內(nèi)存地址,它能夠表示的所有地址,我們叫做尋址空間。 64 位機器的尋址空間就有 2 的 64 次方那么大,也就是有很多很多個 T(Terabyte),大到你的程序根本用不完。不過,操作系統(tǒng)一般會給予一定的限制,不會給你這么大的尋址空間,比如給到 100 來個 G,這對一般的程序,也足夠用了。 在存在操作系統(tǒng)的情況下,程序邏輯上可使用的內(nèi)存一般大于實際的物理內(nèi)存。程序在使用內(nèi)存的時候,操作系統(tǒng)會把程序使用的邏輯地址映射到真實的物理內(nèi)存地址。有的物理內(nèi)存區(qū)域會映射進多個進程的地址空間。 對于不太常用的內(nèi)存數(shù)據(jù),操作系統(tǒng)會寫到磁盤上,以便騰出更多可用的物理內(nèi)存。 當(dāng)然,也存在沒有操作系統(tǒng)的情況,這個時候你的程序所使用的內(nèi)存就是物理內(nèi)存,我們必須自己做好內(nèi)存的管理。 對于這個內(nèi)存,該怎么用呢? 本質(zhì)上來說,你想怎么用就怎么用,并沒有什么特別的限制。一個編譯器的作者,可以決定在哪兒放代碼,在哪兒放數(shù)據(jù),當(dāng)然了,別的作者也可能采用其他的策略。實際上,C 語言和 Java 虛擬機對內(nèi)存的管理和使用策略就是不同的。 盡管如此,大多數(shù)語言還是會采用一些通用的內(nèi)存管理模式。以 C 語言為例,會把內(nèi)存劃分為代碼區(qū)、靜態(tài)數(shù)據(jù)區(qū)、棧和堆。 一般來講,代碼區(qū)是在最低的地址區(qū)域,然后是靜態(tài)數(shù)據(jù)區(qū),然后是堆。而棧傳統(tǒng)上是從高地址向低地址延伸,棧的最頂部有一塊區(qū)域,用來保存環(huán)境變量。 代碼區(qū)(也叫文本段)存放編譯完成以后的機器碼。這個內(nèi)存區(qū)域是只讀的,不會再修改,但也不絕對?,F(xiàn)代語言的運行時已經(jīng)越來越動態(tài)化,除了保存機器碼,還可以存放中間代碼,并且還可以在運行時把中間代碼編譯成機器碼,寫入代碼區(qū)。 靜態(tài)數(shù)據(jù)區(qū)保存程序中全局的變量和常量。它的地址在編譯期就是確定的,在生成的代碼里直接使用這個地址就可以訪問它們,它們的生存期是從程序啟動一直到程序結(jié)束。它又可以細(xì)分為 Data 和 BSS 兩個段。Data 段中的變量是在編譯期就初始化好的,直接從程序裝在進內(nèi)存。BSS 段中是那些沒有聲明初始化值的變量,都會被初始化成 0。 堆適合管理生存期較長的一些數(shù)據(jù),這些數(shù)據(jù)在退出作用域以后也不會消失。比如,我們在某個方法里創(chuàng)建了一個對象并返回,并希望代表這個對象的數(shù)據(jù)在退出函數(shù)后仍然可以訪問。 而棧適合保存生存期比較短的數(shù)據(jù),比如函數(shù)和方法里的本地變量。它們在進入某個作用域的時候申請內(nèi)存,退出這個作用域的時候就可以釋放掉。 講完了 CPU 和內(nèi)存之后,我們再來看看跟程序打交道的操作系統(tǒng)。 2. 程序和操作系統(tǒng)的關(guān)系程序跟操作系統(tǒng)的關(guān)系比較微妙:
在存在操作系統(tǒng)的情況下,因為很多進程共享計算機資源,所以就要遵循一些約定。這就仿佛辦公室是所有同事共享的,那么大家就都要遵守一些約定,如果一個人大聲喧嘩,就會影響到其他人。 程序需要遵守的約定包括:程序文件的二進制格式約定,這樣操作系統(tǒng)才能程序正確地加載進來,并為同一個程序的多個進程共享代碼區(qū)。在使用寄存器和棧的時候也要遵守一些約定,便于操作系統(tǒng)在不同的進程之間切換的時候、在做系統(tǒng)調(diào)用的時候,做好上下文的保護。 所以,我們編譯程序的時候,要知道需要遵守哪些約定。因為就算是使用同樣的 CPU,針對不同的操作系統(tǒng),編譯的結(jié)果也是非常不同的。 好了,我們了解了程序運行時的硬件和操作系統(tǒng)環(huán)境。接下來,我們看看程序運行時,是怎么跟它們互動的。 程序運行的過程你天天運行程序,可對于程序運行的細(xì)節(jié),真的清楚嗎? 1. 程序運行的細(xì)節(jié)首先,可運行的程序一般是由操作系統(tǒng)加載到內(nèi)存的,并且定位到代碼區(qū)里程序的入口開始執(zhí)行。比如,C 語言的 main 函數(shù)的第一行代碼。 每次加載一條代碼,程序都會順序執(zhí)行,碰到跳轉(zhuǎn)語句,才會跳到另一個地址執(zhí)行。CPU里有一個指令寄存器,里面保存了下一條指令的地址。 假設(shè)我們運行這樣一段代碼編譯后形成的程序: 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() 函數(shù),main() 函數(shù)又激活 foo() 函數(shù),然后又激活 bar()函數(shù),bar() 函數(shù)還會激活 foo() 函數(shù),其中 foo() 函數(shù)被兩次以不同的路徑激活。 我們把每次調(diào)用一個函數(shù)的過程,叫做一次活動(Activation)。每個活動都對應(yīng)一個活動記錄(Activation Record),這個活動記錄里有這個函數(shù)運行所需要的信息,比如參數(shù)、返回值、本地變量等。 目前我們用棧來管理內(nèi)存,所以可以把活動記錄等價于棧楨。棧楨是活動記錄的實現(xiàn)方式,我們可以自由設(shè)計活動記錄或棧楨的結(jié)構(gòu),下圖是一個常見的設(shè)計:
你可以看到,每個棧楨的長度是不一樣的。 用到的參數(shù)和本地變量多,棧楨就要長一點。但是,棧楨的長度和結(jié)構(gòu)是在編譯期就能完全確定的。這樣就便于我們計算地址的偏移量,獲取棧楨里某個數(shù)據(jù)。 總的來說,棧楨的設(shè)計很自由。但是,你要考慮不同語言編譯形成的模塊要能夠鏈接在一起,所以還是要遵守一些公共的約定的,否則,你寫的函數(shù),別人就沒辦法調(diào)用了。 在之前的文章中我提到過棧楨,這次我們用了更加貼近具體實現(xiàn)的描述:棧楨就是一塊確定的內(nèi)存,變量就是這塊內(nèi)存里的地址。在下一講,我會帶你動手實現(xiàn)我們的棧楨。 2.從全局角度看整個運行過程了解了棧楨的實現(xiàn)之后,我們再來看一個更大的場景,從全局的角度看看整個運行過程中都發(fā)生了什么。 代碼區(qū)里存儲了一些代碼,main 函數(shù)、bar 函數(shù)和 foo 函數(shù)各自有一段連續(xù)的區(qū)域來存儲代碼,我用了一些匯編指令來表示這些代碼(實際運行時這里其實是機器碼)。 假設(shè)我們執(zhí)行到 foo 函數(shù)中的一段指令,來計算“b+c”的值,并返回。這里用到了mov、add、jmp 這三個指令。mov 是把某個值從一個地方拷貝到另一個地方,add 是往 某個地方加一個值,jmp 是改變代碼執(zhí)行的順序,跳轉(zhuǎn)到另一個地方去執(zhí)行(匯編命令的細(xì)節(jié),我們下節(jié)再講,你現(xiàn)在簡單了解一下就行了)。 mov b的地址寄存器1add c的地址寄存器1mov寄存器1 foo的返回值地址jmp返回地址//或ret指令 執(zhí)行完這幾個指令以后,foo 的返回值位置就寫入了 6,并跳轉(zhuǎn)到 bar 函數(shù)中執(zhí)行 foo 之后的代碼。 這時,foo 的棧楨就沒用了,新的棧頂是 bar 的棧楨的頂部。理論上講,操作系統(tǒng)這時可以把 foo 的棧楨所占的內(nèi)存收回了。比如,可以映射到另一個程序的尋址空間,讓另一個程序使用。但是在這個例子中你會看到,即使返回了 bar 函數(shù),我們?nèi)砸L問棧頂之外的一個內(nèi)存地址,也就是返回值的地址。 所以,目前的調(diào)用約定都規(guī)定,程序的棧頂之外,仍然會有一小塊內(nèi)存(比如 128K)是可以由程序訪問的,比如我們可以拿來存儲返回值。這一小段內(nèi)存操作系統(tǒng)并不會回收。 我們目前只講了棧,堆的使用也類似,只不過是要手工進行申請和釋放,比棧要多一些維護工作。 總結(jié)本文帶你了解了程序運行的環(huán)境和過程,我們的程序主要跟 CPU、內(nèi)存,以及操作系統(tǒng)打交道。你需要了解的重點如下:
以上這些內(nèi)容就是一個程序運行時的秘密。你再面對代碼時,腦海里就會想象出它是怎樣跟CPU、內(nèi)存和操作系統(tǒng)打交道的了。而且有了這些背景知識,你也可以讓編譯器生成代碼,按照本文所說的模式運行了! |
|