我們站在哪:一堆已完成、卻互不相連的碎片
從第 1 篇你已經知道連結器是第四、也是最後一個建置階段;從第 3 篇你也知道它接到手裡的是什麼:一組目的檔,每一個都是真正的機器碼、而且是完整、卻尚未相連的。每個 `.o` 都帶著一張符號表——一份它所「定義」的名字(它真的裝著其程式碼或資料的函式與全域變數),加上一份它只是「用到」、自己卻沒有的名字。後面這份,就是那組未定義符號:一個洞,等於在說「我呼叫了一個叫 `printf` 的東西,但我不知道它在哪裡」。
所以連結器的輸入,真的是一副拼圖:每一片內部都已成形,但邊上有標好記號的卡榫,必須喀地一聲扣進別片標好記號的凹槽。在整個分離編譯流程裡,連結器是唯一一個會同時看見所有碎片的程式。編譯器在完全孤立的狀態下處理每一個翻譯單元,相信那些洞稍後會有人來填。連結器就是那個人。它的全部目的,是把許多各自獨立的 `.o` 檔變成一個整體,而它靠的恰好是兩個操作:符號解析與重定位。
工作一——符號解析:把每個名字對應到它唯一的定義
符號解析是記帳,你在腦子裡就能做。連結器讀過每一個目的檔,建出一本符號的總帳。對每一個名字,它問一個簡單的問題:誰「定義」了它,誰又只是「用到」它?某個 `.o` 裡的每一個未定義符號,都必須對應到另一個 `.o` 或函式庫裡的某個已定義符號。當你的 `main.o` 說「我需要 `helper`」,而你的 `helper.o` 說「我定義了 `helper`」,連結器就在它們之間畫一條線。那條線就是解析。對每一個未定義名字、橫跨每一個輸入檔都做一次,拼圖的卡榫就全配上了凹槽。
從這本總帳裡,直接掉出兩種失敗,而它們正是最常見的兩種連結器錯誤。如果一個名字被「用到」、卻「哪裡都沒定義」,解析就以 `undefined reference to 'helper'` 失敗——你呼叫了它,卻沒有任何一片供應它(你忘了把 `helper.c` 一起編進來,或忘了連結裝著它的函式庫)。如果一個名字「同時在兩個地方被定義」——比方說你不小心把一個非 `static` 的全域變數放進了一個被兩個 `.o` 檔都引入的標頭裡——解析就以另一種方式失敗,回報 `multiple definition of 'count'`。解析堅持每個名字恰好一個定義:不能是零個,也不能是兩個。
工作二——重定位:每一片都以為自己住在位址零
解析只配對了名字;它還沒決定任何東西「住在哪裡」。重定位要解決的問題就在這裡。組譯器做出每個 `.o` 時,根本不知道它會跟哪些別的檔接在一起,所以它把每一個都寫成彷彿自己會坐在位址 0x0——它的函式從位移 0 開始、它的全域變數從很小的位移開始,全都從一個私有的零量起。如今連結器手上有好幾片這樣的東西,它們顯然不可能同時都佔住位址 0。於是連結器先把它們首尾相接地排好,決定最終佈局:`main` 的程式碼放在某個基底位址、`helper` 的程式碼緊接其後、全域變數放在它們自己的一塊資料區裡。
但是搬動一塊程式碼,會改變烘焙在它裡頭的每一個位址。如果 `main` 的程式碼被挪到從 0x4000 開始,那麼一條當初假設基底為 0 而編碼的 `call helper` 指令,現在就指錯了地方。這正是重定位要修的。每個 `.o` 在它的符號表旁邊,還帶著一份「重定位項」清單:每一筆都是一張便條,寫著「在我程式碼內部的某個位元組位移處,有一個依賴最終佈局的位址——等你知道 `helper` 落在哪裡之後,回來把這個數字補上」。連結器走過那份清單,把每一個這樣的欄位,用如今已知的最終位址重寫一遍。
before linking -- main.o, written as if based at 0x0:
0x0000: ... call <helper> ; target = 0x0000, RELOC: "patch to helper"
0x0005: ...
the linker chooses the final layout:
main placed at base 0x4000
helper placed at base 0x4020
after relocation -- the call slot is rewritten:
0x4000: ... call 0x4020 ; hole filled in: helper's real address
0x4005: ...把它兜起來:從 .o 檔到單一可執行檔
所以對最單純的靜態連結而言,連結器整趟執行,就只是依序做這幾個動作。想像三個輸入——你的 `main.o`、你的 `helper.o`、以及裝著 `printf` 的 C 函式庫目的檔——流過連結器每次都重複的那條小裝配線。
- 把每一個輸入目的檔(並從函式庫裡拉進任何定義了「仍被需要的符號」的目的檔)收進一本合併的符號總帳。
- 解析:把每個未定義符號對應到恰好一個定義;若不存在就以「undefined reference」中止,若有兩個就以「multiple definition」中止。
- 佈局:為每一塊程式碼與資料選定一個最終位址,把碎片打包進可執行檔的各個區段(程式碼放一塊區域、已初始化的資料放另一塊)。
- 重定位:走過每一筆重定位項,用如今已知的真正最終位址,把每個位址欄位補上。
- 輸出一個檔案,採用平台的可執行檔格式,記下執行該從哪裡開始(進入點)。
出來的這個檔,不只是一袋位元組——它依一個有定義的可執行檔格式而結構化(Linux 上是 ELF、macOS 上是 Mach-O、Windows 上是 PE)。那個格式記下連結器建出的各個分開區域(程式碼區、唯讀資料、可寫的全域變數),以及最關鍵的那個單一起始位址。當你稍後執行 `$ ./a.out`,第 1 篇裡的載入器讀的正是這個格式,才知道要把什麼複製進記憶體、要往哪裡跳。連結寫下地圖;載入照著它走。
靜態相對於動態:現在就連進來,還是啟動時再找
到目前為止的一切,都假設了靜態連結:連結器把函式庫真正的程式碼複製進你的可執行檔,所以完成的檔案是自足的,`printf` 的機器位元組實實在在地住在你的 `a.out` 裡。從一個靜態函式庫(一個 `.a` 檔,其實只是一袋 `.o` 檔的封存)拉進程式碼,運作起來正如上面所述——解析、重定位、複製進來。好處是一個什麼都不需要就能跑的單一檔案。壞處是大小與陳舊:每一支用到 `printf` 的程式,都帶著自己私有的一份副本,而函式庫裡修好的一個 bug,在你重新連結之前都到不了你身上。
動態連結把這份工作一分為二。在建置時,連結器只做記帳那一半:它確認那個符號存在於某個共享函式庫裡,並記下一個「承諾」——「這支程式需要 `libc` 的 `printf`」——但不複製任何程式碼進來。真正的解析延後到程式啟動時,由載入器的一部分(動態連結器)找到那個早已在記憶體裡的共享函式庫、把它映射進來、那時才把這些呼叫接上。這就是為什麼磁碟上一份 C 函式庫的副本,能讓每一支正在執行的程式同時共用,也是為什麼對那一個檔案的一個安全修補,能在大家一重新啟動的瞬間就修好每一個人——不需要重新連結。
兩種選擇都不是免費的,誠實地談這份取捨很值得。靜態連結換來一個自足、可預測的二進位檔,代價是磁碟空間與更新的摩擦。動態連結換來共享的記憶體、較小的檔案、集中式的更新,代價是一種新的失敗模式:程式可以完美地建置出來,卻拒絕啟動,因為它被承諾的那個共享函式庫在啟動時找不到——那令人聞之色變的 `error while loading shared libraries`。那個錯誤,是動態連結器版本的 `undefined reference`,只是在執行時、而非建置時才到來。同一個概念——一個沒有定義的名字——不同的時機。