作者簡介:
程磊,某手機(jī)大廠系統(tǒng)開發(fā)工程師,閱碼場榮譽(yù)總編輯,最大的愛好是鉆研Linux內(nèi)核基本原理。
-
一、編譯系統(tǒng)的形成與發(fā)展
-
二、編譯系統(tǒng)的邏輯結(jié)構(gòu)
-
2.1 狹義編譯
-
2.2 最狹義編譯
-
2.3 鏈接過程
-
2.4 組建系統(tǒng)
-
-
三、編譯原理簡介
-
3.1 詞法分析
-
3.2 語法分析
-
3.3 語義分析
-
3.4 中間碼生成
-
3.5 中間碼優(yōu)化
-
3.6 機(jī)器碼生成
-
3.7 機(jī)器碼優(yōu)化
-
3.8 小型編譯器推薦
-
-
四、靜態(tài)鏈接與動態(tài)鏈接
-
4.1 靜態(tài)鏈接
-
4.2 動態(tài)鏈接
-
4.3 實(shí)例解析
-
-
五、總結(jié)回顧
一、編譯系統(tǒng)的形成與發(fā)展
什么是編譯器?為什么要有編譯器?編譯器的作用是什么?編譯系統(tǒng)的組成部分有哪些,它們之間的關(guān)系是什么?有一句名言說的非常好:了解一件事情最好從它的歷史開始。要想對整個編譯系統(tǒng)有個全面透徹地理解,我們就必須要先去認(rèn)真研究它的發(fā)展歷史。下面我們就來看一下編譯系統(tǒng)的發(fā)展歷史。
1.1 手工硬件編程
最早開始的時候,我們是手工硬件編程,注意是手工硬件編程,不是面向硬件編程。手工硬件編程是程序員直接用手去改變計(jì)算機(jī)中的跳線連接方式來編程。把所有的跳線連接都改完之后,插上電源,按執(zhí)行按鈕,計(jì)算機(jī)就可以執(zhí)行程序了。很顯然這種編程方式很原始、很low、很麻煩。如果我們有兩個程序A和B,我們執(zhí)行完A之后想要執(zhí)行B,就要先把A毀掉之后再把B給手工編到硬件上去才能執(zhí)行B。下次再需要執(zhí)行A的時候還需要重新再把A編到硬件上。這是多么的麻煩,于是大神馮諾依曼就提出了一個想法叫做存儲程序設(shè)計(jì),就是我們把程序存儲到存儲器上,計(jì)算機(jī)每次運(yùn)行的時候都去存儲器讀指令來執(zhí)行就可以了。存儲程序設(shè)計(jì)是一個非常偉大的想法,是計(jì)算機(jī)歷史上發(fā)展的一大步。以前當(dāng)我看到書上對存儲程序設(shè)計(jì)大加贊賞時,我就非常疑惑不解,存儲程序,還用你說,程序不是本來就存在硬盤上嗎,這不是理所應(yīng)當(dāng)?shù)膯?,這有什么好夸獎的。當(dāng)我們對一個東西習(xí)以為常、司空見慣的時候,我們就會覺得這個東西沒什么,但是實(shí)際上這個東西的提出或者發(fā)明是非常偉大的。就好比我們都站著走路覺得這個沒什么,這不是應(yīng)該的嗎,本來就是這樣。但是實(shí)際上人類直立行走是人從動物進(jìn)化到人的過程中非常重要的一步,如果沒有直立行走,人類現(xiàn)在說不定還是生活在原始森林里的動物。好,我們接著往下說,有了存儲程序設(shè)計(jì),我們就從手工硬件編程進(jìn)化到了面向硬件編程。
1.2 面向硬件編程
面向硬件編程又分為1.0 二進(jìn)制編程、2.0 手工匯編編程、3.0 匯編編程三個版本。由于計(jì)算機(jī)硬件采取的是二進(jìn)制實(shí)現(xiàn)方式,所以1.0版本的面向硬件編程就是二進(jìn)制編程,就是直接用一堆0110101來編程。計(jì)算機(jī)為什么要采用二進(jìn)制的方式來實(shí)現(xiàn)而不是采用人類最熟悉的十進(jìn)制方式或者其他進(jìn)制方式來實(shí)現(xiàn)呢?這里面有多個原因,首先是因?yàn)槎M(jìn)制是最簡單的進(jìn)制,不可能有比二進(jìn)制更簡單的進(jìn)制了。二是因?yàn)橛布暮芏嗵卣髡每梢院唵螌?yīng)到二進(jìn)制的0和1上,比如高電壓是1,低電壓是0,通路是1,斷路是0,非常方便,便于實(shí)現(xiàn)。三是因?yàn)檫壿嬤\(yùn)算布爾代數(shù)正好可以對應(yīng)到二進(jìn)制0和1上。如果采取3進(jìn)制、5進(jìn)制、8進(jìn)制、10進(jìn)制,將會面臨著很多的麻煩。采用二進(jìn)制也存在一個問題,就是人類熟悉10進(jìn)制,不熟悉2進(jìn)制,這個問題可以通過在顯示的時候把2進(jìn)制轉(zhuǎn)換為10進(jìn)制來解決,人們用電腦時看到的都會是10進(jìn)制數(shù),沒有影響。
有了二進(jìn)制編程,我們就可以直接書寫處理器能直接識別和執(zhí)行的二進(jìn)制指令來編程了,比手工硬件編程方便了很多。但是顯然直接書寫一堆0110101的代碼,很違背直覺,很難寫,也很容易出錯。如果不小心把某個0寫成了1,想把這個錯誤找出來都非常困難。于是人們想出了一個方法,我先在草稿紙上用偽代碼編程,比如,加法的指令是10101010,我先用 ADD 來代替。等把程序?qū)懞昧?,我再一個一個地把 ADD SUB JMP 等這些指令助記符手工轉(zhuǎn)換成0110,再輸入到電腦上去執(zhí)行。我們把這種編程方式叫做手工匯編編程,這種編程方式和手工硬件編程、二進(jìn)制編程相比都有很大的進(jìn)步,但是依然很麻煩。于是我們就想,把助記符轉(zhuǎn)換成二進(jìn)制指令的這個過程,我能不能用程序來實(shí)現(xiàn)呢,何必非要自己手工去做呢,于是就有了匯編器程序。我們把二進(jìn)制指令叫做機(jī)器語言,把助記符指令叫做匯編語言,你用匯編語言寫的程序叫做匯編程序,匯編器程序把你寫的匯編程序轉(zhuǎn)換成二進(jìn)制程序,然后就可以運(yùn)行了。匯編器程序也是程序啊,該用什么語言來寫呢,顯然此時只能用二進(jìn)制編程或者手工匯編編程來寫匯編器程序,我們把這個匯編器叫做盤古匯編器。盤古匯編器的特點(diǎn)是它是手工產(chǎn)生的,不需要匯編或者編譯來生成。盤古匯編器有了之后,就可以對所有的匯編程序進(jìn)行匯編了,此時我們就進(jìn)入了匯編編程時代。既然所有的程序都能用匯編語言編寫了,那么我們能不能用匯編語言再寫一個匯編器呢,答案是能,我們把這個匯編器叫做女媧匯編器。我們用盤古匯編器匯編女媧匯編器的源碼,得到二進(jìn)制的女媧匯編器,然后就可以用二進(jìn)制的女媧匯編器匯編女媧匯編器的源碼,又得到了新的二進(jìn)制女媧匯編器。從此我們就可以拋棄盤古匯編器了,實(shí)現(xiàn)二進(jìn)制女媧匯編器和源碼女媧匯編器的雞生蛋、蛋生雞的循環(huán)了。我們就可以不斷地改善女媧匯編器的源碼,實(shí)現(xiàn)女媧匯編器的進(jìn)化,并且還可以編譯其他匯編程序。
第一個編譯器是怎么來的?
在此我們提前回答一個問題,編譯器也是程序,也需要被編譯,那么第一個編譯器是怎么來的,是誰編譯的呢。第一個C語言編譯器是用B語言寫的,用B語言編譯器編譯的,有個這個盤古C語言編譯器,我們就可以再用C語言來重新寫一個C語言編譯器了,叫做女媧C語言編譯器。用二進(jìn)制的C語言盤古編譯器編譯源碼形式的女媧C語言編譯器,就得到了二進(jìn)制的女媧C語言編譯器,然后二進(jìn)制的女媧C語言編譯器再去編譯源碼形式的女媧C語言編譯器,就可以實(shí)現(xiàn)類似女媧匯編器的循環(huán)了。同理你會問那么第一個B語言編譯器是怎么來的呢,第一個B語言編譯器是用BCPL語言書寫的,是用BCPL語言編譯器編譯的。一直這么追問下去,第一個編譯器是怎么實(shí)現(xiàn)的呢?第一個編譯器是用匯編語言寫的,匯編器匯編出來的。再往前推,第一個匯編器是手工書寫的二進(jìn)制代碼。所以編譯器的源頭是我們?nèi)祟愔苯佑脵C(jī)器語言書寫的盤古匯編器,它不需要編譯也不需要匯編,能直接在機(jī)器上運(yùn)行。
1.3 高級語言編程
面向硬件編程3.0版本–匯編編程,雖然已經(jīng)很先進(jìn)了,但它仍然是面向硬件編程啊,程序員仍然需要了解很多的硬件細(xì)節(jié),不能專注于程序自身的邏輯。實(shí)際上絕大部分程序,它的功能實(shí)現(xiàn)都和硬件無關(guān),而且用匯編語言寫的程序不具有跨平臺性,程序員要為每個硬件平臺都寫一份程序或者要移植一下,這非常麻煩。能不能發(fā)明一種語言,屏蔽各種硬件細(xì)節(jié),只注重于實(shí)現(xiàn)程序自身的邏輯,實(shí)現(xiàn)源碼級的跨平臺性呢,于是高級語言誕生了。第一個高級語言大概是Algol吧,然后從Algol一路發(fā)展到BCPL、B、C、C++、JAVA、C# 等。在高級語言里面沒有各種硬件指令,沒有寄存器,沒有復(fù)雜的尋址模式,取而代之的是各種數(shù)據(jù)類型的數(shù)據(jù)定義,和對數(shù)據(jù)的直觀操作,以及if、for、while、switch等控制語句,還有一些更高級的語法構(gòu)造如類、模板等,都是用接近于人類自然語言的方式來表達(dá)的,可讀性非常強(qiáng)。如果想要了解學(xué)習(xí)一些高級語言,如C和C++,可以參看《深入理解C與C++》。
編譯器的定義:好,說到這里,大家對什么是編譯器心里應(yīng)該已經(jīng)很明白了。我們來總結(jié)一下,什么是編譯器,編譯器是人類和計(jì)算機(jī)之間的一個矛盾的產(chǎn)物。這個矛盾就是計(jì)算機(jī)能夠理解和執(zhí)行二進(jìn)制格式的程序卻不能理解和執(zhí)行文本格式的程序。而人類正好相反,人類能理解和書寫文本格式的程序卻難以理解和書寫二進(jìn)制格式的程序。于是編譯器出現(xiàn)了,用來幫助人類解決這個矛盾。人類書寫文本格式的程序,編譯器給翻譯成二進(jìn)制格式的程序,計(jì)算機(jī)再來執(zhí)行。
1.4 編譯系統(tǒng)的組成
上面我們把什么是編譯器給解釋清楚了,我們再繼續(xù)順著這個思路來解釋一下什么是鏈接、加載和組建。剛開始的時候,程序都很簡單,一個C文件就搞定了,我們可以把一個C文件直接編譯成可執(zhí)行程序。但是后來隨著程序越來越龐大,再把程序全寫到一個C文件里就不合適了,一個C文件寫幾千行還算合理,但是寫幾萬行,幾十萬行甚至幾百萬行就不合理了。于是我們把程序?qū)懙蕉鄠€文件里,每個文件單獨(dú)編譯,生成中間目標(biāo)文件,再把這些中間目標(biāo)文件連接合并成一個可執(zhí)行程序,這個連接合并過程就叫做鏈接,執(zhí)行鏈接的程序叫做鏈接器。后來隨著程序的更加復(fù)雜龐大,這種鏈接方式也有問題,因?yàn)橛泻芏喙渤绦?,你把它們都鏈接到每個程序中,這樣每個程序都自己包含一份庫程序,這會導(dǎo)致浪費(fèi)大量的磁盤空間,而且運(yùn)行時也很浪費(fèi)物理內(nèi)存,如果一個庫程序更新了,每個可執(zhí)行程序都要重新鏈接一遍,也很麻煩。為了解決這幾個問題,于是誕生了動態(tài)鏈接,于是就把之前的鏈接方式叫做靜態(tài)鏈接。我們把一些公共庫程序做成動態(tài)鏈接庫,每個可執(zhí)行程序鏈接到動態(tài)庫時只是在程序內(nèi)部做了一個鏈接記錄,并不會把動態(tài)庫復(fù)制到可執(zhí)行程序本身,這樣整個磁盤里面就只有一份這個動態(tài)庫。運(yùn)行時,這個動態(tài)庫也只需要加載進(jìn)物理內(nèi)存一次,然后分別映射到不同進(jìn)程的虛擬內(nèi)存空間中就可以了,這個動作叫做加載。至此我們明白了編譯、鏈接、靜態(tài)鏈接、動態(tài)鏈接、加載這幾個概念,做編譯的是編譯器,做鏈接的是鏈接器,做加載的是加載器。
下面我們說一說什么是組建,假設(shè)我們有個軟件由十幾個文件夾幾百個C文件組成,我們每次想編譯時該怎么做,一個一個地編譯每個C文件,然后再把所有的中間目標(biāo)文件靜態(tài)鏈接、動態(tài)鏈接起來生成最終的可執(zhí)行程序,每次都手工輸入那么多命令是多么麻煩啊。我們該怎么辦,一個最簡單直接的方式是把這些命令都寫到腳本里,后面每次都執(zhí)行這個腳本就可以了。這個想法很好,也解決了問題,但是還存在問題,就是寫這個腳本比較麻煩,修改這個腳本也比較麻煩。于是就出現(xiàn)了一些可以自動生成這個腳本的方法,你寫一些簡單的配置和規(guī)則,然后用一個程序處理這個規(guī)則,就可以自動生成這個腳本,再執(zhí)行這個腳本就可以編譯整個程序了。后來你發(fā)現(xiàn),你的解析程序解析你的規(guī)則文件后,直接在內(nèi)部生成這個腳本執(zhí)行這個腳本就可以了,沒必要非要把這個腳本顯式地寫出來再去執(zhí)行,這一套東西就叫做組建系統(tǒng)。組建系統(tǒng)由兩部分組成,解析程序和規(guī)則文件。最著名的組建系統(tǒng)就是make了,make由兩部分組成,make程序和Makefile文件。你按照Makefile的要求去書寫Makefile文件,然后執(zhí)行make命令就可以編譯整個程序了。后來隨著程序的更加龐大和復(fù)雜化,Makefile也變得越來越龐大越難以書寫了,于是又產(chǎn)生了元組建系統(tǒng),來幫你生成Makefile文件。元組建系統(tǒng)也由兩部分組成,解析程序和規(guī)則文件,你按照它的要求書寫規(guī)則文件,然后執(zhí)行解析程序生成Makefile,然后再執(zhí)行make命令編譯整個程序。針對make的比較著名的元組建系統(tǒng)有CMake、autoconf/automake等。由于make運(yùn)行時需要把每個文件夾下Makefile都include進(jìn)來,這對于小規(guī)模的程序來說還能忍受,但是當(dāng)程序非常龐大、源碼下的文件夾比較多的時候,這個耗時就難以忍受了,于是人們開發(fā)出了ninja組建系統(tǒng),它運(yùn)行時只解析一個build文件,運(yùn)行效率就比較高。不過要手工寫ninja的build文件是很麻煩的,對大型程序來說幾乎是不現(xiàn)實(shí)的,所以對ninja組建系統(tǒng)幾乎是必配元組建系統(tǒng),當(dāng)只修改源碼內(nèi)容時可以直接運(yùn)行ninja命令,但是當(dāng)程序結(jié)構(gòu)發(fā)生變化時必須要先運(yùn)行一下元組建系統(tǒng)重新生成一下ninja的build文件。
現(xiàn)在我們看到整個編譯系統(tǒng)由3部分組成,編譯、鏈接、組建,執(zhí)行它們的分別是編譯器、鏈接器、組建系統(tǒng)。我們在命令行編譯一個程序的時候,只需要調(diào)用一個組建命令,組建系統(tǒng)就會幫我們自己編譯、鏈接整個程序,還會做一些其他輔助工作如打包、簽名等。有些同學(xué)可能會說我從來沒用過什么編譯器、鏈接器、組建系統(tǒng),我每次編程都是直接寫程序,然后直接按一個按鈕就行了,這個東西就叫做集成開發(fā)環(huán)境,它把文本編輯器、編譯器、鏈接器、組建系統(tǒng)、調(diào)試器都集成在一起了,可以方便大家的開發(fā)工作,但也使得很多人都不了解它背后的工作原理。所以說命令行雖然不太好用,但是它的不太好用是指學(xué)會用它比較麻煩,但是一旦學(xué)會用它,你會發(fā)現(xiàn)它非常強(qiáng)大,而且同時你對它背后的原理也理解地比較深刻。
很多時候我們在工作的時候會經(jīng)常說到編譯這個詞,但是在不同的場景下它的含義是不一樣的。下面我們來進(jìn)行一下名詞解析,說一下編譯的四種不同含義:
- 最廣義的編譯:就是組建過程,包括編譯、鏈接、打包、簽名等
- 廣義的編譯:包括狹義的編譯和鏈接
- 狹義的編譯:就是單指編譯,把源文件編譯成目標(biāo)文件的過程
- 最狹義的編譯:就是編譯原理中的編譯,不包括開頭的預(yù)處理和最后的匯編過程(如果有的話)。
編譯原理里面講的編譯就是指這個最狹義的編譯,一般是沒有這個匯編過程的,就是直接生成目標(biāo)文件,如果有匯編過程的則不包括匯編過程,很多編譯器采取的方式是有匯編的過程的。
為什么要在這里做個名詞解析呢,因?yàn)樵诠ぷ骱鸵恍┯懻撝袝驗(yàn)榇蠹艺f的編譯這個詞的含義不同而產(chǎn)生一些說不清的爭論。比如說我們在編譯整個程序的時候,我們只會說編譯這個程序,而不會去說組建這個程序,也不會去說編譯加鏈接加打包這個程序,這樣說會顯得很別扭,所以很多時候會因?yàn)榫幾g的含義不同而產(chǎn)生一些不必要的爭論。例如有一次服務(wù)器編譯出錯了,我說編譯出錯了,有一個同事說編譯沒有錯,是打包出錯了。還有一次有個同事編譯出錯了,讓我?guī)兔纯词窃趺椿厥?,我看了之后說編譯過程沒問題,鏈接階段出錯了,找不到符號,他一臉疑惑的說,這編譯不是出錯了嗎。還有些同學(xué)不明白編譯原理中的編譯和工作中的編譯的不同,會問一些非常有意思的問題,比如有人問過我,預(yù)處理的時候?yàn)槭裁床荒芴崆鞍l(fā)現(xiàn)語法錯誤呢,我說預(yù)處理的時候還沒到編譯階段呢,他說預(yù)處理不是編譯嗎。如果我們知道了編譯的四個不同廣度的含義就能更好地溝通清楚我們要表達(dá)的意思。
二、編譯系統(tǒng)的邏輯結(jié)構(gòu)
前面一章我們對編譯的概念和編譯系統(tǒng)的組成已經(jīng)有了基本的了解,這一章我們來具體講講它們之間的結(jié)構(gòu)關(guān)系和每個組成部分的功能作用。
2.1 狹義編譯
我們首先來看一下狹義編譯:
狹義編譯是對一個源文件和n個頭文件進(jìn)行的編譯,它包含預(yù)處理、最狹義編譯和匯編三個過程。預(yù)處理是對源文件中的預(yù)處理指令進(jìn)行處理的過程,預(yù)處理指令包括頭文件包含指令(#include)、宏定義指令(#define)、取消宏定義指令(#undef)、條件編譯指令(#if #ifdef #ifndef #else #elif #endif)和其他一些編譯指令。預(yù)處理過程會把所有被包含的頭文件插入到源文件中,并對其它預(yù)處理指令進(jìn)行處理,最終生成一個編譯單元 .i文件。然后對編譯單元進(jìn)行最狹義的編譯,也就是編譯原理里面所說的編譯。編譯原理里面說最狹義編譯最后可以生成匯編文件也可以直接生成目標(biāo)文件沒有匯編過程,但是書里一般都是按照直接生成目標(biāo)文件來講的,而現(xiàn)實(shí)中的編譯器一般都是生成匯編文件,最后再經(jīng)歷一個匯編過程。匯編是把匯編文件生成目標(biāo)文件的過程,目標(biāo)文件里面包含可直接執(zhí)行的機(jī)器指令,但是整個文件還不是可執(zhí)行格式。
2.2 最狹義編譯
最狹義編譯是編譯原理中的編譯。編譯原理是一門非常艱深課程,是計(jì)算機(jī)科學(xué)中最有技術(shù)含量的領(lǐng)域之一,是理論和實(shí)現(xiàn)都極其難以理解的一門科學(xué)。我們這里不準(zhǔn)備詳細(xì)講解編譯原理的知識,只是對其框架進(jìn)行大概的介紹。我們先看一張圖:
我們按照直接生成目標(biāo)文件的方式來講,因?yàn)檫@樣整個編譯器結(jié)構(gòu)比較完整,如果是生成匯編文件的話,那么機(jī)器碼生成這塊就不存在了。編譯器一般都分成前端和后端兩個部分,前端負(fù)責(zé)對語言本身進(jìn)行解析,后端負(fù)責(zé)機(jī)器碼生成。為什么要分成前端和后端兩個部分呢,因?yàn)榍岸撕秃蠖瞬⒉皇潜厝魂P(guān)聯(lián)的,分開之后可以有更大的靈活性。靈活性主要有兩個方面,對于同一種語言來說,語法解析我們只需要實(shí)現(xiàn)一次就行了,當(dāng)語言需要移植到另一個CPU架構(gòu)時,只需要實(shí)現(xiàn)后端就可以了。對于不同的語言來說,對于同一個CPU架構(gòu)我們沒必要實(shí)現(xiàn)多個后端,所有的語言都可以共用同一個后端,當(dāng)編譯器需要支持新的語言的時候,只需要實(shí)現(xiàn)前端就行了。前端包括詞法分析、語法分析(句法分析)、語義分析,后端包括中間碼生成、中間碼優(yōu)化、機(jī)器碼生成、機(jī)器碼優(yōu)化,下一章將會對其進(jìn)行稍微詳細(xì)的介紹。
2.3 鏈接過程
經(jīng)過狹義編譯之后我們得到了目標(biāo)文件,但是目標(biāo)文件并不是最終的可執(zhí)行程序,我們需要把目標(biāo)文件鏈接起來才能生成可執(zhí)行程序。程序執(zhí)行時生成進(jìn)程,進(jìn)程是由一個exe主程序和n個so庫程序組成,主程序和庫程序都是由目標(biāo)文件和靜態(tài)庫文件通過靜態(tài)鏈接生成的。靜態(tài)鏈接分為隱式靜態(tài)鏈接和顯式靜態(tài)鏈接,隱式靜態(tài)鏈接是目標(biāo)文件和目標(biāo)文件直接合并在一起,顯式靜態(tài)鏈接是目標(biāo)文件和靜態(tài)庫進(jìn)行鏈接,本質(zhì)上還是和靜態(tài)庫里面的目標(biāo)文件進(jìn)行鏈接。隱式靜態(tài)鏈接和顯式靜態(tài)鏈接區(qū)別不大,本質(zhì)上是沒有區(qū)別的,只是在編譯命令上有形式上的區(qū)別。Exe對so,so對so,是動態(tài)鏈接的,動態(tài)鏈接分為半動態(tài)鏈接和完全動態(tài)鏈接,兩者的本質(zhì)是相同的,都是動態(tài)鏈接的,也就是說沒有復(fù)制文件的過程,但是兩者的實(shí)現(xiàn)形式還是有很大差異的。半動態(tài)鏈接就是我們平常所說的動態(tài)鏈接,它在編程時需要include so的頭文件,在編譯時需要指定so所在的路徑,鏈接后生產(chǎn)的文件中會記錄這個so的相關(guān)信息,在進(jìn)程啟動時加載器會加載這個so,在程序運(yùn)行時調(diào)用了這個so的函數(shù)的時候會去動態(tài)解析符號。完全動態(tài)鏈接是指在代碼中通過函數(shù)dlopen加載相應(yīng)的so,通過函數(shù)dlsym查找要調(diào)用的函數(shù)的符號,然后去調(diào)用這個函數(shù),使用完了之后可以通過dlclose卸載這個so。完全動態(tài)鏈接不需要include相應(yīng)的頭文件,編譯時也不需要指定so的路徑,運(yùn)行時如果找不到so,dlopen會返回NULL,程序不會崩潰,用完了之后還可以把so卸載了。相反,對于半動態(tài)鏈接,如果編譯時找不到so就會編譯不過,運(yùn)行時找不到so程序就會啟動失敗,程序運(yùn)行的過程中也不可能把so給卸載移出進(jìn)程的內(nèi)存空間。
下面看一下編譯時鏈接的過程:
編譯時鏈接,不僅會進(jìn)行靜態(tài)鏈接,也會進(jìn)行半動態(tài)鏈接的第一步。下面再看一下半動態(tài)鏈接的啟動加載和完全動態(tài)鏈接:
進(jìn)程啟動時首先是fork創(chuàng)建進(jìn)程的殼,然后exec加載進(jìn)程的主程序hello.exe和加載器ld.so。然后進(jìn)程返回用戶空間,執(zhí)行權(quán)交給ld,ld會根據(jù)主程序文件中的動態(tài)段的信息去加載所有的so文件,并完成重定位工作,最后把執(zhí)行權(quán)交給主程序。主程序首先執(zhí)行的并不是main函數(shù),而是_start函數(shù),_start函數(shù)會調(diào)用 __libc_start_main函數(shù),此函數(shù)會完成進(jìn)程環(huán)境的初始化,最后調(diào)用main函數(shù)。進(jìn)入了main函數(shù)就是程序執(zhí)行的主體了,程序如果執(zhí)行到了dlopen函數(shù),就會去加載相應(yīng)的so文件。
2.4 組建系統(tǒng)
組建系統(tǒng)英語是build system,也有翻譯成構(gòu)建系統(tǒng)的。什么是組建系統(tǒng)呢,如果你每次編譯程序都要執(zhí)行一大堆gcc ld 命令,那是多么的麻煩,有了組建系統(tǒng)你每次只需要執(zhí)行一個簡單的命令,就能編譯整個程序了。下面我們以使用最普遍的組建系統(tǒng)make為例簡單地講一講。make命令會在同目錄下尋找文件Makefile,然后解析并執(zhí)行它。Makefile的內(nèi)容包含5個部分,1.變量定義,2.顯示規(guī)則,3.隱式規(guī)則,4.文件指示,5注釋。變量定義相當(dāng)于C語言中的宏,使用時會被替換到相應(yīng)的位置。顯示規(guī)則說明了如何生成一個文件,顯示規(guī)則包含3部分,目標(biāo)、依賴和命令。由于很多規(guī)則模式是很常用的,所以make內(nèi)置了一些規(guī)則,我們沒必要把規(guī)則寫全,make會自動推導(dǎo)。文件指示類似于C語言中的預(yù)處理命令,如#include可以包含下層makefile。注釋是以#開頭的行。
顯示規(guī)則的格式如下,隱式規(guī)則沒有命令部分。
三、編譯原理簡介
前面我們講了一下最狹義編譯,也就是編譯原理中的編譯。這里我們把編譯原理給大家簡單介紹一下,就不展開詳細(xì)討論了,因?yàn)榫幾g原理實(shí)在是太深了,想要深入研究的同學(xué)可以去看參考文獻(xiàn)里推薦的書籍。我們先來看一下編譯的總體過程:
3.1 詞法分析
什么是詞法分析,為什么要進(jìn)行詞法分析呢?詞法分析是把一個一個的字符變成一個一個的單詞(Token)。單詞,英文是Token,漢語有翻譯成記號的,也有翻譯成符號的,它是源代碼的最小邏輯單位。詞法分析的作用就相當(dāng)于我們小時候的學(xué)的如何斷句,把一句話分成一個一個的詞,詞有名詞代詞動詞形容詞副詞介詞連詞等,它們再組成了一句話的主謂賓定狀補(bǔ)。類似的,詞法分析中從源代碼字符流中分出一個一個的單詞,并對單詞進(jìn)行屬性分類,方便后面進(jìn)一步地分析:句法分析(語法分析),把單詞組成句子。詞法分析的基本原則也比較簡單,主要就是按照空格劃分,按照換行劃分,但是又不完全如此,又有一些特殊情況。比如 a=b+c,雖然沒有一個空格,但是并不能整體上看成一個單詞,而是要把它看成是 a、=、b、+、c,五個單詞。又比如 s = “hello world”,雖然“hello world”中間有一個空格,但是并不能把它看成是兩個單詞,而是把它看成是一個單詞。
詞法分析器分析出來的單詞都有哪些類別呢,一共有5種類別,分別是關(guān)鍵字、標(biāo)識符、常量、操作符、分界符。關(guān)鍵字其實(shí)也算是標(biāo)識符,只不過是特殊的標(biāo)識符,它是計(jì)算機(jī)語言標(biāo)準(zhǔn)規(guī)定的具有特殊含義的標(biāo)識符,主要有兩類,一類是表示數(shù)據(jù)類型的,比如int、long、float等,另一類是表達(dá)流程的,比如if、while、for。標(biāo)識符是普通的標(biāo)識符,是程序員為程序中的實(shí)體起的名字,主要分為兩類,分別是變量名和函數(shù)名。常量就是程序中的字面常量,包括整數(shù)型常量、浮點(diǎn)型常量、字符常量、字符串常量、布爾常量等。操作符是用來進(jìn)行一些運(yùn)算的符號,如進(jìn)行算術(shù)運(yùn)算的 + - x / % 等運(yùn)算符,進(jìn)行比較操作的 > >= < <= == != 等,進(jìn)行邏輯運(yùn)算的 && || !等運(yùn)算符,還有進(jìn)行移位運(yùn)算、賦值操作、條件操作等操作符。分界符是沒有啥具體含義用來分割一些邏輯單元的邊界的符號,如分號;表達(dá)一個語句的結(jié)束,大括號{}表達(dá)一個代碼塊或者函數(shù)的開始和結(jié)束,小括號用來表示表達(dá)式、函數(shù)參數(shù)等的分界。
那么我們該用什么方法進(jìn)行詞法分析呢,比如我們可以使用正則表達(dá)式,使用有限狀態(tài)機(jī),包括不確定性有限狀態(tài)機(jī)(NFA)和確定性有限狀態(tài)機(jī)(DFA),這方面的具體細(xì)節(jié)可以參看參考文獻(xiàn)里的書籍。
我們該怎么得到一個詞法分析器呢?有兩種方法,一種是手工編寫代碼,一種是用工具幫你生成。手工編寫的好處是自由靈活高效,缺點(diǎn)是麻煩費(fèi)腦子,工具生成的優(yōu)點(diǎn)是簡單快捷,缺點(diǎn)是不夠靈活,沒法自定義。目前大部分主流編譯器采取的都是手工編寫詞法分析器。由于詞法分析的過程像是掃描,所以詞法分析又叫做掃描,詞法分析器又叫做掃描器。一個比較著名的詞法分析器生成工具是flex。
詞法分析把源代碼的字節(jié)流變成了單詞流,并標(biāo)記了單詞的類別,并把標(biāo)識符的相關(guān)信息等放入了符號表,這些都會作為下一步句法分析的輸入。
3.2 語法分析
我們有了單詞,單詞可以組成句子,句子可以構(gòu)成段落,段落可以組成文章,一篇美妙的文章就這么誕生了。對于簡單的文學(xué)作品是這樣的,對于復(fù)雜的文學(xué)作品如《紅樓夢》《三國演義》,有更高一層的結(jié)構(gòu),一篇文章只是其中的一回。同樣的,一個程序也是類似的結(jié)構(gòu),簡單的程序只有一個c文件,復(fù)雜的程序有很多的c文件,一個c文件就是一篇文章,是編譯的基本單元。具體來說,預(yù)處理之后的c文件才是一個編譯單元。從前面的描述我們可以看出來,一個編譯單元,它的結(jié)構(gòu)是樹狀的,它的葉子節(jié)點(diǎn)的底層節(jié)點(diǎn)是單詞。語法分析就是把這些底層節(jié)點(diǎn)(單詞)一步一步地生成一棵樹的過程,這棵樹就是抽象語法樹(AST)。這顆樹的根節(jié)點(diǎn)就是編譯單元,編譯單元的子節(jié)點(diǎn)有兩類,分別是聲明和定義,聲明包括變量聲明和函數(shù)聲明,定義包括變量定義和函數(shù)定義。變量聲明、函數(shù)聲明和變量定義,都比較簡單,都是一句,它們的子節(jié)點(diǎn)很快就到了葉子節(jié)點(diǎn)(單詞)。函數(shù)定義比較復(fù)雜,首先包括函數(shù)簽名和函數(shù)體,函數(shù)體又是由很多語句組成的,語句又有很多的類型。下面我們來畫一下編譯單元的抽象語法樹。
下面我們再來看一個具體的賦值語句 a=b+c; 的抽象語法樹。
生成一個編譯單元的抽象語法樹是一個非常復(fù)雜的事情,那么該如何生成這個抽象語法樹呢?有兩類方法,一類是自頂向下方法,包括遞歸下降算法和LL算法,一個是自底向上方法,包括SR算法和LR算法。具體細(xì)節(jié)請參看參考文獻(xiàn)中的書籍。
3.3 語義分析
經(jīng)過詞法分析和語法分析,我們得到了抽象語法樹,但是此時得到的結(jié)果只能保證程序是形式上合法的,并不能保證程序在語義上是合理的有意義的。比如給指針賦值浮點(diǎn)數(shù),形式上是個賦值操作,實(shí)際上是沒有意義的。因此此時要進(jìn)行語義分析,保證程序在邏輯上是有意義的,沒法發(fā)現(xiàn)語義不合法的就報(bào)錯,停止編譯。
3.4 中間碼生成
現(xiàn)在的編譯器都不是直接生成機(jī)器代碼,而是先生成一種中間語言的代碼。這么做是為了,1.使得所有的語言都可以用同一個后端,同一個語言可以輕松添加對新CPU架構(gòu)的支持,2.所有語言都可以使用同樣的機(jī)器無關(guān)的代碼優(yōu)化方法,避免重復(fù)實(shí)現(xiàn)優(yōu)化算法。中間語言一般采取三地址碼的形式,每個三地址碼都是一個四元組(運(yùn)算符,操作數(shù)1,操作數(shù)2,結(jié)果),由于每個四元組都包含了三個變量,即每條指令最多有三個操作數(shù),所以被稱為三地址碼。中間碼生成是把抽象語法樹轉(zhuǎn)化為中間語言的過程。
3.5 中間碼優(yōu)化
對中間碼進(jìn)行優(yōu)化,優(yōu)化的原則是不改成程序本身的執(zhí)行結(jié)果的情況下,減少程序執(zhí)行的代碼量,能提高程序執(zhí)行的效率。優(yōu)化的方法有,常量表達(dá)式優(yōu)化,直接把常量表達(dá)式的結(jié)果計(jì)算出來,避免其在運(yùn)行時再去計(jì)算。公共子表達(dá)式消除,若兩個表達(dá)式有相同的子表達(dá)式,則只對它們計(jì)算一遍,復(fù)用計(jì)算的結(jié)果。復(fù)制傳播,某些變量的值并未被改變過便賦給其他變量,則可直接引用原值本身。死代碼消除,有些代碼不會被執(zhí)行到,把它們刪除。無用代碼消除,有些代碼的執(zhí)行沒有意義,對程序沒有作用,把它們刪除。代碼外提,有些在循環(huán)中的代碼,在每次循環(huán)中的計(jì)算結(jié)果都是一樣的,把它放在循環(huán)外只計(jì)算一遍就行了。還有很多其他優(yōu)化方法就不一一列舉了,具體情況請參看參考文獻(xiàn)中的書籍。
3.6 機(jī)器碼生成
編譯程序的目的是為了把源碼翻譯成最終能在機(jī)器上執(zhí)行運(yùn)行的程序,所以編譯器最后要把中間碼轉(zhuǎn)換為機(jī)器碼。
3.7 機(jī)器碼優(yōu)化
生成目標(biāo)代碼時要考慮優(yōu)化,生成最高效的代碼,有三個因素需要考慮,1.同一個效果可能有多個不同的指令都能實(shí)現(xiàn),選擇最優(yōu)的指令,2.充分利用寄存器,減少訪問內(nèi)存的次數(shù),3.在不影響程序正確性的情況下對指令進(jìn)行重排序,提高代碼的運(yùn)行效率。
3.8 小型編譯器推薦
當(dāng)我們學(xué)習(xí)了編譯原理之后,非常想通過研究實(shí)際的編譯器代碼來深入理解編譯原理。但是目前最流行的兩個編譯器GCC、LLVM代碼都非常龐大,不適合拿來入手。為此我推薦幾個市面上比較流行的小型編譯器,代碼都比較短小簡單,大家可以用來研究編譯原理。
- c4
一個非常簡單的解釋型C編譯器,整個代碼只有一個文件500多行,包含4個函數(shù)??梢越忉寛?zhí)行一些簡單的C程序,更神奇的是可以解釋執(zhí)行自己。源碼地址:https://github.com/rswier/c4
- ucc
ucc是曾任小米公司MIUI首席架構(gòu)師的汪文俊同學(xué)在研究生期間花了一年多的時間寫的。Ucc 代碼簡潔易懂,結(jié)構(gòu)清晰,易于學(xué)習(xí)和掌握。鄒昌偉編寫的書籍《C編譯器剖析》就是以ucc為源代碼來解析編譯器實(shí)現(xiàn)的。源碼地址:https://github.com/sheisc/ucc162.3
- chibicc
一個簡單而又優(yōu)雅的小型編譯器,實(shí)現(xiàn)了大多數(shù) C11 特性,而且能成功編譯git、sqlite等源碼。源碼地址:https://github.com/rui314/chibicc
- tcc
一個小型但是又非常完整的編譯器,不僅實(shí)現(xiàn)了編譯器前端,還實(shí)現(xiàn)了匯編器和鏈接器。源碼地址:https://github.com/TinyCC/tinycc
四、靜態(tài)鏈接與動態(tài)鏈接
當(dāng)程序變得越來越龐大之后,我們就會對程序進(jìn)行模塊劃分。最先使用的模塊方式是文件,把程序放到不同的源代碼中,最后編譯鏈接成一個可執(zhí)行文件。當(dāng)程序變得更龐大了之后,我們會把程序劃分為好幾個執(zhí)行體,每個執(zhí)行體實(shí)現(xiàn)一個單獨(dú)的功能邏輯。執(zhí)行體分為主執(zhí)行體,也就是exe可執(zhí)行程序,和庫執(zhí)行體,也就是so動態(tài)共享庫。主執(zhí)行體有且只有一個,庫執(zhí)行體有n個,n 大于等于 0。執(zhí)行體一般對應(yīng)一個源碼文件夾,源碼文件夾還可以有多個層級。我們對每個源碼文件進(jìn)行編譯,得到一個目標(biāo)文件,執(zhí)行體都是由若干個目標(biāo)文件靜態(tài)鏈接而生成的。目標(biāo)文件、主執(zhí)行體、庫執(zhí)行體,它們都要有一定的格式。不同的操作系統(tǒng)可以采用不同的格式,Windows用的是PE格式,UNIX(包括Linux)用的是ELF格式。ELF是一個總體格式,對上,目標(biāo)文件、主執(zhí)行體、庫執(zhí)行體又有不同的子類型格式,對下,在不同的硬件平臺上它們又有具體的格式。大家經(jīng)常把靜態(tài)庫和動態(tài)庫放在一起來對比,導(dǎo)致人們習(xí)慣性認(rèn)為它倆是非常對稱的。實(shí)際上它倆只是稍微有一些對稱性,它們的不對稱性還是很大的。靜態(tài)庫和動態(tài)庫最大的區(qū)別就是,靜態(tài)庫會被復(fù)制進(jìn)被鏈接的程序中,而動態(tài)庫不會。也就是這一點(diǎn)區(qū)別導(dǎo)致了它們本質(zhì)性的區(qū)別,動態(tài)庫是執(zhí)行體,而靜態(tài)庫不是,靜態(tài)庫是組成執(zhí)行體的原料。
頭文件的目的和意義:當(dāng)整個程序只有一個源文件的時候,是沒有頭文件的。當(dāng)程序被放到多個源文件的時候,由于每個源文件都是單獨(dú)編譯的,編譯的時候并不知道其他源文件的信息,所以需要頭文件來提供其他源文件的信息。頭文件里面主要放的是變量聲明、函數(shù)聲明、數(shù)據(jù)類型聲明。頭文件里面盡量只放聲明性的東西,不要放定義性東西。聲明和定義的區(qū)別是,聲明性的東西不分配內(nèi)存空間,定義性的東西分配內(nèi)存空間。如果在頭文件里面放定義性的東西的話,由于頭文件會被包含到很多源文件中,所以會導(dǎo)致這個定義會有很多份,這在大多數(shù)情況下都不是想要的情況。有一種情況可以在頭文件中放定義,那就是有些比較短小的又想要內(nèi)聯(lián)的函數(shù)可以放在頭文件中。
4.1 靜態(tài)鏈接
我們經(jīng)常把鏈接到靜態(tài)庫叫靜態(tài)鏈接,其實(shí)目標(biāo)文件的合并也是靜態(tài)鏈接。它倆形式上雖然不太相同,但是本質(zhì)上是一樣的。靜態(tài)庫僅僅是對目標(biāo)文件的打包而已,鏈接到靜態(tài)庫實(shí)際上還是目標(biāo)文件的合并。為了區(qū)分兩者,我們把前者叫做顯式靜態(tài)鏈接,后者叫做隱式靜態(tài)鏈接。如果我們的程序有兩個源文件組成,那么最終生成程序的時候就是兩個目標(biāo)文件的合并,也就是隱式靜態(tài)鏈接。而我們鏈接到靜態(tài)庫的顯示靜態(tài)鏈接,和這個隱式靜態(tài)鏈接沒有區(qū)別。明白了這件事,我們就容易想明白一個問題,那就是我們的程序如果依賴于一個靜態(tài)庫,那么這個靜態(tài)庫的依賴,我們需要在本程序里要再聲明一次。因?yàn)殪o態(tài)庫在這個意義上和你自己的某個C文件沒有區(qū)別,相當(dāng)于是你自己對外部的依賴。這點(diǎn)和動態(tài)庫就有很大的不同,你依賴于動態(tài)庫,并不需要知道動態(tài)庫依賴于誰。
顯式靜態(tài)鏈接的編譯命令和動態(tài)鏈接的命令是一樣的,和隱式靜態(tài)鏈接不一樣,但是在處理邏輯上正好反了過來。
4.2 動態(tài)鏈接
動態(tài)鏈接也分為兩種,我們經(jīng)常說的動態(tài)鏈接叫半動態(tài)鏈接,通過dlopen進(jìn)行的鏈接叫做完全動態(tài)鏈接。因?yàn)閐lopen確實(shí)是完全動態(tài)的,只有dlopen代碼執(zhí)行到了才會進(jìn)行鏈接,執(zhí)行不到根本就不會鏈接。而半動態(tài)鏈接,你在編譯時就要在命令參數(shù)中指明要鏈接的動態(tài)庫,鏈接后動態(tài)庫的信息會被放到被鏈接的執(zhí)行體的動態(tài)段里面。程序啟動的時候加載器還會把一個程序所依賴的所有動態(tài)庫都加載到進(jìn)程的地址空間,并做好重定位。你對動態(tài)庫中的函數(shù)的調(diào)用,直到你第一次調(diào)用時才會進(jìn)行函數(shù)地址解析。完全動態(tài)鏈接,dlopen的時候加載動態(tài)庫,dlsym的時候進(jìn)行函數(shù)地址解析,dlclose還能把動態(tài)庫給移出進(jìn)程的地址空間。
半動態(tài)鏈接和完全動態(tài)鏈接所使用的動態(tài)庫格式是一樣的,沒有啥區(qū)別,不同的是使用它們的方式。
4.3 實(shí)例解析
下面我們寫一個hello程序來演示一下隱式靜態(tài)鏈接、顯示靜態(tài)鏈接、半動態(tài)鏈接、完全動態(tài)鏈接,它們的區(qū)別和用法。所有的文件截圖如下:
hello.c
#include?
#include?
#include?"hello_static_implicit.h"
#include?"hello_static_explicit.h"
#include?"hello_dynamic_partial.h"
void?say_hello_direct()
{
?printf("say?hello?direct
");
}
int?main(int?argc,?char?const?*argv[])
{
?printf("----------------------------------
");
?say_hello_direct();
?printf("----------------------------------
");
?say_hello_static_implicit();
?printf("----------------------------------
");
?say_hello_static_explicit();
?printf("----------------------------------
");
?say_hello_dynamic_partial();
?printf("----------------------------------
");
?void?*handle?=?dlopen("./libhello_dynamic_full.so",?RTLD_LAZY);
?void?(*say_hello_dynamic_full)(void);
?say_hello_dynamic_full?=?(void?(*)(void))dlsym(handle,?"say_hello_dynamic_full");
?say_hello_dynamic_full();
?dlclose(handle);
?printf("----------------------------------
");
?return?0;
}
hello_static_implicit.c
#include?
#include?"hello_static_explicit.h"
void?say_hello_static_explicit()
{
?printf("say?hello?static?explicit
");
}
其他幾個文件是類似的,就不再貼出代碼了。
Makefile
#?This?is?hello?link?program
CFLAGS?+=?-std=c99?-g?-Wall
all?:?hello?libhello_dynamic_full.so
hello?:?hello.o?hello_static_implicit.o?libhello_static_explicit.a?libhello_dynamic_partial.so
?gcc?-o?hello?hello.o?hello_static_implicit.o?-Wl,-rpath=.?-L.?-lhello_static_explicit?-lhello_dynamic_partial
hello.o?:?hello.c?hello_static_implicit.h?hello_static_explicit.h?hello_dynamic_partial.h
?gcc?-o?hello.o?-c?hello.c
hello_static_implicit.o?:?hello_static_implicit.c?hello_static_implicit.h
?gcc?-o?hello_static_implicit.o?-c?hello_static_implicit.c
hello_static_explicit.o?:?hello_static_explicit.c?hello_static_explicit.h
?gcc?-o?hello_static_explicit.o?-c?hello_static_explicit.c
libhello_static_explicit.a?:?hello_static_explicit.o
?ar?rv?libhello_static_explicit.a?hello_static_explicit.o
libhello_dynamic_partial.so?:?hello_dynamic_partial.c?hello_dynamic_partial.h
?gcc?-o?libhello_dynamic_partial.so?-shared?hello_dynamic_partial.c
libhello_dynamic_full.so?:?hello_dynamic_full.c
?gcc?-o?libhello_dynamic_full.so?-shared?hello_dynamic_full.c
.PHONY?:
clean:
?rm?-f?hello?*.o?*.a?*.so
從Makefile中可以看出他們的鏈接關(guān)系。下面是程序運(yùn)行的結(jié)果:
可以看出hello程序可以直接調(diào)用本文件內(nèi)的函數(shù),也可以調(diào)用隱式靜態(tài)鏈接中的函數(shù),也可以調(diào)用顯示靜態(tài)鏈接也就是鏈接到靜態(tài)庫的函數(shù),也可以調(diào)用半動態(tài)鏈接中的so的函數(shù),也可以通過dlopen、dlsym進(jìn)行完全動態(tài)鏈接。前三者在調(diào)用方式上沒有區(qū)別,都是先聲明后調(diào)用,最后一個不需要聲明函數(shù),但是需要手動解析函數(shù)的地址,并賦值給函數(shù)指針才能調(diào)用。
我把演示代碼放到GitHub上了,大家可以參考一下 https://github.com/orangeboyye/hello-link
五、總結(jié)回顧
本文中,我們先回顧了編譯系統(tǒng)的發(fā)展歷史,理解了編譯系統(tǒng)各個組成部分的原理和相互之間的關(guān)系,然后又介紹了各個模塊內(nèi)部的知識。都是一些簡單的概念性的介紹,想要深入學(xué)習(xí)的同學(xué)可以去研究參考文獻(xiàn)中的書籍。
審核編輯:湯梓紅
評論
查看更多