程序員的自我修養閱讀筆記--第二章 編譯與連接

編譯與連接

對於平時的應用程序開發,咱們通常不須要關注編譯和連接過程,由於一般的開發環境都是集成開發環境(IDE),好比 Visual Studio、Dlephi 等。這樣的 IDE 通常都將編譯和連接的過程一步完成,一般將這種編譯和連接合併到一塊兒的過程稱爲構建(Build)前端

1.被隱藏了的過程

#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
複製代碼

使用 GCC 來編譯一個簡單的 Hello World 程序,這個編譯的過程能夠分解爲 4 個步驟:程序員

  • 預處理(Prepressing)
  • 編譯(Compilation)
  • 彙編(Assembly)
  • 連接(Linking)

1.1 預編譯

預編譯過程主要處理那些源代碼文件中的以 # 開始的預編譯指令。好比 #include#define 等,主要處理規則以下:算法

  • 將全部的 #define 刪除,而且展開全部的宏定義。
  • 處理全部條件預編譯指令,好比 #if#ifdef#elifelse#endif
  • 處理 #include 預編譯指令,將被包含的文件插入到該預編譯指令的位置。注意,這個過程是遞歸進行的,也就是說唄包含的文件可能還包含其餘文件。
  • 刪除全部的註釋 ///* */
  • 添加行號和文件名標識,好比 #2 hello.c 2,以便於編譯時編譯器產生調試用的行號信息及用於編譯時產生編譯錯誤或警告時可以顯示行號。
  • 保留全部的 #pragma 編譯器指令,由於編譯器須要使用它們。

通過預編譯的 .i 文件不包含任何宏定義,由於全部的宏已經被展開,而且包含的文件也已經被插入到 .i 文件中。因此當咱們沒法判斷宏定義是否正確或者頭文件包含是否正確時,能夠查看編譯後的文件來肯定問題。編程

1.2 編譯

編譯過程就是把預處理完的文件進行一系列詞法分析、語法分析、語義分析及優化後生產相應的彙編代碼文件,這個過程每每是咱們所說的整個程序構建的核心部分,也是最複雜的部分之一。後端

1.3 彙編

彙編器是將彙編代碼轉變成機器能夠執行的指令,每個彙編代碼幾乎都對應一條機器指令。因此彙編器的彙編過程相對於編譯器來說比較簡單,它沒有複雜的語法,也沒有語義,也不須要作指令優化,只是根據彙編指令和機器指令的對照表一一翻譯就能夠了。數組

1.4 連接

連接一般是一個讓人費解的過程,爲何彙編器不直接輸出可執行文件而是輸出一個目標文件呢?連接過程到底包含了什麼內容?爲何要連接?咱們須要將一大堆文件連接起來才能夠獲得可執行文件,爲何呢?這也是這本書要介紹的內容,它們看似簡單,其實涉及編譯、連接和庫,甚至是操做系統一些很底層的內容。這些都會在以後的章節一一細說。bash

2.編譯器作了什麼

從最直觀的角度來說,編譯器就是將高級語言翻譯成機器語言的一個工具。編程語言

回到編譯器自己的職責上,編譯過程通常能夠分爲 6 步:模塊化

  • 掃描
  • 語法分析
  • 語義分析
  • 源代碼優化
  • 代碼生成
  • 目標代碼優化

2.1 詞法分析

首先源代碼程序被輸入到 掃描器(Scanner),掃描器的任務很簡單,它只是簡單地進行詞法分析,運用一種相似於 有限狀態機(Finite State Machine) 的算法能夠很輕鬆地將源代碼的字符序列分割成一系列的記號(Token)函數

詞法分析產生的記號通常能夠分爲以下幾類:關鍵字、標識符、字面量(包括數字、字符串等)和特殊符號(如加號、等號)。在識別記號的同時,掃描器也完成了其餘工做。好比將標識符存放到符號表,將數字、字符串常量存放到文字表等,以備後面的步驟使用。

另外對於一些預處理的語言,好比 C 語言,它的宏替換和文件包含等工做通常不納入編譯器的範圍而交給一個獨立的預處理器。

2.2 語法分析

接下來語法分析器(Grammar Parser) 將對由掃描器產生的記號進行語法分析,從而產生語法樹(Syntax Tree)。整個分析過程採用了上下文無關語法(Context-free Grammar) 的分析手段。

簡單來講,由語法分析器生成的語法樹就是以 表達式(Expression) 爲節點的樹。咱們知道,C 語言的一個語句是一個表達式,而複雜的語句是不少表達式的組合。

2.3 語義分析

語義分析由 語義分析器(Semantic Analyzer) 來完成。語法分析僅是完成了對錶達式語法層面的分析,可是它並不瞭解這個語句是否真正有意義。好比 C 語言裏面兩個指針作乘法運算是沒有意義的,可是這個語句在語法上是合法的;好比一樣一個指針和一個浮點數作乘法運算是否合法等。編譯器所能分析的語義是 靜態語義(Static Semantic) ,所謂靜態語義是指在編譯期能夠肯定的語義,與之對應的 動態語義(Dynamic Semantic) 就是隻有在運行期才能肯定的語義。

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

通過語義分析階段之後,整個語法樹的表達式都被標識了類型,若是有些類型須要作隱式轉換,語義分析程序會在語法樹中插入相應的轉換節點。

語義分析器還對符號表裏的符號類型也作了更新。

2.4 中間語言的生成

現代的編譯器有着不少層次的優化,每每在源代碼級別會有一個優化過程。咱們這裏所描述的 源碼級優化器(Source Code Optimizer) 在不一樣的編譯器中可能會有不一樣的語義或有一些其餘的差別。

源代碼級優化器會在源代碼級別進行優化,好比 (2 + 6) 這個表達式能夠被優化成 8。其實直接在語法樹上作優化比較困難,因此源代碼優化器每每將整個語法樹轉換成 中間代碼(Intermediate Code),它是語法樹的順序表示,其實它已經很是接近目標代碼了。可是它通常跟目標機器和運行時環境是無關的,好比它不包含數據的尺寸、變量地址和寄存器名字等。中間代碼有不少種類型,在不一樣的編譯器中有着不一樣的形式,比較常見的有:三地址碼(Three-address Code)P-代碼(P-Code)

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

2.5 目標代碼生成與優化

源代碼級優化器產生中間代碼標誌着下面的過程都屬於編譯器後端。編譯器後端主要包括 代碼生成器(Code Generator)目標代碼優化器(Target Code Optimizer)

讓咱們先來看看代碼生成器。代碼生成器將中間代碼轉換成目標機器代碼,這個過程十分依賴於目標機器,由於不一樣機器有着不一樣的字長、寄存器、整數數據類型和浮點數數據類型等。

最後目標代碼優化器對目標代碼進行優化,好比選擇合適的尋址方式、使用位移來代替乘法運算、刪除多餘的指令等。

現代的編譯器有着異常複雜的結構,這是由於現代高級編程語言自己很是的複雜,好比 C++ 語言的定義就極爲複雜,至今沒有一個編譯器可以完整支持 C++ 語言標準所規定的全部語言特性。另外現代的計算機 CPU 至關的複雜,CPU 自己採用了諸如流水線、多發射、超標量等諸多複雜的特性,爲了支持這些特性,編譯器的機器指令優化過程也變的十分複雜。使得編譯過程更爲複雜的是有些編譯器支持多種硬件平臺,即容許編譯器編譯出多種目標 CPU 的代碼。好比著名的 GCC 編譯器就幾乎支持全部 CPU 平臺,這也致使了編譯器的指令生成過程更爲複雜。

通過這些掃描、語法分析、語義分析、源代碼優化、代碼生成和目標代碼優化,編譯器忙活了這麼多步驟之後,源代碼終於被編譯成了目標代碼。可是這個目標代碼中有一個問題是:index 和 array 的地址尚未肯定。若是咱們要把目標代碼使用匯編器編譯成真正可以在機器上執行的指令,那麼 index 和 array 的地址應該從哪兒獲得呢?若是 index 和 array 定義在跟上面的源代碼同一個編譯單元裏,那麼編譯器能夠爲 index 和 array 分配空間,肯定它們的地址;那若是是定義在其它的程序模塊呢?

這個看似簡單的問題引出了咱們的一個很大的話題:目標代碼中有變量定義在其餘模塊,該怎麼辦?事實上,定義其餘模塊的全局變量和函數在最終運行時的絕對地址都要在最終連接的時候才能肯定。因此現代的編譯器能夠將一個源代碼文件編譯成一個未連接的目標文件,而後由連接器最終將這些目標文件連接起來造成可執行文件。

3.連接器年齡比編譯器長

最開始的程序要須要直接手寫機器語言,也就是 010101110…… 後來先驅們發明了彙編語言,生產力大大提升,隨之而來的是軟件的規模也開始日漸龐大,這時程序的代碼量也已經開始快速的膨脹,致使人們要開始考慮將不一樣功能的代碼以必定的方式組織起來,使得更加容易閱讀和理解,以便於往後修改和重複使用。天然而然,人們開始將代碼按照功能或性質劃分,分別造成不一樣的功能模塊,不一樣的模塊之間按照層次結構或其餘結構來組織。這個在現代的軟件源代碼組織中很常見,好比在 C 語言中,最小的單位是變量和函數,若干個變量和函數組成一個模塊,存放在一個 .c 的源代碼文件裏,而後這些源代碼文件按照目錄結構來組織。在比較高級的語言中,如 Java 中,每一個類都是一個基本的模塊,若干個類模塊組成一個 包(Package),若干個包組合成一個程序。

在現代軟件開發中,軟件的規模每每都很大,動輒數百萬行代碼,若是都放在一個模塊確定沒法想象。因此現代的大型軟件每每擁有成千上萬個模塊,這些模塊之間相互依賴又相互獨立。這種按照層次化及模塊化存儲和組織源代碼有許多好處,好比代碼更容易閱讀、理解、重用,每一個模塊能夠單獨開發、編譯、測試,改變部分代碼不須要編譯整個程序等。

在一個程序被分割成多個模塊後,這些模塊之間最後如何組合造成一個單一的程序是須解決的問題。模塊之間如何組合的問題能夠歸結爲模塊之間如何通訊的問題,最多見的屬於靜態語言的 C/C++ 模塊之間通訊有兩種方式,一種是模塊間的函數調用,另一種是模塊間的變量訪問。函數訪問須知道目標函數的地址,變量訪問也須知道目標變量的地址,因此這兩種方式能夠歸結爲一種方式,那就是模塊間符號的引用。模塊間依靠符號來通訊相似於拼圖版,定義符號的模塊多出一塊區域,引用該符號的模塊恰好少了那一塊區域,二者一拼接恰好完美組合。

這個模塊的拼接過程就是本書的一個主題:連接(Linking)

4.模塊拼裝——靜態連接

程序設計的模塊化是人們一直在追求的目標,由於當一個系統十分複雜的時候,咱們不得不將一個複雜的系統逐步分割成小的系統以達到各個突破的目的。一個複雜的軟件也如此,人們把每一個源代碼模塊獨立地編譯,而後按照須要將它們 「組裝」 起來,這個組裝模塊的過程就是 連接(Linking)。連接的主要內容就是把各個模塊之間相互引用的部分都處理好,使得各個模塊之間可以正確地銜接。從原理上講,它的工做無非就是把一些指令對其餘符號地址的引用加以修正。連接過程主要包括了 地址和空間分配(Address and Storage Allocation)符號決議(Symbol Resolution)重定位(Relocation) 等這些步驟。

符號決議有時候也被叫作符號綁定(Symbol Binding)、名稱綁定(Name Binding)、名稱決議(Name Resolution),設置還有叫作地址綁定(Address Binding)、指令綁定(Instructiong Binding)的,大致上它們的意思都同樣,但從細節角度來區分,它們之間仍是存在必定區別的,好比 「決議」 更傾向於靜態連接,而 「綁定」 更傾向於動態連接,即它們所使用的範圍不同。在靜態連接,咱們統一稱爲符號決議。

每一個模塊的源代碼(如 .c)文件通過編譯器編譯成 目標文件(Object File,通常擴展名爲 .o 或 .obj),目標文件和 庫(Library) 一塊兒連接造成最終可執行文件。而最多見的庫就是 運行時庫(Runtime Library),它是支持程序運行的基本函數的集合。庫其實就是一組目標文件的包,就是一些最經常使用的代碼編譯成目標文件後打包存放。

現代的編譯和連接過程也並不是想象中的那麼複雜,它仍是一個比較容易理解的概念。好比咱們在程序模塊 main.c 中使用另一個模塊 func.c 中的函數 foo()。咱們在 main.c 模塊中每一處調用 foo 的時候都必須確切知道 foo 這個函數的地址,可是因爲每一個模塊都是單獨編譯的,在編譯器編譯 main.c 的時候它並不知道 foo 函數的地址,因此它暫時把這些調用 foo 的指令的目標地址擱置,等待最後連接的時候由連接器去將這些指令的目標地址修正。若是沒有連接器,須要咱們手工把每一個調用 foo 的指令修正,填入正確的 foo 函數地址。當 func.c 模塊被從新編譯,foo 函數的地址有可能改變時,咱們在 main.c 中全部使用到 foo 的地址的指令將要所有從新調整,這些繁瑣的工做將成爲程序員的噩夢。使用連接器,你能夠直接引用其餘模塊的函數和全局變量而無需知道它們的地址,由於連接器在連接的時候,會根據你所引用的符號 foo,自動去相應的 func.c 模塊中查找 foo 的地址,而後將 main.c 模塊中全部引用到 foo 的指令從新修正,讓它們的目標地址爲真正的 foo 函數的地址。這就是靜態連接的最基本的過程和做用。

在連接過程當中,對其餘定義在目標文件中的函數調用的指令須要被重調整,對使用其餘定義在其餘目標文件的變量來講,也存在一樣的問題。讓咱們來結合具體的 CPU 指令來了解這個過程。假設咱們有個全局變量叫作 var,它在目標文件 A 裏面。咱們在目標文件 B 裏面要訪問這個全局變量,好比咱們在全局變量中有這麼一條指令:

mov1 $0x2a, var
複製代碼

這條指令就是給這個 var 變量賦值 0x2a,至關於 C 語言裏面的語句 var = 42。而後咱們編譯目標文件 B,獲得這條指令機器碼。

因爲在編譯目標文件 B 的時候,編譯器並不知道變量 var 的目標地址,因此編譯器在無法肯定地址的狀況下,將這條 mov 指令的目標地址置爲 0,等待連接器在將目標文件 A 和 B 連接起來的時候再將其修正。咱們假設 A 和 B 連接後,變量 var 的地址唄肯定下來爲 0x1000,那麼連接器就會把這個指令的目標地址部分修改成 0x10000。這個地址修正的過程也被叫作 重定位(Resolution),每一個要被修正的地方叫一個 重定位入口(Resloution Entry)。重定位所作的就是給程序中每一個這樣的絕對地址引用的位置 「打補丁」,使它們指向正確的地址。

5.本章小結

在這一章中,咱們首先回顧了從程序源代碼到最終可執行文件的 4 個步驟:預編譯、編譯、彙編、連接,分析了它們的做用及相互之間的聯繫,IDE 集成開發工具和編譯器默認的命令一般將這些步驟合併成一步,使得咱們一般不多關注這些步驟。

咱們還詳細回顧了上面這 4 個步驟中的主要部分,即編譯步驟。介紹了編譯器將 C 程序源代碼轉變成彙編代碼的若干個步驟:詞法分析、語法分析、語義分析、中間代碼生成、目標代碼生成與優化。最後咱們介紹了連接的歷史和靜態連接的一系列基本概念:重定位、符號、符號決議、目標文件、庫、運行時庫等概念。

相關文章
相關標籤/搜索