Lecture 9: 链接器与动态链接(Linkers and Dynamic Linking)

目录 · ← l7 · l9 →

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)的任务
    1. 合并同类段(所有代码段合并、所有数据段合并);
    2. 计算内存布局(代码段多大?数据段从哪开始?);
    3. 修改地址以匹配布局(填充函数调用与外部数据引用的真实地址)。

目标文件的内容

| 内容 | 说明 | | :— | :— | | 段(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 都经由跳转表间接跳转。
  • 优点:多进程共享一份库代码(内存节省)、库升级无需重编程序;代价:一次间接跳转 + 启动时解析开销。
  • 你每天用的 lddLD_LIBRARY_PATHdlopen 都与这一讲直接相关。

关键要点

  1. 进程内存布局 = 代码 + 数据 + 堆(向上)+ 栈(向下);链接器负责决定各段的位置。
  2. 目标文件携带”段 + 符号表 + 未解析引用”,链接器三遍扫描完成合并、布局、重定位。
  3. 静态链接产生完整独立程序;动态链接通过跳转表在运行时解析共享库引用。
  4. 共享库 = 内存中一份库被多进程共享;加载时重定位。
  5. 堆栈增长方向、动态内存分配调用(malloc/new 会向 OS 请求扩大数据段)都与本讲的内存布局有关。

常见陷阱与注意事项

  • 未定义符号:链接器报 “undefined reference”——符号表里没有该符号。
  • 多重定义:两个目标文件定义了同名全局符号。
  • 忘记链接库:如 -lm-lpthread;C++ 还需 -lstdc++
  • 把动态库路径搞错LD_LIBRARY_PATH 设置不当导致运行时 “cannot open shared object”。
  • 循环依赖的共享库:链接时库顺序错误。

思考题

  1. 问题:为什么汇编器不能直接生成最终可执行文件?
    • 答案:汇编器单独处理一个源文件,不知道其它文件的代码/数据最终落在内存何处,也不知道库函数地址,因此只能留下占位符和未解析引用;只有链接器掌握全部目标文件后才能计算布局、解析所有引用。
  2. 问题:动态链接相比静态链接的主要代价是什么?
    • 答案:调用需经跳转表间接跳转(一次额外跳转),程序启动时需运行动态加载器解析库引用;另外共享库版本不兼容(DLL hell)可能引入运行时故障。