1. 从目标文件到进程
一句话总结: 编译器只负责把单个源文件翻译成目标文件,真正让程序跑起来的是链接器、加载器与 C 运行时三者的接力。
一个源文件经过编译后得到的是目标文件(object file),它包含机器指令、只读数据、未初始化的 BSS 区、一张符号表,以及若干条「这里要填一个地址」的待决记录。目标文件本身不可执行:它不知道自己会被放在内存的哪个位置,也不知道外部函数 printf 究竟在哪儿。把这些碎片拼成完整映像的工作交给链接器(linker),把映像装进内存并交接控制权的交给加载器(loader),而 main 之前的环境准备则由 C 运行时(crt)完成。
# 观察这条链路: 编译 -> 汇编 -> 链接
cc -c hello.c -o hello.o # 编译+汇编, 得到目标文件
nm hello.o # 查看符号表: T 已定义, U 未定义
readelf -r hello.o # 查看重定位表
cc hello.o -o hello # 链接成可执行文件
readelf -h hello # ELF 头: 入口点 Entry point
| 阶段 | 输入 | 输出 | 核心动作 |
|---|---|---|---|
| 编译 | 源文件 | 汇编 | 语法语义分析、代码生成 |
| 汇编 | 汇编 | 目标文件 | 指令编码、符号表、重定位表 |
| 链接 | 目标文件 + 库 | 可执行/共享库 | 符号解析、重定位、段合并 |
| 加载 | 可执行文件 | 进程映像 | 映射内存、动态链接、跳转入口 |
理解这条链路能解释很多日常现象:为什么 undefined reference to 'foo' 是链接错误而非编译错误;为什么改了动态库不用重编主程序;为什么 ldd 能列出运行时依赖;为什么全局构造函数会在 main 之前执行。这些问题的答案都藏在链接与加载的细节里。
2. 符号解析与重定位
一句话总结: 链接器把每个目标文件的符号表汇总成全局符号表,再用重定位表把「占位地址」替换成真实的虚拟地址。
目标文件里每个函数与全局变量都是一个符号(symbol)。符号分三类:已定义(在本文件里给出实现,nm 显示 T/D/B)、未定义(只声明,等待别人提供,显示 U)、局部(static 修饰,不参与跨文件解析,显示小写字母)。链接器扫描所有输入文件,为每个符号确定唯一提供者,这一步叫符号解析。
// a.c: 提供 add, 引用 shared
int shared = 42; // 已定义符号 D
static int hidden = 1; // 局部符号 t, 不外泄
int add(int x, int y) { return x + y; }
// b.c: 引用 add, 自己定义 main
extern int add(int, int);
extern int shared;
int main(void) { return add(shared, 1); }
b.c 编译出的目标文件里,调用 add 处是一条 call 0 的占位指令,配套一条重定位记录说明「这条指令的操作数要填入 add 的地址」。链接器完成符号解析后,为每个段分配虚拟地址,再把重定位记录逐条落实。x86-64 上常见的重定位类型有两种:
| 重定位类型 | 含义 | 典型场景 |
|---|---|---|
R_X86_64_PC32 | 相对当前指令的 32 位偏移 | 同一映像内的函数调用 |
R_X86_64_64 | 绝对 64 位地址 | 数据段里的函数指针 |
R_X86_64_PLT32 | 经 PLT 的相对调用 | 跨共享库调用 |
R_X86_64_GOTPCREL | 相对 GOT 表项的偏移 | PIC 下取全局变量地址 |
# 极简重定位: 把符号地址填进占位位置
def relocate(sections, symbols, relocs):
out = bytearray(sections[".text"])
for offset, sym, addend, kind in relocs:
addr = symbols[sym] + addend
if kind == "PC32":
addr -= offset + 4 # 相对下一条指令
out[offset:offset + 4] = addr.to_bytes(4, "little", signed=True)
return bytes(out)
print(relocate({".text": b"\x00" * 8},
{"add": 0x401000},
[(0, "add", -4, "PC32")]))
符号解析里最容易踩坑的是强弱符号规则:多个文件定义同名强符号(普通函数、已初始化全局变量)会直接报 multiple definition;若一个是强符号、其余是弱符号(__attribute__((weak))),则强符号胜出;若全是弱符号,链接器任选一个。C++ 还多一层名称修饰(name mangling),int add(int,int) 在符号表里是 _Z3addii,extern "C" 的作用就是关掉修饰以便与 C 互通。
3. 静态链接与动态链接
一句话总结: 静态链接把库代码复制进可执行文件,动态链接只留下库名与跳转桩,把真正的绑定推迟到加载或首次调用时。
链接有两条路线。静态链接把库(.a 归档文件)中被用到的目标文件整份复制进可执行文件,产物自包含、启动快、无运行时依赖,代价是体积大且库升级必须重新链接。动态链接只把共享库(.so)的名字与符号需求写进产物,真正的代码在加载时映射,多个进程还能共享同一份物理内存页。
# 静态链接: 产物自包含
cc -static hello.c -o hello_static
file hello_static && ls -lh hello_static
# 动态链接: 默认行为, 只记录依赖
cc hello.c -o hello_dyn
ldd hello_dyn # 列出运行时依赖的 .so
readelf -d hello_dyn | head # .dynamic 段: NEEDED / SONAME
| 维度 | 静态链接 | 动态链接 |
|---|---|---|
| 可执行体积 | 大(含库代码) | 小(只存引用) |
| 内存占用 | 每进程独立 | 多进程共享只读页 |
| 库升级 | 必须重新链接 | 替换 .so 即可 |
| 启动速度 | 快(无解析开销) | 略慢(需解析重定位) |
| 部署 | 无依赖,适合容器基础镜像 | 需保证库版本兼容 |
| 适用 | 嵌入式、Go 默认、musl 镜像 | 桌面系统、插件架构 |
动态链接又分两种时机:加载时链接在进程启动、加载器映射共享库时一次性完成全部符号绑定;运行时链接则通过 dlopen/dlsym 在程序运行中途按需加载,插件系统与 Python 的 C 扩展都依赖它。动态链接带来的灵活性也带来「依赖地狱」:libfoo.so.1 与 libfoo.so.2 的 ABI 不兼容,升级一个库可能悄悄破坏另一个程序,因此共享库普遍采用 soname + 版本号 + 符号版本的组合来管理兼容性。
4. GOT、PLT 与延迟绑定
一句话总结: 位置无关代码通过 GOT 间接取地址、通过 PLT 间接跳转,延迟绑定进一步把符号解析推迟到函数第一次被调用。
共享库会被映射到进程地址空间的任意位置,因此库里的代码不能写死任何绝对地址,这种约束叫位置无关代码(PIC)。PIC 用两个间接层解决「不知道自己住在哪」的问题:GOT(全局偏移表,Global Offset Table)是一张存放外部符号实际地址的表,代码只持有 GOT 表项的偏移(偏移是编译期常量,与装载位置无关);PLT(过程链接表,Procedure Linkage Table)是一组跳转桩,每个外部函数对应一个表项,桩的第一条指令就是跳到 GOT 里记录的地址。
# 调用外部函数 foo 时的 PIC 序列 (x86-64, 简化)
call foo@PLT # 跳到 PLT 桩
# PLT 桩内部:
# foo@plt:
# jmp *foo@GOTPCREL(%rip) # 第一次: 跳到 PLT[0] 解析器
# push $index # 把符号索引压栈
# jmp PLT[0] # 进入 _dl_runtime_resolve
// 观察 PIC 与 GOT: 编译成共享库后反汇编
// $ cc -shared -fPIC -O2 lib.c -o lib.so
// $ objdump -d lib.so | grep -A3 "<call_foo>"
extern int foo(int);
int call_foo(int x) { return foo(x) + 1; }
延迟绑定(lazy binding)是动态链接最精巧的优化:如果程序启动时就把所有外部符号全部解析,哪怕一个只用到 strlen 的小工具也要为几百个库符号付出代价。于是 PLT 的第一次跳转故意「跳错」——跳到解析器 _dl_runtime_resolve,由它查出真实地址后改写 GOT 表项,之后再调用就直接命中。整个进程生命周期内,每个符号最多只解析一次。
| 机制 | 作用 | 代价 |
|---|---|---|
| GOT | 存放外部符号真实地址 | 每次访问多一次内存读 |
| PLT | 外部调用的跳转桩 | 每次调用多一次跳转 |
| 延迟绑定 | 推迟解析到首次调用 | 首次调用有解析开销 |
-Wl,-z,now | 启动时全部绑定(BIND_NOW) | 启动慢,但无首次调用抖动 |
-fno-plt | 直接经 GOT 调用,省去 PLT | 需 GOT 提前填充 |
安全加固也大量依赖这些机制:RELRO(Relocation Read-Only)在重定位完成后把 GOT 标为只读,防止攻击者改写 GOT 劫持控制流;-z now 配合完整 RELRO 是最强形态。而 -fno-plt 让调用直接 call *foo@GOTPCREL(%rip),绕过 PLT 桩,在频繁调用外部函数时能省下可观的分支开销,现代发行版已默认启用。
5. 动态库的加载与查找
一句话总结: 加载器按 soname 在搜索路径中定位共享库,路径规则、rpath 与预加载共同决定了「到底加载了哪一个版本」。
当可执行文件的 .dynamic 段里写着 NEEDED libfoo.so.1,加载器就要在文件系统里找到它。查找顺序依次是:DT_RPATH(已废弃)→ 环境变量 LD_LIBRARY_PATH → DT_RUNPATH(-Wl,-rpath)→ /etc/ld.so.cache 缓存 → 默认目录 /lib、/usr/lib。缓存由 ldconfig 扫描生成,是发行版管理库的主路径;LD_LIBRARY_PATH 虽然灵活但会污染子进程,生产环境更推荐 RUNPATH。
# 定位与诊断动态库
ldd ./app # 静态展示依赖解析结果
LD_DEBUG=libs ./app # 运行时打印库搜索全过程
LD_DEBUG=bindings ./app 2>&1 | head # 打印符号绑定
readelf -d ./app | grep -E 'NEEDED|RUNPATH|SONAME'
ldconfig -p | grep libfoo # 查询缓存中的库
// 运行时按需加载: 插件架构的标准做法
#include <dlfcn.h>
#include <stdio.h>
int main(void) {
void *h = dlopen("./plugin.so", RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
int (*run)(int) = (int (*)(int)) dlsym(h, "plugin_run");
if (run) printf("result=%d\n", run(41));
dlclose(h);
return 0;
}
| 变量 | 作用 | 注意事项 |
|---|---|---|
LD_LIBRARY_PATH | 追加搜索目录 | 影响所有子进程,慎用 |
LD_PRELOAD | 强制优先加载 | 可用来打桩与劫持 |
RUNPATH | 编译期写死的搜索路径 | 只对直接依赖生效 |
LD_DEBUG | 打印加载器内部过程 | 排错利器 |
LD_PRELOAD 值得单独一提:它让指定库在其余库之前加载,同名符号由先加载者胜出,因此可以拦截 malloc、open、gettimeofday 等函数做内存统计、故障注入或 mock 测试。这也是「同一个符号在不同库里各有一份实现」时最容易出问题的地方——符号插入(symbol interposition)是动态链接的默认语义,PIC 下所有全局符号默认可被覆盖,若不想被覆盖需要用 -Bsymbolic 或符号可见性属性收窄。
6. 程序启动流程
一句话总结: 内核把控制权交给
_start,C 运行时初始化环境后调用main,返回时再走exit完成清理与析构。
很多人的心智模型是「内核加载程序后直接跳 main」,实际并非如此。ELF 头里的入口点(entry point)指向的是 _start,它由 C 运行时提供(crt1.o/Scrt1.o)。_start 从栈上取出内核准备好的 argc、argv、envp,然后调用 __libc_start_main,后者依次完成:初始化线程局部存储与 errno、注册 fini 处理、运行 .init_array 里的全局构造函数、调用 main,最后用 main 的返回值走 exit。
# x86-64 上 crt1.o 里 _start 的骨架 (简化)
_start:
xor %ebp, %ebp # 清空帧指针, 便于回溯终止
mov %rdx, %r9 # rtld_fini
pop %rsi # argc
mov %rsp, %rdx # argv
and $-16, %rsp # 对齐 16 字节
push %rax
push %rsp # stack_end
mov $main, %edi # 主函数地址
call __libc_start_main@PLT
hlt # main 不应返回; 兜底
// 用 __attribute__ 观察构造/析构函数的执行顺序
#include <stdio.h>
__attribute__((constructor))
static void before_main(void) { printf("1: constructor\n"); }
__attribute__((destructor))
static void after_main(void) { printf("4: destructor\n"); }
int main(void) {
printf("3: main\n");
return 0;
}
// 链接器还会插入 C++ 的全局对象构造, 顺序与链接顺序相关
| 阶段 | 执行者 | 关键动作 |
|---|---|---|
| 内核装载 | execve | 映射段、设置栈、传递 auxv |
| 动态链接 | ld.so | 加载依赖库、重定位、运行库的 init |
| 运行时初始化 | __libc_start_main | TLS、构造函数、atexit 注册 |
| 用户代码 | main | 业务逻辑 |
| 收尾 | exit | atexit 回调、析构、_exit 系统调用 |
启动阶段也是可观测性的富矿:LD_DEBUG=statistics 能打印重定位耗时,perf record -g 抓到的 _dl_relocate_object 火焰说明程序启动瓶颈在动态链接。对启动时间敏感的服务,常见手段包括静态链接、-Wl,-z,now 之外的预链接(prelink)、削减 .init_array 中的构造函数,以及把大量初始化改为惰性。
7. 链接脚本与内存布局
一句话总结: 链接脚本用 SECTIONS 描述「输入段怎么拼、拼到哪个地址」,是控制程序内存布局与嵌入式定址的最终手段。
链接器默认按内置规则布局各段,但嵌入式、内核、Bootloader 等场景需要精确控制:代码必须从 Flash 的 0x08000000 开始,.data 的初值要留在 Flash 而运行时复制到 RAM,某些符号必须落在固定地址。这些都由链接脚本(linker script)描述。脚本用 MEMORY 声明存储区域,用 SECTIONS 描述每个输出段由哪些输入段拼成、放在哪个地址。
/* 典型嵌入式链接脚本 (arm-none-eabi) */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : {
KEEP(*(.isr_vector)) /* 中断向量表必须在最前面 */
*(.text*) /* 所有代码 */
*(.rodata*)
} > FLASH
.data : {
_sdata = .; /* 记录 RAM 中数据段起点 */
*(.data*)
_edata = .;
} > RAM AT> FLASH /* 运行在 RAM, 初值存放在 FLASH */
.bss : { *(.bss*) *(COMMON) } > RAM
_end = .; /* 堆的起点 */
}
# 使用自定义脚本与查看布局
cc -T my.ld hello.c -o hello
readelf -S hello | head -30 # 查看最终段表
readelf -l hello # 查看段(program header)与权限
objdump -h hello # 段大小与地址
| 脚本构造 | 含义 | 常见用途 |
|---|---|---|
MEMORY | 声明地址区间与权限 | 区分 Flash / RAM / CCM |
SECTIONS | 段合并与定址 | 控制布局 |
AT> | 指定加载地址与运行地址不同 | .data 初值存 Flash |
KEEP | 防止被 --gc-sections 回收 | 向量表、注册表 |
PROVIDE | 定义链接期符号 | _end、__bss_start |
链接脚本与 --gc-sections 的交互尤其值得注意:开启函数级垃圾回收后,那些「只被数据引用、没有代码引用」的函数(中断处理表、__attribute__((section)) 注册项)会被误删,必须用 KEEP 显式保留。同理,LTO 与 --gc-sections 会把未使用的构造函数一并丢弃,如果发现某个 constructor 没执行,先查是不是被回收了。
8. 总结
| 环节 | 要点 |
|---|---|
| 目标文件 | 含符号表与重定位表,本身不可执行 |
| 符号解析 | 汇总全局符号表,强符号唯一,弱符号可被覆盖 |
| 重定位 | 按 PC 相对或绝对方式把地址填入占位处 |
| 静态链接 | 库代码整份复制,自包含但体积大、升级难 |
| 动态链接 | 只存引用,加载时绑定,支持共享与热替换 |
| PIC/GOT/PLT | 间接取址与跳转,使库可映射到任意地址 |
| 延迟绑定 | 首次调用才解析并改写 GOT,节省启动开销 |
| 动态库查找 | RUNPATH / ld.so.cache / LD_LIBRARY_PATH 的优先级 |
| 启动流程 | _start → __libc_start_main → 构造函数 → main |
| 链接脚本 | 用 SECTIONS 精确控制段布局与运行/加载地址 |
链接与加载是「编译器的下游」:前端与优化决定生成什么样的指令,链接器决定这些指令落在什么地址、与谁连接,加载器决定它们何时进入内存、以什么顺序初始化。把这层理解透,undefined reference、cannot open shared object、relocation R_X86_64_32 against ... can not be used when making a PIE object 这类报错都不再是玄学,而是可以按图索骥的确定性问题。下一篇转向编译器的分析基础设施——数据流分析框架,看看优化器是如何在控制流图上「算」出程序性质的。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。