《程序員的自我修養——鏈接、裝載與庫》——鏈接


導讀

  對於平常的應用程序開發,我們很少需要關注編譯和鏈接過程,因為通常的開發環境都是流行的集成開發環境(IDE),比如Visual Studio、Myeclipse等。這樣的IDE一般都將編譯和鏈接的過程一步完成,通常將這種編譯和鏈接合並在一起的過程稱為構建,即使使用命令行來編譯一個源代碼文件,簡單的一句”gcc hello.c”命令就包含了非常復雜的過程。然而,正是因為集成開發環境的強大,很多系統軟件的運行機制與機理被掩蓋,其程序的很多莫名其妙的錯誤讓我們無所適從,面對程序運行時種種性能瓶頸我們束手無策。我們看到的是這些問題的現象,但是卻很難看清本質,所有這些問題的本質就是軟件運行背后的機理及支撐軟件運行的各種平台和工具,如果能深入了解這些機制,那么解決這些問題就能夠游刃有余,收放自如了。

  現在我們通過一個C語言的經典例子,來具體了解一下這些機制:

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

在linux下只需要一個簡單的命令(假設源代碼文件名為hello.c):

$ gcc hello.c
$ ./a.out
Hello World

  事實上,上述gcc過程可分解為4個步驟:預處理、編譯、匯編、鏈接,如圖所示:

 

 

 

 

 

 

 

 

 

 

目錄

  • 預處理(Prepressing)
  • 編譯(Compliation)
  • 匯編(Assembly)
  • 鏈接(Linking)

正文

1、  預編譯(prepressing)

  首先是源代碼文件hello.c和相關的頭文件(如stdio.h等)被預編譯器cpp預編譯成一個.i文件。第一步預編譯的過程相當於如下命令(-E 表示只進行預編譯):

$ gcc –E hello.c –o hello.i

或者,

$ cpp hello.c > hello.i

  預編譯過程主要處理源代碼文件中以”#”開頭的預編譯指令。比如”#include”、”#define”等,主要處理規則如下:

  • 將所有的”#define”刪除,並展開所有的宏定義
  • 處理所有條件預編譯指令,比如”#if”,”#ifdef”,”#elif” ”,#else”,”#endif”
  • 處理”#include”預編譯指令,將被包含的文件插入到該預編譯指令的位置。
  • 刪除所有的注釋“//”和“/**/”
  • 添加行號和文件名標識,比如#2 “hello.c” 2。
  • 保留所有的#pragma編譯器指令

2、  編譯(compliation)

  編譯過程就是把預處理完的文件進行一系列詞法分析、語法分析、語義分析及優化后生產相應的匯編代碼文件,此過程是整個程序構建的核心部分,也是最復雜的部分之一。其編譯過程相當於如下命令:

$ gcc –S hello.i –o hello.s
 

Gcc是好多后台程序的包裝,它會根據不同的參數要求去調用預編譯程序cc1、匯編器as、鏈接器ld。

3、  匯編(assembly)

  匯編器是將匯編代碼轉變成機器可以執行的指令,每一個匯編語句幾乎對應一條機器令。所以匯編器的匯編過程相對於編譯器來講比較簡單,它沒復雜的語法,也沒有語義,也不需要做指令優化,只是根據匯編指令和機器指令的對照表一一翻譯就可以了。其匯編過程相當於如下命令:

as hello.s –o hello.o

或者,

gcc –c hello.s –o hello.o

或者使用gcc命令從C源代碼文件開始,經過預編譯、編譯和匯編直接輸出目標文件:

gcc –c hello.c –o hello.o

4、  鏈接(linking)

  鏈接通常是一個讓人比較費解的過程,為什么匯編器不直接輸出可執行文件而是輸出一個目標文件呢?為什么要鏈接?下面讓我們來看看怎么樣調用ld才可以產生一個能夠正常運行的Hello World程序:

$ ld –static /usr/lib/crt1.o /usr/lib/crti.o

/usr/lib/gcc/i486-linux-gnu/4.3.1/crtbeginT.o

-L/usr/lib/gcc/i486-linux-gnu/4.3.1 –L/usr/lib/ -L/lib hello.o –start-group

-lgcc –lgcc_eh –lc –end-group /usr/lib/gcc/i486-linux-gnu/4.3.1/crtend.o

/usr/lib/ctrn.o

如果把所有的路徑都省略掉,那么上面的命令:

ld –static crti.o crtbeginT.o hello.o –start-group -lgcc –lgcc_eh –lc –end-group crtend.o ctrn.o

可以看到,我們需要將一大堆文件鏈接起來才可以得到“a.out”,即最終的可執行文件。看到這么復雜的命令,你可能會問,這些.o文件是什么?它們做什么用的?–lgcc_eh –lc –end-group這些又是些什么參數?為什么要使用它們?為什么要將它們和hello.o鏈接起來才可能得到可執行文件?等等。

  現在我們一起在探索他們的條案吧。

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

  在一個程序被分割成多個模塊以后,這些模塊之間最后如何組合形成一個單一的程序是須解決的問題。模塊之間如何組合的問題可以歸結為模塊之間如何通信的問題,最常見的屬於靜態語言的C/C++模擬之間通信有兩種方式:

  • 模塊間的函數調用
  • 模塊間的變量訪問

  函數訪問須知道目標函數的地址,變量訪問也須知道目標變量的地址,所以,這兩種方式都可以歸結為一種方式,那就是模塊間符號的引用。模塊間依靠符號來通信類似於拼圖版,定義符號的模塊多出一塊區域,引用該符號的模塊剛好少了那一塊區域,兩者一拼接剛好完美組合。這個模塊的拼接過程就是鏈接。鏈接的主要內容就是把各個模塊之間相互引用的部分都處理好,使得各個模塊之間都能夠正確的銜接,也就是把一些指令對其他符號地址的引用加以修正。鏈接過程主要包括了地址和空間分配(Address and storage allocation)、符號決議(symbol resolution)和重定位(relocation)等這些步驟。

  現在我們舉例解釋一下編譯和鏈接的概念。比如我們在程序模塊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()的指令重新修正,讓它們的目標地址為真正的foo()函數地址。這也就是靜態鏈接的最基本的過程和作用。

  在鏈接過程中,對其他定義在目標文件中的函數調用的指令須要被重新調整,對使用其他定義在其他目標文件的變量來說,也存在同樣的問題。讓我們結合具體的CPU指令來了解這個過程。假設我們有個全局變量叫做var,它在目標文件A里面。我們在目標文件B里面要訪問這個全局變量,比如我們在目標文件B里面有這么一條指令:

mov1 $0x2a, var

這條指令就是給這個var變量賦值0x2a,相當於C語言里面的語句var = 42。然后我們編譯目標文件B,得到這條指令機器碼,如圖:

由於在編譯文件B的時候,編譯器並不知道變量var的目標地址,所以編譯器在沒法確定地址的情況下,將這條mov指令的目標地址置為0,等待鏈接器在將目標文件A和B鏈接起來的時候,再將其修正。我們假定A和B鏈接后,變量var的地址確定下來為0x1000,那么鏈接器將會把這個指令的目標地址部分修改成0x1000。這個地址修正的過程也被叫做重定位(relocation),每一個要被修正的地方叫一個重定位入口(relocation entry)。重定位所做的就是給程序中每個這樣的絕對地址引用的位置“打補丁”,使它們指向正確的地址。


免責聲明!

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



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