链接器与加载器

用生活化的比喻,让你理解编译产物如何变成可执行文件——静态链接 vs 动态链接、符号解析、重定位

前置知识:第01章 编译原理深度理解(理解编译器的前端和后端)


阅读指南(初学者必看)

为什么你需要学习链接器与加载器?

你写完代码,编译器帮你编译成目标文件,然后呢?一个大型项目有成百上千个源文件,编译器只负责单个文件的编译,把多个目标文件"拼"在一起的工作是链接器做的。

  • 你的代码调用了 console.log,但 console.log 的代码在另一个文件里,编译器怎么找到它?
  • 游戏引擎用 DLL 插件,DLL 是怎么被加载的?和静态链接有什么区别?
  • V8 的 d8 可执行文件依赖了哪些动态库?

学完本章,你能回答:

  • 静态链接和动态链接的区别是什么?各有什么优劣?
  • 符号解析和重定位是什么?链接器怎么把多个目标文件合并?
  • 为什么游戏引擎用 DLL/SO 插件而不是静态链接?

本文结构

第一部分:为什么需要链接器(建立动机)
第二部分:静态链接 vs 动态链接(核心对比)
第三部分:符号解析与重定位(链接器的工作原理)
第四部分:加载器与虚拟内存

2.1 为什么需要链接器?

生活类比:一个大项目由多个部门分别完成设计图,最后需要一个"总工程师"把所有设计图拼在一起,检查是否矛盾(符号冲突),然后标注每个零件的位置(重定位)——这就是链接器的工作。

编译过程:

源文件 A.c                     源文件 B.c
  │                              │
  ▼ 编译                         ▼ 编译
A.o(目标文件)                B.o(目标文件)
  │                              │
  └──────────┬───────────────────┘
             │
             ▼ 链接器(Linker)
        可执行文件(a.out / game.exe)

2.2 静态链接 vs 动态链接

静态链接 动态链接
时机 编译时 运行时
原理 把库代码直接拷贝到可执行文件 只记录库的名称和位置,运行时加载
文件大小 大(包含所有库代码) 小(只包含引用)
更新 需要重新编译 替换 .dll/.so 即可
举例 gcc -static Windows DLL、Linux .so
游戏场景 独立打包的游戏 游戏引擎的插件系统

和 V8/游戏开发的关系

  • V8 的 d8 可执行文件是动态链接的,依赖 libc++.so 等库
  • 游戏引擎的 .dll/.so 插件是动态链接的
  • WASM 模块的动态链接(SharedArrayBuffer + 动态加载)

2.3 符号解析与重定位

// A.c
extern int global_var;     // 引用外部符号(未定义)
extern void print_hello(); // 引用外部函数

void A_func() {
  global_var = 42;
  print_hello();
}

// 编译 A.c 时,编译器不知道 global_var 和 print_hello 的地址
// 在 A.o 中留下"占位符",记录需要重定位的符号
// 链接时,链接器把 B.o 中的实际地址填入占位符

// 重定位表(Relocation Table)
// A.o 中:
//   符号          类型         地址
//   global_var    R_X86_64_PC32  0x1A
//   print_hello   R_X86_64_PLT32  0x2F

2.4 加载器与虚拟内存

生活类比:加载器就像"搬家工人"。可执行文件是设计图,加载器把设计图变成真实的房子(进程的虚拟地址空间)。

加载过程:
1. 读取可执行文件头,了解代码段、数据段的大小和位置
2. 分配虚拟地址空间
3. 将代码段、数据段映射到虚拟地址空间
4. 初始化栈和堆
5. 跳转到入口点(_start / main)开始执行

自问自答

Q:为什么大部分程序用动态链接而不是静态链接? A:动态链接有三大优势:1)文件小,多个程序共享同一个 .so/.dll,不重复存储;2)库更新不需要重新编译程序,替换 .so/.dll 即可(安全更新);3)内存中同一份库代码只需加载一次,多个进程共享。静态链接主要用于需要独立运行、不依赖外部环境的场景。

Q:DLL Hell 是什么? A:DLL Hell 是动态链接的"副作用"——多个程序依赖同一个 DLL 的不同版本,更新一个 DLL 可能导致其他程序崩溃。解决方案包括:1)Side-by-side Assembly(Windows),让不同版本的 DLL 共存;2)容器化(Docker),每个容器有自己的依赖;3)静态链接关键库。

Q:WASM 模块有链接过程吗? A:有!WASM 模块可以动态加载和链接。Emscripten 编译的 WASM 模块支持 dynamicLibraries 选项,运行时加载其他 WASM 模块。SharedArrayBuffer 允许多个 WASM 模块共享内存,这是动态链接的基础。

Q:符号冲突怎么解决? A:当多个目标文件定义了同名符号时,链接器会报"多重定义"错误。解决方案:1)使用 static 限制符号作用域(C/C++);2)使用命名空间;3)使用 __attribute__((visibility("hidden"))) 控制符号可见性;4)链接时指定 --allow-multiple-definition(不推荐,只是绕过问题)。


实践任务

  • 任务1:编写两个 C 文件(A.c 和 B.c),A 调用 B 中定义的函数,用 gcc -c 分别编译,再用 gcc 链接,观察整个过程
  • 任务2:用 objdump -d 查看目标文件的汇编代码,找到重定位占位符
  • 任务3:用 nm 查看 A.o 和 B.o 的符号表,理解"未定义符号"和"已定义符号"
  • 任务4:分别用静态链接和动态链接编译同一个程序,对比文件大小和运行行为
  • 任务5:在 Windows 上用 dumpbin /exports 查看 DLL 的导出符号,理解 DLL 的接口

与其他章节的关联

本章内容 关联章节 关联点
目标文件格式 第01章 编译原理 编译器产出目标文件,链接器处理目标文件
动态链接 第03章 进程与线程 动态链接库的加载涉及进程地址空间
符号表 第04章 内存管理 符号地址映射到虚拟内存空间
DLL/SO 加载 第05章 I/O与文件系统 动态链接库的加载是文件系统操作

上一章:01-编译原理深度理解 | 下一章:03-操作系统进程与线程