「链接、加载与运行时启动」

编译器生成的目标文件只是半成品,链接器把符号与重定位串成可执行映像,加载器再把它送进内存并跳到 _start。本文讲解符号解析与重定位、静态与动态链接、GOT/PLT 与延迟绑定、动态库加载、程序启动流程与链接脚本布局。

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_mainTLS、构造函数、atexit 注册
用户代码main业务逻辑
收尾exitatexit 回调、析构、_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 这类报错都不再是玄学,而是可以按图索骥的确定性问题。下一篇转向编译器的分析基础设施——数据流分析框架,看看优化器是如何在控制流图上「算」出程序性质的。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类