🎯 什麼情境該想到我
當你撞上 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 難查的原因。
🔗 相關工具
- 工具-編譯連結與裝載 —— 上游:這張卡展開的是四階段裡「連結」那一階段的內部機制
- 工具-靜態庫與動態庫 —— 姊妹題:符號從哪裡來(庫的粒度與連結順序)
- 工具-動態連結與位址無關程式碼 —— 同一套符號機制搬到執行期,多了全域符號介入這個新坑
- 工具-系統化除錯 —— 遇到連結期怪錯誤時的一般追查方法