02.18 開發者應知道的編譯原理和語言基礎知識

鏈接:https://www.jianshu.com/p/0913993a4c3f

開發者應知道的編譯原理和語言基礎知識

一、以 Hello World開篇

Hello World對程序員而言肯定是如雷貫耳。但是簡單的事物背後往往包含這個複雜的機制,如果深入思考Hello world就會發現很多問題。C語言中的Hello World往往是這樣寫的:

#include <stdio.h>
int main(){
printf("Hello World");
return 0;
}
/<stdio.h>

但是你是否想過以下問題:

1、程序為什麼要被編譯之後才能運行?

2、編譯器在把C語言程序轉換成可以執行的機器碼的過程中做了什麼?

3、最後編譯出來的可執行文件裡面是什麼?除了機器碼還有什麼?如何存放的?

4、#include <stdio.h>的包含意味著什麼?又是如何實現的?/<stdio.h>

5、什麼是編譯器,它以什麼為分界線分為前端和後端?編譯器和解釋器有什麼區別,為什麼會有解釋型語言一說?

6、以及由此延伸出的一些相關問題:Swift 是靜態語言,為什麼還有運行時庫?OC中的Runtime和運行時庫是什麼關係?

7、什麼是ABI ?ABI穩定對一門語言的發展有何影響 ?為什麼 Swift 打包的 App 會平白無故的多出幾Mb ?

8、........

等等,還有很多問題,這些問題實際上和編譯都脫離不了干係。讀完本篇文章,你的這些疑惑都能得到解答。除此之外,你還將掌握一些主流語言的基本知識。另外,繼該篇文章之後,筆者打算後期再寫一篇文章動手試試LLVM。歡迎關注。。。。。。

二、編譯總過程預覽

相信讀者對編譯的整個流程組成部分應該相對比較熟悉。整個流程包括預處理(Prepressing)、編譯(Compilation)、彙編(Assembly)和鏈接(Linking)。

開發者應知道的編譯原理和語言基礎知識

GCC編譯hello world程序過程分解

預編譯

首先是源代碼文件hello.c和相關頭文件,如 stdio.h 被編譯器 cpp預編譯到一個 .i 文件。預編譯過程主要是處理那些源代碼文件中以 # 開始的預編譯指令。比如#include 、#include 等。經預編譯後的 .i 文件不包含任何宏定義,因為所有的宏已經被展開,並且包含的文件已經被插入到 .i 文件中。

編譯

編譯過程就是把預處理的文件經過一系列的詞法分析、語法分析、語義分析、生成中間代碼、生成目標代碼 優化後生產相應的彙編文件代碼。

編譯器以中間代碼為界限,又可以分前端和後端。比如 clang 就是一個前端工具,而 LLVM 則負責後端處理。另一個知名工具 GCC(GNU Compile Collection)則是一個套裝,包攬了前後端的所有任務。

前端主要負責預處理、詞法分析、語法分析,最終生成語言無關的中間代碼。後端主要負責目標代碼的生成和優化。後面我會重點介紹編譯的整個過程的每一步。這裡暫時簡單提一下。

彙編

彙編器將彙編代碼轉變成機器可以執行的指令,每一個彙編語句幾乎都對應一條機器指令。所以彙編起的過程相對於編譯器而言是比較簡單的。因為沒有複雜的語法,也沒有予以,所以就不需要做指令優化,只是根據彙編指令和機器指令的對照便一一翻譯就可以了。到這一步,經過預編譯、編譯和彙編就可直接輸出目標文件。

鏈接

在一個目標文件中,不可能所有變量和函數都定義在同一個文件內部。不同文件之間要做相應的鏈接處理。

三、編譯器做了些什麼

最直白的來說,編譯器就是將高級語言翻譯成機器語言的一個工具。先來看一下編譯器的整個流程。從該流程圖我們可以看到編譯器被分為前端和後端,在前端和後端之間的過度是中間代碼。其中編譯器前端包含詞法分析、語法分析、語義分析、中間代碼生成(嚴格意義來說在此四個步驟之前還有預編譯操作);編譯器後端主要是代碼優化、目標代碼生成以及目標代碼優化,編譯器的大致自責就是如此。編譯器在整個編譯過程中輸入源是源代碼,輸出的是中間代碼。

開發者應知道的編譯原理和語言基礎知識

3.1 詞法分析

首先是源代碼程序被輸入到掃描器,掃描器的任務很簡單,它只是簡單的進行詞法分析。運用有限狀態機的算法可以很輕鬆的將源代碼的字符序列分割成一系列的記號。記號一般分為如下幾類:關鍵字、標識符、字面量(數字和字符串等)以及特殊符號(如+,= .....)。

lex程序可以實現詞法分析,它會按照用戶之前掃描害的詞法規則將輸入的字符串分割成一個個記號。因為這樣一個程序的存在,編譯器開發者就無需為每一個編譯器開發一個獨立的詞法掃描器,而是根據需要改變詞法規則就可以了。

3.2 語法分析

這一步驟語法分析器將由掃描器產生的記號進行語法分析,從而產生語法樹。簡單的講,由語法分析器生成的語法樹就是以表達式為節點的樹。

int fun(int a, int b) {
int c = 0;
c = a + b;
return c;
}

以如上代碼為例,它的語法樹形式如下:

開發者應知道的編譯原理和語言基礎知識

語法樹將字符串格式的源代碼轉化為樹狀的數據結構,更容易被計算機理解和處理。如前面詞法分析的 lex 一樣,語法分析同樣有現成的工具,其中有一個叫做Yet Another Compiler Compiler 簡稱 yacc 的工具。它可以根據用戶給定的語法規則對輸入的記號序列進行解析,從而構建出一棵語法樹。針對不同的語言,一般編譯器開發者只需要改變語法規則,根本不需要為每個編譯器重新寫一個語法分析器,所以它又被稱為編譯器的編譯器。3.3 語義分析

語義分析有語義分析器完成。語義分析之前的語法分析僅僅只是完成了對錶達式成眠的語法層面分析,但是它並不能確定這個語句是否真正有意義。如OC中兩個 Person 對象實例直接做加減乘除運算,實際上是沒有意義的,但是在語法分析上確實合情合理。這裡要說明一下編譯器分析的語義都是靜態語義,靜態語義是指在編譯器件可以確定的語義,與之對應的動態語義只能在運行期間才能被確定。

靜態語義分析通常包括聲明、類型匹配、類型轉換等。如一個浮點類型賦值給整形變量,其中就隱含了浮點類型轉換為整型的語義;動態語義分析是指運行期間出現的相關語義問題。

經過語義分析之後,在語法分析生成的語法樹的基礎上進一步對錶達式做一些標識。如:有些某些類型需要做隱式轉化,語義分析器會在之前的語法樹中插入相應的轉換節點。

3.4 生成中間代碼

3.4.1 生成中間代碼的意義

理論上來說,中間代碼是可以直接被省略的,因為抽象語法樹可以直接轉為目標代碼(彙編代碼)。然而不同的 CPU 架構採用的彙編語法並不一樣,如: Intel 架構和 AT&T 架構的彙編碼中,源操作數和目標操作數位置恰好相反參考鏈接。中間代碼可以理解為抽象的代碼,一方面它和語言無關,同時也和 CPU 無關,它僅僅只是描述了代碼要做的事情,可以將其理解為是全世界通用的語言,任何語言都可以轉換為世界語言,而世界語言又能被任何人翻譯理解。要知道,中間代碼的存在使得編譯器被分為前端和後端。其中編譯器前端主要負責產生與機器無關的中間代碼,編譯器後端主要是將中間代碼轉換成目標機器代碼。因為這意味著針對那些跨平臺的編譯器而言,可以針對不同的平臺使用同一個前端和針對不同機器平臺的多個後端。

3.4.2 生成中間代碼的過程

生成中間代碼主要包含以下步驟,以下是用 GCC 編譯器為實例說明。

  • 1、語法樹轉高端 gimple(簡化樹)
  • 該步驟主要是處理寄存器和棧,比如拿c = a + b來說通常顯示執行a + b,然後將該結果保存到寄存器中,最後再將其賦值給c。此外,調用函數時會進入到此函數自己的棧中,建棧操作需要在gimple中聲明。
  • 2、高端 gimple 轉低端 gimple
  • 該步驟主要是把變量定義、語句執行和返回語句區分開來,分別存儲,這樣可以很好的計算一個函數所需要的棧空間。同時在這一步驟中,return語句會被統一處理轉換成goto語句,返回值同意放在最後處理。
if (1 > 0) {
return 1;
}
else {
return 0;
}
//上述代碼將會被轉換成如下形式:
if (1 > 0) {
goto a;
}
else {
goto b;
}
a:
return 1;

b:
return 0;
  • 3、低端 gimple 經過 cfa 轉 ssa 再轉中間代碼。
  • 該步驟主要是進行各種相關的優化。

3.5 目標代碼生成與優化

經過上面生成中間代碼步驟之後,這一步驟屬於編譯器後端。該步驟主要的任務是生成並優化目標代碼,目標代碼亦稱為彙編代碼(其實和彙編代碼非常接近)。編譯器後端主要包括目標代碼生成器和目標代碼優化去。

代碼生成器將中間代碼轉換成目標機器代碼,此過程依賴目標機器,應為不同的機器有不同的寄存器、整數數據類型和浮點數據類型等。

目標代碼優化器主要是對目標代碼進行優化,如:選擇合適的尋址方式、使用位移代替乘法運算、刪除多餘的指令等。

3.6 編譯過程小結

編譯器的結構實際上是異常複雜的,主要在於三個因素。

  • 1、高級編程語言本身就異常複雜。
  • 就拿C++來說,至今沒有一個編譯器能夠比較完整的支持C++標準語言所規定的所有語言特性。
  • 2、計算機的 CPU 也同樣異常複雜。
  • 3、要求編譯器要支持多種硬件平臺,即要求編譯器能生成與多種 CPU 匹配的代碼。
開發者應知道的編譯原理和語言基礎知識

四、彙編

彙編過程中輸入源是彙編代碼,輸出是二進制機器碼(後綴為 .o 的目標文件)。輸出的二進制機器碼可以直接被 CPU 識別並執行。彙編過程相對於編譯器過程而言相對簡單些,因為沒有複雜的語法、沒有語義、不需要做指令優化,根據彙編指令和機器指令的對照表一一翻譯即可。

由於彙編更接近機器語言,能夠直接對硬件進行操作,生成的程序與其他的語言相比具有更高的運行速度,佔用更小的內存,因此在一些對於時效性要求很高的程序、許多大型程序的核心模塊以及工業控制方面大量應用。

五、鏈接

5.1 鏈接的簡單介紹

大型軟件往往有成千上萬的模塊,模塊之前相互依賴但又獨立。一個程序被分割成多個模塊之後,這些模塊又是通過何種形式組合成一個完整的程序?模塊之間如何組合的問題實際上就是模塊之間的通信問題。

鏈接過程主要包括了:

  • 地址和空間的分配(Address and Storage Alloction)
  • 符號決議(Symbol Resolution)Ps:"決議"更傾向於靜態鏈接,而"綁定"更傾向於動態鏈接。
  • 重定位(Relocation)
開發者應知道的編譯原理和語言基礎知識

鏈接的過程

讓我們來看看什麼是重定位。假設有個全局變量叫做 var ,它在目標文件A裡面。我們在目標文件B裡面要訪問這個全局變量。由於在編譯目標文件B的時候,編譯器並不知道變量var的目標地址,所以編譯器在沒法確定的情況下,將目標地址設置為0,等待鏈接器在目標文件A和B連接起來的時候將其修正。這個地址修正的過程被叫做重定位,每個被修正的地方叫一個重定位入口。

鏈接器就是靠著重定位表來知道哪些地方需要被重定位的。每個可能存在重定位的段都會有對應的重定位表。在鏈接階段,鏈接器會根據重定位表中,需要重定位的內容,去別的目標文件中找到地址並進行重定位。

5.2靜態鏈接的缺點

  • 靜態鏈接這種方法的確很簡單,原理上很容易理解,實踐上很難實現,但是靜態鏈接對於計算機內存和磁盤的空間浪費非常嚴重。特別是多進程操作系統的情況下,靜態鏈接極大的浪費了內存和空間。想象一下每個程序內部除了都保留著print()函數、scanf()函數、strlen()等這樣的公用函數庫,還有數量相當可觀的其他庫以及它們所需要的輔助數據結構。
  • 空間浪費是靜態鏈接的一個問題,另一個問題是靜態鏈接對程序的更新、部署和發佈也會帶來很多麻煩。比如Program1所使用的Lib.o是由一個第三方廠商提供的,當該廠商更新了Lib.o的時候(比如修復了lib.o裡面包含的一個bug),那麼Program1的廠商就需要拿到最新版的Lib.o,然後將其與Program1.o鏈接後將新的Program1整個發佈給用戶。這樣做的缺點很明顯,即一旦程序中有任何模塊更新,整個程序就要重新鏈接、發佈給用戶。

基於上述兩個問題,就引出了一個名詞,動態鏈接。

5.3 動態鏈接

要解決上述兩個問題,就是不對哪些組成程序的目標文件進行鏈接,等到程序要運行時才進行鏈接。也就是說,把鏈接這個過程推遲到了運行時再進行,這就是動態鏈接(Dynamic Linking)的基本思想。所謂的動態鏈接表示重定位發生在運行時而非編譯後。

雖然動態鏈接可以解決上述的兩個問題,但是在性能上要略微比靜態鏈接差一些。筆者之前也寫過一篇Swift性能分析的文章,其中也涉及到一些關於OC和Swift語言動態鏈接相關的點。

六、編譯器和解釋器

6.1 解釋型語言

開發者應知道的編譯原理和語言基礎知識

編譯器和解釋器

解釋器是一條一條的解釋執行源語言,不需要編譯直接由解釋器執行,對應的語言稱為解釋型語言也稱作腳本語言。比如 Php,Ruby,JavaScript、Python 等就是典型的解釋性語言。

源代碼 ---> 解釋器 ---> 執行

解釋型語言同編譯型語言相比,編譯器是把源代碼整個編譯成目標代碼,執行時不在需要再去編譯器,直接在支持目標代碼的平臺上運行,所以執行效率比解釋執行快很多。比如C語言代碼被編譯成二進制代碼(exe程序),在windows平臺上執行。

6.2 解釋型語言和編譯型語言的共同點

兩者的共同點很簡單,一句話總結:都需要轉換成二進制才能執行。

6.3 解釋型語言和編譯型語言的不同點

  • 1、運行的時候是否需要編譯器
  • 編譯型語言運行的是最終的二進制代碼了,所以不再需要編譯器;但是解釋型語言邊解釋、邊運行,所以運行時候還有部分代碼沒有解釋好舉個例子:在瀏覽器裡,要看 html 效果,要通過帶有內置編譯工具的軟件去查看(如:瀏覽器或者模擬瀏覽器的工具)。
  • 2、執行速度
  • 毫無疑問邊翻譯邊執行的解釋型語言的速度會比編譯型語言運行速度要慢。但是CPU的運行速度如果很快,你可能看不出來,偶爾會看到“有點卡”的效果。
  • 3、可移植性對比
  • 編譯型語言運行二進制內容,一旦 CPU 指令改變,之前的二進制文件可能運行不了了。即在其他硬件平臺上運行,可能出錯,如果想在其他平臺運行就需要編譯出新的二進制文件,所以編譯型語言可移植性差;解釋型語言在需要的時候才開始編譯、運行,所以具有可移植性,在很多平臺都能運行起來。
  • 4、升級上對比
  • 編譯型語言的二進制文件如果要升級,需要重新下載一個新的二進制文件了。如QQ的升級,就是要重新下載、安裝、覆蓋;
  • 而解釋型的語言,只要重新寫好源代碼即可。如網站平臺升級,用戶只要重新刷新即可。
  • 5、 應用領域
  • 編譯型語言應用領域通常是安裝軟件,如:桌面或手機上安裝軟件;
  • 解釋型的語言的應用領域通常是互聯網,網站等,刷新就可以看到最新效果。

七、額外擴充(運行時、ABI )

7.1 關於運行時

7.1.1 Runtime

如果你是一個 iOS 開發者,想必都聽過並用過 runtime。但其實 runtime 並非是 Objective-C 的專利,絕大多數語言都有這個概念。所以說 runtime 讓 Objective-C 具有動態性這句話是錯誤的。如果要認清楚這一點,感覺有必要先認清楚運行時庫,要知道 runtime 就是運行時庫的一部分。

7.1.2 運行時庫概念

以 C 語言為例說明運行時庫的概念。在 C 語言中 glibc 這個動態鏈接庫通常會被很多操作依賴,包括字符串處理(strlen、strcpy)、信號處理、socket、線程、IO、動態內存分配等等。由於每個程序都依賴於運行時庫,這些庫一般都是動態鏈接的。這樣一來,運行時庫可以存儲在操作系統中,很多程序共享一個動態庫,這樣就可以節省內存佔用空間和應用程序大小。

7.1.3 Swift運行時庫

參照上述 C 語言的運行石庫,就很容易理解 Swift運行時庫的概念了。一方面,swift 是絕對的靜態語言,另一方面,swift 毫無疑問的帶有自己的運行時庫。按照常理來說類似字符串、數組、print 函數都應該是運行時庫中的一部分。然而,Swift 依然沒有穩定自己的 ABI ,導致每個程序都必須自帶運行時庫,這也就是為什麼目前 swift 開發的 app 普遍會增加幾 Mb 包大小的原因。

7.2 ABI 簡單概念

7.2.1 什麼是ABI?

ABI 是 Application Binary Interface的縮寫,它是一個規範。簡單的說它就是編譯後的 API (API 描述了在應用程序級別,模塊之間的調用約定)。 通過這個規範,所有被獨立編譯的二進制實體才能被鏈接在一起並執行。這些二進制實體必須在一些很低層的細節上達成一致,例如:如何調用函數,如何在內存中表示數據,甚至是如何存儲以及訪問數據。要重點知道,ABI 是平臺相關的,因為它關注的這些底層細節會受到不同的硬件架構以及操作系統的的影響。

為了更好的理解什麼是ABI,如下舉個詳細的列子說明。

比如模塊 A 有兩個整數 a 和 b,它們的內存佈局如下:

開發者應知道的編譯原理和語言基礎知識

其他模塊調用 A 模塊的 b 變量,可以通過初始地址加偏移量的方式獲取b變量。如果後來模塊 A 新增了一個整數 c (該過程可以看做是手機系統更新(伴隨著運行時庫更新)),它的內存佈局可能又會變成如下這種形式。如果還是通過初始地址加偏移量的方式獲取變量,那麼此時獲取的是 a 變量,而不再是之前的 b 變量。如果把模塊 A 看做是 Swift 運行時庫,假設現在該運行時庫已經內置於操作系統中並與手機上不同的應用程序動態鏈接在一些。如果每次更新系統,就會出現某些 App 崩潰的情況。如何定義好 A 模塊獲取變量的規則,其中的規則就是所謂的 ABI 。

開發者應知道的編譯原理和語言基礎知識

7.2.2 什麼是ABI穩定?

ABI 穩定就是將 ABI 鎖定在某種形式下,使之後的相關編譯器可以遵守這種二進制實體,這種二進制實體可以是庫也可以是程序。一旦穩定了 ABI ,基本便是它會伴隨著這個平臺一生一世,甚至是走到滅忙。

對 ABI 做出的每一個決定都會對一門編程語言產生長遠的影響,甚至可能會約束一門語言後期的發展和進化。如:Swift 語言一直尚未申明ABI的穩定,但只要申明瞭某個平臺的 ABI 已經穩定,那麼任何有缺陷的設計將永遠伴隨著這個平臺。

7.2.3 ABI穩定了會怎樣?

ABI 穩定之後,OS 發行商就可以把 Swift 標準庫和運行時作為操作系統的一部分嵌入,由於這些標準庫和運行時可以支持用更老或更新版本 Swift 構建的應用程序,這樣,開發者就無需在分發應用的同時,還要帶上一份自己構建應用時使用的標準庫和運行時拷貝。這使得工具和操作系統可以更好的進行集成。 然而目前 Swift 還是一門年輕的語言,ABI 尚未穩定,暫時還未和 iOS 系統硬件綁定,所以在開發移動端應用的時候會發現 app 普遍會增加幾 Mb 包大小。

八、總結

簡單的做個小結,本文顯示總的介紹的整個編譯過程,之後針對編輯中的每個步驟做了進一步的說明。最後相繼介紹了編譯型和解釋型語言的區別、runtime、運行時庫、什麼是 ABI 以及 ABI 穩定的意義。


分享到:


相關文章: