將 C 或 C++ 原始碼編譯成可執行檔案分成兩步:第一步是將每個原始碼檔案分別編譯成可重定位檔案(relocatable,副檔名為 .o),第二步是將所有的可重定位檔案連結成可執行檔案。在 Linux 中,可重定位檔案和可執行檔案的格式都是 ELF(Executable and Linkable Format)。
本文面向對 ELF 檔案格式不熟悉的讀者,通過圖解的形式講解 ELF 檔案的連結方式,重點分析為什麼要引入各種資料結構,以便讀者對 ELF 的連結過程有形象化的認識。如果讀者對這部分內容已經有所瞭解,可以直接跳到文章末尾的《參考文獻》部分,直接閱讀這些深入講解 ELF 檔案格式的文章和文件。
本文涉及的概念包括段(segment)、節(section)、符號表(symbol table)、字串表(string table)和重定位表(relocation table)。重點講解這些概念是如何互相配合,以服務於 ELF 連結過程的,而不詳細說明這些概念在檔案中的二進位制格式。
為了減少複雜性,本文中的 ELF 程式都不使用共享物件(shared object)和動態載入技術(dynamic loading)。
1 段(segment)
從作業系統的視角來看,將程式載入到記憶體中的最簡單方法是:將程式從檔案中直接拷貝到記憶體的指定位置上,然後跳轉到程式入口處。
因為程式在記憶體中的位置是預先約定好的,所以,每一個函式和全域性變數在記憶體中的位置也都是可以事先知道的。程式的程式碼不需要任何修改就可以直接被執行。
在實際的作業系統中,記憶體是以頁(page)為單位管理的。一頁為 4096 位元組(十六進位制表示為 0x1000),每個記憶體頁都可以設定訪問許可權。在 x86 中,記憶體頁可以設定寫入和執行兩種許可權。
出於系統安全的目的,程式碼所在的記憶體頁可以執行,但不可以寫入,資料所在的記憶體頁可以寫入,但不可以執行。如果有一段記憶體既可以寫入,又可以執行,攻擊者就可以利用程式的 bug 在這個位置寫入攻擊程式碼,然後執行它,從而達到破壞作業系統的目的。
在 ELF 檔案中,記憶體訪問屬性相同的內容在檔案中也連續儲存,稱為段(segment)。程式碼存放在 程式碼段(text segment) 中,資料存放在 資料段(data segment) 中。
除了在檔案中的位置和長度,一個段還需要說明它在記憶體中的位置和長度,以及它所需的記憶體屬性。這些資訊記錄在 程式頭(program header) 中。段和程式頭一一對應。
程式頭以陣列的形式連續地儲存在 ELF 檔案中,這個陣列稱為 程式頭表(program header table)。通常,程式頭表在 ELF 頭之後,但也可以在檔案的其他位置。程式頭表的在檔案中的具體位置記錄在 ELF 頭之中。
作業系統根據 ELF 頭記錄的資訊找到程式頭表。在找到程式頭表之後,作業系統按照每個程式頭的資訊,將對應的段載入到記憶體中的相應位置,最後跳轉到程式入口處開始執行程式。
因為記憶體頁以 4K 為單位,所以程式碼段和資料段的長度必須是 4K 的整數倍。如果通過在段末尾補 0 的方式湊齊 4K 的整數倍,就會有空間浪費。為了避免這種浪費,ELF 檔案的各個段之間是緊密相連的,只是在載入到記憶體中的時候,才對映到不同的記憶體區域。又因為通過 記憶體對映(memory mapping) 載入檔案時必須以 4K 為單位,所以在記憶體中資料段的開頭會有一小部分程式碼,而程式碼段的末尾也會有一小部分資料。
2 節(section)
作業系統最關心的問題是如何將檔案載入到記憶體中,因此,段所記錄的資訊是:
- 段在檔案中的位置和長度;
- 段在記憶體中的位置和長度;
- 段的記憶體屬性。
而從連結器的視角觸發,第二點和第三點都不是連結器所關心的問題。連結器更關心 ELF 檔案中各個部分的功能,以及如何按功能將多個可重定位檔案合併成一個檔案。
一個段可以包含多種需要區別對待的功能:在程式碼段中,普通的程式碼和全域性初始化程式碼應該區別對待;在資料段中,有初始值的全域性變數和沒有初始值的全域性變數也應該區別對待。這樣一段連續的相同功能的區域稱為節(section)。與段不同,每個節都有名稱。
與程式碼和資料相關的節包括:
節名稱 | 描述 | 在可執行檔案中放在哪個段中 |
---|---|---|
.text | 一般的程式碼 | 程式碼段 |
.init | 初始化程式碼,在程式執行的最開始執行 | 程式碼段 |
.fini | 清理程式碼,在程式退出前執行 | 程式碼段 |
.data | 有初始值的全域性資料 | 資料段 |
.bss | 沒有初始值的全域性資料,初始值為 0 | 資料段 |
.rodata | 只讀的全域性資料 | 程式碼段,因為在記憶體屬性上與程式碼段最接近 |
描述節的結構是節頭(section header)。節頭以陣列的形式連續地存放在檔案中,這個陣列被稱為節頭表(section header table)。節頭表通常放置於 ELF 檔案的末尾,它的具體位置記錄在 ELF 頭中。
值得注意的是,節不是段的子結構,而是與段的地位相同的結構。段是從作業系統的視角來看 ELF 檔案的方式,節是從連結器的視角來看 ELF 檔案的方式。它們都是 ELF 檔案的可選部分:可重定位檔案沒有段,而可執行檔案也可以沒有節(但大部分可執行檔案都有節)。
在連結過程中,相同名稱的節會合併成一個節。以 .text 節為例,每個可重定位檔案都有一個 .text 節,它們被連結器合併成一個大的 .text 節,並存放在輸出的可重定位檔案或者可執行檔案中。
3 符號表(symbol table)和字串表(string table)
在寫程式時,一個檔案可以訪問另一個檔案中定義的變數和函式。這些變數和函式統稱為符號(symbol)。其他檔案可以訪問的符號稱為全域性符號(global symbol),只有檔案內部可以訪問的符號稱為本地符號(local symbol)。這與 C 語言的 static 關鍵字的功能是相同的。
如果一個檔案引用了另一個檔案的符號,這個符號也會被記錄在引用者中,稱為未定義符號(undefined symbol)。在最終連結成可執行程式時,所有的未定義符號都應該可以找到同名的全域性符號,否則就會連結失敗。
一個符號除了需要記錄它的名字,以便其他檔案引用,還需要記錄它出現在哪個節中和它在節中的偏移量。這些資訊以陣列的形式連續地儲存在檔案中,這個陣列稱為符號表(symbol table)。符號表是一種特殊的節,它的名字是 .sym。
到目前為止,我們發現 ELF 檔案中有兩處需要儲存字串,一處是節的名字,另一處是符號的名字。因為字串的長度是可變的,如果將其直接存在節頭和符號表中,就需要預先分配足夠的空間,這會造成大量空間被浪費。為了節約字串的儲存空間,ELF 檔案中的所有字串都存放在一個特殊的節中,稱作字串表(string table),它的節名是 .str。
所有的字串都是以 0 結尾的 C 風格字串,它們在字串表中連續儲存。其他地方通過它們在字串表中的下標來引用它們:ELF 頭記錄了字串表在節頭表中的下標,節頭記錄了節名稱在字串表中的下標;每個符號表都與一個字串表關聯,每個符號都記錄了它的名稱在關聯的字串表中的下標。通過這些資訊,連結器就可以找到節名和符號名。
4 重定位表(relocation table)
雖然我們現在可以引用其他檔案中定義的符號,但我們仍未解決一個重要的問題。
訪問全域性變數的操作通常會被編譯器翻譯成訪問記憶體地址的指令。然而,可重定位檔案中的節只記錄了它在檔案中的位置,而不像段一樣記錄它在記憶體中的位置,因此我們並不知道檔案中定義的全域性變數的記憶體地址。除此之外,如果一個檔案引用了另一個檔案定義的全域性變數,那麼直到將它們連結起來之前,我們都不可能知道這個全域性變數的記憶體地址。
進一步地說,直到最終連結成可執行檔案時,我們才能知道全域性變數和函式的記憶體地址,在此之前我們始終無法生成訪問它們的指令。
ELF 檔案的做法是:仍然按照正常的流程生成訪問這些全域性變數和函式的指令,但是在記憶體地址部分填寫 0,並且將這些佔位 0 的出現的位置記錄在 ELF 檔案中。在確定了所有這些符號的記憶體地址後,將佔位 0 改成正確的記憶體地址。
如果訪問的是全域性陣列中的某個元素或者全域性結構的某個成員,我們會將元素或成員的偏移量當作佔位符寫在記憶體地址出現的地方。在確定了符號的記憶體地址後,將這兩者相加就可以得到正確的記憶體地址。這個偏移量被稱作 addon 。
記錄這些佔位符出現位置的結構稱為重定位表(relocation table)。它也是一種特殊的節,而且在檔案中不只有一個。每個需要重定位的節都有一個與之對應的重定位表,重定位表的名字就是在被重定位的節名前加上 .rel 或者 .rela。.text 的重定位表是 .rel.text 或者 .rela.text。
之所以有這兩種命名方式,是因為重定位表有兩種略有不同的格式。.rel 格式的重定位表將 addon 寫在記憶體地址出現的位置,當作佔位符,如同前面描述的一樣;而 .rela 格式的重定位表將 addon 寫在重定位表中,而不是被重定位的位置上。
因為重定位需要符號的記憶體地址,所以每個重定位表除了與被重定位的節相關聯,也與符號表相關聯。綜合來說,重定位表的每個表項(entry)都記錄了四種資訊:需要重定位的佔位符在節中的偏移量、所引用符號在符號表中的下標、重定位時的地址計算方式和addon。
常見的地址計算方式有兩種:
名稱 | 計算方式 |
---|---|
R_386_32 | 符號的記憶體地址 + Addon |
R_386_PC32 | 符號的記憶體地址 + Addon - PC |
PC 指的是程式計數器,該暫存器記錄了當前指令所在的記憶體地址。R_386_PC32 用於相對於 PC 的定址,通常用於生成位置無關程式碼(PIC)。
在連結成可執行檔案時,連結器首先合併相同的節,然後確定節在記憶體中的位置,組成段。這時候,連結器就可以計算出所有的符號在記憶體中的地址。接下來連結器遍歷所有的重定位表,將每個需要重定位的位置改寫為真正的記憶體地址,就完成了重定位操作。這樣生成的程式就可以被作業系統載入到記憶體中執行了。
5 小結
- 記錄如何將程式載入到記憶體的結構是段。
- 記錄 ELF 檔案中各個部分的結構是節。
- 全域性變數和函式統稱為符號。
- 節名和符號名儲存在字串表中。
- 用於將程式中的佔位符改寫為記憶體地址的結構稱為重定位表。
6 參考文獻
ELF Format 是描述 ELF 檔案格式的文件,只有 60 頁但面面俱到地講解了靜態連結和動態載入的原理,是瞭解 ELF 檔案的必讀材料。然而這個版本已經略有過時,如果按照這個文件去分析現在的 ELF 檔案,會發現一些新的屬性在文件中是缺失的。儘管如此,因為這個本手冊比較薄,所以它的可讀性很好,建議在閱讀更詳細的文件前先讀一下這個文件。
Oracle 的 ELF 文件 是描述 ELF 檔案格式最詳細和最新的文件,適合當作手冊來查閱。
Eli Bendersky 的 Load-Time Relocation of Shared Libraries 是講解共享物件載入原理的文章。共享物件按照程式碼是否是位置無關的分成兩種,本文講解的是沒有開啟 PIC (位置無關程式碼)選項下共享物件的載入方式,它在原理上與連結是相同的。本文有程式碼和反彙編等例項,適合讀者去更加深入而具體地瞭解連結的過程。