Lecture 7: 链接 (Linking)

目录 · ← l6 · l8 →

Lecture 7: 链接 (Linking)

讲义对应:CMU 15-213 Lecture 7 — Linking(素材:F25-07-linking.txt教材对应:CS:APP3e 第 7 章 链接(7.1–7.13) 关联 Lab:无直接 lab(但为理解全部 lab 的构建过程提供基础)

7.1 概述

到目前为止,我们都是把程序当作”一个 .c 文件变成一个可执行文件”来理解的。但真实系统里,程序是由多个源文件预编译好的库拼装出来的——把 main.osum.olibc.so 缝合成一个能跑的内存镜像,这件事由链接器(linker, ld完成。本讲回答两个核心问题:链接器做什么(符号解析 + 重定位),以及它怎么做(ELF 格式、符号表、重定位表、静态/动态库、GOT/PLT)。这承接第 5、6 讲的机器级代码与过程调用(链接器改写的正是 callmov 这些指令中的地址字段),并直接通向第 9 讲的内存层次与第 11–12 讲的虚拟内存——共享库”多个进程共用一份代码”正是靠虚拟内存实现的。链接的错误信息往往出现在编译成功之后,是 C 程序员日常最头疼的一类问题。

7.2 核心概念与底层机制图解

7.2.1 编译驱动过程与四种工具(Compiler Driver)

  • 定义与目的gcc 只是一个驱动器(compiler driver),它不亲自干活,而是依次调用 cppcc1asld 四个程序。
  • 直观解释gcc 像一位包工头,自己不动手,只负责按顺序把活派给预处理工、翻译工、装配工和总装工。
  • 底层机制图解
   main.c ──[cpp 预处理]──► main.i ──[cc1 编译器]──► main.s ──[as 汇编器]──► main.o ─┐
   (源文件)   展开宏/头文件     (纯C)   C→汇编语言    (汇编)  汇编→机器码  (可重定位) │
                                                                                    ├─[ld 链接器]─► prog
   sum.c  ──[cpp]──► sum.i ──[cc1]──► sum.s ──[as]──► sum.o ──────────────────────────┘   (可执行)
  • 与机器码/硬件的对应:用 gcc -v 可以看到真实命令行(本机 GCC 12.2.0 实测):
 /opt/.../cc1 -quiet -v main.c ... -o /tmp/ccZHxymZ.s     # C → 汇编
 as -v --64 -o /tmp/ccTnEijw.o /tmp/ccZHxymZ.s            # 汇编 → .o
 /opt/.../collect2 ... -o prog /lib/../lib64/crt1.o ... -lc ...   # 真正的链接

-save-temps 会把 main.i / main.s / main.o 留在当前目录,供你逐阶段观察。

7.2.2 三种目标文件与 ELF 格式(Executable and Linkable Format)

  • 定义与目的:所有目标文件——可重定位(.o)、可执行(a.out)、共享(.so)——都使用统一的 ELF 格式,统称 ELF 二进制文件
  • 直观解释:ELF 像一只分格工具箱:每样零件(代码、只读数据、已初始化数据、未初始化数据、符号表、重定位表)各占一格,并贴有”格号(section header)”标签。
  • 底层机制图解(可重定位目标文件的段布局)
+--------------------------------------------------+ offset 0
| ELF header (字长/字节序/文件类型 .o|EXEC|.so/机器类型) |
+--------------------------------------------------+
| .text      : 机器码                                |  可执行、只读
| .rodata    : 只读数据(跳转表、字符串常量 "%d\n")   |  只读
| .data      : 已初始化的全局/静态变量                  |  可读写
| .bss       : 未初始化全局变量(NOBITS,不占文件空间)  |  可读写
| .symtab    : 符号表(名字/大小/位置/绑定/段索引)      |  不加载
| .rel.text  : .text 的重定位条目                    |  不加载
| .rel.data  : .data 的重定位条目                    |  不加载
| .debug     : 调试信息(gcc -g)                     |  不加载
| .line      : 行号 ↔ 指令地址映射(gcc -g)           |  不加载
| .strtab    : 符号名字符串表                         |  不加载
+--------------------------------------------------+
| Section header table : 每个段的偏移与大小            |  ← 链接器靠它定位
+--------------------------------------------------+

(注:现代 GNU 工具链把重定位段命名为 .rela.text / .rela.data.rel.txt 是教材沿用的旧写法。)

  • 与机器码/硬件的对应.bss 的类型是 NOBITS在文件里不占一字节,但其 Size 在内存镜像里真实存在。实测一个含 int big[1024] 的目标文件:
  [ 3] .data    PROGBITS  0000000000000000  00000054
  [ 4] .bss     NOBITS    0000000000000000  00000060   <- 文件偏移与下个段相同

好处是:4000 字节的零数组不需要在磁盘上占 4000 字节,加载器只需把 4000 字节的内存清零即可。这正是 bss 常被戏称为 “Better Save Space” 的原因。

7.2.3 符号与符号表(Symbols and Symbol Table)

  • 定义与目的符号(symbol) 就是汇编器为每个全局变量、函数、static 变量建立的表项,包含名字、大小、位置(段 + 偏移)。链接器只认识符号,不认识类型
  • 直观解释:符号表像图书馆的索引卡片——只有书名和书架号,没有内容摘要。所以链接器无法察觉两个模块对同一个名字的类型理解不一致
  • 底层机制图解
   符号分类(讲义三分类)
   ┌────────────────┬──────────────────────────────────────────────┐
   │ 全局 global     │ 模块 m 定义、可被其他模块引用(非 static 函数/全局变量)│
   │ 外部 external   │ 模块 m 引用、但定义在其他模块(.o 中标记为 UND)        │
   │ 局部 local      │ 仅模块 m 定义并使用(static 函数/变量;名字可重名)     │
   └────────────────┴──────────────────────────────────────────────┘
  • 与机器码/硬件的对应:局部(非 static)C 变量不在符号表里,它们在栈上;static 局部变量却.bss/.data,编译器为其生成唯一名字(如 xx.0x.1)以避免冲突。实测 nm static-local.o
0000000000000000 T f
0000000000000010 T g
0000000000000020 T h
0000000000000008 d x        <- 文件级 static
0000000000000000 d x.0      <- f() 内的 static
0000000000000004 d x.1      <- g() 内的 static

7.2.4 静态库与”按需提取”(Static Libraries / Archives)

  • 定义与目的.a 归档文件把一批 .oar 打包并加索引;链接器只把真正被引用到的成员拷进可执行文件。
  • 直观解释.a 像一本装订好的活页讲义集,链接器是只复印你要的那几页的复印机,而不是整本复印。
  • 底层机制图解(按需提取)
   main2.o 的未解析符号: {addvec, printf}
        │
        ▼  扫描 libvector.a (含 addvec.o, multvec.o)
   ┌───────────────────────┐   命中 addvec  → 抽入可执行文件,新增未解析 {printf}
   │ addvec.o  [→ 加入]     │
   │ multvec.o [ 丢弃 ]     │   未被任何未解析符号引用 → 整块丢弃
   └───────────────────────┘
        │
        ▼  扫描 libc.a
   printf.o 及其依赖链依次抽入 → 可执行文件 prog2c 链接成功
  • 与机器码/硬件的对应:实测 nm prog2c 只有 addvec,没有 multvec;而把 .o 全部直接列在命令行(prog2all)会额外多出 multvec 与 64 字节:
0000000000401168 T addvec          # prog2c(按需提取)
0000000000401168 T addvec          # prog2all(全量)
0000000000401186 T multvec
-rwxr-xr-x 25888 prog2c    vs    -rwxr-xr-x 25952 prog2all

7.2.5 动态库与 GOT/PLT 延迟绑定(Shared Libraries, GOT/PLT, Lazy Binding)

  • 定义与目的:共享库 .so加载时或运行时才被链接。为了让同一份代码能被映射到不同进程的不同虚拟地址,库必须用位置无关代码(PIC):用 GOT(Global Offset Table) 间接访问全局变量,用 PLT(Procedure Linkage Table) 延迟解析外部函数地址。
  • 直观解释:GOT 像一块”通讯录白板”,先写”还不知道”,第一次打电话(调用)时才把真号码填上;PLT 是每通电话对应的便签,指向白板上那一行。这就是延迟绑定(lazy binding)
  • 底层机制图解(首次调用 addvec 的流程)
   main 中:  callq 401040 <addvec@plt>
                    │
                    ▼
   ┌────────────────────────────────────────────────────────────┐
   │ PLT  (代码段, 只读)                                          │
   │ 401040 <addvec@plt>:                                       │
   │    jmpq *0x2fda(%rip)   # GOT[addvec] ────┐  首次 = 401046  │
   │    pushq $0x1           # 重定位索引 1     │                │
   │    jmpq  401020 <.plt>  # 跳到 PLT[0]      │                │
   ├────────────────────────────────────────────┼────────────────┤
   │ GOT/GOT.PLT (数据段, 可写)                   ▼                │
   │  404008: GOT[1] = link_map 指针  ◄── PLT[0] pushq 0x2fe2(%rip)│
   │  404010: GOT[2] = _dl_runtime_resolve  ◄── PLT[0] jmpq *...   │
   │  404018: GOT[printf] = 401036 (先) → libc printf (后)         │
   │  404020: GOT[addvec] = 401046 (先) → libacc addvec (后)       │
   └────────────────────────────────────────────────────────────┘

   首次: PLT→GOT→回到下一条→push 索引→PLT[0]→_dl_runtime_resolve 写回 GOT
   以后: PLT→GOT 直接命中真实地址(只多一次间接跳转)
  • 与机器码/硬件的对应:实测 objdump -d -j .plt prog2lobjdump -R prog2l
0000000000401040 <addvec@plt>:
  401040: ff 25 da 2f 00 00    jmpq   *0x2fda(%rip)        # 404020 <addvec>
  401046: 68 01 00 00 00       pushq  $0x1
  40104b: e9 d0 ff ff ff       jmpq   401020 <.plt>

DYNAMIC RELOCATION RECORDS
0000000000404018 R_X86_64_JUMP_SLOT  printf@GLIBC_2.2.5
0000000000404020 R_X86_64_JUMP_SLOT  addvec

JUMP_SLOT 就是”这个 GOT 槽最终要被填入函数地址”的声明;若用 LD_BIND_NOW=1-Wl,-z,now 则改为加载时全部绑定(GNU_RELRO 保护 GOT 不可写)。ldd prog2l 显示库的实际落点,readelf -x .interp 显示动态链接器 /lib64/ld-linux-x86-64.so.2

7.3 代码示例与底层机制分析

示例 1:两个模块 + 头文件——符号解析与重定位全过程

代码(三文件,实测位于 /tmp/lk/ex1/

/* sum.h : 接口声明,被两个模块共享 */
int sum(int *a, int n);
extern int array[2];          /* extern = "它在别处定义",避免重复分配空间 */
/* main.c */
#include "sum.h"
int array[2] = {1, 2};        /* 强符号:已初始化全局 → .data */
int main(int argc, char **argv)
{
    int val = sum(array, 2);  /* 引用外部符号 sum */
    return val;
}
/* sum.c */
#include "sum.h"
int sum(int *a, int n)        /* 强符号:函数 → .text */
{
    int i, s = 0;             /* 链接器完全不知道 i、s 的存在 */
    for (i = 0; i < n; i++)
        s += a[i];
    return s;
}

【代码做什么?】

  1. gcc -c 分别把 main.csum.c 编译成 main.osum.o;此时两者都不知道对方的最终地址。
  2. 符号解析main.o 有未解析(UND)的 sumsum.o 有未解析的 array;链接器把它们配对。
  3. 重定位:合并 .text.data,为每个段分配最终虚拟地址,再按重定位表改写指令中的地址字段。
  4. 链接完成后 ./prog 退出码为 3(sum({1,2},2) = 3)。

【底层机制透视】实测 gcc -c -Og -fcf-protection=none + objdump -r -d main.o

0000000000000000 <main>:
   0: 48 83 ec 08        sub    $0x8,%rsp
   4: be 02 00 00 00     mov    $0x2,%esi
   9: bf 00 00 00 00     mov    $0x0,%edi        <- 地址字段全 0,待填
                 a: R_X86_64_32  array               # 绝对寻址重定位条目
   e: e8 00 00 00 00     callq  13 <main+0x13>    <- 调用"下一条指令"
                 f: R_X86_64_PLT32 sum-0x4           # PC 相对寻址重定位条目
  13: 48 83 c4 08        add    $0x8,%rsp
  17: c3                 retq

sum.o 没有任何重定位条目——它不引用外部符号,因此完全位置无关(除 call 外不需要改写)。

【内存布局 / 数据结构图解】链接后的最终地址(实测 objdump -d prog):

  0000000000401106 <main>:
    40110f: bf 28 40 40 00   mov  $0x404028,%edi   <- n=0x404028 即 &array,绝对地址被填入
    401114: e8 05 00 00 00   callq 40111e <sum>    <- sum 被放到 main 之后 0x18 字节
  000000000040111e <sum>:                          <- 0x40111e = 0x401119 + 5

  可执行文件内存镜像:
    0x404028 ┌──────────┐
             │ array[0]=1│ .data(已初始化全局变量)
             │ array[1]=2│
    0x404030 ├──────────┤
             │  (零填充) │ .bss(NOBITS,文件里不占空间)
    0x400000 └──────────┘ ← 只读代码段从这里开始

【与汇编 / 硬件的对应】两种重定位类型:

  • R_X86_64_32绝对寻址):把符号的绝对地址直接写进 4 字节字段。用于 mov $array, %ediint *xp = &x 这类”地址必须是个常量”的场景。
  • R_X86_64_PC32 / R_X86_64_PLT32PC 相对寻址):写入 目标地址 - 重定位处地址 - 4-0x4 修正量正是该字段自身长度)。call 计算验证:0x40111e = 0x401119(下一条指令) + 5,即指令中的位移字段被填成 0x5

【实测验证】readelf -s main.o 的符号表(Ndx = UND 即”引用但未定义”):

   Num:    Value          Size Type    Bind   Vis      Ndx Name
     8: 0000000000000000    24 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     8 OBJECT  GLOBAL DEFAULT    3 array
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND sum

示例 2:静态库 libvector.a 与库顺序陷阱

代码(位于 /tmp/lk/lib/

/* vector.h */
#ifndef VECTOR_H
#define VECTOR_H
void addvec(int *x, int *y, int *z, int n);
void multvec(int *x, int *y, int *z, int n);
#endif
/* addvec.c */                          /* multvec.c 结构相同,做逐元素乘法 */
#include "vector.h"
void addvec(int *x, int *y, int *z, int n) {
    int i;
    for (i = 0; i < n; i++)
        z[i] = x[i] + y[i];
}
/* main2.c —— 只引用 addvec,不引用 multvec */
#include <stdio.h>
#include "vector.h"
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main(void) {
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);
    return 0;
}

构建与运行(真实输出)

$ gcc -c -Og addvec.c multvec.c main2.c
$ ar rs libvector.a addvec.o multvec.o
$ ar -t libvector.a
addvec.o
multvec.o
$ gcc -L. -o prog2c main2.o -lvector        # 库放右边(正确)
$ ./prog2c
z = [4 6]                                    # 1+3=4, 2+4=6

【代码做什么?】ar rsr 表示插入/替换成员,s 表示建立符号索引。链接时链接器按命令行顺序扫描,维护”当前未解析符号表”,每遇到一个 .o/.a 就尝试解析;扫描结束仍有未解析符号则报错。

【底层机制透视】-lvector 放到 main2.o 之前,链接器扫描库时还没有任何未解析符号,addvec.o 不会被抽入,随后扫描 main2.o 才产生未解析的 addvec——但库已经扫完了。实测:

$ gcc -L. -o prog2bad -lvector main2.o
/usr/bin/ld: main2.o: in function `main':
main2.c:(.text+0x19): undefined reference to `addvec'
collect2: error: ld returned 1 exit status

口诀:被依赖者放右边,库放在最后。若库之间存在循环依赖,用 -Wl,--start-group ... -Wl,--end-group 让链接器反复扫描组内成员直至收敛(实测可修复上述错误)。

【内存布局 / 数据结构图解】

   命令行扫描顺序 → 未解析集合的演化

   gcc main2.o  -lvector            (正确)
     main2.o   : {addvec, printf}          ← main2 产生 addvec 需求
     libvector : addvec 命中 → {printf}     ← 抽入 addvec.o,丢弃 multvec.o
     libc      : printf 命中 → {}           ← 链接成功

   gcc -lvector  main2.o            (错误)
     libvector : {} 无未解析符号 → 一个成员都不抽
     main2.o   : {addvec, printf}           ← 库已扫完,永远无法满足
     扫描结束 → undefined reference to `addvec'

【实测验证】按需提取确实节省空间:

$ nm prog2c | grep -E 'addvec|multvec'
0000000000401168 T addvec               # 只有 addvec
$ ls -l prog2c prog2all
-rwxr-xr-x 25888 prog2c                 # 按需提取
-rwxr-xr-x 25952 prog2all               # 全量链接(含 multvec,多 64 字节)

示例 3:动态库、ldd 与 PIC 代码生成

构建(位于 /tmp/lk/lib/,真实输出)

$ gcc -shared -fpic -o libvector.so addvec.c multvec.c
$ gcc -o prog2l main2.c -L. -lvector -Wl,-rpath,/tmp/lk/lib
$ ./prog2l
z = [4 6]
$ ldd prog2l
        linux-vdso.so.1 (0x00007ffd9359c000)
        libvector.so => /tmp/lk/lib/libvector.so (0x00007ffa3affd000)
        libc.so.6 => /lib64/libc.so.6 (0x00007ffa3ac00000)
        /lib64/ld-linux-x86-64.so.2 (0x00007ffa3b004000)

【底层机制透视】可执行文件里没有 addvec 的代码,只有一个 NEEDED 记录和一个待填的 GOT 槽:

$ readelf -d prog2l | grep -E 'NEEDED|RPATH'
 0x0000000000000001 (NEEDED)  Shared library: [libvector.so]
 0x0000000000000001 (NEEDED)  Shared library: [libc.so.6]
 0x000000000000000f (RPATH)   Library rpath: [/tmp/lk/lib]

ld-linux-x86-64.so.2execve 之后、main 之前被内核(按 .interp 段)启动,它 mmap 各 .so、填 GOT、再调用 __libc_start_main0x401038: mov $0x401106,%rdimain 的地址作为参数传入)。库查找顺序为 RPATHLD_LIBRARY_PATHRUNPATH/etc/ld.so.cache → 默认目录。

【与汇编 / 硬件的对应】-fPIC-fno-PIC 的差别(实测 /tmp/lk/pic/):

   -fPIC 访问全局变量 glob:先取 GOT 表项,再解引用(两次访存)
     110d: 48 8b 05 d4 2e 00 00   mov  0x2ed4(%rip),%rax     # GOT 项
     1114: 8b 00                  mov  (%rax),%eax             # 真正的 glob
     重定位条目: R_X86_64_REX_GOTP32  glob - 4        (readelf -r acc_pic.o)

   -fno-PIC:一条 PC 相对寻址直接命中(但地址固定,不能共享)
        0: 8b 05 00 00 00 00      mov  0x0(%rip),%eax
     重定位条目: R_X86_64_PC32  glob - 4

外部函数调用在 PIC 中固定走 PLT:R_X86_64_PLT32 getext - 4为什么库必须 PIC?因为同一个物理页被映射到每个进程的不同虚拟地址,绝对地址无法共享,也就无法只保留一份物理副本。

示例 4:库打桩——用 LD_PRELOAD 拦截 malloc

代码(位于 /tmp/lk/mtrace/

/* mymalloc.c —— 编译成 mymalloc.so,用 LD_PRELOAD 注入 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

static void *(*real_malloc)(size_t) = NULL;
static void  (*real_free)(void *)   = NULL;
static long malloc_count = 0, free_count = 0, malloc_bytes = 0;

void *malloc(size_t size)
{
    if (!real_malloc)                        /* 惰性解析 libc 的真正实现 */
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    malloc_count++;
    malloc_bytes += (long)size;
    return real_malloc(size);
}

void free(void *ptr)
{
    if (!real_free)
        real_free = dlsym(RTLD_NEXT, "free");
    free_count++;
    real_free(ptr);
}

__attribute__((destructor))                  /* 库卸载时打印统计 */
static void mtrace_report(void)
{
    fprintf(stderr, "[mtrace] malloc calls = %ld, free calls = %ld, bytes = %ld\n",
            malloc_count, free_count, malloc_bytes);
}
/* leaky.c —— 被监视的程序:故意泄漏一块 */
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    char *a = malloc(100);
    char *b = malloc(4096);        /* 故意不 free:模拟泄漏 */
    char *c = malloc(64);
    free(a);
    free(c);
    printf("b = %p (deliberately leaked)\n", (void *)b);
    return 0;
}

【代码做什么?】

  1. LD_PRELOAD=/tmp/lk/mtrace/mymalloc.so 让动态链接器优先mymalloc.so 中搜索符号,于是程序中所有 malloc/free 调用都先进入我们的版本。
  2. 我们通过 dlsym(RTLD_NEXT, "malloc") 取”下一个”定义,也就是 libc 的真 malloc,加完计数再转发。
  3. 计数在库卸载时(destructor)打印,因此恰好覆盖整个进程生命周期。

【实测验证】真实运行输出:

$ gcc -g -Wall -o leaky leaky.c
$ ./leaky
b = 0xef9310 (deliberately leaked)

$ LD_PRELOAD=/tmp/lk/mtrace/mymalloc.so ./leaky
b = 0x237d310 (deliberately leaked)
[mtrace] malloc calls = 4, free calls = 2, bytes = 8356

4 次分配 = 100 + 4096 + 64 + 4096,其中第 4 次 4096 字节来自 printf 首次调用时 libc 的缓冲初始化(bytes = 100+4096+64+4096 = 8356);free 只有 2 次,说明净泄漏至少两块。这正是 malloc lab(L5) 调试分配器时最常用的手段(另有 glibc 自带的 mtrace()MALLOC_TRACE)。

【与汇编 / 硬件的对应】打桩有三种时机,讲义与教材并重:

时机手段优点缺点
编译期gcc -Wl,--wrap=malloc,自定义 __wrap_malloc/__real_malloc简单、可只作用于单个程序必须重新编译
链接期malloc.o 这类 .o 直接写在命令行最前无需重编译库需要拿到目标文件
运行期LD_PRELOAD(本示例)不改程序、可用于任意二进制影响整个进程,需防递归
$ gcc -g -Wall -o wr wr.c -Wl,--wrap=malloc && ./wr
p = 0xc732a0
[wrap] malloc(32)                # 编译期打桩真实输出

7.4 实验关联

本讲无直接关联 lab,但它是理解全部 lab 构建过程的基础,并在多处直接复现:

  • L4 Cache(csim-ref):讲义就是用 ldd csim-ref 讲解 .interp/.dynamic/NEEDED,并要求你在 cachelab handout 里解释 libm.so.6 从何而来。
  • L3 Attack:活动讲义明确把 ELF 段标志与攻击实验挂钩——只有 .textCODE 标志,操作系统才把它设为可执行;.data/.bss 外的段带 READONLY,因此写 .rodata 会段错误。这解释了为什么攻击实验里把代码放进栈/数据区会被拦截。
  • L5 Malloc:调试自己的分配器时,示例 4 的 LD_PRELOAD 计数是最有效的”调用轨迹”工具;--wrap 则用于只监视测试驱动。
  • 所有 lab 的 Makefile-fPIC-shared-lm、库顺序、ldd 检查缺库,都是提交前必须过关的工程细节。

7.5 常见错误与调试技巧

  • undefined reference to 'foo'(未定义引用):解析阶段找不到定义。常见三种原因:忘记链接库(-lm)、库顺序错误(库在 .o 之前)、只写了 int foo(int); 声明却没有实现。调试nm -u main.o 看未解析符号,nm -C libfoo.a \| grep foo 确认库里有没有,gcc ... 2>&1 \| c++filt 还原 C++ 名字。
  • multiple definition of 'x'(多重定义):两个模块都定义了强符号(GCC 10+ 默认 -fno-common,把未初始化全局也视为强定义),或同一 .o 被列了两次。调试ld 会指出 first defined here 的文件;用 nm *.o \| grep x 找出所有定义者,把其中一个改成 staticextern
  • cannot find -lxxx:库根本不在搜索路径,与符号解析无关。调试gcc -Wl,--verbose 2>&1 \| grep succeeded 打印所有搜索目录,ldconfig -p \| grep xxx 看系统缓存,用 -L/path 补路径。
  • 同名弱符号悄悄合并(”Evil”/”Nasty”):两个模块各写一次 int x;-fcommon 下)会被合并为同一块内存,一个模块的写入会破坏另一个模块的数据;若一个是 1 字节 char、一个是 8 字节 double,链接器只给 warning,运行期写出界直接压掉相邻变量。调试nm prog \| grep ' [BbDd] ' 检查是否有意外的全局同名,objdump -d 观察写入宽度(movb vs movsd)。
  • 类型不匹配却静默通过mismatch-variable.c 定义 double x = 3.14;mismatch-main.c 声明 long int x;,编译链接零警告。实测打印 4614253070214989087(即 0x40091eb851eb851f,3.14 的 IEEE-754 位模式被当成整数读)。根源:符号表不带类型。调试:把共享声明统一放进头文件(示例 1 的 sum.h),始终 #include 它;-Wmissing-declarations-flto 能查出部分不匹配。
  • 在只读段上写入导致段错误helper.c 里把 global 定义成 const,它会落到 .rodatamain 若声明时漏掉 const 并尝试赋值,链接成功但运行时 Segmentation fault。实测:

    $ readelf -S ro.o \| grep -E 'rodata\|data'
      [ 4] .rodata   PROGBITS   0000000000000000  00000040      # const 全局落在这里
    $ ./roseg
    Segmentation fault (core dumped)        # 退出码 139 = 128 + SIGSEGV(11)
    

    调试readelf -S file.o 确认符号所在的段,objdump -d 看是否用了 movb $0x61,...(%rip) 这类直接写入,gdbcatch signal SIGSEGV 定位故障指令。

  • PIC 缺失gcc -shared 时忘记 -fPIC。实测报错为:

    ld: warning: relocation against `glob' in read-only section `.text'
    ld: relocation R_X86_64_PC32 against symbol `glob' can not be used when making a
        shared object; recompile with -fPIC
    ld: final link failed: bad value
    

    调试readelf -r lib.so 检查是否走了 GOT——PIC 下应看到 R_X86_64_GLOB_DAT(数据符号)与 R_X86_64_JUMP_SLOT(函数符号);若出现 R_X86_64_REX_GOTP32 之外的绝对 R_X86_64_32,说明未生成位置无关代码。

  • LD_LIBRARY_PATH 找不到库error while loading shared libraries: libvector.so: cannot open shared object file调试ldd ./prog 找到哪个 => not found,临时用 LD_LIBRARY_PATH=. ./prog,正式方案是 -Wl,-rpath,'$ORIGIN'ldconfig

7.6 关键要点

  • 链接只有两件事:符号解析(references → definitions)和重定位(相对地址 → 最终绝对地址)。其余概念(ELF、.a.so、PLT/GOT)都是为这两件事服务的工具。
  • 符号表里没有类型。C 的类型信息在编译成汇编时就已经消失,所以链接器永远抓不到”同名不同类型”这类错误——接口必须靠共享头文件来约束。
  • static 是避免命名冲突最便宜的手段:它把符号从 global 降级为 local,同时把变量从栈搬进 .data/.bss 并获得进程生命周期。
  • 静态库按需提取,因此命令行顺序决定成败:被依赖者放右边,库放在最后;循环依赖用 --start-group/--end-group
  • 共享库靠 PIC 换取物理内存共享:全局变量经 GOT 间接访问,外部函数经 PLT 延迟绑定,代价是每次访问多一次访存、每次首次调用多一次解析。
  • .bss 是”文件里不存在、内存里存在”的段:磁盘上零字节,加载时由加载器清零,是”零初始化”这一语义的存储优化。

7.7 思考题(带答案)

题 1(链接行为推演题) 下面三个文件用 gcc -fcommon 链接并运行,输出什么?如果改回现代默认的 -fno-common 又会怎样?

/* m1.c */
int x;                        /* 弱符号 */
void set_x(void) { x = 42; }
/* m2.c */
int x;                        /* 同一个弱符号,另一个模块"又定义"了一次 */
void set_x(void);
int main(void) { printf("%d\n", x); set_x(); printf("%d\n", x); }

-fcommon 下两个 int x; 都是弱符号,链接器任选其一并把两处引用解析到同一块内存(实测 nm 显示一个 B g 风格的单一定义)。因此输出 042——即使两个模块各自看不到对方的定义,它们操作的仍是同一块内存。这通常不是你想要的:若两处本意是各自独立的计数器,就会互相污染。改用 -fno-common(GCC 10+ 的默认)后,ldmultiple definition of 'x' 并失败。正确做法:其中一个改为 static int x;(真正独立),或在头文件里写 extern int x; 而只在一个 .c 中定义。

题 2(推演题:静默灾难) 讲义”Revisiting linker puzzles”的程序在 -fcommon 下,int.cchar x='c'; int y=2;double.cdouble x;p2()x = 20000.00002。两个文件谁先谁后,输出都一样吗?为什么 x 打印出来是 -42,而 y 却没有变化?

:题面中两处 x 都是弱符号(double x; 未初始化,char x-fcommon 下也被视为弱定义),链接器把它们并入同一块存储。实测(本机 GCC 12.2.0,两种命令行顺序结果完全相同):

$ gcc -fcommon -fno-pie -no-pie -fcf-protection=none -O -o p int.c double.c
x=0.000000            # p2 用 %f 读 x:此时低字节还是 'c'(0x63),非规格化数 → 0.000000
x=-42 y=2             # p1 先 movb $'a' 写 x 的低字节,p2 再写 8 字节 double,最后 movsbl 读 x

$ nm p | grep -E ' [BbDd] (x|y)$'
0000000000404034 D x        # x 与 y 都在 .data
0000000000404030 D y

关键点有三:其一,p2()double 写入 8 字节 20000.00002,覆盖了 x 起点的 8 个字节;其二,p1() 之后用 movsbl 只读 x最低一个字节,这个字节来自 double 位模式的最低字节,符号扩展后恰好是 -42;其三,本机布局把 y 放在 x 之前y0x404030x0x404034),所以 8 字节写正好落在 x 之后、没有波及 y——但这只是链接器分配顺序的巧合,不是任何保证。讲义幻灯片上曾出现同一程序 y=1087604736 的结果,正是布局把 y 放到了 x 之后。若不加 -fcommon,链接器直接以 multiple definition of 'x' 拒绝,而这恰恰是好事:报错比静默的数值污染安全得多。

题 3(”直觉但错误”的想法) 有同学认为:”gcc -c a.c b.c 之后链接报 undefined reference,说明编译器没找到函数实现,把 b.c 也加进 gcc 命令就好了。”这个推理错在哪?

:错把编译单元边界当成了错误的原因。undefined reference链接期错误,说明符号解析未完成,具体可能是:(1) 忘链接库(-lm);(2) 库顺序错误(库在 .o 之前);(3) 函数只声明未定义;(4) 名字修饰/命名空间不匹配。把所有 .c 都写进同一条 gcc 命令确实能解决 (1)(2),但它掩盖了真正的问题——大型项目里你不可能把所有源文件列一遍,正确做法是修库顺序或补 -l。另一个常见误判是”编译成功了所以代码没问题”:如题 2 所示,编译成功 + 链接成功完全可能与运行期灾难并存,因为类型信息在汇编层面已经消失。

题 4(计算题) 100 个不同的可执行程序都需要 libvector.a 中的 addvec,其中 addvec.omultvec.o 各 4 KB;每个程序只引用 addvec。若改用共享库 libvector.so(代码共 4 KB + 1 KB 元数据),磁盘占用与物理内存占用各减少多少?

:静态方案下,每个可执行文件都各自拷入一份 addvec.o 的 4 KB(multvec.o 因未被引用而丢弃),所以磁盘上有 $100 \times 4\,\text{KB} = 400\,\text{KB}$;运行时 100 个进程各自的代码页也来自这 100 个不同文件,物理内存同样需要约 $400\,\text{KB}$。动态方案下磁盘只需 $4 + 1 = 5\,\text{KB}$(每个可执行文件里只留一个 NEEDED 字符串和一个待填的 GOT 槽),物理内存只需 1 份 libvector.so 的代码页(各进程把同一物理页映射到各自的虚拟地址)加上每进程私有的少量 GOT 数据。因此磁盘与物理内存都节省约 $400 - 4 = 396\,\text{KB}$,接近 99%。这正是讲义强调”共享库的代码可以被所有执行中的进程共享”的量化含义——注意前提是”不同程序共用同一库”;若 100 个进程跑的是同一个可执行文件,静态链接也能靠页缓存共享,此时共享库的优势主要体现在磁盘与可维护性上。