链接与加载

本页目录

链接把目标文件中的符号、节和重定位组织成可执行映像或共享对象;加载把映像放入进程地址空间,并完成运行所需的动态准备。两者相互配合,但不是同一个阶段。

本文的具体格式与公式以 Linux ELF、x86-64 System V ABI 为例。其他平台的对象格式、调用约定和动态链接策略应查其自身规范。

目标文件里除了机器码还有什么

内容作用
.text、.data、.rodata代码及不同属性的数据
.bss 类 NOBITS 节描述需要零初始化的空间,不存等量零字节载荷
符号表定义、未定义引用、绑定与可见性等
重定位项告诉后续工具如何修补地址相关字段
调试信息把机器位置与源码、类型关联

节是链接视角的组织单位;程序头描述加载视角的段。运行时常按 PT_LOAD 等程序头映射,不是按每个节名逐个 mmap。.bss 有元数据,只是不需要在文件里保存它表示的每个零字节。

符号解析与布局:先决定引用谁

一个目标文件定义 helper,另一个目标文件引用 helper。链接器根据绑定、可见性、版本和链接规则决定该引用对应哪个定义,再布局相关内容并应用重定位。

同名不总是冲突:局部符号有自己的作用域,弱符号与强符号有不同规则,动态符号还受可见性及查找范围影响。反过来,也不能把动态符号解析简化成“哪个库最早加载,永远用哪个”。

重定位:每种类型都有准确公式

令 S 为目标符号地址,A 为 addend,P 为重定位字段所在地址。x86-64 的 R_X86_64_PC32 使用 S+A−P,R_X86_64_64 使用 S+A。不同类型还规定字段宽度与溢出检查。AMD64 ABI 重定位表

PC 相对重定位:P 是待修补字段,不是下一条指令 call 操作码 地址 0x1000 4 字节位移字段 P = 0x1001 下一条指令 地址 0x1005 S = 0x2000,A = −4:S + A − P = 0xFFB CPU 从下一条地址加位移:0x1005 + 0xFFB = 0x2000。不能再额外减一次 4。

在典型 call rel32 编码中,CPU 相对的是下一条指令,而 ABI 公式使用字段位置 P,常见 addend −4 已负责两者差值。若公式再写成 S−(P+4)+A,同时 A 又为 −4,就重复扣了 4。

外部函数调用可能使用 R_X86_64_PLT32,其目标涉及 PLT 入口并可能被链接器放松为直接调用;不能要求每个编译器的外部调用都显示 PC32。理解公式后,应该检查实际重定位类型。

写入四字节前,先检查有符号范围

rel32 位移是有符号 32 位数。目标在调用点之前时位移可以为负,写入小端字段用补码;目标太远时不能简单截断到低 32 位,否则 CPU 会跳到另一个地址。下面只模型化已经确定直接目标的 PC32 计算,不模拟符号查找或 PLT 选择。

def pc32(symbol, addend, place):
    displacement = symbol + addend - place
    if not -(1 << 31) <= displacement < (1 << 31):
        raise OverflowError("PC32 relocation out of range")
    return displacement.to_bytes(4, "little", signed=True)


assert pc32(0x2000, -4, 0x1001) == bytes.fromhex("fb 0f 00 00")
backward = pc32(0x1000, -4, 0x2001)
assert 0x2005 + int.from_bytes(backward, "little", signed=True) == 0x1000
for displacement in (-(1 << 31), (1 << 31) - 1):
    assert int.from_bytes(pc32(displacement, 0, 0), "little", signed=True) == displacement
for displacement in (-(1 << 31) - 1, 1 << 31):
    try:
        pc32(displacement, 0, 0)
    except OverflowError:
        pass
    else:
        raise AssertionError("truncated relocation accepted")

共同平移 S 和 P 不改变位移,这解释了模块内部某些 PC 相对引用为何无需随加载基址重新修补;但模块外定义、绝对地址字段和 TLS 等仍有各自规则。不能从一条内部相对调用推广出“PIC 没有重定位”。

P、下一条指令和 −4,谁已经补过距离?

先标出四字节字段 P,再标出下一条指令 P+4,addend 的作用就不再含糊。下面既能将目标放到调用点之前,也能整体平移模块,还能故意重复减 4。位移越界时不生成截断后的四字节,因为一个“仍能写进文件的数字”不等于 CPU 还能抵达正确地址。

正在呈现知识画面
P、下一条指令和 −4,谁已经补过距离?

PC32 写入 S+A−P,典型 call 的 A=−4 已把字段位置转换到下一条指令基准。S 与 P 一起平移不改变位移;重复减4会跳错。

一个最小目标文件实验

在单独的实验目录创建两个文件:

/* caller.c */
extern int twice(int);
int main(void) { return twice(21) == 42 ? 0 : 1; }
/* twice.c */
int twice(int x) { return x * 2; }
cc -O0 -c caller.c -o caller.o
cc -O0 -c twice.c -o twice.o
readelf -sW caller.o
readelf -rW caller.o
objdump -dr caller.o
cc caller.o twice.o -o demo
./demo

先找未定义的 twice 符号,再看对应重定位,最后观察链接后的程序如何运行。具体偏移、指令与 PIE 默认值随工具链变化,实验重点是将符号引用与修补字段对应起来。

静态库:按规则抽取目标成员

.a 是目标文件归档。传统 Unix 链接器通常在处理归档时按尚未解决的引用抽取成员,因此命令行顺序可能重要:引用者放在提供定义的静态库之前,是常见写法。

循环依赖可以通过库分组重复扫描等机制处理;GNU ld 和其他链接器支持相应选项,但具体扫描策略不同。LLD 还会保存归档中的懒符号并在后续引用时提取,不能把所有链接器都描述成完全相同的左到右一次扫描。

链接器 GC 按节及重定位等引用关系保留从根可达的内容,根还可包含导出符号和链接脚本 KEEP。它不只是从 _start 反汇编追踪 call 指令。按函数或数据拆节有助于细粒度回收,但不是所有输入天然已拆好。

一个成员进了链接,不等于里面每个函数都留下

继续使用前面的 caller.c,把 twice.c 扩展为两个函数:twice 被 main 调用,unused_helper 没有任何引用。用 GNU ld.bfd 做下面的实验,固定链接器以避免将其归档扫描策略误当成所有工具的规则。

/* twice.c:替换前面的版本 */
int twice(int x) { return x * 2; }
int unused_helper(void) { return 99; }
gcc -O0 -ffunction-sections -fdata-sections -c twice.c -o twice.o
ar rcs libtwice.a twice.o
gcc -fuse-ld=bfd caller.o ./libtwice.a -o with-unused
gcc -fuse-ld=bfd caller.o ./libtwice.a -Wl,--gc-sections -o trimmed
nm with-unused
nm trimmed
./with-unused
./trimmed

第一次链接因 caller.o 对 twice 的引用抽取整个 twice.o,所以两个函数都进入输入集合。第二次仍会抽取同一个成员,但节回收发现 .text.unused_helper 不在保留图中,可以将它移除。两份程序均返回 0,nm 中 unused_helper 则只应出现在未回收版本里。这里使用 O0 且未启用 LTO,避免把前端内联/删除效果混入解释。

阶段粒度本例决定
搜索归档目标成员为满足 twice 引用提取 twice.o
节回收输入节及其引用图保留 twice 所在节,删除未引用函数节
最终布局与重定位保留内容的地址和字段为 main 的调用修补最终位移

若把 libtwice.a 放到 caller.o 之前,GNU ld.bfd 在当时没有 twice 的未解决引用,可能不抽取成员,最终报 undefined reference。恢复顺序即可;为解决这个顺序问题而无条件使用 --whole-archive 会扩大输入范围,而且它本身不保证所有节免于后续 GC。GNU ld 归档与节回收选项

两次选择:先让对象成员进入,再决定保留哪些节 caller.o 未解决引用:twice 提取 twice.o twice + unused_helper --gc-sections 只留下可达的 twice -ffunction-sections 提供细粒度边界;根与引用图决定保留结果。 没有抽取进来的归档成员,不会仅因其内部存在某个节就自动参与后续布局。

抽取一个 .o,为什么还能只留下其中一个函数?

把“已经进入链接输入”和“最终活下来”画成不同集合,可以同时解释库顺序与节回收。传统归档扫描时若还没有 twice 的未解决引用,就可能不抽取成员;强制抽取后,unused 所在的独立节仍可能在 GC 中删除。这里固定 GNU ld.bfd 的教学边界,避免把结果直接套给 LLD 等不同实现。

正在呈现知识画面
抽取一个 .o,为什么还能只留下其中一个函数?

归档抽取按成员,节回收按根可达引用图。whole-archive 强制成员进入输入,也不等于所有函数节必须保留;传统归档顺序错误则可能根本没抽到定义。

GOT 与 PLT:间接访问和解析时机分开看

GOT 提供地址槽,PLT 提供调用桩,是某些位置无关与动态调用路径的一部分。在经典惰性函数绑定中,第一次调用经过解析器查找目标并更新相应槽,后续调用利用已经保存的地址。

立即绑定把相关解析提前到加载阶段,例如 Linux 动态链接器可通过 LD_BIND_NOW 控制。它不会仅因解析提前,就自动把原来的 PLT 调用指令改成无间接开销的直接调用。是否使用 PLT、能否放松,以及是否通过 GOT 调用,还取决于编译与链接选择。Linux ld.so

动态符号的 interposition 也受查找作用域、绑定与可见性影响。它给替换实现和预加载带来灵活性,同时可能限制内联或本地绑定假设;不能断言所有 malloc 调用都必然经过完全相同的路径。

加载与 dlopen

动态 ELF 启动:映射与重定位之后才进入程序逻辑 内核读取可执行文件的程序头 按可加载段建立地址空间,识别解释器 1 动态链接器处理依赖与必要重定位 解析符号、TLS 等,按策略处理绑定 2 执行运行时初始化 共享库初始化与语言启动逻辑 3 程序入口继续到 main 等语言入口 main 不是内核直接跳入的唯一入口概念 4 图示范围为常见 Linux 动态 ELF;静态、其他格式与平台流程不同。

dlopen 在进程运行中加载共享对象,dlsym 查找符号,dlclose 减少加载引用等。实际插件接口还要定义 ABI、所有权、线程与卸载条件,并检查错误;不能在仍有函数指针或线程执行库代码时随意卸载。dlopen 手册

动态加载现成库与 JIT 发布机器码不同。JIT 可以在内存中处理对象、重定位和可执行权限,不要求统一走“写 .o、链接 .so、dlopen”的文件流程。见 JIT 编译。

LTO:让优化看到跨编译单元信息

普通独立编译限制优化器能看到的函数体。LTO 保留 IR 或相关中间信息,在链接阶段结合符号解析结果进行跨模块分析和优化,然后生成机器码继续链接。即使没有 LTO,链接器仍执行重定位、节回收和其他链接优化,不能称作只会拼接。

Full LTO 与 ThinLTO 的组织方式不同。ThinLTO 结合模块摘要进行全局分析与有选择的函数导入,再并行执行模块后端,还可使用缓存。Clang 以 -flto=thin 等配置显式选择该模式;它不是所有 gold/LLD 调用天然的默认 LTO 方式。Clang ThinLTO 文档

是否内联由成本模型与可见性等约束决定,不限于“只导入热点调用”。LTO 也未必知道所有动态插件和外部定义,所谓全程序边界需要结合链接与符号假设。性能收益、内存和链接耗时应在实际项目测量,不给统一提升百分比。

定位问题速查

现象优先检查
undefined reference是否生成定义、库顺序、名称修饰、ABI 与可见性
relocation overflow重定位宽度、布局与代码模型
启动找不到共享库依赖记录、搜索路径与平台加载规则
同名符号绑定到意外实现查找作用域、版本、预加载与可见性
LTO 无预期效果编译和链接配置、IR 可用性与优化边界

参考

Keywords: ELF, symbol resolution, relocation, PC32, PLT32, archive, GOT, PLT, lazy binding, dlopen, LTO, ThinLTO