链接器通过符号表和重定位信息找到函数和变量定义:先扫描所有.o文件建立全局符号哈希表,再对UNDEF符号查表绑定地址;若未找到则报undefined reference错误。
链接器本身不看源码,只处理 .o(目标文件)里的符号表和重定位信息。每个 .o 文件在编译后都包含三类关键内容:.text(机器码)、.data(已初始化数据)、.bss(未初始化数据),以及两个核心元数据:symbol table(符号表)和 .rela.text/.rela.data(重定位表)。
符号表里记录了所有 extern、global、static(带本地作用域的)符号,每条记录含名称、类型(FUNC/OBJECT)、绑定(GLOBAL/LOCAL)、大小、所在节区偏移。链接器第一遍扫描所有输入 .o,把 GLOBAL 符号按名称建哈希表;第二遍再扫,对每个 UNDEF 符号(比如调用的 printf 或未定义的 foo()),查这个表——查到就绑定地址,查不到就报 undefined reference 错误。
常见卡点:
static 函数/变量不会进入全局符号表,所以其他 .o 文件无法引用——不是链接失败,是根本“看不见”void bar(); 没实现),编译能过,链接必报 undefined reference to 'bar'
void foo(int) 变成类似 _Z3fooi 的符号名;如果头文件声明和实现文件签名不一致(比如一个写 const int&,另一个写 int&),符号对不上,链接器就找不到定义因为调用指令(如 x86-64 的 call)在目标文件里填的是占位地址——通常是 0x0 或某个预留值。这个位置被标记在 .rela.text 表中,记录了:要修改哪条指令(偏移)、改哪个字段(R_X86_64_PLT32 还是 R_X86_64_PC32)、要填谁的地址(符号名)。链接器在重定位阶段,根据符号最终地址和当前指令位置,算出相对偏移,写回机器码。
典型重定位类型差异:
R_X86_64_PC32:用于直接调用同模块函数,填入「目标地址 - 当前指令下一条地址」的 32 位补码R_X86_64_PLT32:用于调用外部函数(如 printf),填入 PLT 表项地址,靠 PLT + GOT 间接跳转R_X86_64_64:用于全局变量取地址(&g_var),直接填绝对地址——这会导致代码不可重定位(PIE 禁用)如果你看到 relocation truncated to fit 错误,大概率是用了 R_X86_64_64 去填一个本该用 PC-relative 的地方,或者符号地址超出了 32 位表示范围。
链接器默认按“强弱符号”规则处理:函数和已初始化变量是强符号,未初始化变量(int g;)是弱符号。规则是:多个强符号 → 链接错误;一个强 + 多个弱 → 用强的;多个弱 → 任选一个(通常第一个)。
这导致几个经典陷阱:
int global = 42; 并被多个 .cpp 包含 → 每个 .o 都生成一个强符号 → 链接时报 multiple definition
__attribute__((weak)),否则普通 static 或未初始化变量不满足预期行为inline 函数在多个单元定义,靠编译器保证符号弱化或内联消除;但若编译器没内联,又没加 inline 声明,也会触发多重定义别猜,直接用系统工具看真实数据:
nm -C main.o # 查看符号(-C 解析 C++ 名字) readelf -s lib.o # 更详细的符号表(含绑定、类型) readelf -r main.o # 查看重定位入口 objdump -d main.o # 反汇编,看 call 指令后跟着的占位地址
特别注意 nm 输出第一列:大写 T 是强定义函

t 是局部函数;U 是未定义,B/b 是 bss 段变量。如果发现本该定义的符号显示为 U,说明编译时没把它打进去——可能文件根本没参与链接,或者被 static 封装了。
真正容易被忽略的是:链接器不验证类型一致性。它只认名字。哪怕 void f() 和 int f() 在不同文件里定义,只要名字一样,链接器就强行连上,运行时崩在栈错位或返回值解释错误——这种问题必须靠编译期检查(如头文件统一声明)来防,链接器不管。