編譯和鏈接


摘自《程序員自我修養》


鏈接的原因

在一個程序被分割為多個模塊以后,這些模塊之間最后如何組合形成一個單一的程序是須要解決的問題。模塊之間如何組合的問題可以歸結為模塊之間如何通信的問題,最常見的屬於靜態語言的C、C++之間通信的方式,一種是模塊之間的函數調用,另外一種是模塊間的變量訪問。函數訪問須知道目標函數的地址,變量訪問也須知道目標變量的地址,所以這兩種方式都可以歸結為一種方式,那就是模塊之間的符號引用。模塊間依靠符號來通信類似於拼圖版,定義符號的模塊多出一個區域,引用該符號的模塊剛好燒錄那一塊區域,兩者的拼接剛好完美組合。這個模塊組合的過程就是鏈接。

靜態鏈接

鏈接過程主要包括了地址和空間分配符號決議重定位等這些步驟。

符號決議有時候也被叫做符號綁定(Symbol Binding)、名稱綁定(Name Binding)。甚至還有叫做地址綁定(Address Binding)、指令綁定(Instruction Binding)的,大體上它們的意思都一樣,但從細節角度來區分,它們之間還是存在一定區別的,比如“決議”更傾向於靜態鏈接,而“綁定”更傾向於動態鏈接,即它們所使用的范圍不一樣。在靜態鏈接中,我們統一稱為“符號決議”。

最基本的靜態鏈接過程如圖2-8所示。編譯過程如下圖:

現代編譯和鏈接過程並非想象那么復雜,它還是一個容易理解的概念,比如我們在程序模塊main.c使用另外一個模塊func.c中的函數foo()。我們在main.c模塊中每一處調用的foo的時候都必須確切知道foo函數的地址,所以它暫時把這些調用foo的指令的目標地址擱置,等待最后鏈接的時候由鏈接器去將這些指令的目標地址進行修正,則填入正確的foo函數地址。當func.c模塊重新編譯,foo函數的地址有可能改變時,那么我們在main.c中所有使用到foo的地址的指令將要全部重新調整。這些繁瑣的工作將成為程序員的噩夢。使用鏈接器,你可以直接引用其他模塊的函數和全局變量而無需知道它們的地址,因為鏈接器,你可以直接引用其他模塊的函數和全局變量而無須知道它們的地址,因為鏈接器在鏈接的時候會根據引用的符號foo,自動去相應的func.c模塊查找foo的地址,然后將main.c模塊中所有引用到foo的指令重新修正,讓它們的目標地址為真正的foo函數的地址。這就是靜態鏈接的最基本功能和作用。
在鏈接過程中,對其他定義在目標文件中的函數調用的指令須要被重新調整,對使用其他定義在其他目標文件的變量來說,也存在同樣的問題。讓我們結合具體CPU指令來理解這個過程,假設我們有個全局變量,比如我們在目標文件中B里面有一個指令:
movl $0x2a, var
這條指令就是給這個var變量賦值0x2a,相當於C語言中的語句var=42,然后我們編譯目標文件B,得到這條指令機器碼,如圖2-9所示:

由於在編譯目標文件B的時候,編譯器並不知道變量var的目標地址,所以編譯器在無法確定地址的情況下,將這條mov指令的目標地址置為0,等待鏈接器在將目標文件A和B鏈接起來的時候再將其修正。

編譯器做什么?

詞法分析

首先源代碼程序被輸入到掃描器( Scanner),掃描器的任務很簡單,它只是簡單地進行詞法分析,運用一種類似於有限狀態機( Finite State Machine)的算法可以很輕松地將源代碼的字符序列分割成一系列的記號( Token)。比如上面的那行程序,總共包含了28個非空字符,經過掃描以后,產生了16個記號,如表2-1所示。

詞法分析產生的記號一般可以分為如下幾類:關鍵字、標識符、字面量(包含數字、字符串等)和特殊符號(如加號、等號)。在識別記號的同時,掃描器也完成了其他工作。比如將標識符存放到符號表,將數字、字符串常量存放到文字表等,以備后面的步驟使用。

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

另外對於一些有預處理的語言,比如C語言,它的宏替換和文件包含等工作一般不歸入編譯器的范圍而交給一個獨立的預處理器。

語法分析

接下來語法分析器( Grammar Parser)將對由掃描器產生的記號進行語法分析,從而產生語法樹( Syntax Tree)。整個分析過程釆用了上下文無關語法( Context-free Grammar)的分析手段,如果你對上下文無關語法及下推自動機很熟悉,那么應該很好理解。否則,可以參考一些計算理論的資料,一般都會有很詳細的介紹。此處不再贅述。簡單地講,由語法分析器生成的語法樹就是以表達式( Expression)為節點的樹。我們知道,C語言的一個語句是一個表達式,而復雜的語句是很多表達式的組合。上面例子中的語句就是一個由賦值表達式、加法表達式、乘法表達式、數組表達式、括號表達式組成的復雜語句。它在經過語法分析器以后形成如圖2-3所示的語法樹。

從圖23中我們可以看到,整個語句被看作是一個賦值表達式:賦值表達式的左邊是一個數組表達式,它的右邊是一個乘法表達式;數組表達式又由兩個符號表達式組成,等等。符號和數字是最小的表達式,它們不是由其他的表達式來組成的,所以它們通常作為整個語法樹的葉節點。在語法分析的同時,很多運算符號的優先級和含義也被確定下來了。比如乘法表達式的優先級比加法高,而圓括號表達式的優先級比乘法高,等等。另外有些符號具有多重含義,比如星號*在C語言中可以表示乘法表達式,也可以表示對指針取內容的表達式,所以語法分析階段必須對這些內容進行區分。如果出現了表達式不合法,比如各種括號不匹等,編譯器就會報告語法分析階段的錯誤。

語義分析

接下來進行的是語義分析,由語義分析器( Semantic Analyzer)來完成。語法分析僅僅是完成了對表達式的語法層面的分析,但是它並不了解這個語句是否真正有意義。比如C語言里面兩個指針做乘法運算是沒有意義的,但是這個語句在語法上是合法的;比如同樣個指針和一個浮點數做乘法運算是否合法等。編譯器所能分析的語義是靜態語義( Static Semantic),所謂靜態語義是指在編譯期可以確定的語義,與之對應的動態語義( Dynamic Semantic)就是只有在運行期才能確定的語義。

靜態語義通常包括聲明和類型的匹配,類型的轉換。比如當一個浮點型的表達式賦值給個整型的表達式時,其中隱含了一個浮點型到整型轉換的過程,語義分析過程中需要完成這個步驟比如將一個浮點型賦值給一個指針的時候,語義分析程序會發現這個類型不匹配編譯器將會報錯。動態語義一般指在運行期出現的語義相關的問題,比如將0作為除數是一個運行期語義錯誤。

經過語義分析階段以后,整個語法樹的表達式都被標識了類型,如果有些類型需要做隱式轉換,語義分析程序會在語法樹中插入相應的轉換節點。上面描述的語法樹在經

過語義分析階段以后成為如圖2-4所示的形式。

可以看到,每個表達式(包括符號和數字)都被標識了類型。我們的例子中幾乎所有的表達式都是整型的,所以無須做轉換,整個分析過程很順利。語義分析器還對符號表里的符號類型也做了更新。

中間語言生成

現代的編譯器有着很多層次的優化,往往在源代碼級別會有一個優化過程。我們這里所描述的源碼級優化器( Source Code Optimizer)在不同編譯器中可能會有不同的定義或有些其他的差異。源代碼級優化器會在源代碼級別進行優化,在上例中,細心的讀者可能已經發現,(2*6)這個表達式可以被優化掉,因為它的值在編譯期就可以被確定。類似的還有很多其他復雜的優化過程,我們在這里就不詳細描述了。經過優化的語法樹如圖2-5所示。

我們看到(2+6)這個表達式被優化成8。其實直接在語法樹上作優化比較困難,所以源代碼優化器往往將整個語法樹轉換成中間代碼( ntermediate Code),它是語法樹的順序表示,其實它已經非常接近目標代碼了。但是它一般跟目標機器和運行時環境是無關的,比如它不包含數據的尺寸、變量地址和寄存器的名字等。中間代碼有很多種類型,在不同的編器中有着不同的形式,比較常見的有:三地址碼( Three-address Code)和P代碼( P-Code)。我們就拿最常見的三地址碼來作為例子,最基本的三地址碼是這樣的:

這個三地址碼表示將變量y和z進行φp操作以后,賦值給x。這里p操作可以是算數算,比如加減乘除等,也可以是其他任何可以應用到y和z的操作。三地址碼也得名於此因為一個三地址碼語句里面有三個變量地址。我們上面的例子中的語法樹可以被翻譯成三地址碼后是這樣的:

我們可以看到,為了使所有的操作都符合三地址碼形式,這里利用了幾個臨時變量:t1、t2和t3。在三地址碼的基礎上進行優化時,優化程序會將2+6的結果計算出來,得到t1=6,然后將后面代碼中的t替換成數字6。還可以省去一個臨時變量t3,因為t2可以重復利用。

經過優化以后的代碼如下:

t2 = index + 4;
t2 = t2 * 8;
array[index] = t2;

中間代碼使得編譯器可以被分為前端和后端。編譯器前端負責產生機器無關的中間代碼,編譯器后端將中間代碼轉換成目標機器代碼。這樣對於一些可以跨平台的編譯器而言,它們可以針對不同的平台使用同一個前端和針對不同機器平台的數個后端。

目標代碼生成與優化

源代碼級優化器產生中間代碼標志着下面的過程都屬於編輯器后端。編譯器后端主要包括代碼生成器( Code Generator)和目標代碼優化器( Target Code Optimizer)。讓我們先來看看代碼生成器。代碼生成器將中間代碼轉換成目標機器代碼,這個過程十分依賴於目標機器,因為不同的機器有着不同的字長、寄存器、整數數據類型和浮點數數據類型等。對於上面例子中的中間代碼,代碼生成器可能會生成下面的代碼序列(我們用x86的匯編語言來表示,並且假設 index的類型為int型,aray的類型為int型數組)

最后目標代碼優化器對上述的目標代碼進行優化,比如選擇合適的尋址方式、使用位移來代替乘法運算、刪除多余的指令等。上面的例子中,乘法由一條相對復雜的基址比例變址尋址( Base Index Scale Addressing)的lea指令完成,隨后由一條mov指令完成最后的賦值操作,這條mov指令的尋址方式與lea是一樣的

現代的編譯器有着異常復雜的結構,這是因為現代高級編程語言本身非常地復雜,比如C++語言的定義就極為復雜,至今沒有一個編譯器能夠完整支持C++語言標准所規定的所有語言特性。另外現代的計算機CPU相當地復雜,CPU本身采用了諸如流水線、多發射、超標量等諸多復雜的特性,為了支持這些特性,編譯器的機器指令優化過程也變得十分復雜使得編譯過程更為復雜的是有些編譯器支持多種硬件平台,即允許編譯器編譯出多種目標CPU的代碼。比如著名的GCC編譯器就幾乎支持所有CPU平台,這也導致了編譯器的指令生成過程更為復雜。

經過這些掃描、語法分析、語義分析、源代碼優化、代碼生成和目標代碼優化,編譯器忙活了這么多個步驟以后,源代碼終於被編譯成了目標代碼。但是這個目標代碼中有一個問題是:indeⅹ和aray的地址還沒有確定。如果我們要把目標代碼使用匯編器編譯成真正能夠在機器上執行的指令,那么 index和aray的地址應該從哪兒得到呢?如果 index和array定義在跟上面的源代碼同一個編譯單元里面,那么編譯器可以為 index和array分配空間,
確定它們的地址:那如果是定義在其他的程序模塊呢?

一個看似簡單的問題引出了我們一個很大的話題:目標代碼中有變量定義在其他模塊該怎么辦?事實上,定義其他模塊的全局變量和函數在最終運行時的絕對地址都要在最終鏈接的時候才能確定。所以現代的編譯器可以將一個源代碼文件編譯成一個未鏈接的目標文件,然后由鏈接器最終將這些目標文件鏈接起來形成可執行文件。讓我們帶着這個問題,走進鏈接的世界。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM