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

符號版本控制、LTO 與動態載入器

前三篇把機械結構搭了起來:重定位、動態連結、PLT 與 GOT。這一篇拉遠鏡頭,看三個讓真實系統得以運作的收尾——一個共享函式庫如何在同一個名字底下保有同一函式的兩個不相容版本、編譯器如何在連結期跨越檔案邊界做最佳化,以及在你的 main() 跑起來之前那幾毫秒裡,動態載入器究竟做了什麼。

版本控制要解決的問題:一個名字,兩種意思

想像一個極受歡迎的共享函式庫——C 函式庫本身就是經典案例。一個 2005 年編譯的程式從它呼叫某個函式,比方說 realpath(),並指望它以某種方式運作:某種引數列表、回傳值的某種意義。多年之後,函式庫作者需要改變 realpath() 的運作方式,而那個改變若讓那個老程式突然拿到新行為就會弄壞它。但他們不能直接把函式改名,因為外頭有成千上萬個已經編譯好的二進位檔,要的正是名為 realpath 的這個符號。這就是 ABI 相容性 的困局:名字是一份契約,而你不被允許悄悄改變契約所交付的東西。

符號版本控制 就是逃出這個困局的把戲。函式庫把 realpath 的兩個實作都留在同一個 .so 檔裡,但給每一個貼上一個版本標籤:舊的標成 realpath@GLIBC_2.2.5,新的標成 realpath@@GLIBC_2.3(雙 at 符號標示出新連結會採用的那個預設版本)。realpath 這個名字不再是單一符號;它是一個名字加上一個版本標籤,而這兩個帶標籤的變體並排共存。一個對著 2.2.5 版本連結的老二進位檔,記下了那個確切的標籤,於是載入器交給它舊行為;一個新編譯的二進位檔記下 2.3 並拿到新行為。同一個函式庫、同一個檔案、一個原始碼層級的名字,兩份都被信守的契約。

一個版本標籤如何被選定與信守

跟著這個標籤走過它的一生。當你對著一個有版本的函式庫編譯並連結一個程式,靜態連結器解析你對 realpath() 的呼叫,方式就如你已從第二篇知道的那樣——但當它在你的可執行檔裡記下這個未定義符號時,它寫的不只是 realpath,而是 realpath 加上它在連結期找到的那個預設版本(標了 @@ 的那個)。那個版本字串永遠凍進了你的二進位檔。所以「你拿到哪一個 realpath」這個選擇,是在你的連結期一次性做成的,而且是永久的——對著一個更新的函式庫重新編譯,才是讓你前進到一個更新預設值的途徑。

在執行期,動態載入器做配對的那一半。它讀取烤進你二進位檔裡那個帶版本的引用——realpath,版本 GLIBC_2.2.5——並在已載入的函式庫裡尋找一個確切帶著那個標籤的定義,而不只是那個光禿禿的名字。函式庫把它的版本公布在兩張小表裡(你會在 readelf 裡看到它們作 .gnu.version 與 .gnu.version_r);載入器用它們把你那個舊引用,跟舊實作配成一對,即便同一個檔案裡也存在一個更新的預設值。若函式庫不再提供你二進位檔所要的版本,你會在啟動時得到一個乾脆、誠實的失敗——一個「version GLIBC_2.x not found」錯誤——而不是沒有版本時你會遭受的那種沉默而危險的不相符。

這是它最深層的回報,而且值得明白說出來:版本控制讓一個函式庫得以演進它的行為,而不弄壞老的呼叫者、也不強迫全世界在某一天一起重新編譯。代價是函式庫作者那一側真實的紀律——每一個發布出去的版本標籤都是一個永遠不能撤回、只能往上加的承諾——但對一個出貨到數十億台機器上的基礎函式庫來說,那份紀律正是讓一個十五歲的二進位檔仍能跑在今天的系統上的東西。它不是魔法;它是記帳,以極大的小心施行。

連結期最佳化:把最佳化器延後到連結那一刻

現在來看一個完全不同的收尾,但它住在同一條接縫上——連結。從第一篇你知道,一般的編譯一次處理一個翻譯單元:編譯器單獨看 main.c、把它孤立地最佳化、吐出 main.o;它從來看不到定義在另一個檔案裡的函式的本體。那份孤立讓你損失了最佳化。如果 foo.c 裡的 foo() 很小、又只從 main.c 呼叫一次,編譯器很想把它行內展開——把它的本體直接貼進 main、刪掉那個呼叫——但它做不到,因為它編譯 main.c 時,foo 的本體在一個它無法窺看的、分離的世界裡。

連結期最佳化,也就是 LTO,打破了那道牆。訣竅是在逐檔的那一步不要把編譯做完。啟用 LTO 時(gcc -flto -O2),每個 .o 檔不含最終的機器碼;它改為藏起編譯器的內部中介表示——最佳化器實際在上面工作的那個半消化的形式。然後,在連結期,連結器把所有那些 IR 片段一次交還給編譯器,於是最佳化器終於能把整個程式合在一起看:它能跨越舊的檔案邊界把 foo 行內展開進 main、刪掉在一切都可見之後才發現到不到的程式碼、並把常數從一個檔案傳播進另一個。這些最佳化就是你已經見過的那些(行內展開、無用碼消除、常數傳播)——只是現在它們的範圍是整個程式,而不是單一檔案。

啟動:在 main() 之前動態載入器做了什麼

我們在三篇裡順帶提了動態載入器;現在讓我們看它工作,因為在你的程式跑起來之前,確實有另一個程式在跑。當你啟動一個動態連結的可執行檔,核心並不直接跳到你的 main()。它先映射進一個小小的輔助程式——動態載入器本身,通常是 ld-linux.so——它的路徑記在你二進位檔裡一個叫做直譯器的特殊欄位(你可以讀它:readelf -l a.out 會顯示一行 INTERP,指名 /lib64/ld-linux-x86-64.so.2)。核心啟動的是那個程式,而那個在你的行程裡跑著的載入器,把世界準備好,使得當控制權終於到達 main() 時,每一個函式庫都已就位。

載入器怎麼知道該找哪些函式庫?你的二進位檔為它帶著一小串指示,那個 動態節區——一串帶標籤的項目,載入器像讀檢查清單那樣讀它。一個 DT_NEEDED 標籤指名這個二進位檔依賴的每一個函式庫(每個 .so 各一個,例如「libc.so.6」);一個 DT_RPATH 或 DT_RUNPATH 標籤可能建議額外的搜尋目錄;其他標籤指向載入器會需要的符號表與重定位表。載入器走過這串清單,對每一個需要的函式庫執行 函式庫搜尋:一段固定的地點序列——runpath、LD_LIBRARY_PATH 環境變數、系統快取,然後是預設目錄——依序檢查,直到那個檔案出現。第一個相符的勝出,這就是為什麼路徑前段一個走錯地方的函式庫,會遮蔽掉你本來要的那一個。

一旦每個函式庫都映射進來,載入器完成第二篇與第四篇鋪好的工作:它套用那些被延後的重定位。資料類的它急切地做——把每個對應全域變數的 GOT 槽,填上那個變數如今已知的執行期位址。函式類的它能透過延遲繫結延後,讓每個 PLT 短截程式只在第一次呼叫時才解析它真正的目標,正如第四篇所描述。然後,也只有在那時,載入器才把控制權交給你程式的啟動程式碼,後者最終呼叫 main()。這一切——映射、搜尋、重定位、繫結——都發生在你第一行跑起來之前的一眨眼裡。

最後一道強化步驟:RELRO

上面這一切裡藏著一道安全的皺褶,而它有一個值得知道的乾淨修法。第四篇的 GOT 是一張函式指標形狀的槽的表,載入器在啟動時把值寫進去。但「一張程式會跳經其中的指標的表」對攻擊者是塊肥肉:若一個臭蟲讓他們能覆寫一個 GOT 槽,他們就能把一個未來的呼叫重新導向到他們選定的程式碼。緊張之處在於,GOT 必須保持可寫,久到足夠讓載入器把它填好——然而理想上它之後該變成唯讀,好讓事後沒有東西能篡改它。

RELRO——「relocation read-only」(重定位唯讀)——藉著改變 GOT 何時變成唯讀來化解那份緊張。連結器把那些被重定位的表聚成一個區域,而載入器一在啟動時寫完它所有的重定位,就請求核心把那個區域的權限翻成唯讀(就是作業系統到處都在用的同一套分頁保護機制)。在那次翻轉之後,一個找到寫入臭蟲的攻擊者,就是無法在 GOT 上亂寫——硬體會讓那次寫入發生錯誤。「Full RELRO」做到底:它強制急切繫結,使每一個函式重定位都預先解析、讓載入器鎖住整張表,並放棄延遲繫結在啟動時的省力,換來一張被強化的表。它是個小而具體的例子,展示了系統裡反覆出現的取捨:一點點啟動速度換來大量的安全。