🎯 什麼情境該想到我
當你編 .so 撞上 relocation R_X86_64_32 against ... can not be used when making a shared object、兩個第三方庫各自帶了不同版本的同一個依賴導致行為詭異、程式啟動時間隨 .so 數量惡化,或想用 LD_PRELOAD 攔截某個函式的時候。
⚙️ 怎麼用
一、動態連結在做什麼
「动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候。」(p209)
代價是效能,收益是空間與升級彈性。書中對整體損失給了兩個口徑:「动态链接与静态链接相比,性能损失大约在 5% 以下」(p209),另一處寫「靜態連結要比動態庫稍微快點,大約為 1%~5%」(p225)。兩處口徑不同,引用時記得標來源。
二、PIC:把「要被改的部分」搬離指令
共享物件不能假設自己被載到哪個位址。有兩條路:
方案 A:裝載時重定位(gcc -shared 但不加 -fPIC)
載入後遍歷重定位表改指令。跑得快,但指令被改過,程式碼段就無法在行程間共享——動態連結最大的優勢就沒了(p214–215)。
方案 B:PIC(位址無關程式碼)
「基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。」(p215)
PIC 把位址引用分成四類(p215–221):
| 類型 | 做法 |
|---|---|
| ① 模組內函式呼叫/跳轉 | 相對位址呼叫,最便宜 |
| ② 模組內資料存取 | 取得當前 PC 再加固定偏移 |
| ③ 模組間函式呼叫 | 透過 GOT 間接跳轉 |
| ④ 模組間資料存取 | 透過 GOT 間接定址 |
一行指令判斷某個 .so 是不是 PIC(p222):
readelf -d foo.so | grep TEXTREL「如果上面的命令有任何输出,那么 foo.so 就不是 PIC 的,否则就是 PIC 的。」
-fpic 與 -fPIC 功能相同,前者產物更小更快但在某些平台有全域符號數量/程式碼長度限制,所以絕大多數情況用 -fPIC(p222)。可執行檔的對應物是 PIE(-fPIE/-fpie),也是 ASLR 的前提。
三、GOT / PLT 與延遲綁定
動態連結慢在兩件事:跨模組存取要先定位 GOT 再間接定址、啟動時要一次做完所有符號查找與重定位。延遲綁定(Lazy Binding) 解決後者——第一次呼叫時才綁定。
機制就是「多加一層間接跳轉」(p226):
bar@plt:
jmp *(bar@GOT) ← 連結器初始化時「不」填真位址
push n ← 而是填這一行的位址(代價極低)
push moduleID
jmp _dl_runtime_resolve
所以第一次呼叫等於什麼都沒跳,接著把符號在 .rel.plt 的下標與模組 ID 壓棧,交給 _dl_runtime_resolve() 解析並把真位址回填 GOT;第二次起 jmp 直達目標。
ELF 把 GOT 拆成 .got(全域變數)與 .got.plt(函式)。.got.plt 前三項是保留的(p226):
.dynamic段的位址- 本模組的 ID
_dl_runtime_resolve()的位址
(後兩項由動態連結器在裝載時填。PLT 每項固定 16 bytes,剛好放 3 條指令。)
四、全域符號介入——最容易吃悶虧的一條規則
「它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。」(p243)
裝載是廣度優先的,所以是先裝載者勝,而且不會有任何警告。書中用 a1.so / a2.so 都定義 a() 的實驗證明:主程式印出兩次 a1.c,a2.so 的 a() 被完全無視。
「如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。」(p243)
這條規則還反過來影響編譯:正因為模組內的函式隨時可能被外部同名符號覆蓋,編譯器不能對它用快速的模組內相對呼叫,只能走 .got.plt。
→ 加速技巧:只在本編譯單元用的函式宣告成 static,編譯器就能確定不會被覆蓋(p243)。實務上再配 -fvisibility=hidden + 明確 export。
這也正是 LD_PRELOAD 能 hook 任意 libc 函式的原理。
五、排查工具
| 工具 | 用途 |
|---|---|
LD_DEBUG=libs|bindings|symbols|reloc|statistics|all | 印出裝載與綁定全過程(LD_DEBUG=help 看清單)(p269–270) |
LD_PRELOAD / /etc/ld.so.preload | 在所有搜尋規則之前載入,不管程式有沒有依賴它 |
LD_BIND_NOW / dlopen(RTLD_NOW) | 關掉延遲綁定,把「第一次呼叫才爆」提前到啟動就爆 |
readelf -d | 看 .dynamic:DT_NEEDED 依賴清單、TEXTREL |
六、dlopen/dlsym:外掛架構
四個 API 宣告在 <dlfcn.h>,實作在 libdl.so,編譯要 -ldl(p246–250)。要點:
- flag 必須在
RTLD_LAZY與RTLD_NOW二選一;除錯建議用RTLD_NOW,未綁定錯誤能立刻被dlerror()抓到。 dlopen會執行模組的.init(含 C++ 全域物件建構),dlclose靠引用計數,減到 0 才真正卸載並跑.fini。dlsym不能用回傳 NULL 判斷失敗——對常數它回傳的是值,值剛好是 0 時會誤判。必須用dlerror()。- 主模組要被外掛反向呼叫時,需要
gcc -Wl,--export-dynamic。 - 被載入的模組彼此有依賴時,要自己按順序載入。
書中也誠實指出根本限制:編譯器沒把函式簽名存進目標檔,執行期只拿得到位址、拿不到型別,所以外掛系統必須自己約定介面——「从这一点来看,C/C++ 的确不能被称为”高级”语言。」(p251)
🧪 我實際套用的紀錄
- 2026-08-02:(待填)
⚠️ 注意
- 「兩個行程共用同一個 .so,各自有獨立的資料段副本,互不影響;同一行程的兩個執行緒則共享。」(p222–224)這條常被誤解,值得單記。
- 即使是 PIC,資料段仍然需要重定位(
R_386_RELATIVE,p224)。PIC 保證的是「指令段不用改」,不是「什麼都不用改」。 - 延遲綁定會把錯誤延後到執行中途。CLI 工具還好,長時間執行的服務可能跑了三天才第一次進到某條分支然後死掉。要確定性就付啟動時間(
LD_BIND_NOW/ full RELRO)。 LD_LIBRARY_PATH不要隨便 export 到全域——書中明確警告會拖累其他程式,而且它同時會影響 GCC 編譯時找庫的路徑(等同-L);發布版程式也不該依賴LD_PRELOAD才跑得起來(p267–269)。要固定路徑請用-Wl,-rpath。- 動態連結器自己也是共享物件、沒人幫它重定位,所以它的自舉程式碼不能用全域變數、甚至不能呼叫函式(PIC 下連模組內呼叫都走 GOT,而 GOT 還沒填好)——這就是為什麼
ldd /lib/ld-linux.so.2回statically linked(p239–240)。
🔗 相關工具
- 工具-共享庫版本與相容性 —— 下一步:符號找得到之後,還要問「是不是對的版本」
- 工具-符號決議與重定位 —— 上游:同一套符號機制的靜態版本,先看那張再看這張
- 工具-虛擬記憶體與行程位址空間 —— .so 被映射成什麼、為什麼程式碼段能跨行程共享
- 工具-靜態庫與動態庫 —— 決策層:這張卡是動態連結的成本明細,那張是要不要用的取捨