# C 語言的連結器 (Linker) 和執行檔資訊 (ELF) ==[原始共筆](https://hackmd.io/@sysprog/c-linker-loader)== ==[上課錄影](https://youtu.be/7Zraf5487YA)== 本筆記涵蓋「[你所不知道的 C 語言:連結器和執行檔資訊](https://hackmd.io/@sysprog/c-linker-loader)」講座的核心內容,幫助理解連結器 (Linker) 的原理、應用以及在現代軟體開發與最佳化中的重要性。 --- ## 簡介 Linker 是 C 語言開發過程中一個經常被忽略但極其重要的環節。許多開發者可能只停留在編譯器自動調用 Linker 的階段,而未深入探究其內部機制。然而,在如 Linux Kernel 或 Android 開源專案 (Android Open Source Project, AOSP) 等大型專案中, Linker 的身影無處不在。這些專案常利用客製化的連結器腳本 (Linker Script)、針對 GNU ld 或 gold linker 的特定最佳化選項,甚至結合編譯器與 Linker 來實現如可載入核心模組 (Linux Kernel Module) 等高級功能。 本文將: * 回顧從早期 UNIX 系統的載入器 (Loader) 到現代 `ld` (連結器) 的演進過程,探討其名稱中隱含的程式載入器作用。 * 透過範例說明 GCC 編譯器的 GNU extension 如何與 Linker 配合使用 (例如,將一個圖片檔案的內容嵌入到最終的執行檔中),並結合分析工具來探討 ELF (Executable and Linkable Format) 執行檔格式與 Linker 之間的協同工作機制。 * 探討名為 `gold` 的 Linker ,如何協助 Linux 核心實現連結時最佳化 (Link-Time Optimization, LTO),從而編譯出更為精簡且高效能的 Linux 核心映像檔。 --- ## 連結器與載入器的歷史演進 在 1970 年代 UNIX 作業系統初創時期,系統提供的工具中,`loader` 的概念與今日的 `linker` 緊密相關。事實上,早期 UNIX 系統中,`linker` 和 `loader` 這兩個詞彙在很多情況下是可以互換使用的。今日我們所熟知的 UNIX 系統下的 Linker 命令 `ld`,其縮寫並非直接來源於 `Linker`,而是 `Loader`。這反映了 Linker 最核心的職責之一:**準備程式碼以便載入和執行**。 早期的 UNIX 系統相對於現代作業系統而言,功能較為精簡,例如,當時並不存在動態連結器 (Dynamic Linker) 這樣的機制。所有程式碼的連結都是在編譯時期靜態完成的。 ![image](https://hackmd.io/_uploads/ByPK0jgfxe.png) --- ## 連結器的基本操作與應用 Linker 的一個實用功能是將 **任意的二進位資料嵌入到最終產生的執行檔中**,使得程式在執行時期可以直接存取這些資料,而無需依賴外部檔案系統。 ### 範例一:使用 `ld` 嵌入二進位檔案 GNU Binutils 套件中的 `ld` 連結器提供 `-b binary` (或 `--format=binary`) 選項,可以將一個普通檔案視為原始二進位資料,並為其產生特定的符號 (Symbol),以便在 C 程式碼中存取。 #### 範例:嵌入 `uname -a` 的輸出 1. **產生資料檔案**: 建立一個名為 `blob` 的檔案,內容為 `uname -a` 的輸出。 ```shell $ uname -a > blob ``` 我們可以檢查其大小: ```shell $ uname -a | wc -c 113 ``` 2. **使用 `ld` 轉換為 ELF 物件檔**: 使用 `ld` 將 `blob` 檔案轉換成一個 ELF 物件檔 `blob.o`。`-r` 選項表示產生 **可重定位的輸出**,`-b binary` 指定輸入檔案 `blob` 為 **二進位格式**。 ```shell $ ld -r -b binary -o blob.o blob ``` 此命令執行後,`ld` 會為 `blob` 檔案的內容自動產生三個符號: * `_binary_blob_start`: 指向資料內容的起始位址。 * `_binary_blob_end`: 指向資料內容的結束位址 (不包含)。 * `_binary_blob_size`: 表示資料內容的總位元組大小 (一個整數值)。 檔案名稱 `blob` 中的任何非字母數字或底線字元,在符號名稱中會被轉換為底線 `_`。 3. **觀察產生的符號**: 使用 `objdump -t blob.o` 可以查看 `blob.o` 中的符號表 (symbol table): ```shell $ objdump -t blob.o blob.o: file format elf64-x86-64 SYMBOL TABLE: 0000000000000000 l d .data 0000000000000000 .data 0000000000000071 g .data 0000000000000000 _binary_blob_end 0000000000000000 g .data 0000000000000000 _binary_blob_start 0000000000000071 g *ABS* 0000000000000000 _binary_blob_size ``` 這裡 `0x71` 即為十進位的 113。 4. **撰寫 C 測試程式 (`test.c`)**: 在 C 程式中,我們可以使用 `extern` 關鍵字宣告這些由 `ld` 產生的符號,然後取得它們的位址或值。 ```c= #include <stdio.h> #include <string.h> // 用於 memcpy(可選,用於顯示內容) // 宣告由 Linker 提供的 symbol extern char _binary_blob_start[]; extern char _binary_blob_end[]; // _binary_blob_size 是一個代表大小的絕對符號 // extern int _binary_blob_size; // 或者,計算方式為 end - start int main(void) { // 獲取指向嵌入資料開始與結束位置的指標 char *start = _binary_blob_start; char *end = _binary_blob_end; size_t size = end - start; // 計算大小 printf("Data Start Address: %p\n", (void*)start); printf("Data End Address: %p\n", (void*)end); printf("Data Size: %zu bytes\n", size); // 可選:如果內容是文字,則將其印出 printf("Embedded Content:\n"); // fwrite(start, 1, size, stdout); // 更安全地印出二進位資料的方法 char buffer[size + 1]; memcpy(buffer, start, size); buffer[size] = '\0'; // 確保字串以 null 結尾 printf("%s\n", buffer); return 0; } ``` 5. **編譯、連結並執行**: 將 `test.c` 與 `blob.o` 一起編譯連結。 ```shell $ gcc test.c blob.o -o test $ ./test Data Start Address: 0x... Data End Address: 0x... Data Size: 113 bytes Embedded Content: Linux ... <uname -a 的 output> ... ``` 輸出結果確認了資料的大小與內容已成功嵌入。 6. **觀察 `blob.o` 的 Section**: 使用 `readelf -S blob.o` 可以看到嵌入的資料位於 `.data` section。 ```shell $ readelf -S blob.o There are 5 section headers, starting at offset 0x188: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .data PROGBITS 0000000000000000 00000040 0000000000000071 0000000000000000 WA 0 0 1 [ 2] .symtab SYMTAB 0000000000000000 000000b8 0000000000000078 0000000000000018 3 2 8 [ 3] .strtab STRTAB 0000000000000000 00000130 0000000000000037 0000000000000000 0 0 1 [ 4] .shstrtab STRTAB 0000000000000000 00000167 0000000000000021 0000000000000000 0 0 1 ``` ### 範例二:使用 `objcopy` 嵌入二進位檔案 `objcopy` 工具 (同樣來自 GNU Binutils) 也提供了類似的功能,並且更加靈活。 * **主要功能**: * 將輸入檔案轉換為物件檔。 * 控制產生的符號名稱。 * **重要特性**:基於 GNU BFD (Binary File Descriptor) library。 > BFD library 是一個歷史悠久的函式庫 (大約始於 1990 年代初期),旨在提供一個統一的介面來**處理多種不同的物件檔格式**,例如 Linux 上的 ELF、Windows 上的 PE (Portable Executable) 以及 macOS 上的 Mach-O。許多 GNU Binutils 工具,包括 `ld` 和 `objdump`,都使用了 BFD library。 > 值得一提的是,`readelf` 工具雖然功能上與 `objdump` 有部分重疊 (都能顯示 ELF 檔案資訊),但 `readelf` 並不依賴 BFD library。這樣的設計允許在 BFD library 本身可能存在 bug 時,仍有一個獨立的工具可以用於交叉檢驗 ELF 檔案的正確性。 #### 範例:[objcopy_to_carray](https://github.com/vogelchr/objcopy_to_carray) 專案 * **目的**:使用 `objcopy` 將 **任意檔案** (例如 `/etc/passwd`) 轉換為 **C 陣列** 或 **可連結的物件檔**。 在其 Makefile 中,可以看到類似如下的 `objcopy` 命令: ```makefile=19 ...(略) password.o: /etc/passwd objcopy --input-target binary --output-target elf64-x86-64 \ --binary-architecture i386:x86-64 \ --rename-section .data=.rodata,alloc,load,readonly,data,contents \ /etc/passwd password.o ...(略) ``` 更常見的做法,類似 `ld -b binary`,是產生符號: ```shell $ objcopy --input-target binary --output-target elf64-x86-64 \ --binary-architecture i386:x86-64 \ /etc/passwd password.o ``` `objcopy` 同樣會產生 `_binary_path_to_file_start`, `_binary_path_to_file_end`, `_binary_path_to_file_size` 這樣的符號。例如,輸入檔案為 `/etc/passwd`,產生的符號會是 `_binary__etc_passwd_start` 等 (路徑中的 `/` 被轉換為 `_`)。 之後,**C 程式可以像前述 `ld` 範例一樣宣告並使用這些符號來存取嵌入的資料**。 * **應用場景**: * **韌體開發** * **需要將資源檔內建到應用程式中的場景**:Bootloader 可能需要在沒有檔案系統的情況下載入金鑰 (Key) 進行 Secure Boot 的驗證,或者安裝程式需要將圖片、文字等資源打包進 **單一執行檔中**。 --- ## GNU Extension 與 Linker Script 的進階應用 除了基本的檔案嵌入,Linker 配合編譯器的擴展功能 (GNU extensions) 和連結器腳本 (Linker Scripts),可以實現更為複雜和精細的執行檔結構控制。 ### Init Hooks 機制 Init Hooks 是一種允許開發者註冊特定函式,使其在程式或系統啟動的早期階段自動執行的機制。這在作業系統核心或需要精確控制初始化順序的嵌入式系統中非常有用。 #### 以 [F9 microkernel](https://github.com/f9micro/f9-kernel) 為例: F9 microkernel 透過 [Init hooks](https://github.com/f9micro/f9-kernel/blob/master/Documentation/init-hooks.txt) 機制,讓不同的核心模組可以在核心啟動的特定階段執行初始化程式碼。 1. **使用 `INIT_HOOK` 巨集的範例**: 開發者使用 `INIT_HOOK` 巨集來註冊一個初始化函式和其執行層級 (Level)。 ```c #include <init_hook.h> #include <debug.h> void hook_test(void) { dbg_printf(DL_EMERG, "hook test\n"); } INIT_HOOK(hook_test, INIT_LEVEL_PLATFORM - 1) ``` 2. **GNU Extension 指定 ELF Section**: `INIT_HOOK` 巨集的實作 ([include/init_hook.h](https://github.com/f9micro/f9-kernel/blob/master/include/init_hook.h)) 利用了 GCC 的 `__attribute__((section("section_name")))` GNU extension:允許將一個變數或函式 **放置到 ELF 檔案中一個特定的 section**。 ```c=18 typedef void (*init_hook_t)(void); typedef struct init_struct { unsigned int level; init_hook_t hook; const char *hook_name; } init_struct; #define INIT_HOOK(_hook, _level) \ const init_struct _init_struct_##_hook \ __attribute__((section(".init_hook"))) = { \ .level = _level, \ .hook = _hook, \ .hook_name = #_hook, \ }; ``` 每次使用 `INIT_HOOK`,就會創建一個 `init_struct` 結構的實例,並將其放置在名為 `.init_hook` 的 section 中。 3. **Linker Script 配置 Section**: 在 F9 microkernel 的 Linker Script ([platform/stm32f4/f9.ld](https://github.com/f9micro/f9-kernel/blob/master/platform/stm32f4/f9.ld)) 中,定義了兩個特殊的符號 `init_hook_start` 和 `init_hook_end`,它們分別標記了 `.init_hook` section 在記憶體中的起始和結束位置。`KEEP(*(.init_hook))`確保所有被標記為 `.init_hook` 的 section 都會被 Linker 保留並連續放置。 ```ld=48 SECTIONS { .text : /* ... (略) ... */ ``` ```ld=56 init_hook_start = .; /* section 在記憶體中的起始位置 */ KEEP(*(.init_hook)) /* 保留所有 .init_hook sections 內容 */ init_hook_end = .; /* section 在記憶體中的結束位置 */ ``` 4. **執行 Init Hooks**: 在核心初始化程式碼 ([kernel/init.c](https://github.com/f9micro/f9-kernel/blob/master/kernel/init.c)) 中: * 透過 `extern` 宣告 `init_hook_start` 和 `init_hook_end`。 * `run_init_hook` 函式: * 遍歷從 `init_hook_start` 到 `init_hook_end` 這段記憶體區域中的所有 `init_struct` 實例。對於每個實例,如果其 `level` 符合當前執行的初始化階段,則調用其 `hook` 成員指向的函式。 ```c=86 extern const init_struct init_hook_start[]; extern const init_struct init_hook_end[]; static unsigned int last_level = 0; int run_init_hook(unsigned int level) { unsigned int max_called_level = last_level; for (const init_struct *ptr = init_hook_start; ptr != init_hook_end; ++ptr) { if ((ptr->level > last_level) && (ptr->level <= level)) { max_called_level = MAX(max_called_level, ptr->level); ptr->hook(); } } last_level = max_called_level; return last_level; } ``` * **應用場景**: * **作業系統核心** * **需要 C 語言實現 Constructor 或 Destructor 功能的場景**:開發者可以定義不同的 section (如 `.constructors`, `.destructors`),並在程式啟動時或結束前遍歷這些 section 中的函式指標並執行它們。 --- ## 連結器在軟體最佳化扮演重要角色 傳統上,編譯流程被視為編譯器 (Compiler)、組譯器 (Assembler) 和連結器 (Linker) 的順序執行。然而,在現代軟體開發中,為了追求極致效能,Linker 在最佳化過程中扮演了越來越關鍵的角色。 可以複習「你所不知道的 C 語言」系列講座中的 [編譯器和最佳化原理篇](https://hackmd.io/@sysprog/c-compiler-optimization) 和 [動態連結器篇](https://hackmd.io/@sysprog/c-dynamic-linkage) 以獲得更深入的背景知識。 ### 編譯流程與最佳化概念回顧 一個典型的編譯流程從原始程式碼開始,經過預處理器 (Preprocessor)、編譯器 (將原始碼轉換為組合語言)、組譯器 (將組合語言轉換為機器碼,即物件檔 Object File),最後由 Linker 將多個物件檔和函式庫連結成最終的可執行檔。 ![image](https://hackmd.io/_uploads/B1NHnihZel.png) #### 現代編譯器內部進行了更複雜的處理,包括: * **前端 (Frontend)**: * **詞法分析 (Lexical Analysis)**:將原始碼分解為 token。 * **語法分析 (Syntax Analysis)**:根據語言文法檢查 token 序列,建構抽象語法樹 (Abstract Syntax Tree, AST)。 * **語義分析 (Semantic Analysis)**:進行型別檢查、作用域解析等,確保程式碼的語義正確性。例如,區分整數 `7` 和浮點數 `7.0` 在記憶體中的不同表示。 * **中介碼產生 (Intermediate Code Generation)**: * 將 AST 轉換為一種平台無關的中介表示 (Intermediate Representation, IR)。LLVM IR 和 GCC 的 GIMPLE 都是常見的 IR。 * **最佳化 (Optimization)**: * **平台無關最佳化**:在 IR 層次進行,例如常數摺疊、無用程式碼消除、迴圈最佳化等。 * **靜態單賦值形式 (Static Single Assignment, SSA)**:一種 IR 的特性,每個變數只被賦值一次,有利於許多最佳化演算法的實現。 * **後端 (Backend)**: * **平台相關程式碼產生**:將 IR 轉換為目標硬體平台的組合語言或機器碼。 * **平台相關最佳化**:針對特定處理器架構的指令排程、暫存器分配等。 ![image](https://hackmd.io/_uploads/S1gZTi3blx.png) ### IPO / LTO / WPO:跨模組最佳化 **傳統編譯器** 在最佳化時,通常一次只處理一個編譯單元 (Compilation Unit),即一個 `.c` 原始檔及其包含的標頭檔。編譯器無法看到其他 `.c` 檔的內容,這限制了全域最佳化的能力。 > 例如,一個在 `a.c` 中定義的小函式,如果被 `b.c` 呼叫,編譯器在處理 `b.c` 時可能因為不知道 `a.c` 中函式的具體實現和大小,而無法決定是否將其內聯 (inline) 到 `b.c` 中以消除函式呼叫的開銷。`extern` 關鍵字宣告的函式或變數更是如此。 為了克服這個限制,出現了 **跨模組最佳化 (Inter-Procedural Optimization, IPO)** 的概念,具體實現如: * **LTO (Link-Time Optimization)**:在 GCC 和 LLVM/Clang 中使用。 * **WPO (Whole Program Optimization)**:在 Microsoft Visual C++ 中使用。 #### LTO 的基本流程如下: 1. **編譯階段**:編譯器將每個原始檔編譯成包含 IR (或其他特殊格式) 的物件檔,而不直接產生最終的機器碼。 2. **連結階段 (初步)**:Linker **收集所有這些特殊的物件檔**。 3. **最佳化階段**:Linker 將**收集到的所有 IR 交回給編譯器** (或一個特殊的最佳化器)。此時,編譯器擁有了整個程式的全域視圖,可以進行更深層次的最佳化,例如: * 更積極的函式內聯 (Function Inlining)。 * 無用程式碼消除 (Dead Code Elimination):移除在整個程式中從未被呼叫的函式或未被使用的全域變數。 * 常數傳播 (Constant Propagation) 和更多基於全域資訊的最佳化。 4. **程式碼產生與最終連結**:最佳化後的程式碼被轉換為機器碼,然後由 Linker 完成最終的連結,產生可執行檔。 LTO 能夠顯著提升程式效能並縮減程式大小,尤其對於大型複雜應用程式效果更為明顯。 ![image](https://hackmd.io/_uploads/SJWpij3Zxl.png) ### Linker Script (連結器腳本) Linker Script 是一種用來 **告知 Linker 如何組織輸出檔案** (通常是可執行檔或共享函式庫) 內部結構的 **控制檔案**。它本身可以被視為一種特定領域的程式語言。 * 透過 Linker Script,開發者可以精確控制: * 不同 Section (區段) 的 **載入位址 (Load Memory Address, LMA)** 和 **虛擬位址 (Virtual Memory Address, VMA)**。 * Section 的 **排列順序** 和 **對齊方式**。 * 符號的定義和賦值。 * 記憶體區域的劃分。 * 常見的 ELF Section 包括: * `.text`: 程式執行指令 (機器碼)。 * `.data`: 已初始化的全域變數和靜態變數。 * `.rodata`: 唯讀資料,如字串常數。 * `.bss`: 未初始化的全域變數和靜態變數。Linker 只記錄此 section 的大小,執行時由作業系統或啟動程式碼將其清零。 在 **嵌入式系統開發** 或 **作業系統核心開發** 中,Linker Script 的使用尤為重要,因為需要將程式碼和資料精確地放置到特定的硬體記憶體位址。例如,F9 microkernel 和 Mini-OS (成大資工開發的小型作業系統) 都使用了自訂的 Linker Script 來管理其記憶體佈局。 --- ## ELF (Executable and Linkable Format) 執行檔格式 ELF 是 Linux 和許多類 UNIX 系統中標準的執行檔、可重定位物件檔和共享函式庫的 **檔案格式**。 ### ELF 檔案的核心組成 * **ELF Header**:位於檔案開頭,描述了整個檔案的組織結構,如檔案類型 (可執行檔、物件檔、共享庫)、目標機器架構、程式進入點位址、各個頭表 (Header Table) 的位址和大小等。 * **Program Header Table** (僅執行檔和共享庫有):描述了如何將檔案的各個 segment (段) 載入到記憶體中執行。每個 segment 由一個或多個 section 組成。 * **Section Header Table**:描述了檔案中所有 section 的資訊,如 section 名稱、類型、大小、在檔案中的偏移量、記憶體中的位址 (如果需要載入) 等。 * **Sections**:ELF 檔案的主體,包含了程式碼、資料、符號表、重定位資訊等。常見的 section 有: * `.text`: 實際的程式指令 (機器碼)。 * `.data`: 已初始化的全域和靜態變數。 * `.rodata`: 唯讀資料,如字串常數、`const` 變數。 * `.bss`: 未初始化的全域和靜態變數。 * `.symtab` (Symbol Table): 包含了檔案中定義和參照的符號資訊。 * `.strtab` (String Table): 儲存符號名稱等字串。 * `.shstrtab` (Section Header String Table): 儲存 section 名稱的字串。 * `.rel.text` / `.rela.text`: 針對 `.text` section 的重定位資訊 (REL 或 RELA 格式)。 * `.dynamic`: 動態連結所需的資訊。 * `.dynsym`: 動態連結符號表。 * `.dynstr`: 動態連結字串表。 * `.interp`: 指定程式直譯器 (Program Interpreter) 的路徑,通常是動態連結器 (如 `/lib64/ld-linux-x86-64.so.2`)。 ### 關鍵 ELF 特性與機制 * **Symbol (符號)**:一個符號是程式中 **一個實體 (如函式或變數)** 的名稱。Linker 的工作核心就是解析和連接這些符號。 * **Relocation (重定位)**:在編譯時期,編譯器產生的物件檔中的符號參照 (例如,呼叫一個外部函式) 通常只是一個佔位符或相對於當前模組的位址。Linker 在將多個物件檔合併時,會 **計算出這些符號在最終執行檔記憶體佈局中的確切位址,並修改程式碼或資料中的參照**,使其指向正確的位址。這個過程稱為重定位。 > 對於動態連結的函式庫,部分重定位可能延遲到程式載入時由動態連結器完成。 * **[Symbol Visibility (符號可見性)](https://hackmd.io/@Jaychao2099/c-dynamic-linker#Symbol-Visibility-%E7%AC%A6%E8%99%9F%E5%8F%AF%E8%A6%8B%E6%80%A7)** (GCC Extension):GCC 允許透過 `__attribute__((visibility("type")))` 來控制符號 **在模組外部的可見性**。常見的 `type` 有: * `default`: 符號正常匯出,可被其他模組覆蓋 (Interposition)。 * `hidden`: 符號不匯出,僅在當前模組內部可見和使用。外部模組無法直接存取,也不能被覆蓋。 * `protected`: 符號匯出,但不能被其他模組覆蓋。 * `internal`: 更強的隱藏,通常用於編譯器內部。 > C 語言中的 `static` 關鍵字修飾的全域變數或函式,其連結性為內部連結 (Internal Linkage),效果類似於 `hidden` 可見性。 * **[Interposition (符號覆蓋)](https://hackmd.io/@Jaychao2099/c-dynamic-linker#Interpositioning-%E7%9A%84%E6%87%89%E7%94%A8%E5%A0%B4%E6%99%AF%EF%BC%9A)**:在動態連結環境下,可以透過特定機制 (如 `LD_PRELOAD` 環境變數) 讓 **一個共享函式庫中的符號覆蓋掉另一個函式庫或主程式中同名的符號**。這常用於除錯、效能分析或修改現有程式行為 (例如,覆蓋 `malloc` 來追蹤記憶體分配)。 * **Versioning (符號版本化)**:GNU C Library (glibc) 使用符號版本化機制來 **處理向後相容性**。一個函式可以有多個版本的實作,舊程式連結到舊版本,新程式連結到新版本,它們可以同時存在於同一個函式庫中。 * **PIC (Position Independent Code)**:位置無關程式碼。共享函式庫必須編譯為 PIC,這樣它們可以被載入到記憶體中的任意位址執行,而無需修改程式碼本身。PIC 通常透過額外的間接層 (如 GOT) 來實現對全域資料和外部函式的存取。 * **PIE (Position Independent Executable)**:位置無關執行檔。PIE 使得 **主執行檔本身也可以像共享函式庫一樣被載入到隨機的記憶體位址**,這是一種重要的安全性增強措施 (ASLR - Address Space Layout Randomization)。 * [**GOT (Global Offset Table)** 和 **PLT (Procedure Linkage Table)**](https://hackmd.io/@Jaychao2099/c-dynamic-linker#PLT-Procedure-Linkage-Table-%E7%A8%8B%E5%BA%8F%E9%80%A3%E7%B5%90%E8%A1%A8-%E5%92%8C-GOT-Global-Offset-Table-%E5%85%A8%E5%9F%9F%E5%81%8F%E7%A7%BB%E8%A1%A8):這兩個表格是實現 PIC 和動態連結的關鍵。GOT 儲存了全域變數的位址,PLT 則用於間接呼叫外部函式,並在首次呼叫時觸發動態連結器解析函式實際位址。 * **C++ Name Mangling (名稱修飾)**:C++ 支援函式重載、命名空間、類別等特性,這使得同一個函式名稱可能對應多個不同的實體。編譯器會將 C++ 的符號名稱 (包括函式簽名、類別名、命名空間等資訊) 編碼成一個在連結層面唯一的字串。例如,一個函式 `void MyClass::foo(int)` 可能被修飾成類似 `_ZN7MyClass3fooEi` 的形式。可以使用 `c++filt` 工具將修飾後的名稱還原為原始的 C++ 宣告。 ### ELF 分析工具 * `readelf`: **顯示 ELF 檔案的詳細資訊**,包括 ELF header, program headers, section headers, symbol tables, relocation entries 等。 * `readelf -h <file>`: 顯示 ELF header。 * `readelf -S <file>`: 顯示 section headers。 * `readelf -s <file>`: 顯示 symbol table。 * `readelf -r <file>`: 顯示 relocation entries。 * `objdump`: 主要用於 **反組譯** 物件檔或執行檔,也可以顯示檔案的 section 內容、符號表等。 * `objdump -d <file>`: 反組譯可執行 section。 * `objdump -t <file>`: 顯示 symbol table (類似 `nm`)。 * `objdump -h <file>`: 顯示 section headers。 * `nm`: 列出物件檔中的符號。 * `strings`: 顯示檔案中可列印的字串序列,有助於快速查看檔案中包含的文本資訊。 * `ldd`: 列出一個執行檔或共享函式庫所依賴的動態連結函式庫。 ### 比較: | 項目| Object File | ELF File | | :---: | :----------- | :-------- | | **定義** | 通常是指編譯器將原始程式碼<br>編譯後產生的「中間產物」,尚未經過 linking。 | 一種檔案格式。 | | **用途** | 作為 linker 的輸入,將多個<br> object file 和其他程式庫<br>合併成最終可執行檔。 | 在 Linux 與其他 UNIX-like 系統中廣泛使用,可當作 object, executable, 或 shared library。 | | **是否可執行?** | 否 | 視具體用途(可執行或不可執行) | | **是否包含 ELF 格式?** | 有可能 (在 Linux 下幾乎都是) | 是 ELF 格式本身 | | **副檔名** | `.o`, `.obj` | `.o`, `.so`, 無副檔名(執行檔)等 | --- ## 不同 Linker 的比較與發展 隨著軟體規模的增長和對編譯連結效率要求的提高,出現了多種不同的 Linker 實作。 * **`ld.bfd` (GNU Linker)**: * 這是 GNU Binutils 套件中傳統的 Linker,通常簡稱為 `ld`。 * 它基於前面提到的 BFD (Binary File Descriptor) library,因此能夠支援多種物件檔格式和目標平台。 * `ld.bfd` 歷史悠久,功能穩定,但由於其複雜的程式碼庫和對舊平台的支援,維護和發展新特性相對困難,連結速度也較慢。 ![image](https://hackmd.io/_uploads/ryPAUi6-ee.png) * **Gold Linker (GNU Gold Linker)**: * 由 Google 開發,旨在提供一個更快、更現代的 ELF Linker 替代方案。 * Gold 完全為 ELF 格式設計,拋棄了 BFD 的歷史包袱。 * 其設計目標之一是提升大型 C++ 專案的連結速度。Gold Linker 內部實作了平行處理,例如可以平行執行 relocation 操作,因此通常比 `ld.bfd` 快 2 到 5 倍。 * 在雲端運算環境中,即使是微小的效能提升 (例如 1%),累積起來也能節省大量的硬體資源和電力成本。因此,像 Google 這樣的大型公司會投入資源開發如 Gold 這樣的工具。 ![image](https://hackmd.io/_uploads/SyV1wiabgx.png) * **[LLD](https://archive.fosdem.org/2019/schedule/event/llvm_lld/attachments/slides/3423/export/events/attachments/llvm_lld/slides/3423/WhatMakesLLDSoFastPresenterNotes.) (LLVM Linker)**: * LLD 是 LLVM 編譯器基礎設施計畫的一部分。 * 它是一個全新的 Linker 實作,從頭開始設計,沒有 BFD 或 Gold 的歷史遺留問題。 * LLD 的目標是成為各平台上最高效能的 Linker。它支援 ELF (Linux, BSD), COFF (Windows), 和 Mach-O (macOS) 格式。 * 在連結速度上,LLD 通常比 Gold Linker 快 2 到 3 倍,比 `ld.bfd` 快 5 到 10 倍甚至更多,尤其在大型專案上優勢明顯。 * LLD 也被設計為可以作為 GNU Linker 的直接替代品 (Drop-in replacement)。 * Android NDK 從較新的版本開始,預設使用 LLD 作為其 Linker。 ![image](https://hackmd.io/_uploads/BJmnDoaWxg.png) * **`collect2`**: 在使用 GCC 編譯鏈時,實際的連結步驟通常不是由使用者直接調用 `ld`、`gold` 或 `lld` 完成的。GCC 驅動程式 (Driver Program, `gcc` 或 `g++` 命令本身) 會在連結階段調用一個名為 `collect2` 的輔助程式: 1. 收集所有編譯產生的物件檔、指定的函式庫、以及必要的啟動檔案 (如 CRT - C Runtime files: `crt1.o`, `crti.o`, `crtn.o`)。 2. 將所有這些參數和選項正確地傳遞給底層的實際 Linker (如 `ld.bfd`, `gold`, 或 `lld`) 來完成最終的連結工作。 3. `collect2` 也處理一些特殊情況,例如 C++ 的全域 constructors 和 destructors 的收集與註冊。 --- ## Linker 在大型應用程式最佳化中的應用 對於像 Firefox 瀏覽器、LibreOffice 辦公套件這樣的大型應用程式,其程式碼量巨大,依賴關係複雜。傳統的編譯器最佳化 (如 `-O2`, `-O3`) 雖然有效,但往往不足以解決所有效能瓶頸,特別是啟動時間 (Startup Time)。在這些場景下,Linker 及其相關技術扮演著至關重要的角色。 ### 大型應用程式的挑戰 * **啟動時間**:大型應用程式啟動緩慢是影響使用者體驗的主要因素之一。啟動過程可能涉及大量的檔案 I/O、動態連結、資料結構初始化等。 * **Relocations**:如前所述,ELF 執行檔和共享函式庫在載入時需要進行重定位。對於擁有數百萬行程式碼和大量符號的應用,重定位本身就會消耗可觀的時間和 CPU 資源,並可能因為隨機記憶體存取而導致 Cache Miss。 * **動態連結開銷**:雖然動態連結可以節省磁碟空間和記憶體,並允許模組獨立更新,但它在程式啟動和首次呼叫外部函式時會引入額外開銷 (符號解析、函式庫載入等)。 * **程式碼局部性 (Code Locality)**:大型程式中,經常一起執行的程式碼片段如果能在記憶體中連續存放,可以提高 CPU Cache 的命中率,從而提升效能。 ### `elfhack` (Mozilla) Mozilla 為了解決 Firefox 瀏覽器 (尤其是其核心元件 `libxul.so`) 啟動緩慢的問題,開發了 `elfhack` 工具。 * **問題分析**:分析發現 `libxul.so` 中有大量的 ELF relocation 資訊,這些資訊不僅佔用了相當大的檔案空間 (例如,在 32 位元系統上一個 REL relocation 佔 8 bytes,64 位元系統上一個 RELA relocation 佔 24 bytes),而且在程式啟動時處理這些 relocation 非常耗時。`libxul.so` 中約 20% 的映像是 relocation。 ![image](https://hackmd.io/_uploads/H1h_o6aWlg.png) * **[elfhack](https://wiki.mozilla.org/Elfhack) 的解決方案**: * `elfhack` 是一種後處理工具,在 ELF 連結完成後執行。 * 它壓縮 relocation 資訊,特別是針對連續的 IP-relative relocations (常見於 C++ vtables)。 * 它將部分 ELF 標準的 relocation 轉換為自訂的緊湊格式儲存。 * 透過這些手段,`elfhack` 能夠顯著減小 `libxul.so` 中 relocation 部分的大小 (例如,從 7.5MB 減少到 0.3MB),從而減少啟動時的 I/O 和處理時間。 ![image](https://hackmd.io/_uploads/SkWcs66Zlg.png) > 搭配閱讀:[Improving libxul startup I/O by hacking the ELF format](https://glandium.org/blog/?p=1177)。 ### Feedback Directed Optimization (FDO) / Profile Guided Optimization (PGO) FDO (也常稱為 PGO) 是一種強大的最佳化技術,它利用程式在實際運行時的行為資訊 (Profile Data) 來指導編譯器進行更精準的最佳化。 * **基本流程 (Build-Run-Build)**: 1. **插樁編譯 (Instrumentation Build)**:使用特定編譯選項 (如 GCC 的 `-fprofile-generate` 或 `-fprofile-arcs`) 編譯程式。編譯器會在程式碼中 **插入額外的指令 (樁) 來收集執行時期的資訊**,例如函式呼叫頻率、分支跳轉情況、熱點路徑等。 2. **運行與收集 Profile Data (Profiling Run)**:執行插樁後的程式,並使用代表性的工作負載 (Workload) 進行測試。程式運行時會產生 Profile Data 檔案 (如 GCC 的 `.gcda` 檔案)。 3. **最佳化編譯 (Optimized Build)**:使用收集到的 Profile Data **再次編譯原始程式碼** (如 GCC 的 `-fprofile-use`)。編譯器會根據 Profile Data 識別出程式的熱點部分,並進行針對性的最佳化,例如: * 更積極地內聯熱點函式。 * 優化分支預測。 * 改進程式碼佈局,將經常一起執行的程式碼塊放在相鄰位置以提高 Cache 效率。 * 優化迴圈。 ![image](https://hackmd.io/_uploads/HJip3T6-gg.png) * **AutoFDO (Google)**:Google 將 FDO 技術應用於其大規模資料中心的服務中,實現了自動化的 Profile Data 收集和最佳化流程,取得了顯著的效能提升。 * **`Valgrind`**:`Valgrind` 是一個動態分析工具框架。其下的 `Callgrind` 工具可以在 **不重新編譯程式的情況下** 收集函式呼叫圖和執行次數等資訊,這些資訊有時也可以用於手動指導 PGO 或效能分析。 FDO 對於縮短啟動時間和提升整體應用效能非常有效,因為它可以幫助編譯器做出更符合實際運行情況的最佳化決策。 ### Linker Section Garbage Collection (`--gc-sections`) Linker 通常會將輸入物件檔中的所有 section 都包含到最終的輸出檔案中。然而,很多時候程式碼中定義的 **某些函式或資料可能從未被實際使用**。 ```shell $ make stm32_defconfig $ make vmlinux $ size vmlinux text data bss dec hex filename 1704024 144732 117660 1966416 1e0150 vmlinux ``` 1. 編譯器提供了選項 (如 GCC 的 `-ffunction-sections` 和 `-fdata-sections`),使得每個函式和每個全域資料項都被放置在各自獨立的 section 中。 2. Linker 在連結時,可以使用 `--gc-sections` 選項 (Garbage Collect Sections) 來識別並丟棄那些從未被任何其他 section 參照 (Referenced) 的 section。 ```shell $ [hacks for CONFIG_LD_DEAD_CODE_DATA_ELIMINATION] $ make vmlinux $ size vmlinux text data bss dec hex filename 1304516 141672 113108 1559296 17cb00 vmlinux ``` 這種技術被戲稱為「窮人的 LTO」(Poor man's LTO),因為它可以在一定程度上實現無用程式碼消除,但不如完整的 LTO 那樣能夠進行跨函式的深度分析和最佳化。 ### LTO (Link-Time Optimization) 在 Linux 核心中的應用 LTO 技術也被應用於像 Linux 核心這樣的複雜系統軟體中,以縮減其大小並提升效能。 * **成果**: * **Dead-code elimination**:LTO 可以更有效地識別和移除核心中未使用的函式和資料。 * **縮減核心映像檔大小**:實驗表明,在某些嵌入式平台 (如 STM32 微控制器) 上為 Linux 核心啟用 LTO,可以將核心映像檔大小縮減 20% 以上 (例如,從約 1.9MB 降至 1.5MB)。這對於資源受限的嵌入式系統至關重要。 * **移除未使用的系統呼叫 (System Calls)**:結合 LTO 和程式碼修改,可以移除核心中某些特定配置下完全不會被用到的系統呼叫處理常式,進一步縮小核心。 ```shell $ ./scripts/config --enable CONFIG_LTO_MENU $ make vmlinux $ size vmlinux text data bss dec hex filename 1281644 142492 112985 1537121 177461 vmlinux ``` * **考量**: * 啟用 LTO 會顯著增加編譯時間和記憶體消耗。 * 需要工具鏈 (編譯器、連結器) 的良好支援,並且可能需要對核心的建構系統 (Build System) 和部分程式碼進行調整以適應 LTO。 > 延伸閱讀: > * [Shrinking the kernel with link-time optimization (LWN.net)](https://lwn.net/Articles/744507/) > * [Shrinking the kernel with link-time garbage collection (LWN.net)](https://lwn.net/Articles/741494/) > * [Shrinking the kernel with an axe (LWN.net)](https://lwn.net/Articles/746780/) ### XIP (Execute-In-Place) XIP 是一種允許程式碼 **直接在非揮發性記憶體 (如 Flash ROM) 中執行** 的技術,而無需先將其複製到 RAM 中。 * **優點**: * 減少 RAM 的使用量。 * 加快程式啟動速度,因為省略了從 Flash 到 RAM 的複製過程。 * **應用**:常見於記憶體資源極其有限的 **嵌入式系統**。 ![image](https://hackmd.io/_uploads/HkpJDzJfeg.png) * **與 Linker 的關係**:實現 XIP 需要 Linker 將程式碼段 (Code Segment) 和唯讀資料段 (Read-only Data Segment) 放置在 Flash 的正確位址,並 **確保所有對這些段的參照都是有效的**。Linker Script 在此過程中扮演關鍵角色。 --- ## 連結器的語義與形式化驗證 Linker 的行為極其複雜,即使是連結一個簡單的 "hello, world!" 程式,也可能涉及到與 C 函式庫 (一個在 Linker 特性使用上非常複雜的函式庫) 的連結。然而,關於 Linker 內部工作原理的詳細文檔和形式化描述相對缺乏。 劍橋大學的研究人員針對這個問題進行了深入研究,並發表了論文 "[The missing link: explaining ELF static linking, semantically](https://www.cl.cam.ac.uk/~pes20/rems/papers/oopsla-elf-linking-2016.pdf)"。 * **目標**:該研究旨在為 ELF 靜態連結過程提供一個清晰、嚴謹的形式化語義模型。 * **方法**:他們使用形式化方法 (Formal Methods) 和證明輔助工具 (如 Isabelle/HOL) 來描述 Linker 的核心演算法,如符號解析 (Symbol Resolution) 和重定位 (Relocation)。 * **意義**: * 增進對 Linker 複雜行為的理解。 * 為開發更可靠的 Linker 工具和進行 Linker 相關的安全性分析提供了理論基礎。 * 有助於驗證 Linker 實作的正確性,或開發能夠感知 Linker 行為的程式分析工具。 形式化驗證不僅僅適用於原始程式碼,對於整個軟體工具鏈 (編譯器、組譯器、連結器) 的驗證也日益受到重視,尤其在安全攸關 (Safety-critical) 和高可靠性 (High-assurance) 的系統中。 --- ## 延伸閱讀 一些與 Linker 、ELF 及執行時期操作相關的工具和技巧: * **[tramp-test](https://github.com/ncultra/tramp-test)**: * 展示了 Trampoline 的概念。Trampoline 是一小段程式碼,它接收控制權後,再跳轉到另一段程式碼。 * **用途**:可以用於執行時期程式碼產生、函式呼叫的重定向 (Redirection)、在不同特權級別或執行上下文之間切換等。 * 例如,在 GDB 進行函式呼叫時,或是在某些作業系統中處理訊號 (Signal) 時,都可能用到類似 Trampoline 的機制。 * 透過精心構造 Trampoline,可以在執行時期收集函式呼叫資訊,**實現一個輕量級的除錯器或追蹤器**,而無需承受 GDB 這類重量級除錯器的效能開銷。 * **[libelfmaster](https://github.com/elfmaster/libelfmaster)**: * 一個安全的 ELF 解析和載入函式庫。 * 設計用於 **惡意軟體取證分析 (Forensics Reconstruction of Malware)** 和 **開發強健的逆向工程工具**。 * 它提供了對 ELF 檔案結構進行深入分析和操作的能力。 * **[dt_infect](https://github.com/elfmaster/dt_infect)**: * 一個 ELF 共享函式庫注入工具。 * 它利用 ELF 動態段 (Dynamic Segment) 中的 `DT_NEEDED` 條目優先級來實現注入。`DT_NEEDED` 指定了程式執行所依賴的共享函式庫。透過修改或插入 `DT_NEEDED` 條目,可以讓目標程式在啟動時 **優先載入惡意或指定的共享函式庫**。 * 其效果類似於實現了一個永久性的 `LD_PRELOAD`。 * **GNU C Library (glibc) 的向後相容性處理**: * glibc 透過符號版本化 (Symbol Versioning) 機制來確保向後相容性。 * 一個函式庫中的函式可以有多個不同版本的實作,每個版本都帶有一個版本標籤 (例如 `memcpy@GLIBC_2.2.5`)。 * 當程式連結到 glibc 時,它會記錄下所依賴的符號及其版本。在執行時期,動態連結器會確保載入正確版本的符號。 * 這使得 glibc 可以在不破壞舊有已編譯程式的前提下,持續更新和改進其內部實作。 > 參考:[How the GNU C Library handles backward compatibility (Red Hat Developer Blog)](https://developers.redhat.com/blog/2019/08/01/how-the-gnu-c-library-handles-backward-compatibility/) * **深入理解 "Hello World" 背後的連結過程**: * 即便是最簡單的 "Hello World" 程式,其編譯連結和執行的背後也涉及到複雜的過程,包括與 C Runtime (CRT) 的連結、動態連結器 `ld.so` 的介入、標準 I/O 函式庫的載入和符號解析等。 > 參考:[Behind "Hello World" on Linux (Julia Evans' Blog)](https://jvns.ca/blog/2023/08/03/behind--hello-world/) 這些工具和技巧進一步展示了對 Linker 和 ELF 格式的深入理解,不僅有助於軟體開發和最佳化,也在資訊安全、系統分析等領域有著廣泛的應用。