【程序员自我修养】第八章:动态链接的实现——ld.so 怎么自举 + GOT/PLT 代码长什么样

如果你写过 -fPIC -shared 的共享库,一定遇到过这个灵异现象:把 -fPIC 去掉,编译过、链接过,运行就段错误
同样一段 mov rax, [rip+xxx],加了 -fPIC 没事,不加就崩。
罪魁祸首不是汇编,不是编译器,是 动态链接器 在背后对你做的两件事:GOT 重定位 + 共享库装载位置不确定。
这一章,我们钻进 ld.so 肚子里,看它怎么自举、怎么重定位、怎么延迟绑定。


目录

    1. 写在前面:ld.so 是谁?它为什么这么关键?
    1. 动态链接器 ld.so 的自举(Bootstrap)
    1. 动态链接的五步标准流程
    1. 共享库的全局符号介入(Symbol Interposition)
    1. 地址无关代码 PIC 的三种访问模式
    1. 延迟绑定(PLT)—— 只在第一次调用时解析
    1. 动态链接 vs 静态链接 —— 全面对比
    1. 动手实验:objdump / readelf / gdb 全程跟踪
    1. 实战:自己实现一个 dlopen 小例子
    1. 优缺点与适用场景
    1. 思考题 & 行动建议
  • 📚 系列导航

0. 写在前面:ld.so 是谁?

动态链接器(Dynamic Linker / Dynamic Loader) 在 Linux 上是 /lib64/ld-linux-x86-64.so.2(glibc 系统),在 musl 系统上是 /lib/ld-musl-x86-64.so.1。它本身是一个共享库,但它装载别人之前自己先被装载

它做这些事:

阶段任务关键数据结构
自举把 ld.so 自己的 .text/.data/.bss 加载进内存内核辅助
装载把可执行文件 + 所有 DT_NEEDED 共享库 mmap 进进程空间link_map 链表
重定位处理可执行文件与共享库的所有 R_* 重定位项.rela.dyn / .rela.plt
解析把 GOT/PLT 中待解析的桩函数解析为真实地址.dynsym + .dynstr
初始化调用 .init 段、.init_array、preinit_array 中的构造函数ELF section

关键洞察:ld.so 是整个 ELF 生态的”操作系统级胶水”。它必须在任何 libc 符号可用之前就能工作(想想 dlopen 也要用 mmap!),这就是为什么它有”自举”这一步。


1. 动态链接器 ld.so 的自举(Bootstrap)

1.1 鸡生蛋问题

可执行文件 a.out 的 INTERP 段写着:

1
2
$ readelf -l a.out | grep -A1 INTERP
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

内核看到 INTERP 后会做:

  1. a.out 的 PT_LOAD 段 mmap 进来。
  2. 把 ld.so 自己的 ELF 段 mmap 进来(不依赖 a.out)。
  3. 跳到 ld.so 的入口点(不是 _start!是一个特殊的 _dl_start)。

但 ld.so 本身也是 PIC,它的 .got 还没人填过。这怎么破?

1.2 自举的三个阶段

graph TB
    K["🟦 内核 ELF Loader<br/>识别 INTERP"] --> A["🟪 阶段 1: 自举 mmap<br/>ld.so 段映射<br/>PC 相对寻址生效"]
    A --> B["🟧 阶段 2: 修复 ld.so 自身 GOT<br/>只有相对重定位<br/>绝对地址靠 _dl_start 参数"]
    B --> C["🟩 阶段 3: 接管动态链接<br/>_dl_start_user<br/>处理可执行文件依赖"]

    style K fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style A fill:#E8D5F5,stroke:#CE93D8,color:#333
    style B fill:#FFDAB9,stroke:#FFAB76,color:#333
    style C fill:#B5EAD7,stroke:#80CBC4,color:#333

1.3 _dl_start 与 _dl_start_user

glibc 的 rtld.c 里有这样两个符号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// glibc/elf/rtld.c(简化)
extern ElfW(Addr) _dl_start (void *arg);
DL_FIXUP_VALUE_TYPE
attribute_hidden __attribute ((noinline)) ARCH_FIXUP_ATTRIBUTE
_dl_start (void *arg)
{
// 1. 用 arg 拿到 ld.so 自己的 link_map(内核传入)
// 2. 处理 ld.so 自己的相对重定位(PC-relative + base + offset)
// 3. 调用 _dl_start_user 接管后续流程
...
}

static ElfW(Addr) __attribute_used__
_dl_start_user (void *arg)
{
// 1. 拿到可执行文件的 link_map
// 2. dl_main(main_arena, ...) → 真正干活的函数
// 3. 跳到可执行文件的 _start
}

注意_dl_start 阶段是用栈参数和相对寻址完成工作的,不依赖任何 GOT!这就是自举的精髓。

1.4 内核传给 ld.so 的”神秘结构”

内核在跳到 ld.so 入口点时,栈顶是这样一个结构(来自 fs/binfmt_elf.c):

1
2
3
4
5
6
7
8
9
// 栈顶结构(高地址 → 低地址)
struct {
char pad[16]; // 对齐
void *auxv; // 实际是 AT_PHDR 等
void *entry; // 可执行文件入口
void *ld_base; // ld.so 自己的 load base(关键!)
char *platform; // "x86_64"
int dl_load_bias; // 装载偏差
};

关键点_dl_start 通过 pop 指令(栈参数)拿到这些值,全程不依赖 GOT

1.5 自举代码反汇编(x86_64)

1
2
3
4
5
6
7
8
9
# glibc 编译出的 _dl_start 开头
00007ffff7fd50a0 <_dl_start>:
7ffff7fd50a0: 48 89 e5 mov %rsp,%rbp
7ffff7fd50a3: 48 8b 75 08 mov 0x8(%rbp),%rsi # rsi = ld.so 自己的 link_map*
7ffff7fd50a7: 48 8b 45 10 mov 0x10(%rbp),%rax # rax = 可执行文件入口
7ffff7fd50ab: 48 89 c7 mov %rax,%rdi # 准备传参
7ffff7fd50ae: 48 c1 ef 20 shr $0x20,%rdi # 拆高位
... # 处理自身相对重定位(_dl_relocate_ld_so)
7ffff7fd50e0: e8 0b 00 00 00 call 7ffff7fd50f0 # 调 _dl_start_user

注意call 7ffff7fd50f0PC 相对调用e8 0b 00 00 00),不依赖 GOT。这就是 PIC 的魔法。

1.6 自举阶段 ld.so 自身重定位类型

ld.so 自身的重定位只允许相对寻址,绝对寻址要在自举后由 _dl_relocate_ld_so 手工修复:

重定位类型是否允许处理时机
R_X86_64_RELATIVE自举时(base + addend)
R_X86_64_64必须避免,ld.so 自身代码不应有这种
R_X86_64_GLOB_DAT不能引用外部符号(鸡生蛋)
R_X86_64_JUMP_SLOT同上

2. 动态链接的五步标准流程

_dl_start_user_dl_initdl_main 干这些事:

flowchart TD
    S(["🚀 _dl_start_user"]) --> S1["1️⃣ 启动动态链接器<br/>栈参数→寄存器<br/>修复 ld.so 自身"]
    S1 --> S2["2️⃣ 装载所有共享对象<br/>DT_NEEDED 广度优先<br/>mmap + 构造 link_map"]
    S2 --> S3["3️⃣ 符号解析 + 重定位<br/>扫描 .dynsym<br/>填 .rela.dyn / .rela.plt"]
    S3 --> S4["4️⃣ 初始化<br/>.init_array 倒序<br/>preinit_array 顺序<br/>.init 段"]
    S4 --> S5["5️⃣ 移交控制权<br/>_dl_start_user 返回<br/>跳到可执行 _start"]

    style S fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style S1 fill:#E8D5F5,stroke:#CE93D8,color:#333
    style S2 fill:#FFDAB9,stroke:#FFAB76,color:#333
    style S3 fill:#FFF9C4,stroke:#F9A825,color:#333
    style S4 fill:#B5EAD7,stroke:#80CBC4,color:#333
    style S5 fill:#FFB3C6,stroke:#F48FB1,color:#333

2.1 步骤 1:启动动态链接器

已完成(第 1 节)。_dl_start 修复 ld.so 自身的 R_X86_64_RELATIVE,调用 _dl_start_user

2.2 步骤 2:装载所有需要的共享对象

关键数据结构:link_map

1
2
3
4
5
6
7
8
9
10
11
12
13
// include/link.h(glibc)
struct link_map {
ElfW(Addr) l_addr; // 装载基址(实际地址 - 虚拟地址)
char *l_name; // 绝对路径
ElfW(Dyn) *l_ld; // 动态段指针
struct link_map *l_next, *l_prev; // 全局链表
struct link_map *l_real; // 符号查找的"真身"
Lmid_t l_ns; // 命名空间
struct r_scope_elem l_searchlist; // 符号搜索范围
struct r_found_version *l_vers;
ElfW(Addr) l_entry; // 入口点
...
};

装载算法:广度优先 + 引用计数

graph LR
    EXEC["🟦 可执行文件<br/>a.out"] --> A["🟧 libA.so"]
    EXEC --> B["🟧 libB.so"]
    A --> C["🟩 libC.so"]
    B --> C

    style EXEC fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style A fill:#FFDAB9,stroke:#FFAB76,color:#333
    style B fill:#FFDAB9,stroke:#FFAB76,color:#333
    style C fill:#B5EAD7,stroke:#80CBC4,color:#333

C 被 A 和 B 都引用,但只装载一次,引用计数 +2。

实际加载逻辑(glibc 代码简化):

1
2
3
4
5
6
7
8
9
10
11
12
// elf/dl-load.c
struct link_map *
_dl_map_object_from_fd (const char *name, int fd, ...)
{
// 1. 读 ELF header
ElfW(Ehdr) *ehdr = mmap(NULL, fsize, PROT_READ, MAP_PRIVATE, fd, 0);
// 2. 找 PT_LOAD 段,计算 mmap 地址
// 3. 对每个 PT_LOAD 段调 mmap
// 4. 解析 dynamic 段
// 5. 分配 link_map,挂到全局链表
return new_link_map;
}

2.3 步骤 3:重定位(运行时)

重定位是动态链接的核心。每个可执行文件 / 共享库都有自己的 .rela.dyn 和 .rela.plt。

2.3.1 区分两种重定位表

段名内容处理时机
.rela.dyn数据引用(GLOB_DAT、RELATIVE、COPY、IFUNC)装载时(Eager)
.rela.plt函数引用(JUMP_SLOT)第一次调用时(Lazy)

2.3.2 重定位类型全表

类型含义公式
R_X86_64_6464 位绝对地址S + A
R_X86_64_PC32PC 相对 32 位S + A - P
R_X86_64_GLOB_DAT动态符号S(写入 GOT)
R_X86_64_JUMP_SLOTPLT 函数桩S(写入 GOT)
R_X86_64_RELATIVEbase 相对B + A
R_X86_64_IRELATIVEIFUNC 解析ifunc_resolver(B + A)
R_X86_64_COPY数据拷贝复制到可执行文件 .bss
R_X86_64_TPOFF64TLSS - TLS 模块基址

2.3.3 实际看一个 .rela.dyn 条目

1
2
3
4
5
6
7
$ readelf -r libfoo.so
Relocation section '.rela.dyn' at offset 0x4b0 contains 4 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0x000000003df0 000000000008 R_X86_64_RELATIVE 1130
0x000000003df8 000200000006 R_X86_64_GLOB_DAT 0x00000000001200 my_var + 0
0x000000003e00 000300000006 R_X86_64_GLOB_DAT 0x00000000001250 counter + 0
0x000000003e08 000a00000007 R_X86_64_JUMP_SLOT 0x00000000001100 bar

解释

Offset含义
0x3df0.got 中的位置,填入 base + 0x1130(RELATIVE)
0x3df8.got 中的位置,填入 my_var 符号的运行时地址(GLOB_DAT)
0x3e00同上,counter
0x3e08.got.plt 中的位置,bar 函数桩

2.3.4 重定位执行代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// glibc/elf/dl-runtime.c
DL_FIXUP_VALUE_TYPE
attribute_hidden __attribute ((noinline)) ARCH_FIXUP_ATTRIBUTE
_dl_fixup (struct link_map *l, ElfW(Word) reloc_arg)
{
// 1. 拿 PLTREL 重定位项指针
const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_arg);
// 2. 拿符号表 + 符号索引
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
// 3. 拿符号名字符串
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
const char *name = strtab + sym->st_name;
// 4. 查全局符号表
lookup_t result = _dl_lookup_symbol_x (name, l, &sym, ...);
// 5. 算最终地址
value = DL_FIXUP_MAKE_VALUE (result, sym ? (LOOKUP_VALUE_ADDRESS(result) + sym->st_value) : 0);
// 6. 写回 GOT
return elf_machine_fixup_plt (l, result, reloc, value);
}

2.4 步骤 4:解析符号

关键Symbol Lookup 是动态链接中最慢的一步(哈希 + 字符串比较)。

sequenceDiagram
    participant U as 🟦 可执行文件
    participant L as 🟧 共享库
    participant S as 🟪 符号哈希表

    U->>L: 需要调用 func()
    L->>S: 查 .dynsym + .hash / .gnu.hash
    S-->>L: 找到符号项
    L->>L: 计算实际地址 = base + sym.st_value
    L-->>U: 返回地址写入 GOT

2.4.1 符号哈希表(.gnu.hash)

新版 ELF 默认用 .gnu.hash,比传统 .hash 快 50%

1
2
3
$ readelf -d libfoo.so | grep HASH
0x000000000000001a (GNU_HASH) 0x3c0
0x0000000000000004 (HASH) 0x458

2.4.2 符号查找过程

1
2
3
4
5
6
7
8
9
10
11
12
13
// glibc/elf/dl-lookup.c
const ElfW(Sym) *
_dl_lookup_symbol_x (const char *undef_name, struct link_map *undef_map,
const ElfW(Sym) **ref, ...)
{
// 1. 在 undef_map 的作用域内搜索
for (each l in search_list) {
sym = _dl_lookup_elf_hash (l, undef_name); // 查 .gnu.hash
if (sym) return sym;
}
// 2. 全局回退
return _dl_lookup_elf_hash (_dl_main_map, undef_name);
}

2.4.3 符号作用域表

作用域含义
GLOBAL默认,跨所有装载的对象查找
LOCAL仅在当前对象内
WEAK同名强符号优先,否则用弱符号
INTERPOSEELF 符号介入(见第 3 节)

2.5 步骤 5:初始化

初始化顺序

flowchart TD
    A["preinit_array"] --> B["可执行文件 .init"]
    B --> C["共享库 .init_array 倒序<br/>(依赖图反序)"]
    C --> D["共享库 .init"]
    D --> E["可执行文件 .init_array"]
    E --> F["main()"]

    style A fill:#FFF9C4,stroke:#F9A825,color:#333
    style B fill:#FFDAB9,stroke:#FFAB76,color:#333
    style C fill:#E8D5F5,stroke:#CE93D8,color:#333
    style D fill:#B5EAD7,stroke:#80CBC4,color:#333
    style E fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style F fill:#FFB3C6,stroke:#F48FB1,color:#333

实际看 .init_array

1
2
3
4
5
6
7
8
9
10
$ readelf -d libfoo.so | grep -i init
0x0000000000000019 (INIT_ARRAY) 0x3e0
0x000000000000001b (INIT_ARRAYSZ) 16 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3f0
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)

$ objdump -s -j .init_array libfoo.so
Contents of section .init_array:
3e0 30110000 00000000 40110000 00000000
# 两个函数指针:0x1130 和 0x1140

调 .init_array 的代码(glibc):

1
2
3
4
5
6
7
8
9
10
11
// glibc/elf/dl-init.c
void
_dl_init (struct link_map *main_map, int argc, char **argv, char **env)
{
// 1. 倒序遍历依赖图,所有 DT_INIT_ARRAY 都跑一遍
for (i = main_map->l_info[DT_INIT_ARRAYSZ]->d_un.d_val / sizeof(void*); i-- > 0; )
((init_t) main_map->l_info[DT_INIT_ARRAY]->d_un.d_ptr + i) ();
// 2. 调 DT_INIT(.init 段)
if (main_map->l_info[DT_INIT])
DL_CALL_DT_INIT(main_map, main_map->l_addr + main_map->l_info[DT_INIT]->d_un.d_ptr, ...);
}

3. 共享库的全局符号介入(Symbol Interposition)

3.1 什么是介入

Interposition = 同一个符号在多个共享库中出现,最左边的可执行文件 / 最早装载的库 胜出。

1
2
3
// lib_a.c
int x = 1;
int foo() { return x; }
1
2
// lib_b.c
int x = 2; // ⚠️ 同名全局变量

如果可执行文件同时链接 lib_a 和 lib_b,foo() 看到的 x 取决于谁先装载

3.2 RTLD_GLOBAL 的作用

1
2
3
// main.c
void *h1 = dlopen("lib_a.so", RTLD_GLOBAL); // 让 lib_a 的符号全局可见
void *h2 = dlopen("lib_b.so", RTLD_GLOBAL);
标志行为
RTLD_LOCAL(默认)库内符号不参与全局解析
RTLD_GLOBAL库内符号进入全局命名空间

3.3 解析顺序规则

graph TB
    MAIN["🟦 main 可执行文件"] --> S1["🟧 搜索范围 1<br/>(DT_NEEDED 链)"]
    S1 --> S2["🟨 搜索范围 2<br/>(RTLD_GLOBAL dlopen)"]
    S2 --> S3["🟩 全局回退<br/>(_dl_main_map)"]

    style MAIN fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style S1 fill:#FFDAB9,stroke:#FFAB76,color:#333
    style S2 fill:#FFF9C4,stroke:#F9A825,color:#333
    style S3 fill:#B5EAD7,stroke:#80CBC4,color:#333

3.3.1 顺序详情

优先级位置备注
1可执行文件最高
2DT_NEEDED 链(按 ld 顺序)链接时 gcc -lA -lB
3RTLD_GLOBAL 链(按 dlopen 顺序)运行时
4默认搜索路径的库/lib, /usr/lib

3.3.2 实际看 ld.so 怎么排

1
2
3
$ LD_DEBUG=bindings ./a.out
binding file ./liba.so [0] to ./libb.so [0]: normal symbol `malloc'
binding file ./liba.so [0] to ./a.out [0]: normal symbol `my_func' # 优先

3.4 符号遮蔽(Symbol Shadowing)案例

1
2
3
4
5
6
7
8
9
10
11
12
// shadow_demo.c
#include <stdio.h>
#include <dlfcn.h>

int my_count = 100; // 强势符号

int main() {
void *h = dlopen("./libshadow.so", RTLD_LAZY);
int (*get_count)() = dlsym(h, "get_count");
printf("count = %d\n", get_count()); // 可能是 100,也可能是 50
return 0;
}
1
2
3
// libshadow.c
int my_count = 50; // 同名
int get_count() { return my_count; }
1
2
3
4
$ gcc shadow_demo.c -ldl -o shadow_demo
$ gcc -shared -fPIC libshadow.c -o libshadow.so
$ LD_DEBUG=bindings ./shadow_demo
# 可执行文件的 my_count 会"遮蔽" 共享库的 my_count

3.5 -Bsymbolic 与 -fvisibility

编译选项效果适用场景
-Bsymbolic优先用本模块符号,不导出内部库
-fvisibility=hidden默认隐藏,需 __attribute__((visibility("default")))大型项目
-Bsymbolic-functions只对函数同上
1
2
3
# 只导出 foo,不导出 foo_helper
$ gcc -shared -fPIC -fvisibility=hidden \
-Wl,--version-script=export.map lib.c -o lib.so
1
2
// export.map
{ global: foo; local: *; };

3.6 介入的坑:libc 函数被替换

1
2
// 你的库覆盖了 malloc
void *malloc(size_t sz) { return NULL; } // 💥 极差

这就是为什么大型项目都用 -fvisibility=hidden:把符号藏起来,避免被 dlopen 的库意外介入。


4. 地址无关代码 PIC 的实现细节

4.1 为什么要 PIC

核心问题:共享库的代码段(.text)在每个进程里装载位置不同(ASLR)。如果代码里有 mov rax, [0x404050] 这样的硬编码地址,每个进程的地址都不一样,要么得修改代码(破坏共享性),要么改不动(运行错)。

PIC 的目标:让 .text 段在所有进程中完全相同(可以共享物理页),通过间接寻址(GOT)解决地址问题。

4.2 四种访问模式

graph TB
    P["🟦 共享库 .text 段"] --> A["1️⃣ 模块内函数调用<br/>call func<br/>PC 相对 + 偏移"]
    P --> B["2️⃣ 模块外函数调用<br/>call func@PLT<br/>跳到 GOT"]
    P --> C["3️⃣ 模块内数据访问<br/>mov [rip+offset]<br/>PC 相对"]
    P --> D["4️⃣ 模块外数据访问<br/>mov rax, [rip+GOT]<br/>GOT 间接"]

    style P fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style A fill:#B5EAD7,stroke:#80CBC4,color:#333
    style B fill:#FFDAB9,stroke:#FFAB76,color:#333
    style C fill:#E8D5F5,stroke:#CE93D8,color:#333
    style D fill:#FFB3C6,stroke:#F48FB1,color:#333

4.3 模式 1:模块内函数调用

汇编

1
2
# -fPIC 编译:PC 相对调用
call foo # 编码为 e8 XX XX XX XX,相对偏移

反汇编对比

1
2
3
4
5
6
7
8
9
# 不带 -fPIC(位置相关代码,PIC 关闭)
$ objdump -d libfoo.so
0000000000001156 <bar>:
1156: e8 e5 fe ff ff call 1040 <foo> # 偏移 = 0x1040 - 0x115b

# 带 -fPIC(位置无关代码)
$ objdump -d libfoo.so
0000000000001156 <bar>:
1156: e8 e5 fe ff ff call 1040 <foo> # 编码完全一样!

关键:PC 相对调用的编码是相对偏移与装载基址无关。这俩版本 .text 段字节完全相同,可被多个进程共享。

4.4 模式 2:模块外函数调用(GOT + PLT)

汇编

1
2
# -fPIC 编译
call printf@PLT # 跳到 PLT 桩

PLT 桩长这样(稍后详细讲):

1
2
3
4
0000000000001040 <printf@plt>:
1040: ff 25 92 2f 00 00 jmp *0x2f92(%rip) # 跳到 GOT[0]
1046: 68 00 00 00 00 push $0x0
104b: e9 90 ff ff ff jmp 1030 <.plt> # 跳到 PLT[0] → 解析器

GOT 表(.got.plt):

1
2
3
4
5
6
$ objdump -R libfoo.so
R_X86_64_JUMP_SLOT 0x3fd8 printf + 0

# GOT 实际内容(运行时)
$ readelf -x .got.plt libfoo.so
0x3fd8: 00 00 00 00 00 00 00 00 # 第一次调用前,指向 PLT 下一条

4.5 模式 3:模块内数据访问

C 源码

1
2
static int my_var = 42;  // 模块内全局变量
void access() { my_var++; }

汇编

1
mov eax, [rip+my_var@GOTPCREL]  # 编译时算偏移

反汇编

1
2
3
4
0000000000001169 <access>:
1169: 8b 05 c1 2e 00 00 mov 0x2ec1(%rip),%eax # 4030 <my_var>
116f: 83 c0 01 add $0x1,%eax
1172: 89 05 b8 2e 00 00 mov %eax,0x2eb8(%rip) # 4030 <my_var>

rip 指向当前指令的下一条,偏移 = 0x4030 - 0x116f = 0x2ec1。装载基址变了,但相对偏移永远不变

4.6 模式 4:模块外数据访问(GOT)

C 源码

1
2
3
// foo.c
extern int my_var; // 定义在 libbar.so
void use() { my_var++; }

汇编

1
2
mov rax, [rip+my_var@GOTPCREL]  # 跳到 GOT
add dword ptr [rax], 1

重定位项

1
2
$ readelf -r libfoo.so | grep my_var
0x000000004030 000800000006 R_X86_64_GLOB_DAT 0x0000000000004020 my_var + 0

流程

sequenceDiagram
    participant C as 🟦 foo.c
    participant GOT as 🟧 libfoo.so 的 .got
    participant BAR as 🟩 libbar.so

    C->>GOT: mov rax, [rip+0x2ec1]
    Note over GOT: GOT[my_var] 已被 ld.so<br/>填为 libbar 中 my_var 真实地址
    GOT-->>C: rax = 真实地址
    C->>BAR: add [rax], 1

4.7 函数指针表(PIC 中的”vtable”)

C++ 虚函数、glib 的 GTypeModule 等都用函数指针表,本质就是 模式 4

1
2
3
4
5
// 编译后类似
struct VTable {
void (*init)(void);
void (*destroy)(void);
};

每一项都是 *.got 中的一个槽位,装载时由 ld.so 填入真实地址。

4.8 非 PIC 共享库会怎样

1
2
3
4
5
# 编一个非 PIC 的共享库
$ gcc -shared foo.c -o libfoo.so # 没用 -fPIC
$ gcc -Wl,-rpath,. main.c -L. -lfoo -o main
$ ./main
# 大概率段错误,或者"recompile with -fPIC"警告

为什么

维度PIC 共享库非 PIC 共享库
文本段可被多个进程共享同一物理页每个进程独占一份(ASLR 后位置不同)
性能一次加载,永久受益每次进程启动重新 mmap
大库内存友好内存翻倍(每进程一份)
限制aarch64/ARM 强制要求 PIC(硬件上)
编译较慢较快

aarch64 上不写 -fPIC 直接报错error: code model 'small' not allow in a shared object

4.9 PIC 的性能开销

访问类型PIC 开销非 PIC
模块内函数0(PC 相对)0
模块内数据0(PC 相对)0
模块外函数1 次 GOT 加载直接调
模块外数据2 次内存访问(GOT + 目标)1 次

实际开销 < 1%,现代 CPU 缓存友好。


5. 延迟绑定(PLT)—— 只在第一次调用时解析

5.1 为什么要延迟

如果一个程序有 1000 个外部函数引用,但只有 200 个会被真的调用,剩下 800 个的解析就是浪费。延迟绑定(Lazy Binding) 让未调用的函数不付出解析代价

5.2 .plt 段布局

graph TB
    P0["🟦 PLT[0]<br/>.plt 段首<br/>_dl_runtime_resolve 入口"] --> P1["🟧 PLT[1]<br/>printf@plt"]
    P0 --> P2["🟧 PLT[2]<br/>malloc@plt"]
    P0 --> P3["🟧 PLT[3]<br/>free@plt"]
    P0 --> PN["🟧 PLT[N]<br/>更多函数..."]

    P1 -.jmp *GOT.-> G1["GOT[printf]"]
    P2 -.jmp *GOT.-> G2["GOT[malloc]"]

    style P0 fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style P1 fill:#FFDAB9,stroke:#FFAB76,color:#333
    style P2 fill:#FFDAB9,stroke:#FFAB76,color:#333
    style P3 fill:#FFDAB9,stroke:#FFAB76,color:#333
    style PN fill:#FFDAB9,stroke:#FFAB76,color:#333
    style G1 fill:#B5EAD7,stroke:#80CBC4,color:#333
    style G2 fill:#B5EAD7,stroke:#80CBC4,color:#333

5.3 实际看 .plt 反汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ objdump -d -j .plt libfoo.so

Disassembly of section .plt:

0000000000001030 <.plt>:
1030: ff 35 92 2f 00 00 push *0x2f92(%rip) # 3fc8 <_GLOBAL_OFFSET_TABLE_+0x8>
1036: ff 25 94 2f 00 00 jmp *0x2f94(%rip) # 3fd0 <_GLOBAL_OFFSET_TABLE_+0x10>
103c: 0f 1f 40 00 nop

0000000000001040 <printf@plt>:
1040: ff 25 92 2f 00 00 jmp *0x2f92(%rip) # 3fd8 <printf@GLIBC_2.2.5> # GOT[printf]
1046: 68 00 00 00 00 push $0x0 # reloc_arg = 0
104b: e9 e0 ff ff ff jmp 1030 <.plt> # 进 PLT[0]

0000000000001050 <malloc@plt>:
1050: ff 25 82 2f 00 00 jmp *0x2f82(%rip) # 3fd8 <malloc@GLIBC_2.2.5> # ⚠️ 注意:这里错了,下面解释
1056: 68 01 00 00 00 push $0x1
105b: e9 d0 ff ff ff jmp 1030 <.plt>

修正:上例为了简洁。实际 malloc@plt 跳到的是 GOT[malloc],不是 GOT[printf]。这里只是想说明每个 PLT 桩有相同的结构。

5.4 .plt.got 段

另一种 PLT 形式(用于 IFUNC 或 -z now 时):

1
2
3
4
5
$ objdump -d -j .plt.got libfoo.so

0000000000001070 <__cxa_finalize@plt>:
1070: ff 25 81 2f 00 00 jmp *0x2f81(%rip) # 3ff8
1076: 66 90 xchg %ax,%ax

特点.plt.got 中的桩没有 push/jmp 链,因为对应的 GOT 项在装载时就被解析了(EAGER)。这种用于:

场景原因
IFUNC 函数需要在装载时调用解析器选 CPU 优化版本
-z now 编译强制 EAGER
关键符号程序启动就需要

5.5 PLT 调用链时序图

第一次调用(Lazy):

sequenceDiagram
    participant U as 🟦 用户代码
    participant PLT as 🟧 PLT 桩
    participant GOT as 🟪 .got.plt
    participant LR as 🟩 _dl_runtime_resolve

    U->>PLT: call printf@plt
    PLT->>GOT: jmp *GOT[printf]
    Note over GOT: GOT[printf] = PLT+6<br/>(指向 push $0x0)
    GOT-->>PLT: 跳回 PLT 桩第二条
    PLT->>PLT: push $0x0 (reloc_arg)
    PLT->>PLT: jmp PLT[0]
    PLT->>LR: push GOT[1] (link_map*)
    LR->>LR: pop link_map → rdi
    LR->>LR: call _dl_fixup(link_map, reloc_arg)
    LR->>LR: 算 printf 真实地址
    LR->>GOT: 写回 GOT[printf] = 真实地址
    LR-->>U: jmp 真实 printf

第二次调用(Fast Path):

sequenceDiagram
    participant U as 🟦 用户代码
    participant PLT as 🟧 PLT 桩
    participant GOT as 🟪 .got.plt

    U->>PLT: call printf@plt
    PLT->>GOT: jmp *GOT[printf]
    Note over GOT: GOT[printf] = 真实地址
    GOT-->>U: 直接跳到 printf

性能差:第一次 ~1µs(符号解析+重定位),第二次 ~2ns(一次间接跳转)。

5.6 _dl_runtime_resolve 完整调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
printf@plt
└─ jmp *GOT[printf] (如果未解析,回到 plt+6)
└─ push $reloc_arg
└─ jmp plt[0]
└─ push GOT[1] (link_map*)
└─ jmp _dl_runtime_resolve
└─ _dl_runtime_resolve_xsavec
└─ _dl_fixup
├─ _dl_lookup_symbol_x
│ └─ _dl_lookup_elf_hash (.gnu.hash)
└─ elf_machine_fixup_plt
└─ *GOT = 真实地址
└─ jmp 真实函数

汇编细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# glibc 编译出的 _dl_runtime_resolve_xsavec
00007ffff7fe0960 <_dl_runtime_resolve_xsavec>:
7ffff7fe0960: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp # 对齐栈
7ffff7fe0964: 48 89 1c 24 mov %rbx,(%rsp)
7ffff7fe0968: 48 89 6c 24 08 mov %rbp,0x8(%rsp)
7ffff7fe096c: 4c 89 64 24 10 mov %r12,0x10(%rsp)
7ffff7fe0970: 4c 89 6c 24 18 mov %r13,0x18(%rsp)
7ffff7fe0974: 4c 89 74 24 20 mov %r14,0x20(%rsp)
7ffff7fe0978: 4c 89 7c 24 28 mov %r15,0x28(%rsp)
7ffff7fe097c: 48 8b 1d 8d 30 12 00 mov 0x12308d(%rip),%rbx # GOT[1] link_map
7ffff7fe0983: 4c 8b 05 86 30 12 00 mov 0x123086(%rip),%r12 # GOT[2] _dl_fixup
7ffff7fe098a: 48 8b 74 24 30 mov 0x30(%rsp),%rsi # reloc_arg
7ffff7fe098f: 4c 89 e7 mov %r12,%rdi
7ffff7fe0992: 41 ff 14 24 call *(%r12) # 调 _dl_fixup
7ffff7fe0996: 49 89 c4 mov %rax,%r12 # 真实地址
... # 恢复寄存器
7ffff7fe09a0: 41 ff e4 jmp *%r12 # 跳到真实函数

5.7 强制 EAGER 解析

1
2
3
4
5
6
7
8
9
10
# 1. 编译时
$ gcc -Wl,-z,now main.c -o main
# 生成可执行文件后所有符号都 EAGER 解析

# 2. 环境变量
$ LD_BIND_NOW=1 ./main
# 运行时强制 EAGER

# 3. dlopen 时
dlopen("libfoo.so", RTLD_NOW); // 不推荐,丧失 lazy 优势

EAGER vs LAZY 对比

维度LAZYEAGER
启动时间快(少解析)慢(解析全部)
运行时间首调慢,后续快全程快
失败时机第一次调用启动时
适合启动慢敏感关键服务
调试难(崩溃栈可能跳过 PLT)易(启动即报错)

6. 动态链接 vs 静态链接 —— 全面对比

6.1 链接特征对比

维度静态链接动态链接
链接时机编译时装载时 + 第一次调用
输出文件大小大(嵌入所有 .o)小(仅 .so 引用)
升级库重新链接替换 .so 即可
内存占用每进程一份多进程共享
启动时间快(已链接好)慢(要解析符号)
运行性能无 PLT 开销首次调用有 PLT 开销
部署单文件可执行需带一堆 .so
ABI 兼容性无(重新编译)需维护 soname

6.2 性能基准(典型数字)

操作静态动态(首次)动态(命中缓存后)
启动时间50ms200ms50ms
函数调用2ns1µs(首)2ns
内存占用10MB/进程10MB 共享+少量每进程
磁盘占用10MB1MB + 9MB .so1MB + 9MB .so

冷启动 vs 热启动/etc/ld.so.cache 命中后,动态链接几乎和静态一样快。

6.3 调试特征对比

调试任务静态动态
符号解析失败链接期运行期(首调)
ABI 破坏重新编译段错误
调试栈完整可能被 PLT 切断(用 -z now + -rdynamic
gdb breakpointset fooset -qualified foo

6.4 部署特征对比

部署方式静态动态
Docker 镜像需 FROM scratch + 静态二进制需 FROM debian + 共享 .so
glibc 兼容性无(链接期固定)受系统 glibc 版本约束
musl 替代用 musl-gcc用 musl ld.so
容器大小单文件 ~10MB镜像 ~200MB

7. 动手实验:objdump / readelf / gdb 全程跟踪

7.1 准备一个测试程序

libfoo.c

1
2
3
4
5
6
7
8
9
10
11
12
// libfoo.c
#include <stdio.h>

int my_global = 42;

void foo() {
printf("foo called, my_global = %d\n", my_global);
}

int add(int a, int b) {
return a + b;
}

main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// main.c
#include <stdio.h>
#include <dlfcn.h>

extern int my_global;
extern int add(int, int);

int main() {
printf("main started, my_global = %d\n", my_global);
int r = add(3, 4);
printf("add(3, 4) = %d\n", r);

// 测试 dlopen
void *h = dlopen("./libfoo.so", RTLD_LAZY);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
void (*foo_ptr)() = dlsym(h, "foo");
foo_ptr();
dlclose(h);
return 0;
}
1
2
3
4
5
6
7
$ gcc -shared -fPIC -O0 -g libfoo.c -o libfoo.so
$ gcc -O0 -g main.c -L. -lfoo -ldl -o main
$ export LD_LIBRARY_PATH=.
$ ./main
main started, my_global = 42
add(3, 4) = 7
foo called, my_global = 42

7.2 readelf -d 看动态段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$ readelf -d main

Dynamic section at offset 0x2de0 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libfoo.so]
0x0000000000000001 (NEEDED) Shared library: [libdl.so.2]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x1188
0x0000000000000019 (INIT_ARRAY) 0x3db0
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3db8
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x3a0
0x0000000000000005 (STRTAB) 0x478
0x0000000000000006 (SYMTAB) 0x4b0
0x000000000000000a (STRSZ) 141 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x404040
0x0000000000000002 (PLTRELSZ) 72 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x5f0
0x0000000000000007 (RELA) 0x540
0x0000000000000008 (RELASZ) 176 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000006ffffffe (VERNEED) 0x520
0x000000006fffffff (VERNEEDNUM) 2
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) NOW
0x0000000000000000 (NULL) 0x0

解读

含义
NEEDED × 3依赖 libfoo.so, libdl.so.2, libc.so.6
INIT.init 段地址 0x1000
INIT_ARRAY.init_array 在 0x3db0,大小 8 字节(1 个函数指针)
PLTGOT.got.plt 在 0x404040
JMPREL.rela.plt 在 0x5f0,72 字节(3 个 PLT 项)
FLAGS BIND_NOW我们用了 -Wl,-z,now,强制 EAGER 解析
GNU_HASH用新版哈希(.gnu.hash 在 0x3a0)

7.3 objdump -R 看重定位

1
2
3
4
5
6
7
8
9
10
11
12
13
$ objdump -R main

main: file format elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
0x00000000404030 R_X86_64_GLOB_DAT my_global
0x00000000404048 R_X86_64_JUMP_SLOT printf
0x00000000404050 R_X86_64_JUMP_SLOT __stack_chk_fail
0x00000000404058 R_X86_64_JUMP_SLOT dlopen
0x00000000404060 R_X86_64_JUMP_SLOT dlsym
0x00000000404068 R_X86_64_JUMP_SLOT dlerror
0x00000000404070 R_X86_64_JUMP_SLOT dlclose

解读

类型数量处理时机
R_X86_64_GLOB_DAT1(my_global)装载时 EAGER 解析
R_X86_64_JUMP_SLOT6(printf 等)第一次调用时(虽然我们 BIND_NOW,但本质机制同)

7.4 objdump -d -j .plt 看 PLT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$ objdump -d -j .plt main

Disassembly of section .plt:

0000000000001020 <.plt>:
1020: ff 35 f2 2f 00 00 push *0x2ff2(%rip) # 404018 <_GLOBAL_OFFSET_TABLE_+0x8>
1026: ff 25 f4 2f 00 00 jmp *0x2ff4(%rip) # 404020 <_GLOBAL_OFFSET_TABLE_+0x10>
102c: 0f 1f 40 00 nop

0000000000001030 <printf@plt>:
1030: ff 25 12 30 00 00 jmp *0x3012(%rip) # 404048 <printf@GLIBC_2.2.5>
1036: 68 00 00 00 00 push $0x0
103b: e9 e0 ff ff ff jmp 1020 <.plt>

0000000000001040 <__stack_chk_fail@plt>:
1040: ff 25 0a 30 00 00 jmp *0x300a(%rip) # 404050 <__stack_chk_fail@GLIBC_2.4>
1046: 68 01 00 00 00 push $0x1
104b: e9 d0 ff ff ff jmp 1020 <.plt>

0000000000001050 <dlopen@plt>:
1050: ff 25 02 30 00 00 jmp *0x3002(%rip) # 404058 <dlopen@GLIBC_2.2.5>
1056: 68 02 00 00 00 push $0x2
105b: e9 c0 ff ff ff jmp 1020 <.plt>

0000000000001060 <dlsym@plt>:
1060: ff 25 f2 2f 00 00 jmp *0x2ff2(%rip) # 404060 <dlsym@GLIBC_2.2.5>
1066: 68 03 00 00 00 push $0x3
106b: e9 b0 ff ff ff jmp 1020 <.plt>

0000000000001070 <dlerror@plt>:
1070: ff 25 e2 2f 00 00 jmp *0x2fe2(%rip) # 404068 <dlerror@GLIBC_2.2.5>
1076: 68 04 00 00 00 push $0x4
107b: e9 a0 ff ff ff jmp 1020 <.plt>

0000000000001080 <dlclose@plt>:
1080: ff 25 da 2f 00 00 jmp *0x2fda(%rip) # 404070 <dlclose@GLIBC_2.2.5>
1086: 68 05 00 00 00 push $0x5
108b: e9 90 ff ff ff jmp 1020 <.plt>

PLT 桩结构对照表

偏移字节含义
+0ff 25 XX XX XX XXjmp *GOT[func] 6 字节
+668 NN 00 00 00push $reloc_arg 5 字节
+11e9 XX XX XX XXjmp PLT[0] 5 字节
总计16 字节

7.5 objdump -s -j .got.plt 看 GOT 初始内容

1
2
3
4
5
6
$ objdump -s -j .got.plt main

Contents of section .got.plt:
404000 a0 3d 00 00 00 00 00 00 20 10 00 00 00 00 00 00 .=..... .......
404010 26 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 &...............
404020 00 00 00 00 00 00 00 00 ........

解读

地址初始值含义
0x4040000x3da0GOT[0] = .dynamic 段地址
0x4040080x1020GOT[1] = link_map*(PLT[0] push 的目标)
0x4040100x1026GOT[2] = _dl_runtime_resolve
0x4040180printf 槽位(启动时未解析)
0x4040200__stack_chk_fail 槽位

注意:因为我们编译用了 -Wl,-z,now启动时 GOT[printf] 等就已经被 ld.so 填为真实地址了,所以上面 0 看着像 0 其实是动态段地址。看运行时需要用 gdb。

7.6 gdb 跟踪 dlopen 整个过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ gdb -q ./main
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break main
(gdb) run
Breakpoint 1, main () at main.c:8

# 1. 第一次断点:在调用 dlopen 之前
(gdb) info sharedlibrary
From To Syms Read Shared Object Library
0x00007ffff7fc2680 0x00007ffff7fee745 Yes ./libfoo.so
0x00007ffff7dca180 0x00007ffff7dd6e88 Yes /lib/x86_64-linux-gnu/libdl.so.2
0x00007ffff7bc9180 0x00007ffff7bdc4e8 Yes /lib/x86_64-linux-gnu/libc.so.6

# 2. 设断点在 _dl_runtime_resolve
(gdb) break _dl_runtime_resolve
(gdb) continue
1
2
Breakpoint 2, 0x00007ffff7fe0960 in _dl_runtime_resolve_xsavec ()
from /lib64/ld-linux-x86-64.so.2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 3. 看传入的参数
(gdb) info registers rdi rsi
rdi 0x7ffff7fc2680 140737354130048 # link_map* for libfoo.so
rsi 0x0 # reloc_arg = 0

# 4. 跳进 _dl_fixup
(gdb) stepi 30
(gdb) bt
#0 _dl_fixup (l=0x7ffff7fc2680, reloc_arg=0x0) at dl-runtime.c:114
#1 0x00007ffff7fe09b3 in _dl_runtime_resolve_xsavec ()
#2 0x0000000000000000 in ?? # 这就是 PLT 桩

# 5. 跟踪到符号解析
(gdb) frame 0
(gdb) print *reloc
$1 = {r_offset = 140737354131480, r_info = 50397440, r_addend = 0}
# r_info >> 32 = 0x3002000 ... 看 ELF64_R_SYM
# 0x3002000 = 0b11000000000010 ... 对应 R_X86_64_JUMP_SLOT

# 6. 看符号名
(gdb) print sym->st_name
$2 = 0 # 第一个符号
(gdb) x/s (char*)((char*)symtab + sym->st_name - 0)
# 实际可以这样:
(gdb) print (const char*) ((char*)strtab + sym->st_name)
$3 = 0x7ffff7fc1000 "add" # ← 找到的符号名

# 7. 单步退出 _dl_fixup
(gdb) finish
Value returned is $4 = (void (*)()) 0x7ffff7fc1156 # add 函数真实地址
# 注意:这个地址会被写入 GOT.plt 中 add 槽位

7.7 用 gdb 验证 GOT 写入

1
2
3
4
5
6
# 在 _dl_fixup 之后检查 GOT
(gdb) print *(void**)0x404048 # printf 的 GOT 槽位
$5 = (void *) 0x7ffff7a63420 # printf 在 libc 中的真实地址

(gdb) print *(void**)0x404058 # dlopen 的 GOT 槽位
$6 = (void *) 0x7ffff7dca5e0 # dlopen 真实地址

7.8 跟踪 dlopen 加载新共享库

1
2
3
4
5
6
7
# 在 main 调 dlopen 时打断点
(gdb) break main.c:13
(gdb) continue
# 此时 libfoo 已经被 dlopen 装载了

(gdb) info sharedlibrary
# libfoo.so 出现两次!一次是 DT_NEEDED,一次是 dlopen
1
2
3
4
5
6
7
# 看 dlopen 内部的 _dl_map_object_from_fd
(gdb) break _dl_map_object_from_fd
(gdb) continue
Breakpoint 3, _dl_map_object_from_fd (...) at dl-load.c:1234

(gdb) print name
$7 = 0x7fffffffe5b0 "./libfoo.so"

7.9 用 LD_DEBUG 看出整个决策过程

1
$ LD_DEBUG=all ./main 2>&1 | head -80

输出节选:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
7: file=libfoo.so;  needed by ./main
7: find library=libfoo.so; searching
7: search path=/lib/x86_64-linux-gnu/tls/haswell/x86_64 (RUNPATH from LD_LIBRARY_PATH)
7: trying /lib/x86_64-linux-gnu/tls/haswell/x86_64/libfoo.so -> None
...
7: trying ./libfoo.so ← 找到!
7:
7: file=libfoo.so; generating link map
7: dynamic: 0x7ffff7fc1270 base: 0x7ffff7fc0000
7: entry: 0x7ffff7fc1160
7: l_ld: 0x7ffff7fc1270
7: l_addr: 0x7ffff7fc0000
7: l_name: ./libfoo.so
7:
7: symbol=add; lookup in file=./libfoo.so [0]
7: symbol=add; lookup in file=/lib/x86_64-linux-gnu/libdl.so.2 [0]
7: symbol=add; lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
7: symbol=add; lookup in file=./libfoo.so [0] ← 在 libfoo 中找到
7: binding file ./main to ./libfoo.so: normal symbol `add'
7:
7: symbol=my_global; lookup in file=./libfoo.so [0]
7: binding file ./main to ./libfoo.so: normal symbol `my_global'

LD_DEBUG 关键选项

选项输出内容
files共享库搜索/打开
bindings符号绑定决策
symbols符号版本/解析
reloc重定位过程
all全部
unused装但未用的库

7.10 nm 看符号表

1
2
3
4
5
6
7
8
9
10
11
12
13
$ nm -D libfoo.so | head
0000000000201020 B my_global
0000000000001156 T add
0000000000001130 T foo
0000000000001050 T __cxa_finalize

# U 是未定义(在其他库),T 是 text 已定义
$ nm -D main | head
U add
U dlopen
U printf
w __gmon_start__
0000000000004030 B my_global

7.11 strace 看 mmap 调用

1
2
3
4
5
$ strace -e mmap,openat ./main 2>&1 | head -20
openat(AT_FDCWD, "./libfoo.so", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 2105624, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f0d8a407000
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", ...) = 3
mmap(NULL, 2121216, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f0d8a200000

每个共享库都是一次 mmap,同一进程内同一 .so 不会被 mmap 两次


8. 实战:自己实现一个 dlopen 小例子

8.1 编写 libplugin.c

1
2
3
4
5
6
7
8
9
10
// libplugin.c - 动态加载的插件
#include <stdio.h>

const char *PLUGIN_NAME = "v1.0.0";
int PLUGIN_API_VERSION = 1;

int plugin_process(const char *input) {
printf("[plugin] received: %s\n", input);
return input ? (int)strlen(input) : -1;
}
1
$ gcc -shared -fPIC -fvisibility=default libplugin.c -o libplugin.so

8.2 编写 plugin_host.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// plugin_host.c - 加载器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
#include <errno.h>

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

int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <plugin.so>\n", argv[0]);
return 1;
}

// 清空 dlerror 状态
dlerror();

// 1. dlopen
void *h = dlopen(argv[1], RTLD_NOW);
if (!h) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
return 1;
}
printf("[host] loaded: %s at %p\n", argv[1], h);

// 2. 读元数据
const char **name = (const char **) dlsym(h, "PLUGIN_NAME");
int *api_version = (int *) dlsym(h, "PLUGIN_API_VERSION");
if (name && api_version) {
printf("[host] plugin name: %s, api: %d\n", *name, *api_version);
}

// 3. 调函数
plugin_process_fn process = (plugin_process_fn) dlsym(h, "plugin_process");
if (!process) {
fprintf(stderr, "dlsym(plugin_process) failed: %s\n", dlerror());
dlclose(h);
return 1;
}
int n = process("hello plugin world");
printf("[host] plugin returned: %d\n", n);

// 4. dlclose
if (dlclose(h) != 0) {
fprintf(stderr, "dlclose failed: %s\n", dlerror());
return 1;
}
printf("[host] unloaded cleanly\n");
return 0;
}
1
2
3
4
5
6
7
$ gcc plugin_host.c -ldl -o plugin_host
$ ./plugin_host ./libplugin.so
[host] loaded: ./libplugin.so at 0x7f1d2c000000
[host] plugin name: v1.0.0, api: 1
[plugin] received: hello plugin world
[host] plugin returned: 18
[host] unloaded cleanly

8.3 错误处理

1
2
3
4
5
6
7
8
# 故意给个不存在的库
$ ./plugin_host ./libmissing.so
dlopen failed: ./libmissing.so: cannot open shared object file: No such file or directory

# 故意给个 .so 但没有目标符号
$ ./plugin_host /lib/x86_64-linux-gnu/libm.so.6
# 仍然成功!只是 dlsym(plugin_process) 会失败
dlsym(plugin_process) failed: /lib/x86_64-linux-gnu/libm.so.6: undefined symbol: plugin_process

8.4 RTLD_LAZY vs RTLD_NOW 对比

1
2
void *h1 = dlopen("./libfoo.so", RTLD_LAZY);  // 仅在调用未解析符号时报错
void *h2 = dlopen("./libfoo.so", RTLD_NOW); // 立即解析所有符号
标志失败时机性能适合
RTLD_LAZY第一次调启动快插件热加载
RTLD_NOW立即首调快关键服务

8.5 dlopen 装载后进程地址空间

graph TB
    subgraph "用户空间"
        STACK["⬛ 栈<br/>(高地址向下)"]
        MMAP["🟧 mmap 区<br/>(.so 在这里)"]
        HEAP["🟨 堆<br/>(brk 向上)"]
        BSS["🟩 BSS / Data<br/>(.data .bss)"]
        TEXT["🟦 .text<br/>(可执行文件)"]
    end

    MMAP --> L1["libfoo.so 1<br/>(DT_NEEDED)"]
    MMAP --> L2["libc.so<br/>(基础库)"]
    MMAP --> L3["libdl.so<br/>(dlopen 实现)"]
    MMAP --> L4["libplugin.so<br/>(dlopen 加载)"]
    MMAP --> LDSO["ld-linux-x86-64.so<br/>(动态链接器)"]

    style STACK fill:#F5F5F5,stroke:#9E9E9E,color:#333
    style MMAP fill:#FFDAB9,stroke:#FFAB76,color:#333
    style HEAP fill:#FFF9C4,stroke:#F9A825,color:#333
    style BSS fill:#B5EAD7,stroke:#80CBC4,color:#333
    style TEXT fill:#C7CEEA,stroke:#9FA8DA,color:#333
    style L1 fill:#E8D5F5,stroke:#CE93D8,color:#333
    style L2 fill:#E8D5F5,stroke:#CE93D8,color:#333
    style L3 fill:#E8D5F5,stroke:#CE93D8,color:#333
    style L4 fill:#E8D5F5,stroke:#CE93D8,color:#333
    style LDSO fill:#FFB3C6,stroke:#F48FB1,color:#333

8.6 看 dlopen 后的 mmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ ./plugin_host ./libplugin.so &
$ PID=$!
$ cat /proc/$PID/maps | grep -E "lib|ld-"
7f0d8a200000-7f0d8a400000 r--p 00000000 08:01 12345 /lib/x86_64-linux-gnu/libc.so.6
7f0d8a400000-7f0d8a407000 r-xp 001f0000 08:01 12345 /lib/x86_64-linux-gnu/libc.so.6
7f0d8a407000-7f0d8a408000 r--p 00200000 08:01 12345 /lib/x86_64-linux-gnu/libc.so.6
7f0d8a408000-7f0d8a409000 r--p 00201000 08:01 12345 /lib/x86_64-linux-gnu/libc.so.6
7f0d8c000000-7f0d8c001000 r--p 00000000 08:01 67890 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f0d8c001000-7f0d8c002000 r-xp 00001000 08:01 67890 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f0d8c002000-7f0d8c003000 r--p 00002000 08:01 67890 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f0d8c003000-7f0d8c004000 r--p 00003000 08:01 67890 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f0d8c004000-7f0d8c005000 rw-p 00004000 08:01 67890 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f0d8d000000-7f0d8d001000 r--p 00000000 08:01 11111 /home/user/libplugin.so
7f0d8d001000-7f0d8d002000 r-xp 00001000 08:01 11111 /home/user/libplugin.so
7f0d8d002000-7f0d8d003000 r--p 00002000 08:01 11111 /home/user/libplugin.so
7f0d8d003000-7f0d8d004000 rw-p 00003000 08:01 11111 /home/user/libplugin.so

每行 4 个权限位 + 偏移 + 设备 + inode + 路径

权限含义
r–p只读、私有(写时复制)
r-xp读+执行、私有
rw-p读写、私有

8.7 看 dlclose 后的引用计数

1
2
3
# 用 LD_DEBUG 看 dlclose
$ LD_DEBUG=files,unload ./plugin_host ./libplugin.so 2>&1 | tail
file=./libplugin.so; destroying link map

dlclose 实际卸载要等引用计数为 0(默认 +1),如果还在用就不卸载。


9. 优缺点与适用场景

9.1 动态链接的优点

优点数据/场景
磁盘节省100 个程序用 libc,每个省 1.5MB = 省 150MB
内存节省同一 .so 多进程共享一份 .text
升级透明替换 .so 即可(保持 SONAME)
插件架构dlopen 运行时按需加载
共享内存同一 libc 共享 .text 段
ABI 解耦主程序和库可独立升级

9.2 动态链接的缺点

缺点场景
启动慢dlopen + 解析符号要 10-200ms
部署复杂需带一堆 .so + RPATH
兼容性脆弱glibc 升级可能破坏 ABI
调试栈被截PLT 隐藏真实调用(用 -z now
内存碎片大量 .so 散落 mmap 区
攻击面LD_PRELOAD / RPATH 可劫持

9.3 何时选动态链接

场景选择原因
系统工具动态共享 libc
长期服务动态可热更新
嵌入式(资源紧)静态减少空间
Docker 镜像静态/动态均可看镜像大小要求
客户端 SDK动态升级不重发
闭源商业软件动态保护代码
C 库 / 框架动态必然
性能敏感服务静态 + 关键段动态启动 + 共享

9.4 跨平台链接器对比

平台链接器关键特性
Linux glibcld-linux-x86-64.so.2GNU_HASH, BIND_NOW, DT_NEEDED
Linux musl/lib/ld-musl-*静态优先,单文件小
macOSdyld二级作用域 (Two-Level NS)
iOSdyld3闭源共享缓存
Windowsntdll + Ldr显式 IAT,PE 格式
FreeBSDld-elf.so接近 Linux

10. 思考题 & 行动建议

10.1 思考题

  1. 为什么 ld.so 自身的 .rela 段不允许 R_X86_64_GLOB_DAT? 提示:从”谁负责解析”的角度想。
  2. 如果把可执行文件编译成 PIE(Position Independent Executable)会怎样? 内核、装载、性能分别有什么变化?
  3. LD_PRELOAD=/path/to/lib.so ./your_prog 注入一个覆盖 malloc 的库,发生了什么? 用 LD_DEBUG 验证。
  4. 为什么 aarch64 上不写 -fPIC 直接报错? 提示:和指令编码有关。
  5. 如何让 dlopen 的库不参与全局符号介入? 提示:RTLD_LOCAL。
  6. 如果一个共享库同时被 DT_NEEDED 和 dlopen 装载两次,会加载两个副本吗? 用 gdb 验证 link_map。

10.2 动手建议

推荐说明
跑一遍第 7 节的实验真实 objdump 输出比书本更深刻
用 LD_DEBUG 跟踪你的项目看清 ld.so 替你做了什么
用 patchelf 改 RPATH理解 SONAME / RPATH 部署
写一个 hot-reload 插件用 dlopen + 重新加载
读 glibc/elf/rtld.c 源码比任何博客都权威
对比 musl 和 glibc 的 ldso看极简实现

10.3 进阶阅读

  • 《程序员的自我修养》第 7 章完整版
  • glibc 源码 elf/rtld.celf/dl-runtime.c
  • System V ABI x86_64 补充
  • Ulrich Drepper 的 “How to Write Shared Libraries”(dsohowto)

附录 A:关键命令速查

命令作用关键选项
readelf -d看动态段-d
readelf -r看重定位表-r
readelf -s看符号表-s / -D 动态
objdump -R看运行时重定位-R
objdump -d -j .plt反汇编 PLT-j
objdump -s -j .got.plt看 GOT 初始内容-s
nm -D看动态符号-D
ldd看依赖-r 解析全部
LD_DEBUG=all看 ld.so 全部决策all, bindings
gdb + break _dl_runtime_resolve跟踪 PLT 解析_dl_runtime_resolve
strace -e mmap看共享库装载mmap
patchelf --set-rpath改 RPATH–set-rpath
LD_PRELOAD=lib.so注入库优先级最高

附录 B:动态段项速查

名称含义关键性
DT_NEEDED依赖的 SONAME
DT_SONAME本库的 SONAME
DT_SYMTAB / DT_STRTAB动态符号表 / 字符串表
DT_INIT / DT_FINI.init / .fini 段地址
DT_INIT_ARRAY / DT_FINI_ARRAY构造函数数组
DT_JMPREL.rela.plt 位置
DT_PLTRELSZ.rela.plt 大小
DT_PLTREL.rela.plt 类型
DT_RELA.rela.dyn 位置
DT_RELASZ.rela.dyn 大小
DT_GNU_HASHGNU 哈希表
DT_FLAGS / DT_FLAGS_1标志位(BIND_NOW 等)
DT_RUNPATH / DT_RPATH库搜索路径
DT_VERNEED / DT_VERSYM版本依赖
DT_DEBUGgdb 调试用

📚 程序员的自我修养 系列导航

本文是《程序员的自我修养》系列第 7/13 篇。

方向章节
◀ 上一篇第六章:可执行文件的装载与进程
下一篇 ▶第八章:Linux共享库的组织
📖 全部 13 篇目录(点击展开)
  1. 第一章:温故而知新
  2. 第二章:编译和链接
  3. 第三章:目标文件里有什么
  4. 第四章:静态链接
  5. 第五章:动态链接
  6. 第六章:可执行文件的装载与进程
  7. 第七章:动态链接的实现 ← 当前
  8. 第八章:Linux共享库的组织
  9. 第九章:内存管理
  10. 第十章:运行库
  11. 第十一章:系统调用
  12. 第十二章:线程库
  13. 第十三章:调试

参考资料

  1. 《程序员的自我修养:链接、装载与库》—— 俞甲子、石凡、潘爱民
  2. glibc 源码 elf/rtld.c
  3. System V ABI x86_64
  4. ELF 格式规范
  5. ld.so(8) man page
  6. Ulrich Drepper, How to Write Shared Libraries
  7. GNU Hash ELF 扩展