原文連接前端
最近在看《程序員的自我修養——連接、裝載與庫》一書,這本書之前看過一部分,因爲難啃,當時沒有堅持下去。如今工做了,天天接觸的都是業務開發,對底層的一些東西感受愈來愈陌生。因而,又把此書翻了出來拜讀。爲了加深閱讀的印象,打算對書中的一些有價值的內容進行整理,也方便後續回顧。linux
下面以「Hello World」程序爲例,來介紹程序的編譯與連接過程。程序員
// hello.c
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
複製代碼
在Linux下,能夠直接使用GCC來編譯Hello World程序:算法
$ gcc hello.c
$ ./a.out
Hello World!
複製代碼
GCC編譯命令隱藏了構建過程當中的一些複雜的步驟,主要有4個步驟,以下圖所示。編程
預編譯步驟將源代碼文件 hello.c
以及相關頭文件,如:stdio.h
等預編譯生成一個.i文件。對於C++程序,其源代碼文件的擴展名多是.cpp或.cxx,頭文件的擴展名多是.hpp,預編譯生成.ii文件。後端
預編譯步驟至關於執行以下命令(選項-E表示只進行預編譯)數組
$ gcc -E hello.c -o hello.i
複製代碼
或bash
$ cpp hello.c > hello.i
複製代碼
預編譯 主要處理源代碼中的以「#」開始的預編譯指令,如:「#include」、「#define」等,其主要處理規則以下:編程語言
預編譯生成的.i文件不包含任何宏定義,由於全部的宏已經被展開,而且包含的文件也已經被插入到.i文件中。因此當咱們沒法判斷宏定義是否正確或頭文件包含是否正確時,能夠查看預編譯後的文件來肯定問題。函數
編譯 就是把預處理生成的文件進行一系列詞法分析、語法分析、語義分析、優化,生成相應的彙編代碼文件。這個過程是整個程序構建的核心部分,也是最複雜的部分之一。
編譯步驟至關於執行以下命令:
$ gcc -S hello.i -o hello.s
複製代碼
或
$ gcc -S hello.c -o hello.s
複製代碼
如今版本的GCC把預編譯和編譯兩個步驟合併成了一個步驟,使用一個叫cc1的程序來完成。該程序位於「/usr/lib/gcc/x86_64-linux-gnu/4.8/
」,咱們能夠直接調用cc1來完成它:
$ /usr/lib/gcc/x86_64-linux-gnu/4.8/cc1 hello.c
複製代碼
事實上,對於不一樣的語言,預編譯與編譯的程序是不一樣的,以下所示:
GCC是對這些後臺程序的封裝,它會根據不一樣的參數來調用預編譯程序cc一、彙編器as、連接器ld。
彙編 就是將彙編代碼轉換成機器能夠執行的指令,每個彙編語句幾乎都對應一條機器指令。彙編過程相對於編譯比較簡單,其沒有複雜的語法、語義,也無需作指令優化,只是根據彙編指令和機器指令的對照表進行翻譯。
彙編步驟至關執行以下命令:
$ gcc -c hello.s -o hello.o
複製代碼
或
$ gcc -c hello.c -o hello.o
複製代碼
GCC本質上是調用匯編器as來完成彙編步驟的,咱們能夠直接調用as來完成該步驟:
$ as hello.s -o hello.o
複製代碼
連接 主要是將前面步驟生成多個目標文件進行重定位等複雜的操做,從而生成可執行文件。連接可分爲靜態連接和動態連接。
編譯過程能夠分爲6個步驟,以下圖所示。
下面咱們以一行簡單的C語言代碼爲例,簡單描述從 源代碼(Source Code) 到 最終目標代碼 的過程。代碼示例以下:
// CompilerExpression.c
array[index] = (index + 4) * (2 + 6)
複製代碼
首先源代碼被輸入到 掃描器(Scanner),掃描器的任務很簡單,只是簡單地進行詞法分析,運用一種相似於 有限狀態機(Finite State Machine) 的算法將源代碼的字符序列分割成一系列的 記號(Token)。
以上述代碼爲例,總共包含了28個非空字符,通過掃描後,產生了16個記號。
記號 | 類型 | 記號 | 類型 |
---|---|---|---|
array | 標識符 | [ | 左方括號 |
index | 標識符 | ] | 右方括號 |
= | 賦值 | ( | 左圓括號 |
index | 標識符 | + | 加號 |
4 | 數字 | ) | 右圓括號 |
* | 乘號 | ( | 左圓括號 |
2 | 數字 | + | 加號 |
6 | 數字 | ) | 右圓括號 |
詞法分析產生的記號通常能夠分爲一下幾類:關鍵字、字面量(包含數字、字符串等)和 特殊符號(如加號、等號)。
在識別記號的同時,掃描器也完成了其餘工做。如:將標識符存放到符號表,將數字、字符串常量存放到文字表等,以備後面的步驟使用。
有一個名爲lex的程序能夠實現詞法掃描,它會按照用戶以前描述好的詞法規則將輸入的字符串分割成一個個記號。正由於有這樣一個程序存在,編譯器的開發者就無需爲每一個編譯器開發一個獨立的詞法掃描器,而是根據須要改變詞法規則便可。
語法分析器(Grammar Parser) 將對由掃描器產生的記號進行語法分析。從而產生 語法樹(Syntax Tree)。整個分析過程採用了 上下文無關語法(Context-freeGrammar) 的分析手段。簡單地講,由語法分析器生成的語法樹是以 表達式(Expression) 爲節點的樹。
以上述代碼爲例,其中的語句就是一個由賦值表達式、加法表達式、乘法表達式、數組表達式、括號表達式組成的複雜語句,下圖所示爲該語句通過語法分析器後生成的語法樹。
// CompilerExpression.c
array[index] = (index + 4) * (2 + 6)
複製代碼
在語法分析的同時,不少運算符號的優先級和含義也被肯定下來了。如:乘法表達式的優先級比加法高,圓括號表達式的優先級比乘法高,等等。另外,有些符號具備多重含義,如「*」在C語言中能夠表示乘法表達式,也能夠表示對指針取內容的表達式,所以語法分析階段必須對這些內容進行區分。若是出現了表達式不合法,如各類括號不匹配、表達式中缺乏操做符等,編譯器就會報告語法分析階段的錯誤。
有一個名爲yacc(Yet Another Compiler Compiler)的工具能夠實現語法分析。其根據用戶給定的語法規則對輸入的記號序列進行解析,從而構建出語法樹。對於不一樣的編程語言,編譯器的開發者只需改變語法規則,而無需爲每一個編譯器編寫一個語法分析器。所以,其也稱爲「編譯器編譯器(Compiler Compiler)」
語法分析僅僅完成了對錶達式的語法層面的分析,但它並不瞭解這個語句的真正含義,如:C語言裏兩個指針作乘法運算是沒有意義的,但這個語句在語法上是合法的。編譯器所能分析的語義是 靜態語義(Static Semantic),所謂靜態語義是指在編譯期間能夠肯定的語義,與之對應的 動態語義(Dynamic Semantic) 就是隻有在運行期才能肯定的語義。
靜態語義一般包括聲明和類型的匹配,類型的轉換。好比當一個浮點型的表達式賦值給一個整型的表達式時,其中隱含了一個浮點型到整型的轉換過程,語義分析過程當中須要完成該步驟。好比講一個浮點賦值給一個指針時,語義分析程序會發現這個類型不匹配,編譯器將會報錯。動態語義通常是指在運行期出現的語義相關的問題,好比將0做爲除數是一個運行期語義錯誤。
通過語義分析階段以後,整個語法樹的表達式都被標識了類型,若是有些類型須要作隱式轉換,語義分析程序會在語法樹中插入相應的轉換節點。下圖所示爲標記語義後的語法樹。
現代編譯器有着不少層次的優化,源碼優化器(Source Code Optimizer) 則是在源代碼級別進行優化。上述例子中,(2 + 6)這個表達式能夠被優化掉。由於它的值在編譯期就能夠被肯定。下圖所示爲優化後的語法樹。
事實上,直接在語法樹上做優化比較困難,因此源代碼優化器每每將整個語法樹轉換成 中間代碼(Intermediate Code),它是語法樹的順序表示,其實它已經很是接近目標代碼了。但它通常與目標機器和運行時環境是無關的,好比它不包含數據的尺寸、變量地址和寄存器的名字等。
中間代碼有不少種類型,在不一樣的編譯器中有着不一樣的形式,比較常見的有:三地址碼(Three-address Code)、P-代碼(P-Code)。以三地址碼爲例,最基本的三地址碼以下所示:
x = y op z
# 表示將變量y和z進行op操做後,賦值給x。
複製代碼
所以,能夠將上述例子的代碼翻譯成三地址碼:
t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] = t3
複製代碼
爲了使全部的操做符合三地址碼形式,這裏使用了幾個臨時變量:t一、t2和t3。在三地址碼的基礎上進行優化時,優化程序會將2+6的結果計算出來,獲得t1 = 6。所以,進一步優化後能夠獲得以下的代碼:
t2 = index + 4
t2 = t2 * 8
array[index] = t2
複製代碼
中間代碼將編譯器分爲 前端(Front End) 和 後端(Back End)。編譯器前端負責產生機器無關的中間代碼,編譯器後端負責將中間代碼轉換成目標機器代碼。這樣,對於一些可跨平臺的編譯器,它們能夠針對不一樣的平臺使用同一個前端和針對不一樣機器平臺的數個後端。好比clange就是一個前端工具,而LLVM則負責後端處理。GCC則是一個套裝,包攬了先後端的全部任務。
目標代碼生成主要由 代碼生成器(Code Generator) 完成。代碼生成器將中間代碼轉換成目標機器代碼,該過程十分依賴目標機器,由於不一樣的機器有着不一樣的字長、寄存器、整數數據類型和浮點數數據類型等。
上述例子的中間代碼,通過代碼生成器的處理以後可能會生成以下所示的代碼序列(以x86彙編爲例,假設index的類型爲int型,array的類型爲int型數組):
movl index, %ecx ; value of index to ecx
addl $4, %ecx ; ecx = ecx + 4
mull $8, %ecx ; ecx = ecx * 8
movl index, %eax ; value of index to eax
movl %ecx, array(,%eax,4) ; array[index] = ecx
複製代碼
目標代碼生成後,由 目標代碼優化器(Target Code Optimizer) 來進行優化。好比選擇合適的尋址方式、使用位移來代替乘法運算、刪除多餘的指令等。
上述例子中,乘法由一條相對複雜的 基址比例變址尋址(Base Index Scale Addressing) 的lea指令完成,隨後由一條mov指令完成最後的賦值操做,這條mov指令的尋址方式與lea是同樣的。以下所示爲優化後的目標代碼:
movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
複製代碼
通過掃描、語法分析、語義分析、源代碼優化、目標代碼生成、目標代碼優化等一系列步驟以後,源代碼終於被編譯成了目標代碼。可是這個目標代碼中有一個問題:
index和array的地址尚未肯定
若是咱們把目標代碼使用匯編器編譯成真正可以在機器上運行的指令,那麼index和array的地址來自哪裏?
若是index和array定義在跟上面的源代碼同一個編譯單元裏,那麼編譯器能夠爲index和array分配空間,肯定地址;但若是是定義在其餘的程序模塊呢?
事實上,定義其餘模塊的全局變量和函數在最終運行時的絕對地址都要在最終連接的時候才能肯定。因此現代編譯器能夠將一個源文件編譯成一個未連接的目標文件,而後由編譯器最終將這些目標文件連接起來造成可執行文件。
後面,咱們將繼而探討連接的原理。
(完)