藏在「連結」一詞裡的兩件工作
從上一篇你已經知道,一個目的檔不是可執行的程式。它是一個半成品:帶著洞的機器碼,外加一張符號表,列出這個檔案提供哪些名字、又還需要哪些名字。連結器的工作,就是拿一堆這樣的半成品檔案,把它們扣在一起、填滿那些洞,然後交還一個完整的東西。這單一的工作其實乾淨地分成兩個階段,而在腦中把它們分開,正是理解後續一切的關鍵。
第一個階段是符號解析:對於每一個檔案用到卻沒有定義的名字,找出那個確實有定義它的檔案,把兩者配成一對。第二個階段是重定位:既然每個函式與變數都已被指派了最終位址,就回頭走過程式碼,在每一處引用它們的地方把那些位址補進去。解析回答的是「你指的是哪一個 printf?」重定位回答的是「那麼 printf 最後落在哪裡,好讓我把那個數字寫進呼叫指令裡?」一個關於身分,另一個關於位置。
符號解析:把每個名字配對到恰好一個歸宿
回想第一篇那個已定義對未定義的區分。在每個目的檔內部,符號表把每個符號標記為在此定義(這個檔案含有它的程式碼或資料)或未定義(這個檔案用到這個名字,卻指望別人來提供)。當你在 main.c 裡寫了一個對 printf() 的呼叫、卻從未自己寫 printf,你的 main.o 最後就帶著一個名為 printf 的未定義符號——一個貼了標籤的洞,上頭寫著「等 printf 後來變成什麼,就拿那個來把我填上」。
解析,就是連結器掃描每一個輸入檔,把所有已定義的符號建成一張巨大的表,然後走過每一個未定義的引用,直到找到相符的定義。困難的不是配對成功的情形,而是衝突。萬一兩個檔案都定義了一個名為 init 的全域符號呢?連結器無法知道你指的是哪一個,所以預設它會拒絕——那就是「multiple definition」(重複定義)錯誤,正是「undefined reference」的鏡像。這條規則本意是嚴格的:每個名字恰好一個定義,不多也不少。
但有一個刻意留下的逃生口,而且它確實好用:弱符號。一般的定義是強的;一個弱定義說的是「只有在沒有更強的出現時才用我」。當連結器看到同一個名字有一個強定義與一個弱定義,它會默默取那個強的、丟掉弱的——不報錯。當它看到好幾個弱定義而沒有強的,它就隨便挑一個。函式庫正是靠這個來提供某樣東西的預設實作,比方說一個 malloc() 的掛鉤,而你的程式只要定義一個強版本就能自由覆寫它。這很強大,但要誠實面對風險:同一個名字有兩個強的 C 定義是真正的臭蟲,而兩個弱的默默挑一個則可能掩蓋一個真正的臭蟲,所以別靠弱性去掩飾一個你並非有意造成的命名碰撞。
重定位為什麼非存在不可
現在進入第二個階段。要看清它為何無可避免,把自己放進編譯器把 main.c 變成 main.o 時的處境。它是在單獨編譯 main.c,完全不知道還有哪些檔案存在、它們又會落在最終程式的哪裡。當它碰到一個對另一個檔案裡函式的呼叫,它真的無法寫出目標位址——那個位址還不存在,因為什麼版面都還沒排。於是編譯器做了唯一誠實的事:它把那個位址欄位留白(常常字面上就是一堆零),並附上一張字條,寫著「一旦你知道這個符號住在哪裡,回來把這個確切的位置填好」。
那張字條就是一筆重定位項目。每一筆都是一小段紀錄,指名三件事:那個洞在哪裡(某個節區裡的偏移量)、哪一個符號的位址該放進去,以及如何算出要寫入的值——因為不是每個引用都想要同一個數字。一筆重定位項目,實際上就是編譯器交棒給連結器的一項待辦事項:「修補待處理;說明如下。」一個目的檔帶著一整串這樣的項目,它的程式碼或資料裡每一個未解析的位址各對應一筆。
「如何計算」這一部分是微妙的,值得一個具體的例子。有些引用想要符號的絕對位址——它所在的那個完整最終數字。另一些想要相對的:x86-64 上的 call 指令並不儲存目標的絕對位址,它儲存的是從下一道指令到目標的帶號距離。所以一個 call 的重定位計算的是(符號的位址)減去(被修補欄位之後緊接著那個位址),並把那個差值寫進去。同一個符號、幾乎同一個洞,卻因為重定位的型別不同而是個完全不同的數字。這正是為什麼項目必須帶著一個型別,而不只是一個目標。
親手走過一筆重定位
讓我們一步一步修補一個洞,好讓這個機制不再抽象。想像 main.o 裡含有一個對函式 foo 的呼叫,而 foo 定義在 foo.o。在 main.o 裡,那道 call 指令有一個空白的 4 位元組欄位,那裡本該放相對偏移量,還有一筆指向它的重定位項目。以下就是從「位址未知」到一道完成、可執行指令的整套舞步。
- 先解析:連結器把 main.o 那個未定義的符號 foo 配對到 foo.o 裡已定義的 foo。現在兩者成對;foo 有了一個歸宿。
- 排好程式的版面:連結器指派最終位址。假設 foo 落在 0x401130,而 main 那道 call 指令裡那個 4 位元組的洞落在 0x401152(所以洞之後緊接著的位元組在 0x401156)。
- 讀取重定位型別:這一筆是 PC 相對的 call,所以要寫入的值是(目標)減去(欄位之後緊接著的位址):0x401130 減 0x401156。
- 算出偏移量:0x401130 - 0x401156 = -0x26,也就是帶號 32 位元的值 0xffffffda。這個 call 從它原本會繼續的地方往回跳 0x26 個位元組。
- 修補並丟棄:連結器把 0xffffffda 寫進 0x401152 處的那 4 個位元組,然後丟掉這筆重定位項目——這項待辦事項完成了。這道指令現在不論 foo 最後落在哪裡,都會準確地跳到 foo。
main.o BEFORE linking relocation entry
----------------------- ---------------------------------
... e8 00 00 00 00 ... offset = 0x.. (the 4-byte field)
^^^^^^^^^^^ symbol = foo
blank: "fill me" type = R_X86_64_PC32 (PC-relative)
linker lays things out: foo -> 0x401130
field -> 0x401152, next byte -> 0x401156
value = 0x401130 - 0x401156 = -0x26 = 0xffffffda
main AFTER linking
-----------------------
... e8 da ff ff ff ... // call foo, encoded as a backward jump當重定位無法在連結期完成
到目前為止的一切都假設連結器知道最終位址——而對一個經典的靜態連結可執行檔來說,它確實知道。但有兩個真實情況打破了這個假設,而它們正是接下來兩篇要談的。第一,對於一個共享函式庫,程式碼是在執行期被載入到某個選定的位址,每次啟動甚至可能不同,於是載入器必須完成一些靜態連結器無法完成的重定位。第二,與位置無關的程式碼被刻意寫成讓大多數引用根本不需要任何執行期修補——它透過間接層去觸及資料與函式,而不是靠烤死的絕對位址。
所以重定位不是單一的一刻;它是一段關於何時的光譜。有些重定位完全在連結期解析,並永久烙進可執行檔。另一些被延後,以重定位項目的形式留在最終檔案裡,等程式一啟動就由動態載入器套用。還有一些被導向小小的表,使得位址只需在一個地方寫一次,而不必在每一處呼叫點修補。同一個核心概念——一個洞、一個目標、一條填它的規則——橫跨它們全部;只是由誰與何時改變了。