淺談連結器

可酷可樂發表於2020-06-07

編譯過程簡介

C語言的編譯過程由五個階段組成:

  • 步驟1:預處理:主要是處理以#開頭的語句,主要工作如下:1)將#include包含的標頭檔案直接拷貝到.c檔案中;2)將#define定義的巨集進行替換;3)處理條件編譯指令#ifdef;4)將程式碼中的註釋刪除;5)新增行號和檔案標示,這樣的在除錯和編譯出錯的時候才知道是是哪個檔案的哪一行 ;6)保留#pragma編譯器指令,因為編譯器需要使用它們。
gcc -E helloworld.c -o helloworld_pre.c
  • 步驟2: 編譯:將C語言翻譯成彙編,主要工作如下:1)詞法分析;2)語法分析;3)語義分析 4)優化後生成相應的彙編;
gcc -S helloworld.c -o helloworld.s
  • 步驟3: 彙編:將上一步的彙編程式碼轉換成機器碼(machine code),這一步產生的檔案叫做目標檔案;
gcc -c helloworld.c -o helloworld.o
  • 步驟4:連結:將多個目標文以及所需的庫檔案(.so等)連結成最終的可執行檔案(executable file)。
gcc helloworld.c -o helloworld

什麼是連結器?

連結器是一個將編譯器產生的目標檔案打包成可執行檔案或者庫檔案或者目標檔案的程式。

連結器的作用有點類似於我們經常使用的壓縮軟WinRAR(Linux下是tar),壓縮軟體將一堆檔案打包壓縮成一個壓縮檔案,而連結器和壓縮軟體的區別在於連結器是將多個目標檔案打包成一個檔案而不進行壓縮。

寫C或者C++的u同學經常遇到這樣一個錯誤:

undefined reference to function ABC.

連結器可操作的元素:目標檔案

連結器可操作的最小元素是一個簡單的目標檔案
從廣義上來講,目標檔案與可執行檔案的格式幾乎是一模一樣的,在Linux下,我們把它們統稱為ELF檔案。

ELF檔案標準裡面把系統中採用ELF格式的檔案歸為以下四類:

  • 可重定位檔案(Relocatable File):Linux的.o檔案,這類檔案包含了程式碼和資料,可以被用來連結成可執行檔案或共享目標檔案,靜態連結庫也歸屬於這一類;

  • 可執行檔案(Executable File):比如bin/bash檔案,這類檔案包含了可以直接執行的程式,它的代表就是ELF檔案,他們一般都沒有副檔名;

  • 共享目標檔案(shared Object File): 比如Linux的.so檔案,這種檔案包含了程式碼和資料,可以在以下兩種情況下使用,一種是連結器可以直接使用這種檔案跟其他的可重定位檔案和共享目標檔案連結,產生新的目標檔案。第二種是動態連結器可以將幾個這樣的共享目標檔案與可執行檔案結合,作為程式對映的一部分來執行。

  • 核心轉儲檔案(Core Dump File): Linux下面的core dump,當程式意外終止時,系統可以將該程式的地址空間的內容及終止時的一些其他資訊轉儲到核心轉儲檔案中。

符號表(Symbol table)

編譯器在遇到外部定義的全域性變數或者函式時只要能在當前檔案找到其宣告,編譯器就認為編譯正確。而尋找使用變數定義的這項任務就被留給了連結器。連結器的其中一項任務就是要確定所使用的變數要有其唯一的定義。雖然編譯器給連結器留了一項任務,但為了讓連結器工作的輕鬆一點編譯器還是多做了一點工作的,這部分工作就是符號表(Symbol table)。

符號表中儲存的資訊有兩個部分:

  • 該目標檔案中引用的全域性變數以及函式;
  • 該目標檔案中定義的全域性變數以及函式。

編譯器在編譯過程中每次遇到一個全域性變數或者函式名都會在符號表中新增一項,最終編譯器會統計一張符號表。

假設C語言原始碼如下:

// 定義未初始化的全域性變數
int g_x_uninit;

// 定義初始化的全域性變數
int g_x_init = 1;

// 定義未初始化的全域性私有變數,只能在當前檔案中使用
static int g_y_uninit;

// 定義初始化的全域性私有變數
static int g_y_init = 2;

// 宣告全域性變數,該變數的定義在其它檔案
extern int g_z;

// 函式宣告,該函式的定義在其它檔案
int fn_a(int x, int y);

// 私有函式定義,該函式只能在當前檔案中使用
static int fn_b(int x)
{
    return x + 1;
}

// 函式定義
int fn_c(int local_x)
{
    int local_y_uninit;
    int local_y_init = 3;
    // 對全域性變數,區域性變數以及函式的使用
    g_x_uninit = fn_a(local_x, g_x_init);
    g_y_uninit = fn_a(local_x, local_y_init);
    local_y_uninit += fn_b(g_z);
    return (g_y_uninit + local_y_uninit);
}

編譯器將為此檔案統計出如下一張符號表:

名字 型別 是否可被外部引用 區域
g_z 引用,未定義
fn_a 引用,未定義
fn_b 定義 程式碼段
fn_c 定義 程式碼段
g_x_init 定義 資料段
g_y_uninit 定義 資料段
g_x_uninit 定義 資料段
g_y_init 定義 資料段

g_z以及fn_a是未定義的,因為在當前檔案中,這兩個變數僅僅是宣告,編譯器並沒有找到其定義。剩餘的變數編譯器都可以在當前檔案中找到其定義。

本質上整個符號表主要表達兩件事:1)我能提供給其它檔案使用的符號; 2)我需要其它檔案提供給我使用的符號。

目標檔案
資料段
程式碼段
符號表

符號決議

有了符號表,連結器就可以進行符號決議了。如圖所示,假設連結器需要連結三個目標檔案,如下:

連結器會依次掃描每一個給定的目標檔案,同時連結器還維護了兩個集合,一個是已定義符號集合D,另一個是未定義符合集合U,下面是連結器進行符合決議的過程:

  • 對於當前目標檔案,查詢其符號表,並將已定義的符號並新增到已定義符號集合D中。
  • 對於當前目標檔案,查詢其符號表,將每一個當前目標檔案引用的符號與已定義符號集合D進行對比,如果該符號不在集合D中則將其新增到未定義符合集合U中。
  • 當所有檔案都掃描完成後,如果為定義符號集合U不為空,則說明當前輸入的目標檔案集合中有未定義錯誤,連結器報錯,整個編譯過程終止。

連結過程中,只要每個目標檔案所引用變數都能在其它目標檔案中找到唯一的定義,整個連結過程就是正確的。

若連結器在查詢了所有目標檔案的符號表後都沒有找到函式,因此連結器停止工作並報出錯誤undefined reference to function A

庫與可執行檔案

連結器根據目標檔案構建出庫(動態庫、靜態庫)或可執行檔案。

給定目標檔案以及連結選項,連結器可以生成兩種庫,分別是靜態庫以及動態庫,如下圖所示,給定同樣的目標檔案,連結器可以生成兩種不同型別的庫。

靜態庫

靜態庫在Windows下是以.lib為字尾的檔案,Linux下是以.a為字尾的檔案。

靜態庫是連結器通過靜態連結將其和其它目標檔案合併生成可執行檔案的,而靜態庫只不過是將多個目標檔案進行了打包,在連結時只取靜態庫中所用到的目標檔案。

目標檔案分為三段:程式碼段、資料段、符號表,在靜態連結時可執行檔案的生成過程如下圖所示:

可執行檔案的特點如下:

  • 可執行檔案和目標檔案一樣,也是由程式碼段和資料段組成。
  • 每個目標檔案中的資料段都合併到了可執行檔案的資料段,每個目標檔案當中的程式碼段都合併到了可執行檔案的程式碼段。
  • 目標檔案當中的符號表並沒有合併到可執行檔案當中,因為可執行檔案不需要這些欄位。

可執行檔案和目標檔案沒有什麼本質的不同,可執行檔案區別於目標檔案的地方在於,可執行檔案有一個入口函式,這個函式也就是我們在C語言當中定義的main函式,main函式在執行過程中會用到所有可執行檔案當中的程式碼和資料。main函式是被作業系統呼叫。

動態庫

靜態庫在編譯連結期間就被打包copy到了可執行檔案,也就是說靜態庫其實是在編譯期間(Compile time)連結使用的。
動態連結可以在兩種情況下被連結使用,分別是載入時動態連結(load-time dynamic linking)執行時動態連結 (run-time dynamic linking)

  • 載入時動態連結:在這裡我們只需要簡單的把載入理解為程式從磁碟複製到記憶體的過程,載入時動態連結就出現在這個過程。作業系統會查詢可執行檔案依賴的動態庫資訊(主要是動態庫的名字以及存放路徑),找到該動態庫後就將該動態庫從磁碟搬到記憶體,並進行符號決議,如果這個過程沒有問題,那麼一切準備工作就緒,程式就可以開始執行了,如果找不到相應的動態庫或者符號決議失敗,那麼會有相應的錯誤資訊報告為使用者,程式執行失敗。

  • 執行時動態連結:run-time dynamic linking 執行時動態連結則不需要在編譯連結時提供動態庫資訊,也就是說,在可執行檔案被啟動執行之前,可執行檔案對所依賴的動態庫資訊一無所知,只有當程式執行到需要呼叫動態庫所提供的程式碼時才會啟動動態連結過程。

可以使用特定的API來執行時載入動態庫,在Windows下通過LoadLibrary或者LoadLibraryEx,在Linux下通過使用dlopen、dlsym、dlclose這樣一組函式在執行時連結動態庫。當這些API被呼叫後,同樣是首先去找這些動態庫,將其從磁碟copy到記憶體,然後查詢程式依賴的函式是否在動態庫中定義。這些過程完成後動態庫中的程式碼就可以被正常使用了。

在動態連結下,可執行檔案當中會新增兩段,即dynamic段以及GOT(Global offset table)段,這兩段內容就是是我們之前所說的必要資訊。

dynamic 段中儲存了可執行檔案依賴哪些動態庫,動態連結符號表的位置以及重定位表的位置等資訊。
當載入可執行檔案時,作業系統根據dynamic段中的資訊即可找到使用的動態庫,從而完成動態連結

參考

微信公共號

NFVschool,關注最前沿的網路技術。

相關文章