Lecture 9: 链接器与动态链接(Linkers and Dynamic Linking)
Lecture 9: 链接器与动态链接(Linkers and Dynamic Linking)
概述
本讲把注意力从 CPU 转向主存:一个进程的地址空间如何从源码一步步构建出来。我们沿着”编译器 → 汇编器 → 链接器(linkage editor)→ 加载器(loader)”的流水线,理解目标文件(object file)的结构、链接器的三遍扫描(pass)、符号解析,以及现代系统中的共享库与动态链接(跳转表机制)。
核心概念与系统机制图解
进程的内存布局
地址 0 地址 ∞
┌──────────┬──────────┬─────────────────┬──────────┐
│ 代码(text)│ 数据(data)│ 堆(heap) │ 栈(stack)│
│ 只读指令 │ 全局/静态 │ 动态分配(new/ │ 局部变量/ │
│ │ 变量 │ malloc), 向上增长│ 调用帧, 向下增长│
└──────────┴──────────┴─────────────────┴──────────┘
int global = 7; → 数据段
int* gptr = &global; → 数据段(存地址)
void func(int x) {
int local = x; → 栈
int* heap = new int(42); → 堆
...
}
- DRAM 技术参数:易失、字节寻址(按 64 字节缓存行访问)、访问时间约 60–100ns(200–300 个 CPU 周期);笔记本 16–64GB,服务器 512GB–4TB(NUMA)。
从源码到运行进程的流水线
x.c ──gcc(编译)──► x.s ──as(汇编)──► x.o ──┐
y.c ──gcc───────► y.s ──as───────► y.o ───┤ ld(链接)──► a.out ──OS(加载)──► 运行中进程
z.c ──gcc───────► z.s ──as───────► z.o ───┘ + 运行时库(runtime libs)
- 目标文件(object file)不完整:不知道各符号最终落在内存哪里(函数调用地址、外部数据引用是”悬空”的),汇编器用占位值(如 0)。
- 链接器(linkage editor,Linux 的 ld / Windows 的 LINK)的任务:
- 合并同类段(所有代码段合并、所有数据段合并);
- 计算内存布局(代码段多大?数据段从哪开始?);
- 修改地址以匹配布局(填充函数调用与外部数据引用的真实地址)。
目标文件的内容
| 内容 | 说明 | | :— | :— | | 段(sections) | 代码段(text)、数据段(data):记录大小、起始地址、初始内容 | | 符号表(symbol table) | 对外部有意义的外部函数/变量:名字 + 当前位置 | | 未解析引用(unresolved references) | 本文件引用了但未定义的外部符号:位置 + 需要的地址 | | 调试信息 | 源码行号(断点)、结构体布局、变量位置 |
链接器三遍扫描
Pass 1: 读入所有段的大小 → 计算内存布局(内存图)
Pass 2: 读入所有符号 → 在链接器内存中构建完整符号表
Pass 3: 读入段与未解析引用 → 用符号表更新地址 → 写出可执行文件
例(Lecture 9 的 main.o + stdio.o + math.o):
内存图: main.o text: 0..95 stdio.o text: 96..719 math.o text: 720..835
main.o data: 836... stdio.o data: ... (链接后)
符号表: printf → stdio.o T+44 → 140 sin → math.o T+0 → 720 ...
Pass 3: main.o 中 offset 30 的 "call 0" → "call 140" (printf 的真实地址)
动态链接(Dynamic Linking)
- 动机:静态链接使每个程序都包含整套库(浪费内存);库更新需重新链接所有程序。
- 方案:共享库(shared library)——内存中只有一份库,所有进程共享;库的加载地址直到程序运行时才知道 → 需要运行时解析引用。
- 跳转表(Jump Table)机制:
程序加载时: 动态链接后: data段跳转表: data段跳转表: JMP XXX ← 未解析 JMP printf ← 填入库函数真实地址 JMP XXX JMP sin JMP XXX JMP scanf 程序启动时调用动态加载器(dynamic loader): 扫描跳转表 → 映射共享库 → 填充跳转指令 跳转表条目: {函数名, 所在共享库文件名, 跳转指令}
代码示例与系统调用解说
示例:跨文件符号解析(Lecture 9 经典例子)
/* main.c */
extern float sin();
extern printf(), scanf();
int main() {
double x, result;
printf("Type number: ");
scanf("%f", &x);
result = sin(x);
printf("Sine is %f\n", result);
}
/* stdio.c */ /* math.c */
FILE* stdin, stdout; double sin(double x) { ... }
int printf(const char* f, ...) { ... }
int scanf(const char* f, ...) { ... }
编译链接:gcc main.c stdio.c math.c -lm -o sine(-lm 链接数学库,这里演示动态链接)。
【代码做了什么?】 main 引用 printf/scanf/sin 三个外部函数。汇编器在 main.o 中留下三个未解析引用;链接器把 stdio.o 与 math.o 的代码段拼入最终布局,并把三个 call 的占位地址替换为真实地址。
【系统机制透视】
- 若采用动态链接:
sin位于共享库libm.so,链接时只在 main.o 的数据段生成跳转表条目;程序启动时动态加载器把 libm.so 映射进地址空间并填充跳转指令,后续所有call sin都经由跳转表间接跳转。 - 优点:多进程共享一份库代码(内存节省)、库升级无需重编程序;代价:一次间接跳转 + 启动时解析开销。
- 你每天用的
ldd、LD_LIBRARY_PATH、dlopen都与这一讲直接相关。
关键要点
- 进程内存布局 = 代码 + 数据 + 堆(向上)+ 栈(向下);链接器负责决定各段的位置。
- 目标文件携带”段 + 符号表 + 未解析引用”,链接器三遍扫描完成合并、布局、重定位。
- 静态链接产生完整独立程序;动态链接通过跳转表在运行时解析共享库引用。
- 共享库 = 内存中一份库被多进程共享;加载时重定位。
- 堆栈增长方向、动态内存分配调用(malloc/new 会向 OS 请求扩大数据段)都与本讲的内存布局有关。
常见陷阱与注意事项
- 未定义符号:链接器报 “undefined reference”——符号表里没有该符号。
- 多重定义:两个目标文件定义了同名全局符号。
- 忘记链接库:如
-lm、-lpthread;C++ 还需-lstdc++。 - 把动态库路径搞错:
LD_LIBRARY_PATH设置不当导致运行时 “cannot open shared object”。 - 循环依赖的共享库:链接时库顺序错误。
思考题
- 问题:为什么汇编器不能直接生成最终可执行文件?
- 答案:汇编器单独处理一个源文件,不知道其它文件的代码/数据最终落在内存何处,也不知道库函数地址,因此只能留下占位符和未解析引用;只有链接器掌握全部目标文件后才能计算布局、解析所有引用。
- 问题:动态链接相比静态链接的主要代价是什么?
- 答案:调用需经跳转表间接跳转(一次额外跳转),程序启动时需运行动态加载器解析库引用;另外共享库版本不兼容(DLL hell)可能引入运行时故障。
