符號解析與重定位 (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 符號表中的恰好一個定義

linux> gcc -Wall -Og -o linkerror linkerror.c
/tmp/ccSz5uti.o: In function 'main':
/tmp/ccSz5uti.o(.text+0x7): undefined reference to 'foo'
C++/Java 的符號改編 (Mangling) (p.716 Aside)

C++/Java 允許同名多載 (overloaded) 方法,靠 compiler 將「方法名 + 參數列」編碼成唯一的 linker 名稱,稱為 mangling(反向為 demangling)。類別名編碼為「字元數 + 原名」:Foo3Foo;方法 Foo::bar(int, long)bar__3Fooil(方法名 + __ + mangled 類別名 + 各參數的單字母編碼)。C++ 與 Java 使用相容的 mangling 方案。

7.6.1 Linker 如何解析重複符號名 (p.716)

編譯時 compiler 將每個全域符號以 strongweak 匯出給 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 位元樣式)
Rule 2/3 造成的執行期 bug 極難除錯

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——縮小磁碟與記憶體中的執行檔,程式員又只需在命令列寫少數幾個檔名。

替代方案 缺陷
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
 來源檔       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),過程維護三個集合(初始皆空):

對每個命令列輸入檔 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,產出可執行檔
命令列順序有意義 (order matters)

若定義符號的函式庫出現在參照它的 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.aU 是空的 → 沒有任何 member 被加入 E;之後 main2.c 產生的 addvec 參照永遠無解 (p.724)。

排序通則 (p.725):


7.7 重定位 (Relocation) (p.725)

符號解析完成後,程式碼中每個符號參照都已對應到恰好一個符號定義(某輸入模組符號表中的一個 entry),linker 也已知各輸入模組 code/data section 的確切大小。重定位分兩步:

  1. 重定位 section 與符號定義 (Relocating sections and symbol definitions):把所有同型別 section 合併為單一聚合 section(如各模組的 .data 合併成輸出檔唯一的 .data),再為新聚合 section、各輸入模組定義的 section 與符號指派 run-time 記憶體位址。完成後,程式中每條指令與每個全域變數都有唯一的 run-time 位址。
  2. 重定位 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 合併進執行檔時如何修改該參照。

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 位元值作為有效位址,不做修改
Small code model 的 2 GB 假設(一般化敘述的例外)

這兩種型別支援 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 rElf64_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 參照兩個全域符號 arraysum,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 相對參照的佔位組成。

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 的第一條指令
addend = −4 的由來

待修改的參照(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 位元絕對參照佔位組成。

*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)、typesymbol(符號表索引)、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:Foo3Foo;Foo::bar(int, long)bar__3Fooil