符號解析與重定位 (Symbol Resolution & Relocation)
Overview Table
| 小節 | 主題 | 核心內容 | 書頁 |
|---|---|---|---|
| 7.6 | 符號解析 (Symbol Resolution) | 將每個符號參照 (reference) 與「恰好一個」符號定義 (definition) 關聯 | p.715 |
| 7.6.1 | 強/弱符號與三規則 | 函式與已初始化全域變數 = strong;未初始化全域變數 = weak;Rule 1 多強→錯誤、Rule 2 一強多弱→選強、Rule 3 多弱→任選 | p.716-719 |
| 7.6.2 | 靜態函式庫 (Static Library) | archive (.a) 格式,linker 只複製「被參照的」member 模組 |
p.720-723 |
| 7.6.3 | 函式庫解析演算法 | 三集合 E(執行檔成員)/ U(未解析)/ D(已定義),命令列由左至右掃描;順序有意義 | p.724-725 |
| 7.7 | 重定位 (Relocation) | 步驟 1:合併 section、指派 run-time 位址;步驟 2:修改符號參照 | p.725-726 |
| 7.7.1 | 重定位項目 (Relocation Entry) | Elf64_Rela = {offset, type, symbol, addend};放在 .rel.text / .rel.data |
p.726-727 |
| 7.7.2 | 重定位演算法 | R_X86_64_PC32:ADDR(sym)+addend-refaddr;R_X86_64_32:ADDR(sym)+addend |
p.727-730 |
7.6 符號解析 (Symbol Resolution) (p.715)
Linker 的第一大任務:把每個模組中的符號參照,關聯到所有輸入 relocatable object file 符號表中的恰好一個定義。
- 區域符號 (local symbols):解析簡單——compiler 保證每個模組內每個 local symbol 只有一個定義,且
static區域變數會被賦予唯一名稱。 - 全域符號 (global symbols):compiler 遇到當前模組未定義的符號(變數或函式名)時,假設它定義在其他模組,產生一個 linker symbol table entry 交給 linker 處理。
- 若 linker 在所有輸入模組中都找不到定義,印出(常常很難懂的)錯誤並終止:
linux> gcc -Wall -Og -o linkerror linkerror.c
/tmp/ccSz5uti.o: In function 'main':
/tmp/ccSz5uti.o(.text+0x7): undefined reference to 'foo'
- 更棘手的情況:多個模組定義同名全域符號——linker 必須報錯,或選一個定義並丟棄其餘。Linux 的作法由 compiler、assembler、linker 三者合作完成。
C++/Java 允許同名多載 (overloaded) 方法,靠 compiler 將「方法名 + 參數列」編碼成唯一的 linker 名稱,稱為 mangling(反向為 demangling)。類別名編碼為「字元數 + 原名」:Foo → 3Foo;方法 Foo::bar(int, long) → bar__3Fooil(方法名 + __ + mangled 類別名 + 各參數的單字母編碼)。C++ 與 Java 使用相容的 mangling 方案。
7.6.1 Linker 如何解析重複符號名 (p.716)
編譯時 compiler 將每個全域符號以 strong 或 weak 匯出給 assembler,assembler 把此資訊隱含編碼進 relocatable object file 的符號表:
| 符號種類 | 強度 |
|---|---|
| 函式 (functions) | strong |
| 已初始化的全域變數 | strong |
| 未初始化的全域變數 | weak |
Linux linker 處理重複符號名的三條規則:
| 規則 | 情境 | Linker 行為 |
|---|---|---|
| Rule 1 | 多個同名 strong 符號 | 不允許 → 連結錯誤 (multiple definition) |
| Rule 2 | 一個 strong + 多個同名 weak | 選 strong(通常不給任何提示!) |
| Rule 3 | 多個同名 weak | 任選一個 weak(結果不可預測) |
書中五組範例對照 (p.716-719):
| 範例 | 模組 1 | 模組 2 | 結果 |
|---|---|---|---|
| foo1/bar1 | int main() {} |
int main() {} |
Rule 1 → 錯誤 (multiple definition of 'main') |
| foo2/bar2 | int x = 15213; |
int x = 15213; |
Rule 1 → 錯誤(兩個 strong x) |
| foo3/bar3 | int x = 15213; (strong) |
int x; (weak) |
Rule 2 → 兩模組共用 strong x;f() 悄悄把 x 從 15213 改成 15212,linker 無任何提示 |
| foo4/bar4 | int x; (weak) |
int x; (weak) |
Rule 3 → 任選,行為同上但選擇不可預測 |
| foo5/bar5 | int y=15212; int x=15213; (皆 strong) |
double x; (weak) |
Rule 2 → 選 strong 的 int x;x = -0.0 覆寫 x 和 y 兩個位置 |
foo5/bar5 型別不一致的記憶體覆寫示意 (p.719,x86-64 上 double 8 bytes、int 4 bytes):
位址 foo5.c 視角 bar5.c 的 x = -0.0 寫入範圍
0x601020 int x = 15213; <──┐
0x601024 int y = 15212; <──┤ double x 佔 8 bytes,
└ 一次覆寫 x 與 y!
執行結果:x = 0x0 y = 0x80000000 (−0.0 的 IEEE 754 位元樣式)
Linker 通常只給警告甚至完全沉默(foo5 僅出現 alignment 警告),且症狀常在離錯誤點很遠的執行後期才顯現;在數百個模組的大型系統中極難修復。防禦方法:用 gcc -fno-common(遇到多重定義的全域符號直接報錯)或 -Werror(警告全部轉為錯誤)(p.719)。
COMMON vs .bss 慣例的真正原因 (p.719):compiler 翻譯某模組遇到 weak 全域符號 x 時,不知道其他模組是否也定義 x、也無法預測 linker 會選哪一個實例,因此把決定權推遲給 linker,將其放入 COMMON。反之:
| 符號 | 放置區 | 理由 |
|---|---|---|
| 未初始化全域變數 (weak) | COMMON | compiler 無法預測 linker 的選擇,延遲決定 |
| 初始化為 0 的全域變數 (strong) | .bss | strong 依 Rule 2 必唯一,compiler 可放心指派 |
static 符號 |
.data 或 .bss | static 符號建構上即唯一,compiler 可自行指派 |
(接續 07-Linking/01-Static-Linking-and-Object-Files 中「看似任意」的 COMMON/.bss 分配慣例。)
7.6.2 與靜態函式庫連結 (Linking with Static Libraries) (p.720)
靜態函式庫 (static library):把相關 object module 打包成單一檔案供 linker 輸入;linker 建構可執行檔時只複製程式實際參照到的 member object module——縮小磁碟與記憶體中的執行檔,程式員又只需在命令列寫少數幾個檔名。
- Linux 上以 archive 格式儲存(副檔名
.a):多個 relocatable object file 串接,加上描述各 member 大小與位置的 header。 - 標準庫例子 (p.721):
libc.a(標準 I/O、字串、整數數學:atoi、printf、scanf、strcpy、rand…約 5 MB)、libm.a(浮點數學:sin、cos、sqrt…約 2 MB)。C compiler driver 一律自動把libc.a傳給 linker,不必手動列出。 - 為何需要函式庫?其他方案皆有缺陷 (p.721):
| 替代方案 | 缺陷 |
|---|---|
| Compiler 直接辨識並產生標準函式碼(Pascal 作法) | C 標準函式太多,compiler 複雜度暴增;每次增刪修函式就要出新版 compiler |
全部塞進單一 libc.o |
每個執行檔(磁碟)與每個執行中程式(記憶體)都含完整複本;改任一函式要重編整個來源檔 |
每個函式一個 .o,程式員手動列出 |
易錯 (error prone) 且耗時 |
| 靜態函式庫 | 只複製被參照的模組 + 命令列只需一個檔名 → 兼得各方案優點 |
建立與使用函式庫(以 addvec.o/multvec.o 組成的 libvector.a 為例,p.722):
linux> gcc -c addvec.c multvec.c
linux> ar rcs libvector.a addvec.o multvec.o # 用 ar 工具建 archive
linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a # 或等價地:
linux> gcc -static -o prog2c main2.o -L. -lvector
-static:要求 linker 產出完全連結 (fully linked) 的可執行檔——載入記憶體即可執行,載入期不需任何進一步連結。-lvector是libvector.a的縮寫;-L.告訴 linker 在當前目錄尋找函式庫。- 例中
main2.o參照addvec→ linker 複製addvec.o進執行檔;程式未參照multvec.o的任何符號 → 不複製;另從libc.a複製printf.o及若干 C run-time 系統模組 (p.723-724)。
來源檔 main2.c vector.h
│
Translators (cpp, cc1, as)
│
Relocatable main2.o libvector.a libc.a ← 靜態函式庫
object files │ └ addvec.o └ printf.o 及其相依模組
└─────────────┬──────────────────┘
Linker (ld)
│
prog2c ← 完全連結的可執行 object file
7.6.3 Linker 如何用靜態函式庫解析參照 (p.724)
符號解析階段,linker 由左至右依命令列順序掃描 relocatable object file 與 archive(driver 會先把 .c 譯成 .o),過程維護三個集合(初始皆空):
- E:將被合併形成執行檔的 relocatable object file 集合
- U:未解析符號集合(已被參照、尚未定義)
- D:先前輸入檔中已定義的符號集合
對每個命令列輸入檔 f(由左至右):
┌─ f 是 object file ──→ f 加入 E;依 f 的符號定義/參照更新 U、D
f ──→──┤
└─ f 是 archive ─────→ 拿 U 中的未解析符號比對 archive 各 member:
member m 定義了能解析 U 中參照的符號
→ m 加入 E,依 m 更新 U、D
反覆迭代 members 直到 U、D 不再變化 (fixed point)
未進 E 的 member 直接丟棄,續處理下一輸入檔
掃描結束:
U 非空 → 印出錯誤並終止
U 為空 → 合併並重定位 E 中的 object files,產出可執行檔
若定義符號的函式庫出現在參照它的 object file 之前,該參照將無法解析、連結失敗:
linux> gcc -static ./libvector.a main2.c
/tmp/cc9XH6Rp.o: In function 'main':
/tmp/cc9XH6Rp.o(.text+0x18): undefined reference to 'addvec'
處理 libvector.a 時 U 是空的 → 沒有任何 member 被加入 E;之後 main2.c 產生的 addvec 參照永遠無解 (p.724)。
排序通則 (p.725):
- 函式庫放在命令列尾端。
- 各函式庫的 member 彼此獨立(不互相參照)→ 尾端函式庫任意順序皆可。
- 函式庫不獨立 → 必須排序使得:對每個被 archive member 外部參照的符號
s,命令列上至少有一個s的定義跟在該參照之後。例:foo.c呼叫libx.a與libz.a中的函式,兩者又呼叫liby.a→gcc foo.c libx.a libz.a liby.a。 - 循環相依可重複列出函式庫:
foo.c → libx.a → liby.a → libx.a時用gcc foo.c libx.a liby.a libx.a;或直接把libx.a、liby.a合併成單一 archive。
7.7 重定位 (Relocation) (p.725)
符號解析完成後,程式碼中每個符號參照都已對應到恰好一個符號定義(某輸入模組符號表中的一個 entry),linker 也已知各輸入模組 code/data section 的確切大小。重定位分兩步:
- 重定位 section 與符號定義 (Relocating sections and symbol definitions):把所有同型別 section 合併為單一聚合 section(如各模組的
.data合併成輸出檔唯一的.data),再為新聚合 section、各輸入模組定義的 section 與符號指派 run-time 記憶體位址。完成後,程式中每條指令與每個全域變數都有唯一的 run-time 位址。 - 重定位 section 內的符號參照 (Relocating symbol references within sections):修改 code/data section 內文中的每個符號參照,使其指向正確 run-time 位址——依賴 relocatable object module 中的 relocation entry 資料結構。
7.7.1 重定位項目 (Relocation Entries) (p.726)
Assembler 產生 object module 時,既不知道 code/data 最終存放的記憶體位置,也不知道被參照的外部函式/全域變數的位置。因此每遇到最終位置未知的參照,就產生一筆 relocation entry,告訴 linker 合併進執行檔時如何修改該參照。
- Code 的 relocation entry 放在
.rel.text;data 的放在.rel.data。 - ELF 重定位項目格式(
Elf64_Rela,Figure 7.9):
typedef struct {
long offset; /* 待重定位參照的 section offset */
long type:32, /* 重定位型別 */
symbol:32; /* 符號表索引 */
long addend; /* 重定位運算式的常數部分(有號) */
} Elf64_Rela;
ELF 定義 32 種重定位型別(多數很冷僻),本書只關注最基本的兩種:
| 型別 | 定址方式 | CPU 計算有效位址的方式 |
|---|---|---|
R_X86_64_PC32 |
32 位元 PC 相對 (PC-relative) 位址 | 有效位址 = 指令中編碼的 32 位元值 + 當前 run-time PC(PC 永遠是下一條指令在記憶體中的位址) |
R_X86_64_32 |
32 位元絕對 (absolute) 位址 | 直接使用指令中編碼的 32 位元值作為有效位址,不做修改 |
這兩種型別支援 x86-64 small code model(gcc 的預設):假設執行檔的 code+data 總大小 < 2 GB,故 run-time 可用 32 位元 PC 相對位址存取。超過 2 GB 的程式需以 -mcmodel=medium(medium code model)或 -mcmodel=large(large code model)編譯,書中不再討論 (p.727)。
7.7.2 重定位符號參照 (Relocating Symbol References) (p.727)
重定位演算法(Figure 7.10;假設每個 section s 是 byte 陣列、每筆 entry r 是 Elf64_Rela,且 linker 已為每個 section 選好 ADDR(s)、每個符號選好 ADDR(r.symbol)):
foreach section s {
foreach relocation entry r {
refptr = s + r.offset; /* 指向待重定位參照的指標 */
/* 重定位 PC 相對參照 */
if (r.type == R_X86_64_PC32) {
refaddr = ADDR(s) + r.offset; /* 參照的 run-time 位址 */
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
}
/* 重定位絕對參照 */
if (r.type == R_X86_64_32)
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
}
}
範例程式 main.o 的反組譯(objdump -dx main.o,Figure 7.11)顯示 main 參照兩個全域符號 array 與 sum,assembler 各產生一筆 relocation entry:
0: 48 83 ec 08 sub $0x8,%rsp
4: be 02 00 00 00 mov $0x2,%esi
9: bf 00 00 00 00 mov $0x0,%edi ← %edi = &array(32-bit 佔位)
a: R_X86_64_32 array ← 絕對重定位項目
e: e8 00 00 00 00 callq 13 <main+0x13> ← call sum(32-bit 佔位)
f: R_X86_64_PC32 sum-0x4 ← PC 相對重定位項目
13: 48 83 c4 08 add $0x8,%rsp
17: c3 retq
(注意:relocation entry 與指令實際存放於 object file 的不同 section,objdump 只是為方便而並列顯示。)
重定位 PC 相對參照 (p.728-729):call sum 起於 section offset 0xe,由 1-byte opcode 0xe8 加上 32 位元 PC 相對參照的佔位組成。
- Relocation entry:
r.offset = 0xf、r.symbol = sum、r.type = R_X86_64_PC32、r.addend = -4 - 假設 linker 已決定
ADDR(s) = ADDR(.text) = 0x4004d0、ADDR(r.symbol) = ADDR(sum) = 0x4004e8
refaddr = ADDR(s) + r.offset = 0x4004d0 + 0xf = 0x4004df
*refptr = ADDR(r.symbol) + r.addend - refaddr
= 0x4004e8 + (-4) - 0x4004df = 0x5
重定位後: 4004de: e8 05 00 00 00 callq 4004e8 <sum>
執行期驗證:call 位於 0x4004de,CPU 執行它時 PC = 0x4004e3(緊接在 call 之後那條指令的位址)。call 的執行兩步:
1. 將 PC 推入 stack
2. PC ← PC + 0x5 = 0x4004e3 + 0x5 = 0x4004e8 ← 正好是 sum 的第一條指令
待修改的參照(4 bytes)起於 offset 0xf,而 run-time PC 指向指令結尾(參照起點再 +4 處);r.addend = -4 補償「參照位址 refaddr」與「PC 實際值」相差的 4 bytes,使公式算出的位移與 PC 相加後恰好命中目標。
重定位絕對參照 (p.729-730):mov 指令把 array 的位址(32 位元立即值)複製進 %edi,起於 offset 0x9,由 opcode 0xbf 加 32 位元絕對參照佔位組成。
- Relocation entry:
r.offset = 0xa、r.symbol = array、r.type = R_X86_64_32、r.addend = 0 - 假設
ADDR(r.symbol) = ADDR(array) = 0x601018
*refptr = ADDR(r.symbol) + r.addend = 0x601018 + 0 = 0x601018
重定位後: 4004d9: bf 18 10 60 00 mov $0x601018,%edi
重定位完成後(Figure 7.12),執行檔的 .text 與 .data 中所有參照都已指向正確 run-time 位址;載入時 loader 可直接把這些 section 的 bytes 複製進記憶體並執行,不需任何進一步修改 (p.730)。
兩種重定位公式對照
| 型別 | 公式 | 特性 |
|---|---|---|
R_X86_64_PC32 |
*refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr),其中 refaddr = ADDR(s) + r.offset |
結果是相對位移;執行期與 PC 相加得到目標 |
R_X86_64_32 |
*refptr = (unsigned)(ADDR(r.symbol) + r.addend) |
結果直接是目標絕對位址 |
Exam/Test Patterns
| 情境 / 關鍵字 | 答案 |
|---|---|
| 函式、已初始化全域變數 | strong symbol |
| 未初始化全域變數 | weak symbol |
兩個模組各定義 int main(){}(或兩個 int x = 15213;) |
Rule 1 → link error(多重 strong 定義) |
int x = 15213; vs int x; |
Rule 2 → 選 strong(初始化的那個),linker 無錯誤訊息 |
兩模組皆 int x; |
Rule 3 → 任選(答 unknown),不可預測 |
int x(strong)與 double x(weak)同名 |
Rule 2 選 int;寫 8-byte double 會覆寫相鄰變數(p.719:x = 0x0, y = 0x80000000) |
| REF(x.i) → DEF(x.k) 題型(Practice 7.2) | 先判各定義強弱再套三規則;多強寫 error、多弱寫 unknown |
| 如何讓多重定義直接報錯 | gcc -fno-common(或 -Werror 把警告轉錯誤) |
| weak 全域符號放哪個 section?為什麼? | COMMON——compiler 無法預測 linker 會選哪個定義,延遲決定給 linker |
初始化為 0 的全域變數 / static 變數 |
.bss(strong 必唯一)/ .data 或 .bss(static 建構上唯一) |
.a 檔的格式名稱 |
archive:串接的 relocatable object files + 描述各 member 大小位置的 header;用 ar rcs 建立 |
| linker 從函式庫複製什麼? | 只複製被參照到的 member object module(未參照的如 multvec.o 不複製) |
gcc -static ./libvector.a main2.c 失敗原因 |
掃到 archive 時 U 為空 → 不取任何 member;函式庫應放命令列尾端 |
| E / U / D 三集合 | E = 併入執行檔的 object files;U = 未解析符號;D = 已定義符號;掃描完 U 非空即報錯 |
循環相依 libx→liby→libx(Practice 7.3C) |
重複列出:gcc p.o libx.a liby.a libx.a(或把兩庫合併成一個 archive) |
| 重定位兩步驟 | (1) 合併同型別 section + 為 section 與符號定義指派 run-time 位址;(2) 修改 section 內文的符號參照 |
| relocation entry 四欄位 | offset(參照的 section offset)、type、symbol(符號表索引)、addend(有號常數偏差) |
| code / data 的 relocation entry 存放位置 | .rel.text / .rel.data |
| 計算 PC 相對重定位值(Practice 7.4、7.5) | ADDR(symbol) + addend − (ADDR(s) + offset);驗證:PC(下一指令位址)+ 該值 = 目標位址 |
call 執行時 PC 的值 |
下一條指令的位址(不是 call 指令本身的位址) |
| 為何 32 位元定址就夠? | small code model(gcc 預設):假設 code+data < 2 GB;更大程式用 -mcmodel=medium / -mcmodel=large |
| C++ 多載函式如何被 linker 區分 | mangling:Foo → 3Foo;Foo::bar(int, long) → bar__3Fooil |
Related Notes
- 07-Linking/01-Static-Linking-and-Object-Files
- 07-Linking/03-Executable-Loading-and-Dynamic-Linking
- 07-Linking/04-Library-Interpositioning-and-Tools
- 03-Machine-Level-Programs/03-Control-Flow
- 01-Computer-Systems-Tour/01-Information-and-Program-Translation
- 02-Information-Representation/04-Floating-Point