JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

靜態連結 vs 動態連結

你現在已經知道連結器如何解析符號、套用重定位。下一個岔路口是:這份工作是在建置時完成,還是延後到程式執行時才做。靜態連結與動態連結是同一個問題的兩個誠實答案——printf() 的程式碼到底從哪裡來——而它們的權衡一路延伸到磁碟大小,乃至安全更新。

一個問題,兩個答案

到現在,前兩篇的圖像已經很扎實:你的編譯器把每個原始碼檔變成一個塞滿機器碼、但留著洞的目的檔連結器執行符號解析、確定每個名字指向哪個定義,然後用重定位把那些洞補起來。到目前為止每一篇都默默假設這一切都在你執行程式之前就完成了。但還有第二個同樣正當的選擇:把解析與補洞延後——延到程式被啟動時,甚至執行到一半時。這一個決定——現在還是稍後——就是靜態與動態連結的全部。

用最小的程式讓它具體起來:`int main(void) { puts("hi"); return 0; }`。對 puts() 的呼叫在你的目的檔裡是一個未定義符號——你的程式碼不包含 puts(),它只是提到這個名字。得有人提供真正的位元組。在靜態連結下,連結器伸進一個靜態程式庫(封存成 libc.a 的 C 程式庫),把 puts() 的機器碼、以及它依賴的一切,全部複製進你的執行檔,並當場解析這個呼叫。結果是一個自給自足的單一檔案。跑 `ls -l a.out`,它很大,因為 libc 跟著一起搭了便車。

動態連結下,連結器做了一件近乎弔詭的事:它讓對 puts() 的呼叫保持未解析。它不複製任何程式碼,而是記下一個承諾——「這個程式需要一個叫 libc.so.6 的共享程式庫,並且要從中取得符號 puts」——把這個承諾蓋印進執行檔。你的 a.out 現在很小,但它不再自給自足:它無法執行,直到在啟動時有東西找到 libc.so.6 並把這個呼叫接好。那個東西是一個獨立的程式,叫做動態連結器,而這一級其餘部分大致就是它做了什麼的故事。

靜態連結器實際做了什麼

靜態程式庫並不神奇——它幾乎稱不上是一種格式。一個 .a 檔是一個封存檔,本質上是一堆普通目的檔用一個小索引黏在一起、像 tar 那樣的捆包。當你對它連結時,連結器不會盲目地把整個封存檔拉進來。它按需拉進目的檔:從你的未定義符號出發,找到定義每一個的封存成員、加入那個成員,並注意到那個成員自己又有新的未定義符號、於是再去追。這就是為什麼在命令列上連結順序有時很重要——連結器由左到右掃描,而一個封存檔只貢獻那些能解析「在讀到它的當下已經待解析」的符號的成員。

一旦需要的成員蒐集齊全,靜態連結就只是你已經見過的機制:它們的區段與你的合併(所有 .text 在一起、所有 .data 在一起),符號表被解析、使每個未定義的 puts() 現在都指向那份具體的副本,而重定位被套用、把最終位址烤進去。在那之後,程式庫的程式碼與你自己的無從分辨——它只是同一個 .text 區段裡多出來的位元組。執行時沒有任何痕跡顯示 puts() 曾經住在一個獨立的檔案裡。這個程式是完整的、凍結的,而且在最強的意義上可移植:把這一個檔案複製到另一台相同架構的機器上它就能跑,沒有任何依賴要去滿足。

動態連結留給稍後的東西

動態連結把連結器的工作切分到兩個時間點。在建置時,連結器仍然檢查每個符號能夠被滿足——它讀取共享程式庫、確認 puts 真的在那裡,所以漏掉的符號仍然是個建置錯誤,而不是啟動時的惡劣意外。但它不複製程式碼,而是把兩樣東西寫進你的執行檔:一份所需程式庫的清單(每個是一筆像 libc.so.6 的 `DT_NEEDED` 紀錄),以及一個樁(stub)機制、讓對 puts() 的呼叫繞經一個稍後才會被填好的小表。puts() 的實際位址被留白——一個只有執行期才知道如何填補的洞,因為唯有在執行期才知道 libc 落在記憶體的哪裡

為什麼建置期的連結器不能直接把位址寫下來?因為根本沒有一個固定的位址可寫。同一個 libc.so.6 被數百個執行中的行程共享,而每個行程把它放在自己的位址空間佈局恰好有空位的地方——而且在位址空間佈局隨機化下,那個位置出於安全考量會在每次啟動時被刻意打亂。因此共享程式庫不能把自己所在的位置寫死;它必須被建置成位置無關碼,讓同一份位元組無論載入到哪個位址都能運作。那個要求正是下一篇存在的理由,也是為什麼你聽過名字的GOTPLT即將變得重要。

當你啟動一個動態連結的程式時,核心注意到它不是自給自足的,於是先把控制權交給動態連結器(它的路徑記在執行檔裡,通常是 /lib64/ld-linux-x86-64.so.2)。動態連結器讀取 `DT_NEEDED` 清單、找到並把每個共享程式庫映射進記憶體、遞迴地拉進它們的依賴,然後執行與靜態連結器原本會做的同一套符號解析與重定位工作——只是現在針對的是真正、剛剛選定的載入位址。一旦每個承諾的符號都被接好,它就跳進你的程式碼。這一切都發生在你的 main() 執行之前,在第一行執行之前的那一瞬。

誠實道出的權衡

支持動態連結的頭號論點是共享。在一個運作中的系統上,數十個程式都用 libc;如果每個都靜態連結,每個行程就會在磁碟上、記憶體裡各自帶一份 puts() 與同伴的私有副本。有了共享程式庫,libc 的唯讀程式碼分頁被映射進許多行程,卻由 RAM 中一份實體副本支撐——核心的分頁共享讓這幾乎免費。在系統規模上這份節省是真實而巨大的:這就是為什麼你的發行版只出貨一個 libc.so、而上千個程式都倚靠它,而不是把它烤進每一個。

第二個論點是更新,而它是雙刃的。當 libc 裡發現一個安全漏洞——比方說某個剖析器裡的緩衝區溢位——動態連結的世界靠出貨一個新的 libc.so.6 來修它;每個用到它的程式都會在下次啟動時被修補,不必重新建置。相對地,一個靜態連結的二進位檔把那段有漏洞的程式碼永遠凍結在裡面,必須重新編譯、重新散布才能修。那是動態連結一個真實而嚴肅的優勢。但同樣的耦合也是它巨大的危險:以不相容的方式改動程式庫,你可能一口氣弄壞每一個依賴它的程式——那令人聞之色變的「依賴地獄」。這就是為什麼程式庫版本管理與 ABI 相容性是一門紀律、而非事後補救,也是為什麼下一篇的姊妹篇要講符號版本管理。

靜態連結的反方論點同樣誠實。它的二進位檔較大、更新很痛,但作為回報它自給自足且可預測:它能在一個沒裝任何程式庫的赤裸容器裡跑,它不會因為有人在它底下升級了某個共享程式庫而壞掉,而且它沒有每次啟動時尋找並接好程式庫的成本。還有一個小但真實的執行期差異:對 puts() 的動態呼叫會經過一層間接(我們提過的那張表),而靜態呼叫是一個直接跳躍——通常微不足道,但在熱迴圈裡可量測。沒有哪個選擇是放諸四海皆準的。這個權衡是「可移植性與更新速度(動態)」對上「自給自足與可重現性(靜態)」,而嚴肅的系統會逐案刻意挑選。

親眼看看

你不必把這一切當作信仰來接受——工具讓你親眼看著那個岔路發生。把這個小程式用兩種方式編譯,差異立刻顯現:`gcc hi.c` 給你一個動態連結的 a.out,而 `gcc -static hi.c` 給你一個靜態連結的。對兩者跑 `ls -l`,靜態那個明顯大得多。最乾淨的示範是 `ldd` 指令,它列出一個二進位檔在執行期還需要什麼:在動態建置上它印出 libc.so.6 與動態連結器;在靜態建置上它印出「not a dynamic executable」,因為已經沒有東西要解析了。

  1. 寫出檔案:`int main(void) { puts("hi"); return 0; }`,存成 hi.c。
  2. 用兩種方式建置:`gcc -O2 hi.c -o hi_dyn` 與 `gcc -O2 -static hi.c -o hi_static`。
  3. 用 `ls -l hi_dyn hi_static` 比較大小——看著靜態二進位檔隨著 libc 一起搭便車而膨脹。
  4. 用 `ldd hi_dyn` 檢視依賴(列出 libc.so.6 與載入器),接著 `ldd hi_static`(說它不是動態的)。
  5. 用 `strace ./hi_dyn` 追蹤動態建置的啟動,看著動態連結器在 main() 執行之前就 open 並 mmap libc。