本文已收錄GitHub混埠,更有互聯(lián)網(wǎng)大廠面試真題,面試攻略诗轻,高效學(xué)習(xí)資料等
編譯器的任務(wù)钳宪,是要生成能夠在計(jì)算機(jī)上運(yùn)行的代碼,但要生成代碼扳炬,我們必須對程序的運(yùn)行環(huán)境和運(yùn)行機(jī)制有比較透徹的了解吏颖。
你要知道,大型的恨樟、復(fù)雜一點(diǎn)兒的系統(tǒng)半醉,比如像淘寶一樣的電商系統(tǒng)、搜索引擎系統(tǒng)等等劝术,都存在一些技術(shù)任務(wù)缩多,是需要你深入了解底層機(jī)制才能解決的。比如淘寶的基礎(chǔ)技術(shù)團(tuán)隊(duì)就曾經(jīng)貢獻(xiàn)過养晋,Java 虛擬機(jī)即時(shí)編譯功能中的一個(gè)補(bǔ)丁衬吆。
這反映出掌握底層技術(shù)能力的重要性,所以匙握,如果你想進(jìn)階成為這個(gè)層次的工程師咆槽,不能只學(xué)學(xué)上層的語法,而是要把計(jì)算機(jī)語言從上層的語法到底層的運(yùn)行機(jī)制都了解透徹圈纺。
文本我會(huì)對計(jì)算機(jī)程序如何運(yùn)行秦忿,做一個(gè)解密,話題分成兩個(gè)部分:
- 了解程序運(yùn)行的環(huán)境蛾娶,包括 CPU灯谣、內(nèi)存和操作系統(tǒng),探知它們跟程序到底有什么關(guān)系蛔琅。
- 了解程序運(yùn)行的過程胎许。比如,一個(gè)程序是怎么跑起來的,代碼是怎樣執(zhí)行和跳轉(zhuǎn)的辜窑,又是如何管理內(nèi)存的钩述。
首先,我們先來了解一下程序運(yùn)行的環(huán)境穆碎。
程序運(yùn)行的環(huán)境
程序運(yùn)行的過程中牙勘,主要是跟兩個(gè)硬件(CPU 和內(nèi)存)以及一個(gè)軟件(操作系統(tǒng))打交道。
本質(zhì)上所禀,我們的程序只關(guān)心 CPU 和內(nèi)存這兩個(gè)硬件方面。你可能說:“不對啊,計(jì)算機(jī)還有其他硬件色徘,比如顯示器和硬盤啊恭金。”但對我們的程序來說褂策,操作這些硬件横腿,也只是執(zhí)行某些特定的驅(qū)動(dòng)代碼,跟執(zhí)行其他代碼并沒有什么差異辙培。
1. 關(guān)注CPU和內(nèi)存
CPU 的內(nèi)部有很多組成部分蔑水,對于本文來說,我們重點(diǎn)關(guān)注的是寄存器以及高速緩存扬蕊,它們跟程序的執(zhí)行機(jī)制和優(yōu)化密切相關(guān)搀别。
寄存器是 CPU 指令在進(jìn)行計(jì)算的時(shí)候,臨時(shí)數(shù)據(jù)存儲(chǔ)的地方尾抑。CPU 指令一般都會(huì)用到寄存器歇父,比如,典型的一個(gè)加法計(jì)算(c=a+b)的過程是這樣的:
- 指令 1(mov):從內(nèi)取 a 的值放到寄存器中再愈;
- 指令 2(add):再把內(nèi)存中 b 的值取出來與這個(gè)寄存器中的值相加榜苫,仍然保存在寄存器中;
- 指令 3(mov):最后再把寄存器中的數(shù)據(jù)寫回內(nèi)存中 c 的地址翎冲。
寄存器的速度也很快垂睬,所以能用寄存器就別用內(nèi)存。盡量充分利用寄存器抗悍,是編譯器做優(yōu)化的內(nèi)容之一驹饺。
而高速緩存可以彌補(bǔ) CPU 的處理速度和內(nèi)存訪問速度之間的差距。所以缴渊,我們的指令在內(nèi)存讀一個(gè)數(shù)據(jù)的時(shí)候赏壹,它不是老老實(shí)實(shí)地只讀進(jìn)當(dāng)前指令所需要的數(shù)據(jù),而是把跟這個(gè)數(shù)據(jù)相鄰的一組數(shù)據(jù)都讀進(jìn)高速緩存了衔沼。這就相當(dāng)于外賣小哥送餐的時(shí)候蝌借,不會(huì)為每一單來回跑一趟昔瞧,而是一次取一批,如果這一批外賣恰好都是同一個(gè)寫字樓里的菩佑,那小哥的送餐效率就會(huì)很高自晰。
內(nèi)存和高速緩存的速度差異差不多是兩個(gè)數(shù)量級(jí),也就是一百倍擎鸠。比如缀磕,高速緩存的讀取時(shí)間可能是 0.5ns缘圈,而內(nèi)存的訪問時(shí)間可能是 50ns劣光。不同硬件的參數(shù)可能有差異,但總體來說是幾十倍到上百倍的差異糟把。
你寫程序時(shí)绢涡,盡量把某個(gè)操作所需的數(shù)據(jù)都放在內(nèi)存中的連續(xù)區(qū)域中,不要零零散散地到處放遣疯,這樣有利于充分利用高速緩存雄可。這種優(yōu)化思路,叫做數(shù)據(jù)的局部性缠犀。
這里提一句数苫,在寫系統(tǒng)級(jí)的程序時(shí),你要對各種 IO 的時(shí)間有基本的概念辨液,比如高速緩存虐急、內(nèi)存、磁盤滔迈、網(wǎng)絡(luò)的 IO 大致都是什么數(shù)量級(jí)的止吁。因?yàn)檫@都影響到系統(tǒng)的整體性能,也影響到你如何做程序優(yōu)化燎悍。如果你需要對程序做更多的優(yōu)化敬惦,還需要了解更多的 CPU 運(yùn)行機(jī)制,包括流水線機(jī)制谈山、并行機(jī)制等等俄删,這里就不展開了。
講完 CPU 之后奏路,還有內(nèi)存這個(gè)硬件畴椰。
程序在運(yùn)行時(shí),操作系統(tǒng)會(huì)給它分配一塊虛擬的內(nèi)存空間思劳,讓它在運(yùn)行期可以使用迅矛。我們目前使用的都是 64 位的機(jī)器,你可以用一個(gè) 64 位的長整型來表示內(nèi)存地址潜叛,它能夠表示的所有地址秽褒,我們叫做尋址空間壶硅。
64 位機(jī)器的尋址空間就有 2 的 64 次方那么大,也就是有很多很多個(gè) T(Terabyte)销斟,大到你的程序根本用不完庐椒。不過,操作系統(tǒng)一般會(huì)給予一定的限制蚂踊,不會(huì)給你這么大的尋址空間约谈,比如給到 100 來個(gè) G,這對一般的程序犁钟,也足夠用了棱诱。
在存在操作系統(tǒng)的情況下,程序邏輯上可使用的內(nèi)存一般大于實(shí)際的物理內(nèi)存涝动。程序在使用內(nèi)存的時(shí)候迈勋,操作系統(tǒng)會(huì)把程序使用的邏輯地址映射到真實(shí)的物理內(nèi)存地址。有的物理內(nèi)存區(qū)域會(huì)映射進(jìn)多個(gè)進(jìn)程的地址空間醋粟。
對于不太常用的內(nèi)存數(shù)據(jù)靡菇,操作系統(tǒng)會(huì)寫到磁盤上,以便騰出更多可用的物理內(nèi)存米愿。
當(dāng)然厦凤,也存在沒有操作系統(tǒng)的情況,這個(gè)時(shí)候你的程序所使用的內(nèi)存就是物理內(nèi)存育苟,我們必須自己做好內(nèi)存的管理较鼓。
對于這個(gè)內(nèi)存,該怎么用呢宙搬?
本質(zhì)上來說笨腥,你想怎么用就怎么用,并沒有什么特別的限制勇垛。一個(gè)編譯器的作者脖母,可以決定在哪兒放代碼,在哪兒放數(shù)據(jù)闲孤,當(dāng)然了谆级,別的作者也可能采用其他的策略。實(shí)際上讼积,C 語言和 Java 虛擬機(jī)對內(nèi)存的管理和使用策略就是不同的肥照。
盡管如此,大多數(shù)語言還是會(huì)采用一些通用的內(nèi)存管理模式勤众。以 C 語言為例舆绎,會(huì)把內(nèi)存劃分為代碼區(qū)、靜態(tài)數(shù)據(jù)區(qū)们颜、棧和堆吕朵。
一般來講猎醇,代碼區(qū)是在最低的地址區(qū)域,然后是靜態(tài)數(shù)據(jù)區(qū)努溃,然后是堆硫嘶。而棧傳統(tǒng)上是從高地址向低地址延伸,棧的最頂部有一塊區(qū)域梧税,用來保存環(huán)境變量沦疾。
代碼區(qū)(也叫文本段)存放編譯完成以后的機(jī)器碼。這個(gè)內(nèi)存區(qū)域是只讀的第队,不會(huì)再修改哮塞,但也不絕對。現(xiàn)代語言的運(yùn)行時(shí)已經(jīng)越來越動(dòng)態(tài)化斥铺,除了保存機(jī)器碼彻桃,還可以存放中間代碼,并且還可以在運(yùn)行時(shí)把中間代碼編譯成機(jī)器碼晾蜘,寫入代碼區(qū)。
靜態(tài)數(shù)據(jù)區(qū)保存程序中全局的變量和常量眠屎。它的地址在編譯期就是確定的剔交,在生成的代碼里直接使用這個(gè)地址就可以訪問它們,它們的生存期是從程序啟動(dòng)一直到程序結(jié)束改衩。它又可以細(xì)分為 Data 和 BSS 兩個(gè)段岖常。Data 段中的變量是在編譯期就初始化好的,直接從程序裝在進(jìn)內(nèi)存葫督。BSS 段中是那些沒有聲明初始化值的變量竭鞍,都會(huì)被初始化成 0。
堆適合管理生存期較長的一些數(shù)據(jù)橄镜,這些數(shù)據(jù)在退出作用域以后也不會(huì)消失偎快。比如,我們在某個(gè)方法里創(chuàng)建了一個(gè)對象并返回洽胶,并希望代表這個(gè)對象的數(shù)據(jù)在退出函數(shù)后仍然可以訪問晒夹。
而棧適合保存生存期比較短的數(shù)據(jù),比如函數(shù)和方法里的本地變量姊氓。它們在進(jìn)入某個(gè)作用域的時(shí)候申請內(nèi)存丐怯,退出這個(gè)作用域的時(shí)候就可以釋放掉。
講完了 CPU 和內(nèi)存之后翔横,我們再來看看跟程序打交道的操作系統(tǒng)读跷。
2. 程序和操作系統(tǒng)的關(guān)系
程序跟操作系統(tǒng)的關(guān)系比較微妙:
一方面我們的程序可以編譯成不需要操作系統(tǒng)也能運(yùn)行,就像一些物聯(lián)網(wǎng)應(yīng)用那樣禾唁,完全跑在裸設(shè)備上效览。
另一方面些膨,有了操作系統(tǒng)的幫助,可以為程序提供便利钦铺,比如可以使用超過物理內(nèi)存的存儲(chǔ)空間订雾,操作系統(tǒng)負(fù)責(zé)進(jìn)行虛擬內(nèi)存的管理。
在存在操作系統(tǒng)的情況下矛洞,因?yàn)楹芏噙M(jìn)程共享計(jì)算機(jī)資源洼哎,所以就要遵循一些約定。這就仿佛辦公室是所有同事共享的沼本,那么大家就都要遵守一些約定噩峦,如果一個(gè)人大聲喧嘩,就會(huì)影響到其他人抽兆。
程序需要遵守的約定包括:程序文件的二進(jìn)制格式約定识补,這樣操作系統(tǒng)才能程序正確地加載進(jìn)來,并為同一個(gè)程序的多個(gè)進(jìn)程共享代碼區(qū)辫红。在使用寄存器和棧的時(shí)候也要遵守一些約定凭涂,便于操作系統(tǒng)在不同的進(jìn)程之間切換的時(shí)候、在做系統(tǒng)調(diào)用的時(shí)候贴妻,做好上下文的保護(hù)切油。
所以,我們編譯程序的時(shí)候名惩,要知道需要遵守哪些約定澎胡。因?yàn)榫退闶鞘褂猛瑯拥?CPU,針對不同的操作系統(tǒng)娩鹉,編譯的結(jié)果也是非常不同的攻谁。
好了,我們了解了程序運(yùn)行時(shí)的硬件和操作系統(tǒng)環(huán)境弯予。接下來戚宦,我們看看程序運(yùn)行時(shí),是怎么跟它們互動(dòng)的熙涤。
程序運(yùn)行的過程
你天天運(yùn)行程序阁苞,可對于程序運(yùn)行的細(xì)節(jié),真的清楚嗎祠挫?
1. 程序運(yùn)行的細(xì)節(jié)
首先那槽,可運(yùn)行的程序一般是由操作系統(tǒng)加載到內(nèi)存的,并且定位到代碼區(qū)里程序的入口開始執(zhí)行等舔。比如骚灸,C 語言的 main 函數(shù)的第一行代碼。
每次加載一條代碼慌植,程序都會(huì)順序執(zhí)行甚牲,碰到跳轉(zhuǎn)語句义郑,才會(huì)跳到另一個(gè)地址執(zhí)行。CPU里有一個(gè)指令寄存器丈钙,里面保存了下一條指令的地址非驮。
假設(shè)我們運(yùn)行這樣一段代碼編譯后形成的程序:
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ù)還會(huì)激活 foo() 函數(shù),其中 foo() 函數(shù)被兩次以不同的路徑激活星岗。
我們把每次調(diào)用一個(gè)函數(shù)的過程填大,叫做一次活動(dòng)(Activation)。每個(gè)活動(dòng)都對應(yīng)一個(gè)活動(dòng)記錄(Activation Record)俏橘,這個(gè)活動(dòng)記錄里有這個(gè)函數(shù)運(yùn)行所需要的信息允华,比如參數(shù)、返回值寥掐、本地變量等靴寂。
目前我們用棧來管理內(nèi)存,所以可以把活動(dòng)記錄等價(jià)于棧楨曹仗。棧楨是活動(dòng)記錄的實(shí)現(xiàn)方式榨汤,我們可以自由設(shè)計(jì)活動(dòng)記錄或棧楨的結(jié)構(gòu),下圖是一個(gè)常見的設(shè)計(jì):
- 返回值:一般放在最頂上怎茫,這樣它的地址是固定的。foo() 函數(shù)返回以后妓灌,它的調(diào)用者可以到這里來取到返回值轨蛤。在實(shí)際情況中,我們會(huì)優(yōu)先通過寄存器來傳遞返回值虫埂,比通過內(nèi)存?zhèn)鬟f性能更高祥山。
- 參數(shù):在調(diào)用 foo 函數(shù)時(shí),把參數(shù)寫到這個(gè)地址里掉伏。同樣缝呕,我們也可以通過寄存器來傳遞,而不是內(nèi)存斧散。
- 控制鏈接:就是上一級(jí)棧楨的地址供常。如果用到了上一級(jí)作用域中的變量,就可以順著這個(gè)鏈接找到上一級(jí)棧楨鸡捐,并找到變量的值栈暇。
- 返回地址:foo 函數(shù)執(zhí)行完畢以后,繼續(xù)執(zhí)行哪條指令箍镜。同樣源祈,我們可以用寄存器來保存這個(gè)信息煎源。
- 本地變量:foo 函數(shù)的本地變量 b 的存儲(chǔ)空間。
- 寄存器信息:我們還經(jīng)常在棧楨里保存寄存器的數(shù)據(jù)香缺。如果在 foo 函數(shù)里要使用某個(gè)寄存器手销,可能需要先把它的值保存下來,防止破壞了別的代碼保存在這里的數(shù)據(jù)图张。這種約定叫做被調(diào)用者責(zé)任锋拖,也就是使用寄存器的人要保護(hù)好寄存器里原有的信息。某個(gè)函數(shù)如果使用了某個(gè)寄存器埂淮,但它又要調(diào)用別的函數(shù)姑隅,為了防止別的函數(shù)把自己放在寄存器中的數(shù)據(jù)覆蓋掉,要自己保存在棧楨中倔撞。這種約定叫做調(diào)用者責(zé)任讲仰。
你可以看到,每個(gè)棧楨的長度是不一樣的痪蝇。
用到的參數(shù)和本地變量多鄙陡,棧楨就要長一點(diǎn)。但是躏啰,棧楨的長度和結(jié)構(gòu)是在編譯期就能完全確定的趁矾。這樣就便于我們計(jì)算地址的偏移量,獲取棧楨里某個(gè)數(shù)據(jù)给僵。
總的來說毫捣,棧楨的設(shè)計(jì)很自由。但是帝际,你要考慮不同語言編譯形成的模塊要能夠鏈接在一起蔓同,所以還是要遵守一些公共的約定的,否則蹲诀,你寫的函數(shù)斑粱,別人就沒辦法調(diào)用了。
在之前的文章中我提到過棧楨脯爪,這次我們用了更加貼近具體實(shí)現(xiàn)的描述:棧楨就是一塊確定的內(nèi)存则北,變量就是這塊內(nèi)存里的地址。在下一講痕慢,我會(huì)帶你動(dòng)手實(shí)現(xiàn)我們的棧楨尚揣。
2.從全局角度看整個(gè)運(yùn)行過程
了解了棧楨的實(shí)現(xiàn)之后,我們再來看一個(gè)更大的場景守屉,從全局的角度看看整個(gè)運(yùn)行過程中都發(fā)生了什么惑艇。
代碼區(qū)里存儲(chǔ)了一些代碼,main 函數(shù)、bar 函數(shù)和 foo 函數(shù)各自有一段連續(xù)的區(qū)域來存儲(chǔ)代碼滨巴,我用了一些匯編指令來表示這些代碼(實(shí)際運(yùn)行時(shí)這里其實(shí)是機(jī)器碼)思灌。
假設(shè)我們執(zhí)行到 foo 函數(shù)中的一段指令,來計(jì)算“b+c”的值恭取,并返回泰偿。這里用到了mov、add蜈垮、jmp 這三個(gè)指令耗跛。mov 是把某個(gè)值從一個(gè)地方拷貝到另一個(gè)地方芒率,add 是往
某個(gè)地方加一個(gè)值裹虫,jmp 是改變代碼執(zhí)行的順序,跳轉(zhuǎn)到另一個(gè)地方去執(zhí)行(匯編命令的細(xì)節(jié)头镊,我們下節(jié)再講惠猿,你現(xiàn)在簡單了解一下就行了)羔砾。
mov b的地址寄存器1
add c的地址寄存器1
mov寄存器1 foo的返回值地址
jmp返回地址//或ret指令
執(zhí)行完這幾個(gè)指令以后,foo 的返回值位置就寫入了 6偶妖,并跳轉(zhuǎn)到 bar 函數(shù)中執(zhí)行 foo 之后的代碼姜凄。
這時(shí),foo 的棧楨就沒用了趾访,新的棧頂是 bar 的棧楨的頂部态秧。理論上講,操作系統(tǒng)這時(shí)可以把 foo 的棧楨所占的內(nèi)存收回了扼鞋。比如申鱼,可以映射到另一個(gè)程序的尋址空間,讓另一個(gè)程序使用云头。但是在這個(gè)例子中你會(huì)看到润讥,即使返回了 bar 函數(shù),我們?nèi)砸L問棧頂之外的一個(gè)內(nèi)存地址盘寡,也就是返回值的地址。
所以撮慨,目前的調(diào)用約定都規(guī)定竿痰,程序的棧頂之外,仍然會(huì)有一小塊內(nèi)存(比如 128K)是可以由程序訪問的砌溺,比如我們可以拿來存儲(chǔ)返回值影涉。這一小段內(nèi)存操作系統(tǒng)并不會(huì)回收。
我們目前只講了棧规伐,堆的使用也類似蟹倾,只不過是要手工進(jìn)行申請和釋放,比棧要多一些維護(hù)工作。
總結(jié)
本文帶你了解了程序運(yùn)行的環(huán)境和過程鲜棠,我們的程序主要跟 CPU肌厨、內(nèi)存,以及操作系統(tǒng)打交道豁陆。你需要了解的重點(diǎn)如下:
- CPU 上運(yùn)行程序的指令柑爸,運(yùn)行過程中要用到寄存器、高速緩存來提高指令和數(shù)據(jù)的存取效率盒音。
- 內(nèi)存可以劃分成不同的區(qū)域保存代碼表鳍、靜態(tài)數(shù)據(jù),并用棧和堆來存放運(yùn)行時(shí)產(chǎn)生的動(dòng)態(tài)數(shù)據(jù)祥诽。
- 操作系統(tǒng)會(huì)把物理的內(nèi)存映射成進(jìn)程的尋址空間譬圣,同一份代碼會(huì)被映射進(jìn)多個(gè)進(jìn)程的內(nèi)存空間,操作系統(tǒng)的公共庫也會(huì)被映射進(jìn)進(jìn)程的內(nèi)存空間雄坪,操作系統(tǒng)還會(huì)自動(dòng)維護(hù)棧厘熟。
- 程序在運(yùn)行時(shí)順序執(zhí)行代碼,可以根據(jù)跳轉(zhuǎn)指令來跳轉(zhuǎn)诸衔;棧被劃分成棧楨盯漂,棧楨的設(shè)計(jì)有一定的自由度,但通常也要遵守一些約定笨农;棧楨的大小和結(jié)構(gòu)在編譯時(shí)就能決定就缆;在運(yùn)行時(shí),棧楨作為活動(dòng)記錄谒亦,不停地被動(dòng)態(tài)創(chuàng)建和釋放竭宰。
以上這些內(nèi)容就是一個(gè)程序運(yùn)行時(shí)的秘密。你再面對代碼時(shí)份招,腦海里就會(huì)想象出它是怎樣跟CPU切揭、內(nèi)存和操作系統(tǒng)打交道的了。而且有了這些背景知識(shí)锁摔,你也可以讓編譯器生成代碼廓旬,按照本文所說的模式運(yùn)行了!