ELF 到底是什麼
回到工具鏈那一級,你看著組譯器把一個翻譯單元變成一個目的檔,接著看著連結器把目的檔縫成一個執行檔、看著載入器把它帶進記憶體。那些工具讀寫的都是同一種檔案,而在 Linux 上,那種檔案就是 ELF——可執行與可連結格式(Executable and Linkable Format)。這個名字本身就是重點:一種格式同時服務連結端與載入端。ELF 檔不是魔法;它在磁碟上就是一個普通的位元組陣列,有著嚴格、有完整文件的佈局,而一旦你能讀懂這個佈局,整條工具鏈就不再是個黑箱。
ELF 檔有三種風味,全都共用同一格式。一個可重定位(relocatable)檔(組譯器吐出的 .o)是未完成的:它有程式碼與資料,但位址還沒釘死,它預期由連結器來修正。一個可執行(executable)檔(你的 a.out)已準備好在已知位址執行。而一個共享物件(shared object)(一個 .so,也就是動態函式庫)是個混血——可重定位到能被載入到許多位址,又完整到能執行。從頭到尾都是同樣的標頭結構、同樣的節區概念、同樣的符號表機制;只有類型欄位與完成程度不同。正是這種一致性,讓一個工具家族能處理整條流水線。
標頭:檔案的目錄頁
每個 ELF 檔都以一個固定大小的 ELF 標頭開場,而它是你永遠知道位置的那一部分:它就坐落在檔案偏移 0x0。最前面四個位元組是魔數 0x7f 接著 ASCII 字母 'E'、'L'、'F'——所以任何 ELF 檔的十六進位傾印開頭都是 7f 45 4c 46,這就是核心與工具一眼認出此格式的方式。ELF 標頭接著記下要點:32 位元或 64 位元、小端或大端、檔案類型(可重定位/可執行/共享/核心傾印)、目標機器(x86-64、AArch64……),以及進入點(entry point)——檔案被載入後,執行開始的那個虛擬位址。
但標頭最重要的工作,是指向檔案裡另外兩張大表,而這正是 ELF 的雙重本質第一次現身的地方。它存著節區標頭表(section header table)的檔案偏移與項目數——連結器的地圖——以及另外分開的程式標頭表(program header table)的檔案偏移與項目數——載入器的地圖。一個小小的標頭,兩個指標,對同一堆位元組主體的兩種截然不同的視角。注意這兩張表都不需要待在固定位置;標頭會告訴你各自從哪裡開始,所以工具先讀標頭、再順著指標走。檔案裡其餘的一切,都是從這張目錄頁跳過去找到的。
節區:連結器的視角
一個節區(section)是一段具名、有型別、為單一目的而存在的位元組,而節區正是連結器思考的方式。你在記憶體那一級已經以執行期區域的形式見過這群角色,但在這裡它們是檔案裡具體的片段。你的編譯器吐出的程式碼住在 .text。已初始化的全域變數(一個 `static int n = 5;`)住在 .data。唯讀常數與字串字面值住在 .rodata。符號表本身是一個叫 .symtab 的節區,那些符號用到的名字住在 .strtab,而重定位清單住在像 .rela.text 這樣的節區。每個節區標頭記下該節區的名稱、型別、大小、檔案偏移與所需對齊——每個片段一筆整齊的目錄項。
有一個節區值得特別關注,因為它教了一個真本事:.bss,零初始化全域變數的家。寫下 `static int table[1000];` 而不給初值,C 標準說每個元素都從零開始。在檔案裡存一千個明確的零位元組純屬浪費,所以 .bss 節區在磁碟上根本不佔空間——它的節區標頭記著一個大小,但檔案足跡為零。載入時,載入器只需映射一塊那麼大、新鮮的、填滿零的區域。這是第一個暗示:檔案佈局與記憶體佈局並不是同一回事——一個節區可以在記憶體裡很大,在磁碟上卻不佔分量。
連結器為什麼想要這種細粒度的視角?因為連結根本上是一件逐節區進行的合併與解析工作。當你連結三個目的檔,連結器把它們所有的 .text 接成一個、所有的 .data 接成一個、所有的 .rodata 接成一個,依此類推,並為每個合併後的節區選定一個最終位址。然後——運用符號表與下一篇要深挖的重定位項——它修補每一個參考,讓合併後的結果內部一致。節區視角存在的目的,正是為了讓連結器能物以類聚、刻意安排佈局。這種逐節區的合併,正是讓分離編譯真正可行的東西。
區段:載入器的視角
現在翻到另一張地圖。一個區段(segment),由程式標頭表裡的一筆項目描述,是載入器會一次性映射進記憶體的一塊檔案,帶著同一組權限。載入器不在乎你的程式碼來自 .text、常數來自 .rodata;它只在乎哪些位元組進到位址空間的哪裡、以及它們被允許做什麼。所以區段是粗的,而節區是細的。一個典型的執行檔只有寥寥幾個區段:一個可讀加可執行的區段載著程式碼、一個唯讀區段載著常數、一個可讀寫區段載著資料。節區與區段之分正是本篇的核心——同樣的位元組、兩種分組、兩種目的。
這兩種視角之間的關係是一種分組:每個可載入區段,是由一個或多個剛好需要同一組權限的完整節區建成的。.text 與 .init 程式碼節區疊進可執行區段;.rodata 與同伴疊進唯讀區段;.data 與 .bss 疊進可寫區段。這就是為什麼權限如此重要——程式碼被映射為可讀可執行但不可寫(你不能塗改自己的指令,這是抵禦攻擊的真實防線)、常數唯讀、而只有真正的資料才可寫。這種切分不是官僚作風;它是你在我們講位址空間佈局時遇到的記憶體保護的根基。
ONE ELF FILE, TWO MAPS
section view (linker) segment view (loader)
------------------------- ----------------------------
.text code ---\
.init startup code ----+--> LOAD r-x (executable)
/
.rodata constants ---+----> LOAD r-- (read-only)
.data init'd globals ---\
.bss zero globals ----+--> LOAD rw- (writable)
.symtab symbols ---/ (not loaded -- link-time only)
.strtab names --- (not loaded -- link-time only)
fine-grained, by purpose coarse, by permission符號:把一切綁起來的名字
少了第三根支柱,兩張地圖都沒多大意義:那就是符號表(symbol table)。一個符號不過是連結器可能需要參考的一個具名事物——一個函式、一個全域變數、一個節區起點。當你在一個檔案裡寫一個函式、卻從另一個檔案呼叫它,呼叫處根本不知道那個函式最終會落在哪裡;它只知道它的名字。符號表就是把名字變成位置的目錄。每一筆項目把一個名字(一個進到 .strtab 的索引)配上一個值(通常是個偏移或位址)、一個大小、一個型別(這是函式還是物件?),以及最關鍵的——一個繫結與一個定義狀態。
定義狀態是驅動連結的那個區分。一個符號要嘛是已定義(defined)——這個檔案實際含有那個東西,這是它的位置——要嘛是未定義(undefined),意思是「我用到這個名字,但必須由別人提供」。在你的程式碼裡呼叫 printf(),你的 .o 就把 printf 列為未定義;C 函式庫的 .o 把它列為已定義。符號解析的整個工作,就是把每一個未定義符號,配上輸入裡某處恰好一個的定義。如果兩個檔案定義了同一個名字,你會得到重複定義錯誤;如果沒有檔案定義一個被用到的名字,你會得到每個 C 程式員終究會遇到的那個連結錯誤,那令人聞之色變的「undefined reference to ...」。
還有兩個屬性把整幅圖補齊,兩者稍後都各有專篇。一個符號的繫結(binding)可以是全域(對其他檔案可見,非 static 函式的預設)或區域(只屬於自己的檔案,這正是 `static` 做的事——它把名字擋在所有其他人的解析之外),還有一個較柔和的第三選項,弱符號(weak symbol),一個全域符號,當存在更強的定義時會禮貌地退讓。另外分開地,一個全域符號帶著一個可見性(visibility),控制它究竟會不會從共享函式庫匯出。合在一起——名稱、值、型別、繫結、可見性、是否已定義——每個符號這寥寥幾個欄位,就是連結器用來解析你整個程式的全部字母表。
親手讀它,以及前方的路
這一切都不必停留在抽象——格式是公開的,而讀它的工具隨每套工具鏈一起出貨。執行 `readelf -h a.out` 印出 ELF 標頭,`readelf -S a.out` 列出節區(大寫 S,連結器的地圖),`readelf -l a.out` 列出程式標頭(載入器的地圖,看它印出哪些節區映射進哪個區段),`readelf -s a.out` 傾印符號表。`nm a.out` 指令更精簡地顯示符號,每個配一個字母:'T' 代表已定義的文字符號、'U' 代表未定義、'W' 代表弱。把這些和做反組譯的 `objdump -d` 搭起來,你大約一分鐘就能在一個真實的二進位檔上驗證本篇的每一個論斷。
抓緊這三根支柱,這一級其餘部分就從它們展開。我們有節區視角(連結器如何分組與合併)、區段視角(載入器如何映射與保護),以及符號表(名字如何變成位置)。我們還沒解釋的,是真正的修補:當連結器合併節區、選定位址時,每一個指向某個位址剛剛改變的符號的參考,都必須被校正。那個校正叫做重定位(relocation),而下一篇會打開重定位項,並從頭到尾走一遍符號解析。在那之後我們比較靜態與動態連結,接著認識讓動態呼叫運作的 PLT 與 GOT,最後是符號版本控制與動態載入器本身。