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

靜態程式庫與動態程式庫

這一路上你一直在連結 C 標準程式庫,卻從沒去想過它。本篇掀開那個黑盒子:一個程式庫究竟是什麼,以及你能談成的兩筆截然不同的交易——把程式碼烤進你的程式裡,或在啟動時才向它借。

程式庫究竟是什麼

到現在你已經能把一個 `.c` 檔編譯成一個目的檔,並讓連結器把好幾個縫合成一個執行檔。一個程式庫並不是什麼更奇異的東西,它不過是一袋你沒親手寫、整理過的目的檔,打包成讓連結器能準確掏出你所呼叫那些片段的形式。當你寫下 `printf("hi")` 而程式就這麼跑起來,那是因為你編譯器隨附的 C 標準程式庫裡裝著一份預先編譯好的 `printf`,連結器替你找到了它。你從hello world起就一直在用程式庫;這裡唯一的新觀念,是那段借來的程式碼何時如何加入你的程式碼。

對那個何時恰好有兩個答案,它們給出了兩種程式庫。一個靜態程式庫連結期被摺進你的執行檔裡——它的程式碼被實際複製進最終檔案中,就像上一篇 Make 把你自己的目的檔複製、組合在一起那樣。一個動態程式庫(也叫共享程式庫)則被留在你執行檔之外,較晚才與它接合,在載入期、在程式每次被啟動時。同樣的原始碼、同樣的 `printf`;差別在於是有一份副本住進你的檔案裡,還是你的檔案只攜帶一張字條寫著「我需要 libc 裡的 printf,請替我找到它」。

靜態:在連結期烤進去

把一個靜態程式庫想成那捆 `.o` 檔,外加一份索引、記著哪個函式住在哪個目的檔裡。當你連結它時,連結器不會盲目地把整袋複製進來。它會看你程式還需要的那些符號——每一個它用了卻還沒定義的名字——只拉進供應這些符號的那些目的檔,然後反覆進行,直到沒有任何符號懸而未決。在一千個函式裡只呼叫了一個,最單純的情況下,大約一個目的檔份量的程式碼會落進你的執行檔。結果是一個自給自足的檔案:它執行所需的一切都已經在裡頭了。

# build a static library from two objects, then link a program against it
gcc -c rope.c knot.c              # -> rope.o  knot.o
ar rcs libclimb.a rope.o knot.o   # bundle into the archive libclimb.a
gcc -O2 -Wall main.c -L. -lclimb -o climber
#   -L.  : look for libraries in the current directory
#   -lclimb : pull in libclimb.a  (lib... .a added automatically)
# the bytes of the functions main() calls are now COPIED inside ./climber
./climber                         # runs with no .a file present anywhere
ar 建出封存檔;-L 旗標把一個目錄加進程式庫搜尋路徑,而 -lclimb 把 libclimb.a 拉進來。連結之後,那個執行檔不再需要任何別的東西就能執行。

那份自給自足正是它全部的吸引力所在。這個執行檔能在一台從沒聽過 `libclimb.a` 的機器上執行;你可以把這單獨一個檔案交給別人,它就能用。代價則用三種錢幣支付。檔案更大,因為程式庫的程式碼被複製在它裡頭——也被複製在每一個同樣靜態連結這個程式庫的別的程式裡。程式庫裡的一個安全修補,只有在你重新建置並重新出貨之後才會到達你;舊的程式碼被永遠焊死在舊的二進位檔裡。而如果一台機器上的十個程式各自靜態嵌入了同一份 200 KiB 的輔助程式碼,那就是磁碟上的 2 MiB,更糟的是,執行期在記憶體裡有十份各自獨立的副本,彼此之間毫無共享。

動態:在啟動時才借

動態連結押了相反的賭注。當你連結一個 `.so` 時,連結器把任何函式本體複製進你的執行檔。取而代之,它寫下一個小得多的東西:一張你所依賴的程式庫清單(「需要 libclimb.so」)以及一張你將呼叫之符號的表。你的執行檔以一個滿是窟窿的檔案出貨——`printf` 與夥伴們本該在的地方,是一個個佔位符。那些窟窿在磁碟上保持空著,只有在程式真正被啟動時才被填滿。

填補窟窿是一個安靜的助手的工作,它叫做動態載入器——在 Linux 上是個像 `ld.so` 的小程式,核心會在你的 `main()` 取得控制權之前就執行它。它讀那張「需要」清單,沿著程式庫搜尋路徑逐一獵捕每個 `.so`,把它對映進行程的位址空間,並修補你那些空表,讓每個佔位符如今都指向共享程式庫裡真正的函式。唯有到那時,你的程式碼才開始跑。所以一個動態連結的程式分兩階段才算完成:連結器在建置期備好窟窿,而載入器在啟動期把它們填滿,每一次啟動都如此。

現在省下來的好處浮現了。程式庫的程式碼只存在一次,在那個 `.so` 檔裡,而非在每個程式裡都複製一份。更棒的是,作業系統能把那一份實體副本同時對映進許多行程,讓它們全都真正共享記憶體裡的那些位元組——這正是為什麼一個系統能跑一百個都用到 libc 的程式,而不需常駐一百份 libc。而對共享程式庫的一個修補,會在每個程式下次啟動時抵達它,不需重新建置任何一個。這正是為什麼你的發行版幾乎一切都動態連結到區區幾個共享 `.so` 檔上。

為什麼共享的程式碼必須與位置無關

在許多程式之間共享一份程式碼,提出了一個尖銳的問題。一個用老方式編譯的函式假設自己住在一個固定的位址——當它跳到一個輔助函式或讀一個全域變數時,那些去的都是連結期烤進去、寫死的位置。但一個共享程式庫無法要求一個固定的位址:它根本不知道哪些程式會載入它,而其中兩個可能早已把那一段位址空間用在別的東西上了。每個行程可能得把同一個 `.so` 對映到一個不同的虛擬位址,於是任何焊進程式碼裡的位址,在某處就只會是錯的。

解方是把程式庫編譯成與位置無關的程式碼(PIC),你用 `-fPIC` 旗標來要求它。PIC 從不寫死絕對位址;它改用相對於程式碼恰好被載入之處的方式,透過載入器填好的小型跳躍表,去抵達全域變數與其他函式。其結果是,不論每個行程把這些位元組對映到哪個位址,同樣的那些位元組都能正確運作——而這恰恰就是讓同一份實體副本,能在對它身處何處看法相左的眾行程之間共享的關鍵。代價是某些存取上多一絲間接;實務上微不足道,而在共享程式庫上它是必須的。

建出同一個程式庫的共享版本,只是對先前那些指令做兩個小改動。你在編譯目的檔時加上 `-fPIC`——`gcc -O2 -fPIC -c rope.c knot.c`——並用 `-shared` 而非 `ar` 來連結它們:`gcc -shared rope.o knot.o -o libclimb.so`。把你的程式連結到它,看起來和靜態的情況一模一樣,`gcc -O2 -Wall main.c -L. -lclimb -o climber`,但這次沒有任何函式本體被複製進來。麻煩在啟動時才到:載入器必須真的找到 `libclimb.so`,所以一個放在非標準位置的程式庫需要一點提示,比方 `LD_LIBRARY_PATH=. ./climber` 告訴它去當前目錄找——一個下一篇會完整接手的路徑問題。

如何抉擇,以及版本的陷阱

動態連結極大的便利裡,藏著它極大的危害。你的執行檔只攜帶一個名字與一個承諾——「我會呼叫一個叫 `climb`、收這些引數、回傳這個的函式」。載入器會用它在啟動時找到的隨便哪個 `libclimb.so` 來兌現那個承諾,而那未必是你當初連結的那一個。如果有人更新了程式庫、改了 `climb` 被呼叫的方式——換了一個引數、一個長出新欄位的結構——你的舊二進位檔會帶著舊的預期,興高采烈地呼叫新的程式碼,然後崩潰,或更糟,默默地弄壞資料。這份契約就是人稱的 ABI(應用程式二進位介面),而要讓它跨版本保持穩定,是門細緻的工夫。

兩個真實的失敗讓這件事變得具體,且兩者都有民間的名號。Windows 上的「DLL 地獄」與 Linux 上斷掉的符號連結錯誤,是同一道傷口:程式索求一個不存在、或存在卻是錯誤版本的程式庫。為了馴服它,共享程式庫帶著一個附版本號的 soname,像 `libclimb.so.1`,而一個程式記下它建置時所依據的主版本號;一個相容的 `libclimb.so.1.4` 之所以能取代 `libclimb.so.1.3`,正是因為主版本號——那個承諾——沒有改變。當一個改動確實破壞了 ABI,主版本號就跳到 `.so.2`,新舊便能並肩共存,不會互相毒害。