📘 程式設計師的自我修養
連結、裝載與庫
🎯 什麼情境該想到我
當你遇到「編譯過但連結失敗、undefined symbol、找不到入口」這類錯誤,或想搞懂「原始碼怎麼變成能跑的程式」時。
⚙️ 怎麼用(理解四階段,對症下藥)
gcc hello.c 這一句其實是四個階段的縮寫,而且每一階段都能單獨跑出來看(PDF p63–66):
| 階段 | 實際程式 | 拆出來看 | 這一關出錯的意思 |
|---|---|---|---|
| 預處理 | cpp | gcc -E hello.c -o hello.i | 標頭/巨集展開錯 |
| 編譯 | cc1(C)/ cc1plus(C++) | gcc -S hello.i -o hello.s | 語法/型別問題 |
| 彙編 | as | gcc -c hello.s -o hello.o | 組語層問題 |
| 連結 | ld(GCC 實際呼叫的是 collect2) | ld ... | 符號有引用、沒定義 |
gcc 本身只是包裝,依參數去叫上面這些後端程式。
預處理只做 6 件事(p64):展開 #define、處理 #if/#ifdef/#elif/#else/#endif、遞迴插入 #include、刪註解、加行號與檔名標記、保留 #pragma。
書中直接點出可執行動作:「所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题」(p65)。巨集或標頭出問題時的第一動作是看
.i檔,不是靠猜。
編譯階段內部是六步(p66–72):掃描(詞法)→ 語法分析 → 語義分析 → 源碼優化 → 代碼生成 → 目標代碼優化。中間代碼是前端/後端的分界線——前端產機器無關的中間碼,後端轉成目標機器碼,所以跨平台編譯器能一個前端配 N 個後端(p72)。這也解釋了錯誤的分層:括號不匹配是語法錯(p68)、型別不匹配是語義錯(p70)、除以 0 是執行期錯。
連結不是一步,是三個子步驟(p76):
- 位址與空間分配(Address and Storage Allocation)
- 符號決議(Symbol Resolution)
- 重定位(Relocation)
「重定位所做的就是给程序中每个这样的绝对地址引用的位置”打补丁”,使它们指向正确的地址。」(p78)
術語辨析:「決議 Resolution」偏靜態連結、「綁定 Binding」偏動態連結(p76)——讀 ld 與 glibc 文件時(例如 -z now 的 “bind now”)常撞到這組詞。內部機制見 工具-符號決議與重定位。
裝載階段做的是建立映射,不是搬資料(p182–183):建立獨立虛擬位址空間 → 建立「虛擬空間 ↔ 可執行檔」的映射 → 把指令暫存器設成入口位址。做完這三步一個位元組都還沒進記憶體,靠 page fault 按需載入。
- 靜態連結時入口是 ELF header 的
e_entry;動態連結時入口是動態連結器(p200、p244)。 - 而入口也不是
main——中間還隔著執行庫的初始化,見 工具-程式啟動與執行庫。
🔎 undefined symbol 診斷樹
同一句錯誤訊息,有三個完全不同的發生時間點:
| 時間點 | 長什麼樣 | 常見成因 |
|---|---|---|
| 連結期 | ld: undefined reference to 'foo' | ①少連某個庫 ②輸入目標檔路徑不對 ③符號的宣告與定義不一致(C↔C++ mangling、呼叫慣例不同)(p133、p323–324) |
| 執行期・啟動時 | error while loading shared libraries / undefined symbol | .so 找不到或版本不對(p266) |
| 執行期・第一次呼叫時 | 跑了一陣子才爆 | 延遲綁定——用 LD_BIND_NOW / RTLD_NOW 可以把它提前(p225–227、p248) |
先分清楚是哪一關,再往下查。
🧰 工具鏈速查
| 想做什麼 | Linux | Windows |
|---|---|---|
| 看段結構 | readelf -S / objdump -h | dumpbin /HEADERS |
| 看執行視圖的 Segment | readelf -l | — |
| 看符號 | nm / readelf -s / objdump -t | dumpbin /SYMBOLS |
| 看重定位項 | objdump -r | — |
| 反解 C++ 修飾名 | c++filt | UnDecorateSymbolName() |
| 看動態依賴 | ldd / readelf -d | dumpbin /IMPORTS |
| 追裝載與綁定 | LD_DEBUG=libs|bindings|statistics|help | — |
| 看靜態庫內容 | ar -t / ar -x | lib /LIST |
| 瘦身 | strip(或 -Wl,-s 全部/-Wl,-S 只去除錯符號) | — |
🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意
- 「編譯錯」與「連結錯」是不同階段的問題,訊息也不同——先分清楚是哪一關。
-g產生的.debug_*段往往比程式碼本身大好幾倍,發布前記得strip(p119–120)。但別對要除錯的產物做——書中也警告不要為了瘦身而 strip 掉共享庫的除錯資訊或用-fomit-frame-pointer,「没有帧指针之后,无法准确定位函数的调用轨迹(Stack Trace)」(p314)。-nostartfiles/-nostdlib會讓全域物件的建構與解構不執行(p369、p388)。裸機/韌體情境要自己補。- 這四階段是心智模型,不是實作規格。連結階段內部的兩趟掃描、裝載階段的按需分頁,各自都是獨立的一整套機制。
🔗 相關工具
- 工具-符號決議與重定位 —— 把「連結」這個黑盒打開:兩趟掃描、強弱符號、name mangling、重定位算式
- 工具-虛擬記憶體與行程位址空間 —— 把「裝載」這個黑盒打開:VMA、page fault、Section vs Segment
- 工具-程式啟動與執行庫 —— 接在裝載之後:跳到入口位址之後、進
main之前發生了什麼 - 工具-靜態庫與動態庫 —— 連結階段的分岔選擇,也是 undefined symbol 這類錯誤的常見來源
- 工具-系統化除錯 —— 遇到連結期或載入期怪錯誤時的追查方法
預處理 → 編譯 → 彙編 → 連結 gcc 只是包裝,實際叫 cc1 / as / ld
巨集或標頭出問題?
先看 gcc -E 的 .i 檔,不要猜
中間代碼是前端/後端的分界線 一個前端可以配 N 個後端
⚙️ 編譯:四階段
🎯 什麼情境該想到我
當你撞上 undefined reference to 'foo'、multiple definition of 'x',或遇到「明明有實作、連結卻說找不到」「兩個 .c 都定義了同名全域變數卻能連過、行為卻錯亂」這類看起來像玄學的連結錯誤時。
⚙️ 怎麼用
一、先建立心智模型:連結就是「按符號拼拼圖」
模組之間只有兩種互動——函式呼叫、變數存取——兩者都可歸結為同一件事:模組間符號的引用(p75)。定義符號的模組多出一塊,引用符號的模組剛好少那一塊。
編譯 main.c 時編譯器不知道 foo() 在哪,就把呼叫指令的目標位址欄位先填 0 擱置,等連結器補。每個要被修正的地方叫一個重定位入口(Relocation Entry)。
「重定位所做的就是给程序中每个这样的绝对地址引用的位置”打补丁”,使它们指向正确的地址。」(p78)
二、靜態連結是兩趟掃描
- 空間與位址分配:掃描所有
.o,把相同性質的段合併(所有.text併成一個),算出輸出檔中各段的虛擬位址,同時把所有符號定義與引用收集進一張全域符號表。
— 為什麼要合併而不是按檔案順序疊加?因為疊加會產生成百上千個零散段,每段都要按 4096 對齊,浪費巨大(p123–125)。 - 符號解析與重定位:讀重定位表,把每個引用點的位址修正到正確位置(p125)。
x86 32 位元只有兩種基本修正算式(p134–135):
R_386_32(絕對定址)→ S + AR_386_PC32(相對定址)→ S + A − P
(S = 符號實際位址,A = 被修正處原本的值,P = 被修正處本身的位址。看懂這兩條,objdump -r 的輸出就沒有秘密了。)
三、undefined reference 的診斷樹
書中直接給出三大成因(p133):
| 成因 | 怎麼確認 |
|---|---|
| 少連了某個庫 | nm -C / ar -t 檢查該符號到底在哪個 .a/.o |
| 輸入目標檔路徑不對 | 檢查 -L / -l / 相對路徑 |
| 符號的宣告與定義不一致 | 最常見是 C ↔ C++ 的 name mangling 不一致,見下一節 |
機制上,readelf -s a.o 裡那些 UND 符號之所以存在,正是因為 a.o 裡有針對它們的重定位項;連結器掃完全部輸入仍找不到定義,才報錯。
四、C++ 符號修飾(name mangling)與 extern "C"
C++ 有重載、命名空間、類別,所以編譯器用「函式簽名 → 唯一符號名」的規則產生符號。GCC 規則是 _Z 開頭、巢狀接 N、每段名字前寫長度、E 結尾、參數型別接在後(p111–113):
int N::C::func(int) → _ZN1N1C4funcEi
- 反解用
c++filt。 - 變數的型別不進修飾名,全域變數只按命名空間修飾。
- MSVC 用完全不同的
?func@C@N@@AAEHH@Z風格,而且規則未公開——這是不同編譯器產物無法互連的主因之一。 - 標準寫法:對外標頭檔用
#ifdef __cplusplus包住extern "C" {(p115–116)。
五、強符號、弱符號與 COMMON——「忘了加 extern 卻能連過」的真相
C/C++ 中函式與已初始化的全域變數是強符號,未初始化的全域變數是弱符號;也可用 __attribute__((weak)) 手動標記。連結器三條規則(p117):
- 強符號不可重複定義,否則
multiple definition。 - 一強多弱 → 選強符號。
- 全弱 → 選佔空間最大的那個。(A 檔宣告成
int佔 4 bytes、B 檔宣告成double佔 8 bytes,連結後是 8 bytes。書中加註「尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误」。)
未初始化全域變數不直接進 .bss,而是標成 SHN_COMMON,因為單獨編譯時還不知道別的編譯單元裡同名符號要多大,只有連結器讀完全部輸入才能定案(p136–137)。書中對這個設計直言不諱:
「这种使用 COMMON 块的方法实际上是一种类似”黑客”的取巧办法……但最本质的原因还是链接器不支持符号类型。」(p137)
逃生開關:-fno-common(或 __attribute__((nocommon)))——未初始化全域變數變成強符號,重複定義直接報錯。GCC 10 起這是預設值,所以升級編譯器後突然一堆 multiple definition,根因就在這裡。
六、弱引用:做「選配相依」
強引用找不到定義 → 連結錯誤;弱引用找不到定義 → 連結器不報錯,把該符號值設為 0(p118)。
__attribute__((weak)) int pthread_create(...);
...
if (pthread_create) { /* 多執行緒版 */ } else { /* 單執行緒版 */ }書中實測:gcc pthread.c -o pt 印 single-thread,加 -lpthread 印 multi-thread(p118–119)。用途有三:函式庫提供可被使用者強符號覆寫的預設實作、執行期偵測 optional 相依是否存在、讓程式功能更容易裁剪與組合(少連某個 .o 仍可連結,只是少了功能)。
🧪 我實際套用的紀錄
- 2026-08-02:(待填)
⚠️ 注意
undefined symbol有三個不同時間點,訊息長得像但根因完全不同:①連結期(ld報 undefined reference)②執行期啟動時(動態連結器解析失敗)③延遲綁定下第一次呼叫該函式時才爆。這張卡只管第 ①種,②③見 工具-動態連結與位址無關程式碼。.bss只記大小、不佔檔案空間(SHT_NOBITS,objdump -h裡沒有CONTENTS屬性)。所以「宣告了 1MB 全域陣列,執行檔大小卻沒變」是正常的。順帶一提static int x = 0;會被歸到.bss而非.data——初始化成 0 等同未初始化(p91–92)。- 連結器連靜態庫的粒度是「目標檔」不是「函式」:用到
printf()就整個printf.o被拉進來。這也是為什麼 libc 刻意做成一個函式一個.o(p147–148)。 - 強弱符號規則是「靜默」的——一強多弱時不會有任何警告。這正是這類 bug 難查的原因。
🔗 相關工具
- 工具-編譯連結與裝載 —— 上游:這張卡展開的是四階段裡「連結」那一階段的內部機制
- 工具-靜態庫與動態庫 —— 姊妹題:符號從哪裡來(庫的粒度與連結順序)
- 工具-動態連結與位址無關程式碼 —— 同一套符號機制搬到執行期,多了全域符號介入這個新坑
- 工具-系統化除錯 —— 遇到連結期怪錯誤時的一般追查方法
模組間的一切互動 都可歸結為符號的引用
兩趟掃描 ① 合併段、分配位址、建全域符號表 ② 符號解析 + 重定位
「重定位所做的就是給每個絕對位址引用的位置打補丁。」
強弱符號三規則 全弱時選佔空間最大的那個
未初始化全域變數 → COMMON 這就是「忘了加 extern 卻能連過」的真相
🔗 連結:符號與重定位
ELF / PE 目的檔、執行檔、動態庫本質是同一種格式
段 Sections .text(碼)/ .data / .bss(只記大小、不佔檔案空間)
指令與資料分家的第三個也是最重要的理由:共享 IE7 實測:112,844K 總量,96,900K 是共享的
📦 可執行檔格式
🎯 什麼情境該想到我
當你要回答「這支程式的記憶體到底花在哪」「為什麼二進位檔 200MB 卻啟動很快」「為什麼多開 10 個行程記憶體沒有變 10 倍」,或看 /proc/<pid>/maps、pmap、RSS 卻讀不懂的時候。
⚙️ 怎麼用
一、先理解演化:粒度決定一切
直接用實體位址有三個病(PDF p39–40):位址空間不隔離、記憶體使用效率低、程式執行的位址不確定。
- 分段(Segmentation) 解決了第 1、3 個——一整段虛擬空間映射到一塊實體空間,隔離了,也不必重定位。
- 但沒解決第 2 個:映射粒度仍是「整個程式」,記憶體不足時換入換出的是整支程式(p42)。
- 分頁(Paging) 靠局部性原理把粒度縮到 4KB 才真正解決(p42)。32 位元 4GB 空間按 4KB 分,共 1,048,576 頁。
全書的主線法寶:「计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决」(p33,出處不可考,一說 Butler Lampson,一說 David Wheeler)。虛擬記憶體正是這句話最經典的實作。
二、「裝載」的本質是建立映射,不是搬資料
建立一個行程只做三件事(p182–183):
- 建立獨立的虛擬位址空間——在 i386 Linux 上其實只是配一個 page directory,連頁映射關係都先不設。
- 讀可執行檔頭,建立「虛擬空間 ↔ 可執行檔」的映射關係。這一步才是傳統意義的「裝載」。
- 把 CPU 指令暫存器設成入口位址,啟動執行。
做完這三步,程式的指令與資料一個位元組都還沒進記憶體。真正的載入靠 page fault:CPU 摸到空頁 → 硬體捕獲 → OS 查映射關係算出該頁在檔案的哪個偏移 → 配實體頁 → 讀入 → 建映射 → 回到出錯指令重跑。
也因為裝載其實是映射,「可執行檔很多時候又被叫做映像檔(Image)」(p183)。
三、看懂 /proc/<pid>/maps
08048000-080b9000 r-xp 00000000 08:01 2801887 ./SectionMapping.elf
080b9000-080bb000 rw-p 00070000 08:01 2801887 ./SectionMapping.elf
080bb000-080de000 rw-p 00000000 00:00 0 [heap]
bf7ec000-bf802000 rw-p 00000000 00:00 0 [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]
欄位依序是:位址範圍/權限/在映像檔中的偏移/設備主次號/inode/路徑(p191)。
- 權限的
p= private(COW),s= shared。 - 設備號與 inode 都是 0 = 匿名虛擬記憶體區域(heap、stack、vdso 都是)。
- 這張表的單位叫 VMA(Virtual Memory Area)。OS 的基本原則是:把「權限相同 + 來自同一映像檔」的東西合併成一個 VMA(p191)。
四、Section 是給連結器看的,Segment 是給裝載器看的
同一個 ELF 檔的兩種視角(p189):
- Section → 連結視圖(Linking View),
readelf -S - Segment → 執行視圖(Execution View),
readelf -l
映射以 4096 為單位,一個 ELF 有十幾個 Section,各自映射會產生大量頁內碎片。所以連結器把權限相同的 Section 合併成 Segment,大致三類(p186):可讀可執行(程式碼)、可讀可寫(資料 + BSS)、唯讀(唯讀資料)。一個 Segment 對應一個 VMA,這就是上面 maps 只有兩行檔案映射的原因。
書中的算例很直觀:.text 4097 bytes + .init 512 bytes,分開映射要 3 頁,合併成一個 Segment 只要 2 頁(p186)。
五、順手可查的
readelf -l的p_memsz > p_filesz,多出來的部分自動填 0——那就是 BSS,所以不需要獨立的 BSS Segment。- 空間切分:Linux 預設核心 1GB(
0xC0000000以上)+ 使用者 3GB;Windows 預設核心 2GB + 使用者 2GB(p175、p309)。 - 書中實測 32 位元下
malloc最大約 Linux 2.9GB / Windows 1.5GB,會受動態庫數量影響(p193)。
🧪 我實際套用的紀錄
- 2026-08-02:(待填)
⚠️ 注意
- RSS 高不代表洩漏。 有映像檔的 VMA 是檔案映射(可回收、可跨行程共享),匿名 VMA 才是真的獨佔實體記憶體——看 maps 時先分這兩類。
- 「程式碼段共享」是分頁機制給的,不是動態連結給的。 多個行程跑同一支程式,唯讀的指令部分記憶體只需一份;書中量測 IE 7.0 總虛存 112,844 KB,其中私有只有 15,944 KB,96,900 KB 是共享的(p85)。
- 冷啟動慢的成因常常在這裡:page fault 是同步的磁碟 I/O。「檔案大但啟動快」與「跑一陣子 RSS 才慢慢長起來」是同一個機制的兩面。
- 書中的位址佈局圖是 2.4 核心(共享庫在
0x40000000);2.6 之後共享庫被挪到接近堆疊的高位址,heap 從 BSS 末端到 stack 之間幾乎一馬平川(p332–333)。看老書的圖要留意。
🔗 相關工具
- 工具-編譯連結與裝載 —— 上游:這張卡展開的是四階段裡「裝載」那一階段的內部機制
- 工具-動態連結與位址無關程式碼 —— 下游:入口位址交給誰、.so 怎麼被映射進這些 VMA
- 工具-程式啟動與執行庫 —— 銜接:映射建好、跳到入口之後,進 main 之前還做了什麼
分段解決「隔離」與「重定位」 分頁才解決「效率」——粒度縮到 4KB
裝載的本質是建立映射,不是搬資料 三步做完,一個位元組都還沒進記憶體
Section 是連結視圖,Segment 是執行視圖 一個 Segment 對應一個 VMA
🧠 虛擬記憶體與裝載
🎯 什麼情境該想到我
當你編 .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 被映射成什麼、為什麼程式碼段能跨行程共享
- 工具-靜態庫與動態庫 —— 決策層:這張卡是動態連結的成本明細,那張是要不要用的取捨
把「連結」從裝載前推遲到裝載時 代價約 5% 效能
PIC:把要被改的部分搬離指令段 不然程式碼段就無法跨行程共享
延遲綁定=多加一層間接跳轉 PLT 第一項故意填「下一條指令」
⚠️ 全域符號介入:同名符號先裝載者勝, 後者被靜默忽略
🔌 動態連結
🎯 什麼情境該想到我
當你要決定「用靜態還是動態連結」,或遇到「執行期找不到 .so/.dll、版本衝突」時。
⚙️ 怎麼用(依需求選)
- 靜態庫(.a/.lib):連結時把庫的程式碼打進執行檔。
- ✅ 部署簡單(單一檔、無外部依賴)、版本鎖定。
- ❌ 執行檔大、庫更新要重新編譯整個程式、多程式共用時記憶體重複。
- 動態庫(.so/.dll):執行期才載入、多程式共享。
- ✅ 執行檔小、庫可獨立更新、記憶體共享。
- ❌ 部署要帶對版本、易遇「依賴地獄/版本衝突」、執行期才炸。
取捨:要部署省事/版本可控 → 靜態;要省空間/可熱更新庫 → 動態。容器時代常用靜態或把動態依賴一起打包。
📦 先搞清楚「庫」到底是什麼
「库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。」(PDF p77)
.a 就是 ar 把一堆 .o 打包再加索引而已:ar -t libfoo.a 列出內容、ar -x 全部解出來(Windows 用 lib /LIST)。書中量測 libc.a 約 2.8 MB、含約 1400 個目標檔(p142–144)。
關鍵:連結器連靜態庫的粒度是「目標檔」,不是「函式」。 用到 printf() 就整個 printf.o 被拉進來——這就是為什麼執行庫刻意做成一個函式一個 .o,把「連進來卻沒用到」的浪費壓到最小(p147–148)。
相依還是遞迴的:printf.o 依賴 stdout(在 stdio.o)與 vfprintf(在 vfprintf.o),書中示範手動連結必然失敗(p144–145)。所以 gcc -static --verbose 揭露的真實連結行長這樣(p145–147):
crt1.o crti.o crtbeginT.o your.o --start-group -lgcc -lgcc_eh -lc --end-group crtend.o crtn.o
這串 crt*.o 是什麼、順序為什麼不能亂,見 工具-程式啟動與執行庫。
💰 動態庫的真實成本(取捨表通常漏掉的那半)
| 成本 | 細節 |
|---|---|
| 執行速度 | 跨模組資料與函式都要先定位 GOT 再間接定址。書中兩個口徑:整體損失「大约在 5% 以下」(p209);另一處「靜態比動態快約 1%~5%」(p225) |
| 啟動時間 | 啟動時要做符號查找與重定位。.so 越多、導入符號越多,啟動越慢;延遲綁定只是把它推遲,沒有消滅 |
| PIC 的取捨 | 不加 -fPIC 只加 -shared → 走裝載時重定位,跑得快但程式碼段無法跨行程共享,動態連結最大的優勢就沒了(p214–215)。判斷指令:readelf -d x.so | grep TEXTREL,有輸出就不是 PIC(p222) |
| 錯誤延後 | 未定義符號可能在啟動時才爆,甚至延遲綁定下第一次呼叫該函式時才爆 |
反過來,動態庫的收益也不只是「省磁碟」——還包括減少實體頁面的換入換出、提高 CPU 快取命中率(p206–207)。
一條常被誤解的語義,值得單記(p222–224):
兩個行程用同一個
.so,各自有獨立的資料段副本,互不影響;同一行程的兩個執行緒則共享。
🔧 靜態連結體積太大時
-ffunction-sections/-fdata-sections(MSVC 是/Gy)讓每個函式/變數各自成段,連結器就能只挑用到的(p138)。代價:編譯連結變慢、段數暴增。
— ⚠️ 書中只講了編譯器端把段切碎,沒講連結器端的死碼回收。實務上要自己補-Wl,--gc-sections。strip(或-Wl,-s去除所有符號/-Wl,-S只去除錯符號)。書中說 strip 後檔案常只剩一半或更小(p272)。- 極端案例作為量感:同一支程式靜態連結 glibc 約 538KB,換成書中手寫的 Mini CRT 只有 5083 bytes(p456–457)。
🧩 弱符號:讓庫可裁剪、可覆寫
__attribute__((weak)) 讓庫提供的預設實作可被使用者的強符號覆寫;弱引用則讓「少連某個 .o 程式仍可連結,只是少了功能」(p117–119)。詳見 工具-符號決議與重定位。
🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意
- 動態庫的「執行期才報錯」最難查;用工具(ldd/依賴檢視)先確認依賴齊全與版本相符。
- 不要跨庫邊界交換資源:不能在 A 庫
new/malloc而在 B 庫delete/free(分屬不同的堆)、不能跨庫傳FILE*、不能共享 locale。「如果不违反上述规则,可能会使程序发生莫名其妙的错误并且很难发现」(p374–375)。這條在今天的 Rust/C++ FFI、Python C extension 依然是頭號地雷。 - C++ 不適合當共享庫介面。 修飾規則、物件佈局、虛表、模板實例化、例外機制全算 ABI,標準又沒規範——換編譯器甚至換版本就連不起來。書中結論是「不要改变接口的任何部分或干脆不要使用 C++ 作为共享库接口」(p257)。要跨邊界就用純虛介面 +
extern "C"工廠函式。 - 靜態連結不只是體積差別。 書中給了一個硬證據:Windows 上靜態連結 CRT(
/MT)沒有DllMain,用CreateThread建執行緒時_tiddata無人釋放 → 每建一個執行緒漏一份,是教科書級的緩慢記憶體洩漏;動態連結 CRT(/MD)因為有DllMain就不會(p381–382)。解法是一律用_beginthread(ex)/_endthread(ex)。 - 一個工程裡所有目標檔與 DLL 應使用同一版本的 CRT(p374–375)。混用會出現
LNK2005 already defined或LNK4098 defaultlib conflicts。
🔗 相關工具
- 工具-共享庫版本與相容性 —— 選了動態庫之後,版本怎麼命名、什麼時候該升主版本、
GLIBC_2.xx not found怎麼解 - 工具-動態連結與位址無關程式碼 —— 動態庫執行期到底做了什麼:PIC、GOT/PLT、全域符號介入
- 工具-編譯連結與裝載 —— 上游:庫在哪一階段被連進來
- 工具-符號決議與重定位 —— 庫裡的符號怎麼被挑出來、連結順序為什麼重要
- 工具-資料編碼與演進 —— 同構問題:資料格式的前後相容 vs 二進位介面的前後相容
🎯 什麼情境該想到我
當你在新機器編譯、拿到舊機器執行卻報 version 'GLIBC_2.xx' not found;當你要決定「升級這個 .so 會不會炸掉線上服務」;當你自己要發佈一個函式庫、不知道該怎麼命名與打包;或當兩個相依都要同一個庫的不同版本時。
⚙️ 怎麼用
一、版本號的語義(libname.so.x.y.z)
| 位置 | 名稱 | 語義 |
|---|---|---|
| x | 主版本號 | 重大升級,不同主版本完全不相容。舊程式必須改寫重編,或系統保留舊版 |
| y | 次版本號 | 增量升級,只加新介面、不改舊的。高次版本向後相容低次版本 |
| z | 發佈版本號 | 只修 bug/改效能,完全相容,雙向可換 |
二、SO-NAME:程式依賴的是介面,不是檔名
SO-NAME = 檔名去掉次版本號與發佈版本號,只保留主版本號。
libfoo.so.2.6.1 → SO-NAME 是 libfoo.so.2(p258)。
系統為每個庫建一個叫 SO-NAME 的軟連結,指向該主版本下最新的實體檔;連結時寫進 .dynamic 的 DT_NEEDED 記的是 SO-NAME 而非完整檔名。所以增量升級只要換檔案 + 更新軟連結,不必保留一大堆舊版本。
「总之,SO-NAME 表示一个库的接口,接口不向后兼容,SO-NAME 就发生变化,这是基本的原则。」(p260)
建庫時務必給 -soname:
gcc -shared -fPIC -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0.0 foo.c沒指定 -soname 的庫根本沒有 SO-NAME,ldconfig 對它完全無效(p270)——這是「檔案明明拷過去了卻還是找不到」的常見死因。
(例外:Glibc 自己不守這規則,用 libc-x.y.z.so 命名,但 SO-NAME 仍是 libc.so.6。)
三、什麼時候該升主版本號?ABI 相容性判準
書中直接列了表(p256):
| 動作 | 相容? |
|---|---|
| 增加一個導出符號 | ✅ 相容 |
| 刪除導出符號 | ❌ 不相容 |
| 增加或減少函式參數 | ❌ 不相容 |
| 改變被導出用到的 struct 記憶體佈局 | ❌ 不相容 |
| 改變函式語義(同名同簽名但行為變了) | ❌ 不相容 |
| 只修 bug、不改語義 | ✅ 相容 |
「要破坏一个共享库的 ABI 十分容易,要保持 ABI 的兼容却十分困难。」(p256)
C++ 更難:虛表佈局、模板實例化、多重繼承、名稱修飾、例外機制各家實作不同,標準沒有規範 ABI。書中給了 7 條注意事項後直接下結論:
「最重要的是,不要改变接口的任何部分或干脆不要使用 C++ 作为共享库接口!」(p257)
書中借《COM 本質論》的 StringFind.DLL 案例說明有多脆:只加一個私有成員變數,主程式 new 出來的還是舊版的 4 bytes,而庫裡的建構函式認為物件有 8 bytes——直接寫進不屬於自己的記憶體(p298–300)。
四、符號版本:SO-NAME 解決不了的那一半
SO-NAME 只比對主版本。在高次版本庫上編譯的程式拿到低次版本庫的機器上跑,動態連結器認為 SO-NAME 一致就放行,結果跑到一半才發現缺符號——書中叫它次版本號交會問題(Minor-revision Rendezvous Problem)(p261)。
解法是給每個導入/導出符號附一個版本標籤(GLIBC_2.5 這種)。關鍵設計:連結時記錄的不是庫當時的最新版本,而是這支程式實際用到的最小夠用版本;執行時比對,不滿足就直接拒絕啟動,而不是跑到一半炸掉(p261–262)。
./main: ./lib.so: version 'VERS_1.2' not found (required by ./main)
這就是現代最常見的 version GLIBC_2.34 not found 的完整解釋——也直接說明了為什麼常見對策是「在最舊的目標系統上編譯」、manylinux、musl、或靜態連結。
GCC 在 Solaris 方案上加了兩個擴充(p264–265):asm(".symver ...") 內嵌指令;以及允許同名符號在同一個庫裡多版本共存(printf@VERS_1.1 與 printf@VERS_1.2 各指向不同實作),這樣連改語義都不必動主版本號。
自己的庫要用的話:
gcc -shared -fPIC lib.c -Xlinker --version-script lib.ver -o lib.soversion script 裡的 local: *; 是範圍機制(Scoping),把其餘全域符號降級為區域符號——等於補上 C 語言缺乏的可見性控制。
五、找不到 .so 時的排查順序
動態連結器的查找順序是(p266–268):
LD_LIBRARY_PATH/etc/ld.so.cache- 預設目錄(
/lib、/usr/lib)
ld.so.cache 由 ldconfig 產生:遍歷預設目錄與 /etc/ld.so.conf 指定的目錄,建立/更新每個庫的 SO-NAME 軟連結,並收集成查找專用的快取檔。
→ 安裝或更新任何共享庫之後都必須跑 ldconfig。 沒有 root 權限時用 ldconfig -n <dir> 自己建軟連結。
FHS 三個目錄的分工:/lib 系統關鍵、/usr/lib 開發用、/usr/local/lib 第三方。
六、版本衝突的四種解法譜系
書中在 DLL HELL 一節整理的四條路,概念完全通用(p301–304):
- 靜態連結 —— 終極解法,代價是放棄動態連結的一切好處。
- 防覆蓋 —— 阻止未授權程式覆蓋系統庫(Windows File Protection)。
- 每個應用自帶一份依賴 —— 載入時先找自己目錄再找系統目錄。
- 強識別 + 多版本共存 —— Windows 的 Side-by-Side:用 name + version + processorArchitecture + publicKeyToken 組成「強檔名」,同名不同版本在
WinSxS下共存,靠 manifest 指定版本。
DLL HELL 的三種成因也值得記(p301):安裝程式用舊版覆蓋新版(最常見)、新版行為無意改變、新版引入新 bug。
🧪 我實際套用的紀錄
- 2026-08-02:(待填)
⚠️ 注意
- 「完全向後相容」實務上做不到——這是 DLL HELL 的第二種成因,也是為什麼「只是修個 bug」的升級照樣會出事。
- 跨庫邊界不要交換資源:不能在 A 庫裡
new/malloc而在 B 庫裡delete/free(分屬不同的堆)、不能跨庫傳FILE*、不能共享 locale。書中原話:「如果不违反上述规则,可能会使程序发生莫名其妙的错误并且很难发现」(p375)。這條在今天的 Rust/C++ FFI、Python C extension 依然是頭號地雷。 - 容器不是免死金牌。把
libfoo.so.1.2.3拷進映像檔卻沒建 SO-NAME 軟連結、或忘了跑ldconfig,一樣找不到。 - 第四節那套「強識別 + 多版本共存」,就是今天
node_modules巢狀安裝、Go module 的 semantic import versioning、乃至容器化走的同一條路——問題沒有變,只是換了包裝。
🔗 相關工具
- 工具-動態連結與位址無關程式碼 —— 上游:符號怎麼被解析、為什麼會有全域符號介入這種撞名問題
- 工具-靜態庫與動態庫 —— 決策層:讀完這張卡的成本,回去重新評估要不要用動態連結
- 工具-資料編碼與演進 —— 同構問題:那張講資料格式的前後相容,這張講二進位介面的前後相容,判準思路一致
.a 就是 ar 打包的一堆 .o
連結粒度是目標檔,不是函式
SO-NAME 表示一個庫的介面 介面不向後相容,SO-NAME 就得變
符號版本解決 SO-NAME 解決不了的
次版本號交會問題 → GLIBC_2.xx not found
📚 庫:取捨與版本
🎯 什麼情境該想到我
當程式「還沒進 main 就掛了」、全域/靜態物件的初始化順序出問題、你想在 main 之前注入一段初始化、或用了 -nostdlib/-nostartfiles 之後冒出一堆莫名其妙的未定義符號時。
⚙️ 怎麼用
一、程式不是從 main 開始的
「正如基督徒认为世界的诞生起于 7 天创世一样,任何一个合格的 C/C++ 程序员都应该知道一个事实:程序从 main 函数开始。但是事情的真相真是如此吗?」(p343)
書中給三個「鐵證」(p343–344):進 main 時全域變數已初始化完、argc/argv 已備好、堆與 I/O 已初始化(所以能直接 malloc/printf);C++ 更多,全域物件的建構子已跑完;而 atexit 註冊的函式在 main 結束之後才跑。
標準流程四步(p344):
- OS 建立行程後,把控制權交給執行庫的入口函式(不是
main)。 - 入口函式初始化堆、I/O、執行緒、全域變數建構。
- 呼叫
main。 main返回後回到入口函式,做全域解構、銷毀堆、關閉 I/O,再用系統呼叫結束行程。
二、兩條真實路徑
glibc(p345–349)
_start ← 由 ld 的預設連結腳本指定,可用參數改
├ xor %ebp,%ebp ← 清零表示這是最外層函式
├ 從棧上取 argc / argv / envp
└ __libc_start_main(main, argc, argv, __libc_csu_init, __libc_csu_fini, ...)
├ __libc_csu_init ← main 之前的初始化
├ main()
└ exit(result)
├ 遍歷 __exit_funcs 鏈表(atexit 註冊的)
└ _exit() ← 真正的 exit 系統呼叫
MSVC(p349–352,作者評語是思路更清晰)
mainCRTStartup
→ 取 OS 版本 → _heap_init → _ioinit
→ __setargv / _setenvp → _cinit
→ 在 __try/__except 中呼叫 main → _cexit
一道好題目:入口函式一開頭為什麼用 _alloca 而不是 malloc?
「因为在程序的一开始堆还没有被初始化,而 alloca 是唯一可以不使用堆的动态分配机制。」(p350)
三、crt*.o 是什麼、為什麼連結順序不能亂
glibc 在每個目標檔引入 .init 與 .fini 兩個段,連結器把所有輸入檔的這兩段按順序收集合併,形成 _init() 與 _fini(),執行庫保證它們先於/後於 main 執行(p367)。
這兩個函式的頭尾分別來自 crti.o 與 crtn.o,所以連結順序被寫死(p368–369):
ld crt1.o crti.o [你的 .o] [系統庫] crtn.o
crti.o 必須在使用者檔案之前、crtn.o 必須在之後——順序錯了拼出來的函式就是壞的。
分工也要記清楚(p370):
| 檔案 | 屬於 | 作用 |
|---|---|---|
crt1.o / crti.o / crtn.o | glibc | _start、.init/.fini 的頭尾 |
crtbeginT.o / crtend.o | GCC | C++ 全域建構解構(.ctors 的邊界) |
libgcc.a | GCC | 平台差異補丁(如 32 位元平台上的 64 位元運算) |
libgcc_eh.a | GCC | C++ 例外處理支援 |
(C++ 全域建構之所以歸 GCC 而非 glibc,是因為那是編譯器的事,glibc 只是 C 執行庫。)
四、C++ 全域物件是怎麼被建構的
不是魔法,是**「段合併形成函式指標陣列」**(p384–388):
- 編譯器為每個編譯單元(.cpp)生成一個特殊函式(例
_GLOBAL__I_Hw),負責建構本單元所有全域物件。 - 在該目標檔的
.ctors段放一個指向它的指標。 - 連結時所有
.ctors合併成一個函式指標陣列;crtbegin.o提供起始邊界__CTOR_LIST__、crtend.o提供__CTOR_END__。 _do_global_ctors_aux從頭到尾逐一呼叫。
解構早期用對稱的 .dtors,但要求合併順序必須是 .ctors 的嚴格反序、增加連結器負擔;後來改成在每個編譯單元的建構函式裡用 __cxa_atexit() 註冊解構函式,靠「先註冊後呼叫」自然得到反序(p388)。
MSVC 機制完全同構,只是換名字:_initterm(__xc_a, __xc_z) 遍歷函式指標陣列,__xc_a/__xc_z 被 #pragma section 放進 .CRT$XCA 與 .CRT$XCZ,各編譯單元放 .CRT$XCU——連結器把相同屬性的段按段名字母序合併,A < U < Z,自然夾成陣列(p389–391)。
「这再一次证明了虽然各个操作系统、运行库、编译器在细节上大相径庭,但是在基本实现的机制上其实是完全相通的。」(p391)
實用推論:想在 main 之前跑一段程式碼,有兩招(p387)——
__attribute__((constructor)) void my_init(void) { ... }
// 或直接把函式指標塞進 .ctorsMSVC 上則可用 .CRT$XCG(G 在 U 之前)之類的段名控制自己的初始化次序(p391–392)。
五、-nostdlib 之後的那串未定義符號
第 13 章手寫 Mini CRT 時列出了完整的逃生開關(p455–456、472):
| 症狀 | 開關 |
|---|---|
GCC 內建把 strlen/strcmp 展開掉 | -fno-builtin |
缺 __stack_chk_fail(GCC)/__security_cookie、__security_check_cookie(MSVC) | -fno-stack-protector / /GS- |
缺 type_info::vftable | -fno-rtti / /GR- |
| 例外支援符號未定義 | -fno-exceptions |
缺 __dso_handle | 是 __cxa_atexit 的第三個參數,標示解構函式屬於哪個共享物件 |
連結時 -e mini_crt_entry 指定入口,crtbegin.o 必須在最前、crtend.o 必須在最後(p456、473)。
成果:Linux 上靜態連結 5083 bytes(同樣程式靜態連結 glibc 約 538KB);Windows 上 5120 bytes 且 dumpbin /IMPORTS 只依賴 KERNEL32.dll(p456–457)。
🧪 我實際套用的紀錄
- 2026-08-02:(待填)
⚠️ 注意
- 用了
-nostartfiles或-nostdlib,全域物件的建構與解構就不會執行。(p369、p388)這是「明明加了__attribute__((constructor))卻沒跑」最常見的原因。 - 多個建構函式之間預設沒有順序保證。 可用
__attribute__((constructor(5)))指定優先級:建構時數字小的先跑、解構時相反,剛好配對成「先申請的後釋放」(p272–273)。 - 這一整套是 GCC/MSVC 的擴充與實作細節,不是語言標準。 換編譯器、換版本都可能變。跨平台專案不要把架構壓在上面。
- 靜態初始化順序問題(static initialization order fiasco)在這個模型裡就很好理解了:跨編譯單元的
.ctors順序由連結器的輸入順序決定,語言層面根本沒定義。 - 動態庫的情況不同:
.so的.init由動態連結器執行,dlopen時則在dlopen回傳前執行;而可執行檔本身的.init不由動態連結器執行,由程式初始化程式碼負責(p244)。
🔗 相關工具
- 工具-編譯連結與裝載 —— 上游:這張卡接在四階段的「裝載」之後,補上「跳到入口 → 進 main」這一段
- 工具-虛擬記憶體與行程位址空間 —— 前一步:映射建好、CPU 跳到入口位址,然後才輪到這張卡
- 工具-靜態庫與動態庫 —— 相關:
crt*.o、libgcc.a、CRT 版本選擇都是連結決策的一部分 - 工具-符號決議與重定位 —— 這裡出現的一串未定義符號,用那張卡的診斷樹去分類
程式不是從 main 開始的
入口是執行庫的 _start / mainCRTStartup
C++ 全域建構=段合併成函式指標陣列 解構靠 atexit「先註冊後呼叫」自然反序
「运行库相当于是向操作系统批发了一块较大的堆空间,然后零售给程序用。」