Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.7 链接、加载与库

本章回答

两个 .o 文件怎样变成一个可执行文件?为什么少写一个 -lm 会在链接时报 undefined reference,而不是在运行时?GOT、PLT、ld.so 各自负责哪一步?

读完你会

用 readelf/objdump/nm 读目标文件;解释强符号、弱符号与归档的解析顺序;手算一次 PC 相对重定位;用 LD_PRELOAD 和 dlopen 替换或加载库函数。

1.1 把 hello.c 走了一遍「预处理—编译—汇编—链接—加载」。那里只掀开了链接阶段的一角:可执行文件里有一个 puts@plt,真正的 puts 在 libc.so.6 里,运行时才被接上。这一章把这一角完全打开。

链接器面对的不是源代码,而是一组可重定位目标文件和归档库。它要解决两个问题:同一个符号在多个文件里出现时听谁的(符号解析);指令里留下的地址空洞填什么(重定位)。这两个问题决定了你日常遇到的一多半构建错误:multiple definition、undefined reference、cannot find -lxxx、symbol lookup error。

加载则是另一个程序的工作。内核只把 PT_LOAD 段映射进地址空间,动态链接器 ld.so 接着解析依赖、填 GOT、调用构造函数,最后才跳到 _start。把链接与加载分开看,你才能理解「编译通过、链接失败、运行时报找不到符号」分别发生在哪一层。

1.7.1 链接器在流水线中的位置

gcc 是驱动程序,不是编译器。它按后缀和选项调用 cc1、as、collect2,最后由 collect2 调用 ld。打开 verbose 模式能看到真实的命令行:

cc -v hello.c -o hello 2>&1 | sed -n '/collect2/,/^$/p'

输出里会出现 ld -pie --eh-frame-hdr ... -dynamic-linker /lib64/ld-linux-x86-64.so.2 ... -lc。三件事值得注意:

  • -pie 默认开启:现代发行版的 GCC 默认生成位置无关可执行文件(position-independent executable, PIE),类型是 ET_DYN 而不是 ET_EXEC。
  • -dynamic-linker 指定解释器:可执行文件里写死一个 ld.so 路径,内核加载时先映射它。
  • -lc 由驱动程序补上:你只写 hello.c,GCC 自动加 C 启动文件、libc 和动态链接器参数。
定义 1.7.1 · 静态链接与动态链接

静态链接在构建时把被引用目标模块的代码和数据复制进可执行文件,产物不依赖外部库文件;动态链接只记录依赖的共享库名字,把符号绑定推迟到加载时(或首次调用时),由动态链接器完成。两者不是「有没有库」的区别,而是「绑定发生在构建期还是运行期」的区别。

链接器的输入只有两类:可重定位目标文件(.o,类型 ET_REL)和静态库(.a,其实是一组 .o 的归档)。它输出可执行文件(ET_EXEC/ET_DYN)或共享库(ET_DYN)。核心工作可以拆成四步:

步骤做什么失败时的典型报错
符号解析每个未定义符号找到唯一强定义undefined reference to 'foo'
合并节把所有 .text/.data/.rodata 拼成输出节很少直接报错
重定位按重定位表改写指令/数据中的地址relocation truncated to fit
布局分配虚拟地址、生成程序头、设置入口section .text will not fit

「编译通过」只说明每个 .c 文件内部语法和类型正确;跨文件的符号是否存在、地址是否放得下,是链接器的事。这也是为什么一个函数声明了却从未定义,编译器不会报警——它只看到声明。

1.7.2 ELF:节、段与符号表

ELF(Executable and Linkable Format)用同一套容器装三种东西:可重定位目标文件、可执行文件、共享库。区别写在文件头的 e_type 里。readelf -h 先看这个字段:

readelf -h hello | grep -E 'Type|Machine|Entry|Start of program|Start of section'

Type: DYN 说明它是 PIE 或共享库;Entry point 是内核交棒后执行的第一条指令地址,通常是 _start,不是 main。

两种视图:节与段

链接器按节(section)工作,加载器按段(segment)工作。节是可合并、可重定位的细粒度单元;段是内核映射的粗粒度单元,一段包含一或多个节,按页对齐、按权限合并。

节内容典型权限运行时还在吗
.text机器指令R-X在,映射为代码段
.rodata字符串、常量表、跳转表R--在
.data已初始化全局/静态变量RW-在
.bss未初始化全局/静态变量RW-占内存,不占文件
.symtab / .strtab链接期符号表与字符串表不加载可被 strip 删除
.rela.text代码段的重定位表不加载链接后消失
.got / .got.plt全局偏移表RW-在,动态链接用
.plt过程链接桩R-X在
.dynamic / .dynsym / .dynstr动态链接元数据R--在,运行必需

.bss 的 NOBITS 类型值得单独记:它在文件里只占一个表项,记录大小和地址;加载器把它映射到零页,第一次写触发缺页才分配物理页。static char pool[64 << 20]; 不会让二进制膨胀 64 MiB。

ELF 可执行文件(PIE,加载基址随机)
┌─────────────────────────────────────────────────────────────┐
│ ELF header:e_type=DYN, e_entry, e_phoff, e_shoff            │
├─────────────────────────────────────────────────────────────┤
│ Program headers(加载器视图)                                │
│  PT_INTERP  → "/lib64/ld-linux-x86-64.so.2"                 │
│  PT_LOAD #1 → R--  .interp .note .dynsym .dynstr .rela.dyn  │
│  PT_LOAD #2 → R-X  .init .plt .text .fini                   │
│  PT_LOAD #3 → RW-  .init_array .dynamic .got .data .bss     │
│  PT_GNU_RELRO → 只读化 .dynamic/.got 的一部分               │
│  PT_GNU_STACK → 栈权限(RW,无 X)                           │
├─────────────────────────────────────────────────────────────┤
│ .text:  call  puts@plt                                      │
│            │                                                 │
│ .plt:   ┌──▼──────────────┐    .got.plt                     │
│         │ puts@plt:       │    ┌──────────────────┐         │
│         │  jmp *0x2f(%rip)├───►│ GOT[3] = 0x...   │         │
│         │  push $0x2      │    ├──────────────────┤         │
│         │  jmp plt0       │    │ GOT[1] link_map  │         │
│         └─────────────────┘    │ GOT[2] resolver  │         │
│                                └──────────────────┘         │
│  首次调用:GOT[3] 指向 push 序列 → 解析 → 改写为真实 puts     │
└─────────────────────────────────────────────────────────────┘

读图抓住两条线:链接器视图(节)和加载器视图(段)。readelf -S 看节,readelf -l 看段;调试链接错误看前者,调试加载/权限问题看后者。图下半部分的 PLT/GOT 是 1.7.6 的主题,先记住「代码段不直接写死库函数地址」。

符号表里有什么

.symtab 的每一项描述一个符号:

readelf -s hello.o | sed -n '1,12p'

典型一行:

Num:    Value          Size Type    Bind   Vis      Ndx Name
  4: 0000000000000000    27 FUNC    GLOBAL DEFAULT    1 main
  5: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

四个字段决定链接行为:

  • Type:FUNC、OBJECT、NOTYPE、SECTION、FILE。
  • Bind:LOCAL(static)、GLOBAL、WEAK。
  • Ndx:符号所在节的下标;UND 表示未定义,ABS 表示绝对值,COM 表示 common。
  • Vis:DEFAULT、HIDDEN、PROTECTED。HIDDEN 的符号不进入动态符号表,不能被外部库引用。

static 函数不是「运行时私有」,而是链接期局部:它进 .symtab 的 LOCAL 项,不参与跨文件解析。strip 删掉 .symtab 后,nm 看不到局部符号,但 .dynsym 里的动态符号仍在——否则动态链接器无法工作。

1.7.3 符号解析:强、弱与归档顺序

链接器维护三个集合:已定义符号、未定义符号、归档成员。每读一个输入文件就更新集合。规则不复杂,但边界很多。

定义 1.7.2 · 强符号与弱符号

函数和已初始化的全局变量是强符号(strong symbol);未初始化全局变量在传统 C 里是弱符号(weak symbol,COMMON),用 __attribute__((weak)) 可显式声明弱符号。链接规则:

  1. 多个强符号同名 → 报 multiple definition;
  2. 一个强符号 + 多个弱符号 → 选强符号;
  3. 多个弱符号 → 选体积最大的那个(COMMON 的历史行为)。

第 3 条是历史包袱。两个文件都写 int counter;,链接器会把它们合并成一个 common 符号,而不是报错;如果两个文件对它的类型理解不同(一个 int、一个 double),运行时就会读到错误的值。GCC 10 起默认 -fno-common,把未初始化全局变量放进 .bss 并视为强符号,这类静默合并才变成链接错误。

显式弱符号的典型用途是「可覆盖的默认实现」:

/* weak.c:库提供弱默认值,应用可以强定义覆盖 */
#include <stdio.h>

int __attribute__((weak)) plugin_init(void)
{
    return -1;                 /* 没有插件时的默认行为 */
}

int main(void)
{
    if (plugin_init() == 0)
        puts("plugin: ready");
    else
        puts("plugin: absent");
    return 0;
}

单独编译 weak.c,程序打印 plugin: absent。若再链接一个定义了强符号 plugin_init 的 plugin.o,强定义获胜,打印 plugin: ready。若弱符号只有声明没有定义,链接器把它的地址解析为 0;所以调用前必须判断 if (plugin_init),否则跳到 0 地址。

静态库不是「一个文件」,是归档

libm.a 是 ar 归档,里面是一堆 .o。链接器扫描归档时不会把整个库装进去,只按需抽取成员:

  1. 从左到右扫描命令行;
  2. 遇到 .o 就加入;
  3. 遇到 .a,反复检查当前未定义符号,抽取能解决它们的成员,直到没有新成员被抽出;
  4. 扫描结束后仍有未定义符号 → 报错。

这解释了两个常见现象:

cc main.o -lfoo -lbar      # 如果 foo 依赖 bar,顺序正确
cc main.o -lbar -lfoo      # 如果 bar 的成员先被扫描,可能留下未定义
cc main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group   # 循环依赖时强制多轮

库的依赖顺序应当「被依赖者放右边」。--start-group/--end-group 让链接器反复扫描组内库,代价是更慢;--whole-archive 则强制装入所有成员,常用于注册表式插件。

关于符号解析的四个误解
  1. 「声明了就能链接」——声明只告诉编译器签名,定义必须来自某个 .o 或库。undefined reference 永远是「找不到定义」,不是「头文件没包含」。
  2. 「static 函数不能被外部调用,所以运行时也没有名字」——它只是不参与跨文件链接;调试信息里仍有名字,nm -a 也能看到(未 strip 时)。
  3. 「多个 int x; 一定报错」——在 -fcommon 的传统模式下会合并;GCC 10 起默认报错。判断依据是编译选项,不是语言标准。
  4. 「-lfoo 会搜索整个磁盘」——只在 -L 指定目录、默认库目录和 ld.so 缓存对应的开发库路径里找 libfoo.so/libfoo.a。

1.7.4 重定位:把地址填进指令

编译单个 .c 时,编译器不知道外部符号最终落在哪,也不知道本文件的 .text 会被放到输出文件的什么地址。它留下「占位 + 重定位条目」,由链接器填。

x86-64 用 ELF64_Rela,三个字段的布局是:

#include <stdint.h>

/* ELF64_Rela 的字段布局(名字换成 _view,避免与 <elf.h> 冲突) */
typedef struct {
    uint64_t r_offset;   /* 需要改写的位置(节内偏移或虚拟地址) */
    uint64_t r_info;     /* 符号下标 + 重定位类型 */
    int64_t  r_addend;   /* 加数 */
} rela_view;

常见类型:

类型计算式用在哪
R_X86_64_64S + A64 位绝对地址(指针表、.data 中的函数指针)
R_X86_64_PC32S + A - PPC 相对寻址(lea、全局变量访问)
R_X86_64_PLT32L + A - Pcall/jmp 到 PLT 桩
R_X86_64_32SS + A,检查符号扩展非 PIE 的 32 位绝对地址
R_X86_64_GOTPCRELGOT(S) + A - PPIC 代码经 GOT 取地址

S 是符号最终地址,P 是被改写位置的地址,A 是加数,L 是 PLT 桩地址。看一个真实的重定位:

cc -c -O2 -fno-pic call.c -o call.o
objdump -dr call.o | sed -n '/<main>:/,+8p'
readelf -r call.o

输出里 call 指令的字节是 e8 00 00 00 00,后面跟着 R_X86_64_PLT32 puts-0x4。e8 是 call rel32,五个零是占位;链接器把 rel32 改成 puts@plt - (P + 4)。减 4 是因为 P 指向重定位字段本身,而 CPU 计算目标时用「下一条指令」的地址,即 P + 4。

例 1.7.1 · 手算一次 PC 相对重定位

设 call 指令的 opcode 位于虚拟地址 0x401140,rel32 字段从 0x401141 开始,所以 P = 0x401141。puts@plt 的最终地址 S = 0x401030,条目里的加数 A = -4。则

rel32 = S + A - P = 0x401030 - 4 - 0x401141 = -0x115。

补码写作 0xfffffeeb,指令字节变成 e8 eb fe ff ff。CPU 执行完这条 5 字节指令后 %rip = 0x401145,目标 = 0x401145 - 0x115 = 0x401030,正好是 puts@plt。

若漏掉 A = -4,算出的 rel32 会多 4,落点偏到 PLT 桩中间。读重定位时先看 A,不要自己猜该减 4 还是减 5。

这个例子说明:重定位不是「填一个绝对地址」,而是按类型做一次带符号的算术。R_X86_64_32S 在地址超过 2 GiB 时会报 relocation truncated to fit,因为 32 位字段放不下;PIE 默认用 PC 相对和 GOT,正是为了避免这种绝对地址。

1.7.5 加载:从 execve 到 _start

内核的 execve 最终调用 load_elf_binary(fs/binfmt_elf.c)。它做这些事:

  1. 读 ELF 头,检查魔数、类(32/64 位)、字节序、机器类型;
  2. 读程序头,找到 PT_INTERP,把动态链接器映射进来;
  3. 对每个 PT_LOAD 段,用 mmap 建立映射,设置 R/W/X;文件尾部不足一页的部分清零;
  4. 对 .bss 对应的 p_memsz > p_filesz 部分,映射匿名零页;
  5. 把 argv、envp、辅助向量(auxv)复制到新栈顶;
  6. 设置 %rip 为入口(有解释器时是 ld.so 的入口),%rsp 指向新栈;
  7. 返回用户态。

/proc/<pid>/maps 能直接看到结果:

cat /proc/self/maps | sed -n '1,6p'
# 555555554000-555555555000 r--p 00000000 08:01 1234 /usr/bin/cat
# 555555555000-555555556000 r-xp 00001000 08:01 1234 /usr/bin/cat
# 555555556000-555555557000 r--p 00002000 08:01 1234 /usr/bin/cat
# 555555557000-555555558000 r--p 00002000 08:01 1234 /usr/bin/cat
# 555555558000-555555559000 rw-p 00003000 08:01 1234 /usr/bin/cat
# 7ffff7dc0000-7ffff7de8000 r--p 00000000 08:01 5678 /usr/lib/libc.so.6

同一个文件被映射成多个段,权限分别是 r--、r-x、r--、rw-。最后一段的 rw- 里既有 .data 也有 .bss;.bss 在文件里没有对应偏移,所以 Offset 与前一页相同,但 Size 只由内存大小决定。

PIE 的加载基址每次运行都不同(ASLR),非 PIE 的 ET_EXEC 固定加载到 0x400000。判断一个二进制是不是 PIE:

readelf -h ./prog | grep Type
# Type: DYN  → PIE(或共享库)
# Type: EXEC → 固定地址

ldd 显示依赖,但它会调用动态链接器,对不可信二进制不安全。更安全的做法是 objdump -p ./prog | grep NEEDED,只读文件不执行。

1.7.6 动态链接:ld.so、GOT 与 PLT

动态链接的可执行文件不包含 libc 的代码,只包含:

  • PT_INTERP:解释器路径;
  • .dynamic:依赖库、符号表位置、重定位表位置、初始化函数数组;
  • .dynsym/.dynstr:动态符号与名字;
  • .rela.dyn/.rela.plt:需要动态链接器处理的重定位;
  • .got/.got.plt:运行时填写的地址表。

readelf -d 看 .dynamic:

readelf -d ./prog | grep -E 'NEEDED|SONAME|RPATH|RUNPATH|BIND_NOW|FLAGS'

NEEDED 是直接依赖的 soname;RPATH/RUNPATH 是搜索路径;BIND_NOW 表示立即绑定。

搜索顺序

ld.so 找 libfoo.so 的顺序(简化,Linux 6.x):

  1. 若存在 DT_RPATH 且没有 DT_RUNPATH:DT_RPATH 目录;
  2. 环境变量 LD_LIBRARY_PATH(安全执行模式下忽略);
  3. DT_RUNPATH 目录;
  4. /etc/ld.so.cache(由 ldconfig 生成);
  5. 默认目录 /lib、/usr/lib(以及 lib64 变体)。

注意 DT_RPATH 与 DT_RUNPATH 的差别:RPATH 会作用于依赖的依赖(传递),RUNPATH 只作用于直接依赖。新链接器默认生成 RUNPATH,所以 -Wl,-rpath,$ORIGIN/../lib 对间接依赖不生效,需要给每个库都设置,或用 LD_LIBRARY_PATH。

延迟绑定:第一次调用才解析

PLT/GOT 的协作分两步:

  • 首次调用:puts@plt 的第一条指令 jmp *GOT[n] 跳到的不是 puts,而是 PLT 里的 push $index; jmp plt0;plt0 把 link_map 和重定位索引传给 ld.so 的解析器,解析器找到 puts 的真实地址,改写 GOT[n],再跳过去。
  • 后续调用:jmp *GOT[n] 直接到 puts,只剩一次间接跳转。

这就是延迟绑定(lazy binding)。它缩短启动时间,但让第一次调用的开销不可预测,也让 GOT 在运行时可写。安全加固通常用 -Wl,-z,now(立即绑定)加 -Wl,-z,relro(GOT 只读),把 GOT 在重定位后设为只读,减少劫持面。

cc -O2 -fPIE -pie -Wl,-z,relro,-z,now prog.c -o prog
readelf -l prog | grep GNU_RELRO
readelf -d prog | grep -E 'BIND_NOW|FLAGS'

运行时加载:dlopen

dlopen 让程序在运行时加载共享库,适合插件、驱动式扩展和按需加载:

/* dlopen.c:加载插件并调用约定符号;cc ... -ldl */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>

typedef int (*plugin_fn)(const char *);

int main(int argc, char **argv)
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s <lib.so> <symbol>\n", argv[0]);
        return 2;
    }

    void *h = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
    if (h == NULL) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    dlerror();                              /* 清除旧错误 */
    void *sym = dlsym(h, argv[2]);
    const char *err = dlerror();
    if (err != NULL) {
        fprintf(stderr, "dlsym: %s\n", err);
        dlclose(h);
        return 1;
    }

    plugin_fn fn = NULL;
    _Static_assert(sizeof(fn) == sizeof(sym), "function pointer size");
    memcpy(&fn, &sym, sizeof(fn));          /* 避免函数指针与 void* 的直接转换告警 */

    int rc = fn("from main");
    printf("plugin returned %d\n", rc);
    dlclose(h);
    return rc == 0 ? 0 : 1;
}

关键点:RTLD_NOW 在加载时解析全部未定义符号,错误立刻暴露;RTLD_LAZY 推迟到调用。RTLD_LOCAL 让库的符号不进入全局命名空间,避免不同插件互相覆盖;需要共享时用 RTLD_GLOBAL。dlclose 只是减少引用计数,不保证卸载。

dlsym 返回 void *,C 标准不保证它能无损转成函数指针。上面的 memcpy 是保守写法;POSIX 环境直接强转通常也能工作,但打开 -Wpedantic 会告警。

1.7.7 PIC、PIE 与安全加固

位置无关代码(position-independent code, PIC)的核心约束是:代码段里不出现需要链接期确定的绝对地址。这样同一份 .text 可以映射到任意基址,被多个进程共享。

编译器用两种手法实现:

  • PC 相对寻址:模块内的函数、静态变量用 lea sym(%rip), %reg,距离在链接时确定。
  • GOT 间接:跨模块的全局变量、外部函数地址放在 .got,代码只引用 GOT 表项相对自己的偏移。

-fPIC 生成共享库用的 PIC;-fPIE 生成可执行文件用的 PIE。两者都位置无关,但 PIE 可以利用「本可执行文件不会被其他模块插入同名符号」的假设做更多优化。链接时 -pie 生成 ET_DYN,-no-pie 生成 ET_EXEC。

选项组合产物基址典型用途
-fno-pic -no-pieET_EXEC固定 0x400000老式二进制、内核模块
-fPIE -pieET_DYN随机现代发行版默认
-fPIC -sharedET_DYN随机共享库
-fPIC -pieET_DYN随机少见,但合法

安全加固不止 PIE:

# 查看一个二进制的加固项(需安装 checksec)
checksec --file=./prog
# RELRO  STACK CANARY  NX  PIE  RPATH  RUNPATH  FORTIFY
  • NX:栈和堆不可执行,靠 PT_GNU_STACK 标记;
  • Stack Canary:-fstack-protector-strong,函数返回前检查哨兵;
  • RELRO:-Wl,-z,relro 把 GOT 中不需要延迟绑定的部分设为只读;-z now 让全部 GOT 只读(full RELRO);
  • FORTIFY:-D_FORTIFY_SOURCE=2 在编译期检查 memcpy/printf 等边界。
关于动态库的五个误解
  1. 「LD_LIBRARY_PATH 一定优先」——当库带 DT_RPATH 且没有 DT_RUNPATH 时,RPATH 先于环境变量;setuid 程序还会忽略 LD_LIBRARY_PATH。
  2. 「ldd 只是读文件」——ldd 会启动动态链接器,对恶意二进制可能执行代码;只读检查用 objdump -p。
  3. 「strip 后没有符号了」——删的是 .symtab;.dynsym 必须保留,否则动态链接失败。
  4. 「替换 .so 不需要重新链接」——只要 soname 和 ABI 兼容可以;改了结构体布局、删了导出符号,旧程序会在运行时崩溃或报 symbol lookup error。
  5. 「静态链接的程序完全不依赖系统」——仍依赖内核 ABI、/etc/nsswitch.conf 等运行时配置;glibc 的 NSS、dlopen、locale 在纯静态下行为受限。

1.7.8 库打桩:在链接期和运行期换实现

「打桩」(interposition)指用自己的实现替换库函数,常用于统计、注入故障、兼容层和安全审计。三种时机:

时机手段影响范围典型场景
编译期#define malloc my_malloc、--wrap单个构建单元测试替换依赖
链接期-Wl,--wrap=malloc单个可执行文件统计某函数的调用
加载期LD_PRELOAD=./hook.so该进程及其子进程全局观测、故障注入

LD_PRELOAD

LD_PRELOAD 让指定共享库先于其他库被加载,同名符号优先使用它。下面这个库统计 malloc 调用次数:

/* count_malloc.c:cc -shared -fPIC -O2 count_malloc.c -o count_malloc.so -ldl */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dlfcn.h>
#include <stddef.h>
#include <stdio.h>
#include <unistd.h>

static void *(*real_malloc)(size_t);
static unsigned long long calls;

static void resolve(void)
{
    real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
    if (real_malloc == NULL)
        _exit(127);                 /* 解析失败,直接退出,避免递归 */
}

void *malloc(size_t size)
{
    if (real_malloc == NULL)
        resolve();
    calls++;
    return real_malloc(size);
}

__attribute__((destructor)) static void report(void)
{
    char buf[64];
    int n = snprintf(buf, sizeof(buf), "malloc calls: %llu\n", calls);
    if (n > 0)
        (void)write(STDERR_FILENO, buf, (size_t)n);
}

编译并在任意程序上试用:

cc -shared -fPIC -O2 count_malloc.c -o count_malloc.so -ldl
LD_PRELOAD=./count_malloc.so /bin/ls >/dev/null

RTLD_NEXT 是关键:它在「当前库之后的搜索顺序」里找 malloc,也就是真正的 libc 实现。若写成 dlsym(RTLD_DEFAULT, "malloc"),会找到自己,造成无限递归。

这个实现有三个真实缺陷,正好说明打桩的边界:calls++ 不是原子操作,多线程会丢计数;dlsym 自身可能调用 malloc,首次解析有递归风险;snprintf 在析构函数里也可能分配内存。生产级的统计库会用线程局部计数、__libc_malloc 或 malloc 的弱别名绕开这些问题。

链接期 –wrap

--wrap=symbol 把对 symbol 的引用改写成 __wrap_symbol,同时提供 __real_symbol 指向原实现:

/* wrap.c:cc -Wl,--wrap=malloc wrap.c */
#include <stdio.h>
#include <stdlib.h>

void *__real_malloc(size_t);

void *__wrap_malloc(size_t size)
{
    void *p = __real_malloc(size);
    fprintf(stderr, "wrap malloc(%zu) = %p\n", size, p);
    return p;
}

int main(void)
{
    char *p = malloc(32);
    free(p);
    return 0;
}

--wrap 只影响当前链接里对该符号的未定义引用,不影响共享库内部已经绑定的调用;它比 LD_PRELOAD 可控,但需要重新链接。

观测工具的分工

工具看什么典型命令
file文件类型、架构、是否动态链接file ./prog
readelfELF 头、节、段、符号、重定位、动态段readelf -S/-l/-s/-r/-d
objdump反汇编、重定位混排、动态符号表objdump -dr、objdump -T
nm符号与地址(含未定义)nm -C --defined-only
ldd依赖解析结果(会执行 loader)ldd ./prog
ldconfig -p缓存中的库与路径ldconfig -p | grep libssl
LD_DEBUG加载/绑定过程的详细日志LD_DEBUG=libs,bindings ./prog

调试「运行时找不到库」时,按这个顺序取证:

  1. readelf -d 确认 NEEDED 的 soname;
  2. objdump -p 确认 RPATH/RUNPATH;
  3. LD_DEBUG=libs ./prog 看搜索了哪些目录;
  4. ldconfig -p 确认缓存里有没有;
  5. 最后才怀疑 LD_LIBRARY_PATH 或权限。

1.7.9 ELF 文件布局:逐字段读一个目标文件

这一节回答:readelf -h 打印的那些字段在文件里到底位于哪个偏移?节头、符号、重定位项各自是什么结构?把结构体摊开,objdump/readelf 的输出就不再是「工具魔法」。

ELF64 有两种「视图」:链接视图(节 section,给链接器用)与执行视图(段 segment,给加载器用)。目标文件(.o)只有节,可执行文件两者都有。

ELF 头(Elf64_Ehdr,64 字节)

字段偏移大小含义
e_ident[16]0167f 45 4c 46、类别(1=32/2=64)、字节序(1=小端)、版本、OSABI
e_type1621=REL(.o)、2=EXEC、3=DYN(共享库/PIE)、4=CORE
e_machine18262=x86-64、183=AArch64、243=RISC-V
e_version204固定 1
e_entry248入口地址(可执行文件)
e_phoff328程序头表偏移
e_shoff408节头表偏移
e_flags484架构相关标志
e_ehsize522ELF 头大小(64)
e_phentsize / e_phnum542/2程序头项大小与数量
e_shentsize / e_shnum582/2节头项大小(64)与数量
e_shstrndx622节名字符串表所在的节索引

一个 x86-64 目标文件的头部十六进制是:

00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00
00000010  01 00 3e 00 01 00 00 00  00 00 00 00 00 00 00 00
00000020  00 00 00 00 00 00 00 00  28 02 00 00 00 00 00 00

逐段读:7f 45 4c 46 是魔数;02 表示 64 位;01 表示小端;01 是 ELF 版本;01 00 是 ET_REL;3e 00 是 62(x86-64);28 02 … 是 e_shoff = 0x228。这些值与 1.7.12 的程序输出一致。

节头(Elf64_Shdr,64 字节)

字段偏移含义
sh_name0节名在 .shstrtab 中的偏移
sh_type41=PROGBITS、2=SYMTAB、3=STRTAB、4=RELA、8=NOBITS、9=REL、11=DYNSYM
sh_flags81=WRITE、2=ALLOC、4=EXEC、0x20=STRINGS、0x400=TLS
sh_addr16运行时的虚拟地址(.o 中为 0)
sh_offset24文件内偏移
sh_size32字节数
sh_link40关联节索引(SYMTAB→STRTAB、RELA→SYMTAB)
sh_info44附加信息(重定位节=目标节)
sh_addralign48对齐
sh_entsize56表项大小(符号 24、Rela 24)

sh_link 是读符号表的关键:符号的名字不在符号项里,而是 st_name 偏移 + sh_link 指向的字符串表。

符号(Elf64_Sym,24 字节)

字段大小含义
st_name4名字在字符串表中的偏移
st_info1高 4 位绑定(0=LOCAL、1=GLOBAL、2=WEAK),低 4 位类型(1=OBJECT、2=FUNC、3=SECTION、4=FILE、5=COMMON、6=TLS)
st_other1可见性(0=DEFAULT、2=HIDDEN、3=PROTECTED)
st_shndx2所属节索引;0=UNDEF、0xfff1=ABS、0xfff2=COMMON
st_value8节内偏移或地址
st_size8对象大小

重定位(Elf64_Rela,24 字节)

字段大小含义
r_offset8要修改的位置(节内偏移或虚拟地址)
r_info8高 32 位符号索引,低 32 位类型
r_addend8加数(REL 格式没有这一项,加数在指令里)

程序头(Elf64_Phdr,56 字节)描述段:p_type(1=LOAD、2=DYNAMIC、3=INTERP、6=PHDR、7=TLS、0x6474e551=GNU_STACK、0x6474e552=GNU_RELRO)、p_flags(1=X、2=W、4=R)、p_offset、p_vaddr、p_filesz、p_memsz、p_align。加载器只关心段,链接器主要关心节。

为什么 .bss 不占文件空间

.bss 的类型是 SHT_NOBITS,sh_offset 指向文件里一个位置但没有实际字节;加载器按 p_memsz > p_filesz 的差值分配零页。所以「全局未初始化数组」不会让二进制变大,但会占内存。-fno-common(GCC 10 默认)把 tentative definition 也放进 .bss,而不是 COMMON 符号。

1.7.10 符号解析:强、弱、COMMON 与归档顺序

这一节回答:两个文件定义了同名函数,链接器选谁?静态库里的符号为什么有时「找不到」?答案在符号的绑定类型与链接器的扫描顺序。

强符号与弱符号

定义方式绑定冲突时
int f(void) { … }强(GLOBAL)多个强定义 → 链接错误
int x = 1;强(GLOBAL)同上
__attribute__((weak)) int f(void) { … }弱(WEAK)强定义存在时用强,否则用弱
__attribute__((weak)) int x;弱同上
int x;(无初始化,-fcommon)COMMON多个 COMMON 取最大大小,合并为一块

规则可以概括为三句话:

  1. 不允许两个强符号同名。
  2. 强 + 弱 → 选强,弱定义被忽略。
  3. 弱 + 弱 → 选任意一个(通常是先出现的),不报错。

未解析的弱符号不会导致链接失败,而是取地址 0(或在 GOT 里填 0);这也是「弱符号做可选钩子」的原理:

/* 库代码:若使用者定义了 my_hook 就调用,否则跳过 */
extern void my_hook(void) __attribute__((weak));
if (my_hook) my_hook();

归档(.a)的扫描顺序

静态库是 .o 的归档。链接器从左到右扫描命令行,只把「能解决当前未定义符号」的成员拉进来;已经被拉进来的成员不会再扫描。于是:

$ cc main.o -lfoo -lbar      # 顺序有意义
$ cc main.o -lbar -lfoo      # 若 foo 依赖 bar,可能失败

循环依赖(foo 依赖 bar,bar 又依赖 foo)用分组解决:

$ cc main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group

其他相关选项:

选项作用
--whole-archive把归档里所有成员都链进来(用于注册表/插件)
--as-needed只保留实际用到的共享库依赖(发行版默认)
--no-as-needed强制保留(某些插件的 DT_NEEDED 需要)
--gc-sections删除未引用的节(配合 -ffunction-sections)

符号版本与可见性

glibc 用符号版本区分 ABI:memcpy@@GLIBC_2.14 是默认版本,memcpy@GLIBC_2.2.5 是旧版本。readelf -s 能看到 @ 后缀;dlvsym 可以按版本取符号。

可见性控制导出范围:

__attribute__((visibility("hidden"))) void internal(void);

配合 -fvisibility=hidden,共享库只导出显式标记的符号,减少 PLT/GOT 项与符号冲突。C++ 的 -fvisibility-inlines-hidden 同理。

静态链接与 --gc-sections

-static 把 libc 也链进来,产物不依赖 ld.so,但体积大、无法享受库的安全更新。--gc-sections 需要编译时 -ffunction-sections -fdata-sections,把每个函数/变量放进独立节,链接器才能按引用关系删除。嵌入式与 initramfs 常用这套组合。

关于符号解析的四个误解
  1. 「弱符号就是『可以被覆盖』」——只有强定义能覆盖弱定义;两个弱定义谁赢取决于链接顺序,不可依赖。
  2. 「-lfoo 放在哪里都一样」——静态库按命令行顺序扫描,被依赖的库要放在依赖者右边。
  3. 「extern int x; 是定义」——它是声明;int x; 在 -fcommon 下是 COMMON 定义,在 -fno-common 下是 BSS 强定义。
  4. 「static 只是文件内可见」——它还让符号变成 LOCAL,不进入动态符号表,链接器可以做更多优化(内联、删除)。

1.7.11 重定位:把地址填进指令

这一节回答:目标文件里的 call 为什么目标地址是 0?链接器改写了哪几个字节?公式里的 S、A、P 分别是什么?

记号:

  • S:符号的最终地址(或 PLT 项地址 L)。
  • A:加数(Rela 的 r_addend)。
  • P:被修改字段的地址(r_offset 对应的运行时地址)。
  • B:加载基址(PIE/共享库)。

常见类型

类型计算用在哪
R_X86_64_64S + A64 位绝对地址(指针表、.quad sym)
R_X86_64_PC32S + A - PRIP 相对数据访问、mov sym(%rip)
R_X86_64_PLT32L + A - Pcall func(经 PLT)
R_X86_64_32 / 32SS + A(截断 32 位)非 PIE 的 32 位绝对地址
R_X86_64_GOTPCRELG + GOT + A - Pmov sym@GOTPCREL(%rip)
R_X86_64_GLOB_DATSGOT 中的数据符号
R_X86_64_JUMP_SLOTSGOT 中的函数(延迟绑定)
R_X86_64_RELATIVEB + APIE 内部的绝对地址
R_X86_64_COPY复制 S 的内容可执行文件引用共享库的数据对象
R_X86_64_TLSGD/TPOFFTLS 偏移__thread 变量

手算一次 call

目标文件里 call 的编码是 e8 00 00 00 00,位移字段位于 P = 0x0a3,重定位项为 R_X86_64_PLT32 vsum - 4。链接后:

rel32 = S + A - P = vsum + (-4) - 0xa3 = vsum - 0xa7

0xa7 正是下一条指令的地址(P + 4)。CPU 执行 call 时把 rel32 加到 %rip(此时 %rip 已指向下一条),所以最终跳转地址是 vsum。加数 -4 就是在补偿「P 是字段地址,而 CPU 用下一条指令地址」这个差值。

手算一次 RIP 相对数据访问

movsd .LC0(%rip), %xmm0 的位移字段在 P=0x93,重定位 R_X86_64_PC32 .LC0 - 4:

disp32 = .LC0 - 4 - 0x93 = .LC0 - 0x97

0x97 是下一条指令地址。链接器把 disp32 填进 4 个字节,运行时 %rip + disp32 正好指向常量。

静态链接与动态链接的重定位差异

阶段谁处理典型类型何时完成
编译/汇编汇编器生成占位符 + 重定位项编译期
静态链接ld/lldPC32、PLT32、64链接期
加载时ld.soRELATIVE、GLOB_DAT、COPYexecve 后、main 前
首次调用ld.so(延迟绑定)JUMP_SLOT第一次经过 PLT

-z now(或 LD_BIND_NOW=1)把所有 JUMP_SLOT 在加载时解析完,牺牲启动时间换运行期确定性;安全加固(RELRO + BIND_NOW)让 GOT 在启动后只读,防止 GOT 覆盖攻击。

读一条 `readelf -r` 记录
Offset          Info           Type           Sym. Value    Sym. Name + Addend
0000000000a3  000a00000004 R_X86_64_PLT32    0000000000000000 vsum - 4

Offset=0xa3 是 P;Info 的高 32 位是符号索引 10,低 32 位是类型 4;vsum - 4 是 S + A 中的符号与加数。链接器把 S - 4 - 0xa3 写成 4 字节小端,objdump -d 就能看到解析后的 call 目标。

1.7.12 完整程序:不依赖 libelf 解析 ELF

readelf 很好用,但它把细节藏起来了。这个程序用 pread/fread 直接读文件,按 1.7.9 的结构体布局解析 ELF 头、节头与符号表。它不链接 libelf,也不依赖 <elf.h>,因此在任何平台都能编译——你可以用它读交叉编译出来的目标文件。

/* elfdump.c — 不依赖 libelf,直接按文件布局解析 ELF64
 * 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 elfdump.c
 * 运行:./elfdump demo.o
 */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

typedef struct __attribute__((packed)) {
    unsigned char e_ident[16];
    uint16_t e_type, e_machine;
    uint32_t e_version;
    uint64_t e_entry, e_phoff, e_shoff;
    uint32_t e_flags;
    uint16_t e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx;
} Ehdr;

typedef struct __attribute__((packed)) {
    uint32_t sh_name, sh_type;
    uint64_t sh_flags, sh_addr, sh_offset, sh_size;
    uint32_t sh_link, sh_info;
    uint64_t sh_addralign, sh_entsize;
} Shdr;

typedef struct __attribute__((packed)) {
    uint32_t st_name;
    unsigned char st_info, st_other;
    uint16_t st_shndx;
    uint64_t st_value, st_size;
} Sym;

static const char *sh_type_name(uint32_t t)
{
    switch (t) {
    case 1: return "PROGBITS";
    case 2: return "SYMTAB";
    case 3: return "STRTAB";
    case 4: return "RELA";
    case 8: return "NOBITS";
    case 9: return "REL";
    case 11: return "DYNSYM";
    default: return "OTHER";
    }
}

static const char *sym_type_name(unsigned t)
{
    switch (t) {
    case 0: return "NOTYPE";
    case 1: return "OBJECT";
    case 2: return "FUNC";
    case 3: return "SECTION";
    case 4: return "FILE";
    case 5: return "COMMON";
    case 6: return "TLS";
    default: return "?";
    }
}

int main(int argc, char **argv)
{
    if (argc != 2) { fprintf(stderr, "usage: %s <elf64>\n", argv[0]); return 2; }
    FILE *f = fopen(argv[1], "rb");
    if (!f) { perror("fopen"); return 1; }
    if (fseek(f, 0, SEEK_END) != 0) { perror("fseek"); return 1; }
    long size = ftell(f);
    rewind(f);
    unsigned char *buf = malloc((size_t)size);
    if (!buf) { perror("malloc"); return 1; }
    if (fread(buf, 1, (size_t)size, f) != (size_t)size) { perror("fread"); return 1; }
    fclose(f);

    if (size < (long)sizeof(Ehdr) || memcmp(buf, "\177ELF", 4) != 0) {
        fprintf(stderr, "%s: not an ELF file\n", argv[1]);
        return 1;
    }
    const Ehdr *eh = (const Ehdr *)buf;
    printf("ELF%d type=%u machine=%u entry=0x%llx shoff=0x%llx shnum=%u shstrndx=%u\n",
           buf[4] == 2 ? 64 : 32, eh->e_type, eh->e_machine,
           (unsigned long long)eh->e_entry, (unsigned long long)eh->e_shoff,
           eh->e_shnum, eh->e_shstrndx);

    const Shdr *sh = (const Shdr *)(buf + eh->e_shoff);
    const Shdr *shstr = &sh[eh->e_shstrndx];
    const char *strtab = (const char *)(buf + shstr->sh_offset);

    printf("\n[节头] idx name                 type      addr             off      size\n");
    for (int i = 0; i < eh->e_shnum; i++) {
        const char *name = strtab + sh[i].sh_name;
        printf("      %2d %-20s %-9s 0x%012llx 0x%06llx %6llu\n", i, name,
               sh_type_name(sh[i].sh_type), (unsigned long long)sh[i].sh_addr,
               (unsigned long long)sh[i].sh_offset, (unsigned long long)sh[i].sh_size);
    }

    for (int i = 0; i < eh->e_shnum; i++) {
        if (sh[i].sh_type != 2 && sh[i].sh_type != 11) continue;   /* SYMTAB / DYNSYM */
        const Sym *sym = (const Sym *)(buf + sh[i].sh_offset);
        size_t n = sh[i].sh_size / sizeof(Sym);
        const char *names = (const char *)(buf + sh[sh[i].sh_link].sh_offset);
        printf("\n[符号表 %s] %zu 项(前 20 项)\n", strtab + sh[i].sh_name, n);
        for (size_t k = 1; k < n && k <= 20; k++) {
            const char *nm = names + sym[k].st_name;
            if (!*nm) nm = "(null)";
            printf("  %-24s value=0x%08llx size=%5llu bind=%u type=%s shndx=%u\n",
                   nm, (unsigned long long)sym[k].st_value,
                   (unsigned long long)sym[k].st_size, sym[k].st_info >> 4,
                   sym_type_name(sym[k].st_info & 0xf), sym[k].st_shndx);
        }
    }
    free(buf);
    return 0;
}

对一个交叉编译的 demo.o(x86-64 ELF)运行:

$ ./elfdump demo.o
ELF64 type=1 machine=62 entry=0x0 shoff=0x228 shnum=11 shstrndx=1

[节头] idx name                 type      addr             off      size
       0                      OTHER     0x000000000000 0x000000      0
       1 .strtab              STRTAB    0x000000000000 0x0001c0     99
       2 .text                PROGBITS  0x000000000000 0x000040     26
       3 .rela.text           RELA      0x000000000000 0x000160     48
       4 .data                PROGBITS  0x000000000000 0x00005c      4
       5 .comment             PROGBITS  0x000000000000 0x000060     48
       6 .note.GNU-stack      PROGBITS  0x000000000000 0x000090      0
       7 .eh_frame            OTHER     0x000000000000 0x000090     64
       8 .rela.eh_frame       RELA      0x000000000000 0x000190     48
       9 .llvm_addrsig        OTHER     0x000000000000 0x0001c0      0
      10 .symtab              SYMTAB    0x000000000000 0x0000d0    144

[符号表 .symtab] 6 项(前 20 项)
  -                        value=0x00000000 size=    0 bind=0 type=FILE shndx=65521
  (null)                   value=0x00000000 size=    0 bind=0 type=SECTION shndx=2
  add                      value=0x00000000 size=   14 bind=1 type=FUNC shndx=2
  g                        value=0x00000000 size=    4 bind=1 type=OBJECT shndx=4
  main                     value=0x00000010 size=   10 bind=1 type=FUNC shndx=2

对照 1.7.9 的表读:

  • e_shoff=0x228、e_shnum=11:节头表在文件 0x228,共 11 项,每项 64 字节。
  • .text 在 0x40,26 字节;add 的 st_value=0、st_size=14,main 在偏移 0x10。目标文件里 st_value 是节内偏移,不是虚拟地址。
  • .rela.text 有 48 字节 = 2 项 × 24 字节,正好对应 1.7.11 的两条重定位(call 与 RIP 相对访问)。
  • .symtab 的 sh_link=1 指向 .strtab,st_name 是那张表的偏移。
  • shndx=65521(0xfff1)是 SHN_ABS,65522(0xfff2)是 SHN_COMMON;FILE 符号的名字是编译器的输入名(这里是 -)。

这个程序只处理小端 ELF64;大端或 ELF32 需要换解析函数。真实工具(readelf、llvm-readobj)还要处理压缩节、SHT_GROUP、.gnu.hash、扩展节号(e_shnum=0 时用 section 0 的 sh_size)等边界。理解主干后,这些都能按同样的「读结构体、按偏移跳转」方式加。

如果给它一个非 ELF 文件,程序会在检查魔数后退出:

$ ./elfdump /bin/ls.dylib
/bin/ls.dylib: not an ELF file
为什么 `readelf -S` 的节序与文件偏移不按大小排

节头表按链接器写入的顺序排列,.text 通常在文件前部,.symtab/.strtab 在后部;节索引只是数组下标,不代表地址顺序。用 readelf -S 时看 Offset 列,不要假设「索引越大越靠后」。

1.7.13 完整程序:LD_PRELOAD 与 --wrap 打桩

链接期打桩有两种典型方式:动态链接期用 LD_PRELOAD 抢先加载一个共享库,静态链接期用 --wrap 把引用改到自己的函数。下面两个程序分别演示。

LD_PRELOAD:统计净分配次数

/* malloc_count.c — LD_PRELOAD 统计净分配次数
 * 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -fPIC -shared \
 *          malloc_count.c -o libcount.so -ldl
 * 运行:LD_PRELOAD=./libcount.so ls
 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dlfcn.h>
#include <stddef.h>
#include <unistd.h>

static void *(*real_malloc)(size_t);
static void (*real_free)(void *);
static unsigned long live;
static int resolving;

static void write_num(int fd, const char *tag, unsigned long v)
{
    char buf[64];
    int n = 0;
    while (tag[n]) { buf[n] = tag[n]; n++; }
    char tmp[24];
    int k = 0;
    do { tmp[k++] = (char)('0' + v % 10); v /= 10; } while (v);
    while (k) buf[n++] = tmp[--k];
    buf[n++] = '\n';
    ssize_t ignored = write(fd, buf, (size_t)n);
    (void)ignored;
}

void *malloc(size_t n)
{
    if (!real_malloc && !resolving) {
        resolving = 1;
        real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
        resolving = 0;
    }
    void *p = real_malloc(n);
    if (p && !resolving) live++;
    return p;
}

void free(void *p)
{
    if (!real_free && !resolving) {
        resolving = 1;
        real_free = (void (*)(void *))dlsym(RTLD_NEXT, "free");
        resolving = 0;
    }
    if (p && !resolving) live--;
    real_free(p);
}

__attribute__((destructor)) static void report(void)
{
    write_num(2, "malloc_count: live allocations = ", live);
}

编译与运行:

$ cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -fPIC -shared \
     malloc_count.c -o libcount.so -ldl
$ LD_PRELOAD=./libcount.so ls > /dev/null
malloc_count: live allocations = 0
$ LD_PRELOAD=./libcount.so cat /etc/hostname > /dev/null
malloc_count: live allocations = 0

「净分配为 0」说明程序在退出前把申请的内存都释放了;若某个库泄漏,这个数会大于 0。真实工具(valgrind --tool=memcheck、heaptrack)用同样的机制跟踪每次分配,只是记录更多信息。

关键点:

  • dlsym(RTLD_NEXT, "malloc") 取「下一个」malloc,也就是 libc 的实现;直接调用 malloc 会递归到自己。
  • resolving 防递归:dlsym 内部可能调用 malloc,第一次解析时要避免再次进入钩子。
  • __attribute__((destructor)) 在进程退出前执行,适合输出统计;但不要在里面用 printf(可能分配内存),用 write。
  • 只能拦截动态链接的调用。静态链接、直接 syscall(SYS_brk)、或经 __libc_malloc 内部调用的路径不会被拦截。

--wrap:链接期替换

/* wrap.c — cc -O2 wrap.c -Wl,--wrap=puts -o wrap */
#include <stdio.h>

int __real_puts(const char *s);

int __wrap_puts(const char *s)
{
    fputs("[wrap] ", stdout);
    return __real_puts(s);
}

int main(void)
{
    puts("hello");
    return 0;
}
$ cc -O2 wrap.c -Wl,--wrap=puts -o wrap
$ ./wrap
[wrap] hello

--wrap=puts 让链接器把所有未定义引用 puts 改写成 __wrap_puts,同时把 __real_puts 解析为原来的 puts。它只影响链接到该可执行文件的引用;共享库内部的调用、或同文件内被内联的调用不受影响。常用来在测试中替换 malloc、time、open 等外部依赖,而不改源码。

手段生效阶段作用范围典型用途
LD_PRELOAD加载期整个进程(含依赖库)性能分析、故障注入、兼容层
--wrap链接期当前链接单元单元测试打桩
#define/函数指针编译期源码级可测试性设计
ptrace/eBPF运行期内核/用户态观测、热补丁
打桩的四个边界
  1. 静态链接的程序不吃 LD_PRELOAD——没有动态符号表与 ld.so,预加载不会发生。
  2. dlsym 本身可能分配内存——钩子里要先设防递归标志,否则栈溢出。
  3. --wrap 不影响已内联的调用——-O2 把 puts 内联后,链接器看不到引用;测试打桩要用 -fno-builtin 或 -O0。
  4. glibc 2.34+ 把 dlopen/dlsym 并入 libc——-ldl 仍可加,但不再是必需;老系统需要它。

1.7.14 Rust 与链接:同一套 ELF,不同的名字

Rust 编译产物就是普通 ELF,链接器、readelf、LD_PRELOAD 都照常工作。差异在符号命名与代码生成:

主题CRust
符号名与函数名相同默认经过 v0 mangling(_RNv...)
导出 C ABI天然#[no_mangle] pub extern "C"
结构体布局按 ABI默认可重排,FFI 要 #[repr(C)]
静态库.a--crate-type staticlib(含 Rust 运行时)
动态库.so--crate-type cdylib
链接时优化-flto-C lto=fat,跨 crate 内联
未使用代码--gc-sections默认按可达性删除,#[used] 保留
符号可见性-fvisibility=hidden默认隐藏,#[no_mangle] 才导出

把 Rust 代码放进指定节:

#![allow(unused)]
fn main() {
#[used]
#[link_section = ".rodata.mydata"]
static BLOB: [u8; 4] = *b"RUST";
}

#[used] 防止编译器删除,link_section 让链接脚本或 C 代码能按节名找到它。内核模块、eBPF、嵌入式固件常用这招把元数据放进固定节。

Rust 的 panic 默认展开(unwind),跨 extern "C" 边界会 abort;需要 C++ 异常兼容时用 -C panic=abort 或 catch_unwind 在边界内处理。nm -C 能还原 Rust 的 v0 mangling,perf 也支持 demangle;发布库建议只导出 #[no_mangle] 的薄封装,把 mangled 符号藏在 pub(crate) 后面。

1.7.15 实验:读一个真实可执行文件的链接信息

$ cc -O2 -g -o hello hello.c
$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, dynamically linked, ...
$ readelf -h hello | grep -E 'Type|Machine|Entry'
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x1050
$ readelf -d hello | grep -E 'NEEDED|RPATH|RUNPATH'
 0x0000000000000001 (NEEDED)   Shared library: [libc.so.6]
$ ldd hello
    linux-vdso.so.1 (0x00007ffd...)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
    /lib64/ld-linux-x86-64.so.2 (0x00007f...)

Type: DYN 说明它是 PIE:入口是相对地址,加载时由内核选基址(ASLR)。readelf -r 会看到大量 R_X86_64_RELATIVE;-no-pie 编译则变成 EXEC,入口是固定地址,RELATIVE 消失。

符号与重定位:

$ nm -C hello | grep ' T '
0000000000001139 T main
$ nm -D hello | head          # 动态符号表
                 U puts@GLIBC_2.2.5
$ objdump -d --section=.plt hello | head -20
0000000000001020 <puts@plt>:
    1020: ff 25 e2 2f 00 00     jmp    QWORD PTR [rip+0x2fe2]
    1026: 68 00 00 00 00        push   0x0
    102b: e9 e0 ff ff ff        jmp    1010 <_init+0x10>

PLT 项的三条指令就是 1.7.6 讲的「懒绑定」:第一次 jmp 跳到 GOT 里的 resolver 地址,push 索引,jmp 到公共解析器;解析后 GOT 被改成 puts 的真实地址,后续直接跳转。LD_BIND_NOW=1 ./hello 会在启动时全部解析,LD_DEBUG=bindings 能打印每次绑定。

最后看加载器找了哪些库:

$ LD_DEBUG=libs ./hello 2>&1 | grep 'calling init\|find library'
$ LD_PRELOAD=./libcount.so ./hello

LD_DEBUG 的输出会列出搜索路径、DT_NEEDED、初始化顺序;LD_PRELOAD 则把 libcount.so 插到 libc.so.6 之前。两者都是 ld.so 的行为,不需要重新链接。

1.7.16 十二条链接陷阱

#现象原因修复
1undefined reference to 'foo'声明有、定义没链进来,或库顺序不对把提供 foo 的库放在引用者右边
2多个 multiple definition两个强符号同名改 static/弱符号/合并定义
3静态库里的符号找不到归档只按需拉取,且只扫一遍--start-group/--whole-archive 或调整顺序
4C++ 调用 C 函数链接失败C++ 名字修饰头文件加 extern "C"
5LD_PRELOAD 不生效程序静态链接,或调用被内联动态链接;-fno-builtin;改用 --wrap
6dlsym(RTLD_NEXT) 返回 NULL符号被 -fvisibility=hidden 隐藏,或版本不匹配导出符号;用 dlvsym
7PIE 下绝对地址报错-fPIE 代码里用了非 PIC 的绝对重定位用 -fPIC 编译,或 -no-pie
8运行时报 cannot open shared objectRUNPATH 未包含库路径-Wl,-rpath,'$ORIGIN',或设 LD_LIBRARY_PATH
9--wrap 没替换成功调用被内联,或目标是共享库内部调用关闭内联;改在链接单元外调用
10Rust 符号在 nm 里找不到默认 v0 mangling 且可能被 LTO 删除#[no_mangle] + #[used],或 nm -C
11升级 glibc 后旧二进制跑不了符号版本要求高于系统在旧系统构建,或静态链接(慎用)
12-Wl,--as-needed 导致插件加载失败依赖被判定「未使用」而丢弃--no-as-needed 或显式 dlopen
三个关于链接的误解
  1. 「链接就是拼文件」——还要做符号解析、重定位、节合并、垃圾回收、PLT/GOT 生成;每一步都可能改变语义。
  2. 「动态库更新一定兼容」——ABI 兼容要求结构体布局、符号版本、调用约定都不变;加字段、改 enum 大小都可能破坏。
  3. 「-static 更稳定」——静态链接绕过了库更新,但也拿不到安全修复,且 glibc 静态链接在 NSS/DNS 上有已知限制。

1.7.17 本章出口检查

检查项达标线没达标去哪
说出链接器四步能区分符号解析与重定位1.7.1
读 readelf -S/-l能指出 .text/.data/.bss 与对应段1.7.2
解释 multiple definition能说出强/弱符号规则1.7.3
手算 PC 相对重定位会代 S + A - P 并处理 A=-41.7.4
解释 PIE 与 ASLR能说出 ET_DYN 与随机基址的关系1.7.5
解释 PLT 首次调用能画出 GOT 从 resolver 到真实地址的变化1.7.6
用 LD_PRELOAD 替换函数能写 RTLD_NEXT 并说明递归风险1.7.8
即时自测 · 1.7

Q1 nm hello.o 显示 U printf,但链接 hello.o 时不加 -lc 会报什么?为什么加了 -lc 就能过?

Q2 两个文件都定义 int counter;,GCC 13 默认编译后链接报 multiple definition,而 GCC 9 不报。解释差异,并给出恢复旧行为的选项。

Q3 一个共享库的 DT_RPATH=/opt/lib,同时进程设了 LD_LIBRARY_PATH=/tmp/lib。ld.so 先搜哪个?如果这个程序是 setuid 的呢?

Q4 call puts@plt 第一次执行时,%rip 最终跳到哪?GOT 表项在这前后有什么变化?

答案

A1 报 undefined reference to 'printf'。hello.o 里只有对 printf 的未定义引用和重定位条目,没有实现;-lc 把 libc.so 加入链接,链接器在其中找到定义并完成重定位。C 标准库函数不是编译器凭空生成的。

A2 GCC 10 起默认 -fno-common,未初始化全局变量进入 .bss 并算强符号,两个强定义冲突。GCC 9 及更早默认 -fcommon,两个 counter 被合并为一个 common 符号。用 -fcommon 可恢复旧行为,但正确做法是把定义放进一个 .c,其他文件用 extern 声明。

A3 若该库只有 DT_RPATH 而没有 DT_RUNPATH,先搜 /opt/lib,再搜 LD_LIBRARY_PATH。若是 setuid 程序,进入安全执行模式,LD_LIBRARY_PATH 被忽略,RPATH 仍可能生效(取决于加载器与权限),所以不要把可写目录放进 RPATH。

A4 第一次执行先跳到 PLT 桩的 push index; jmp plt0,由 ld.so 解析出 puts 的真实地址;解析器把该地址写进对应的 GOT 表项,再跳过去。之后同一调用点的 jmp *GOT[n] 直接到 puts,不再进解析器。

母本对应

母本章节本书落点程度
C(CSAPP)第 7 章 链接(7.1–7.14)1.7.1–1.7.8完整:编译驱动、ELF、符号、重定位、加载、动态链接、PIC、打桩
C(CSAPP)第 7 章 7.14 处理目标文件的工具1.7.8改写为取证顺序与工具分工表
K(Kerrisk)第 41 章 共享库基础1.7.5–1.7.6精选:soname、搜索路径、ldconfig
K(Kerrisk)第 42 章 共享库高级特性1.7.6–1.7.8精选:dlopen、符号可见性、LD_PRELOAD
G(鸟哥)第 21 章 软件安装:源代码与 Tarball1.7.1、1.7.8背景:./configure && make 中的链接步骤

CSAPP 第 7 章小节清单

CSAPP 小节页码状态本书落点
7.1 编译器驱动程序501合并1.7.1
7.2 静态链接502已覆盖1.7.1、1.7.3
7.3 目标文件502已覆盖1.7.2
7.4 可重定位目标文件503已覆盖1.7.9(Ehdr/Shdr/Sym/Rela 逐字段)
7.5 符号和符号表504已覆盖1.7.2、1.7.9
7.6 符号解析506已覆盖1.7.3、1.7.10(强/弱/COMMON/归档顺序)
7.7 重定位514已覆盖1.7.4、1.7.11(PC32/PLT32 手算)
7.8 可执行目标文件519合并1.7.5、1.7.15
7.9 加载可执行目标文件520已覆盖1.7.5
7.10 动态链接共享库521已覆盖1.7.6
7.11 从应用程序中加载和链接共享库523合并1.7.6(dlopen/dlsym)
7.12 位置无关代码525已覆盖1.7.7
7.13 库打桩机制532已覆盖1.7.8、1.7.13(LD_PRELOAD、--wrap)
7.14 处理目标文件的工具532已覆盖1.7.15(readelf/nm/objdump/ldd)
7.15 小结532合并1.7.16

下一站:链接好的程序被 execve 装入后,从哪里开始执行?一个除零、一次缺页、一个 Ctrl-C,在内核里分别走哪条路径?进入 1.8 异常控制流,看 CPU 与内核如何交接控制权。