文章目录
-
- 库制作与原理(三):动态链接与加载机制
- 一、进程地址空间与ELF加载
-
- [1.1 虚拟地址空间回顾](#1.1 虚拟地址空间回顾)
- [1.2 ELF文件中的预设地址](#1.2 ELF文件中的预设地址)
- [1.3 进程地址空间的初始化](#1.3 进程地址空间的初始化)
- [1.4 查看运行时的地址空间](#1.4 查看运行时的地址空间)
- 二、动态链接概述
-
- [2.1 为什么需要动态链接](#2.1 为什么需要动态链接)
- [2.2 动态链接vs静态链接](#2.2 动态链接vs静态链接)
- [2.3 动态链接器](#2.3 动态链接器)
- 三、位置无关码(PIC)
-
- [3.1 问题的提出](#3.1 问题的提出)
- [3.2 什么是位置无关码](#3.2 什么是位置无关码)
- [3.3 编译动态库时的-fPIC选项](#3.3 编译动态库时的-fPIC选项)
- [3.4 验证动态库的加载地址](#3.4 验证动态库的加载地址)
- 四、全局偏移表(GOT)
-
- [4.1 问题:如何访问全局变量](#4.1 问题:如何访问全局变量)
- [4.2 GOT表的原理](#4.2 GOT表的原理)
- [4.3 查看GOT表](#4.3 查看GOT表)
- [4.4 运行时GOT表的填充](#4.4 运行时GOT表的填充)
- 五、过程链接表(PLT)
-
- [5.1 问题:如何调用函数](#5.1 问题:如何调用函数)
- [5.2 PLT的原理](#5.2 PLT的原理)
- [5.3 PLT的结构](#5.3 PLT的结构)
- [5.4 PLT桩代码详解](#5.4 PLT桩代码详解)
- [5.5 完整的调用流程](#5.5 完整的调用流程)
-
- [5.5.1 第一次调用printf](#5.5.1 第一次调用printf)
- [5.5.2 第二次调用printf](#5.5.2 第二次调用printf)
- [5.6 查看GOT表内容](#5.6 查看GOT表内容)
- 六、动态链接的完整流程
-
- [6.1 编译时:生成动态链接信息](#6.1 编译时:生成动态链接信息)
- [6.2 加载时:动态链接器的工作](#6.2 加载时:动态链接器的工作)
- [6.3 运行时:延迟绑定](#6.3 运行时:延迟绑定)
- 七、动态库的查找路径
-
- [7.1 查找顺序](#7.1 查找顺序)
- [7.2 设置RPATH和RUNPATH](#7.2 设置RPATH和RUNPATH)
- [7.3 使用ORIGIN变量](#7.3 使用ORIGIN变量)
- 八、动态库版本管理
-
- [8.1 SO-NAME机制](#8.1 SO-NAME机制)
- [8.2 创建带版本的动态库](#8.2 创建带版本的动态库)
- [8.3 版本兼容性](#8.3 版本兼容性)
- 九、符号的可见性控制
-
- [9.1 为什么需要控制符号可见性](#9.1 为什么需要控制符号可见性)
- [9.2 使用__attribute__控制](#9.2 使用__attribute__控制)
- [9.3 使用-fvisibility编译选项](#9.3 使用-fvisibility编译选项)
- 十、动态加载(dlopen)
-
- [10.1 dlopen系列函数](#10.1 dlopen系列函数)
- [10.2 实战:动态加载插件](#10.2 实战:动态加载插件)
- [10.3 dlopen的flag参数](#10.3 dlopen的flag参数)
- 十一、常见问题与调试
-
- [11.1 undefined symbol错误](#11.1 undefined symbol错误)
- [11.2 版本冲突](#11.2 版本冲突)
- [11.3 使用LD_DEBUG调试](#11.3 使用LD_DEBUG调试)
- 十二、总结
库制作与原理(三):动态链接与加载机制
💬 欢迎讨论:在前两篇中,我们学习了库的制作和静态链接原理。但动态链接是如何工作的?为什么动态库可以被多个进程共享?GOT和PLT是什么?本篇将深入动态链接的底层机制,揭示位置无关码、全局偏移表、过程链接表的奥秘,带你理解从动态库加载到函数调用的完整过程。
👍 点赞、收藏与分享:这篇文章包含了动态链接的核心原理、GOT/PLT机制的完整解析,配合大量实战验证,内容深入,如果对你有帮助,请点赞、收藏并分享!
🚀 循序渐进:建议先学习前两篇的库制作和静态链接知识,这样理解本篇的动态链接原理会更轻松。
一、进程地址空间与ELF加载
1.1 虚拟地址空间回顾
在深入动态链接之前,我们先回顾进程地址空间的概念:
每个进程都有独立的虚拟地址空间:
bash
┌─────────────────────────────┐ 0xFFFFFFFFFFFFFFFF
│ 内核空间 (Kernel Space) │
│ (进程不能直接访问) │
├─────────────────────────────┤ 0x00007FFFFFFFFFFF
│ │
│ 栈 (Stack) ↓ │
│ │
├─────────────────────────────┤
│ │
│ 共享库映射区 (mmap) │
│ │
├─────────────────────────────┤
│ │
│ 堆 (Heap) ↑ │
│ │
├─────────────────────────────┤
│ .bss (未初始化数据) │
│ .data (已初始化数据) │
│ .rodata (只读数据) │
│ .text (代码段) │
├─────────────────────────────┤ 0x400000 (典型起始地址)
│ │
│ 保留区域 │
│ │
└─────────────────────────────┘ 0x0000000000000000
1.2 ELF文件中的预设地址
查看可执行文件的加载地址:
bash
readelf -l a.out | grep LOAD
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000748 0x0000000000000748 R E 0x200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x0000000000000228 0x0000000000000230 RW 0x200000
关键信息:
- 第一个LOAD段从
0x400000开始(代码段) - 第二个LOAD段从
0x600e10开始(数据段) - 这些地址是虚拟地址
1.3 进程地址空间的初始化
程序启动时的加载过程:
bash
1. 用户执行:./a.out
↓
2. 内核创建新进程
↓
3. 读取ELF文件头
↓
4. 解析Program Header Table
↓
5. 根据LOAD段的描述
将文件内容映射到虚拟地址空间
↓
6. 如果有动态链接器(INTERP段)
先加载动态链接器
↓
7. 跳转到入口点(Entry Point)
开始执行
在之前我们讲过进程描述符中的地址空间信息:
c
struct task_struct {
// ...
struct mm_struct *mm; // 内存描述符
// ...
};
struct mm_struct {
unsigned long start_code; // 代码段起始地址
unsigned long end_code; // 代码段结束地址
unsigned long start_data; // 数据段起始地址
unsigned long end_data; // 数据段结束地址
unsigned long start_brk; // 堆起始地址
unsigned long brk; // 堆当前位置
unsigned long start_stack; // 栈起始地址
// ...
};
这些值从哪里来?
bash
ELF的Program Header → mm_struct
┌────────────────────┐
│ LOAD Segment 1 │
│ VirtAddr: 0x400000 │ → mm->start_code
│ Flags: R E │ mm->end_code
├────────────────────┤
│ LOAD Segment 2 │
│ VirtAddr: 0x600000 │ → mm->start_data
│ Flags: RW │ mm->end_data
└────────────────────┘
1.4 查看运行时的地址空间
查看进程的内存映射:
bash
# 启动程序(让它sleep等待)
cat > test.c << 'EOF'
#include <unistd.h>
int main() {
sleep(100);
return 0;
}
EOF
gcc test.c -o test
./test &
[1] 12345
# 查看内存映射
cat /proc/12345/maps
00400000-00401000 r-xp 00000000 08:01 1234567 /home/user/test
00600000-00601000 r--p 00000000 08:01 1234567 /home/user/test
00601000-00602000 rw-p 00001000 08:01 1234567 /home/user/test
7ffff7a0d000-7ffff7bcd000 r-xp 00000000 08:01 2345678 /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7bcd000-7ffff7dcd000 ---p 001c0000 08:01 2345678 /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7dcd000-7ffff7dd1000 r--p 001c0000 08:01 2345678 /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7dd1000-7ffff7dd3000 rw-p 001c4000 08:01 2345678 /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7dd3000-7ffff7dfb000 r-xp 00000000 08:01 3456789 /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffb000-7ffff7ffc000 r--p 00028000 08:01 3456789 /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffc000-7ffff7ffd000 rw-p 00029000 08:01 3456789 /lib/x86_64-linux-gnu/ld-2.31.so
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
可以看到:
- 可执行文件被映射到
0x400000 - C库被映射到
0x7ffff7a0d000 - 动态链接器被映射到
0x7ffff7dd3000 - 栈在高地址
0x7ffffffde000
二、动态链接概述
2.1 为什么需要动态链接
静态链接的问题:
bash
程序A: 使用printf → libc代码链接进A (2MB)
程序B: 使用printf → libc代码链接进B (2MB)
程序C: 使用printf → libc代码链接进C (2MB)
磁盘浪费:3份libc代码
内存浪费:同时运行时3份libc代码都在内存中
动态链接的优势:
bash
程序A: 使用printf → 运行时加载libc.so
程序B: 使用printf → 共享同一份libc.so
程序C: 使用printf → 共享同一份libc.so
磁盘节省:只有1份libc.so
内存节省:只有1份libc.so代码在内存中
2.2 动态链接vs静态链接
| 特性 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 可执行文件大小 | 大(包含所有库代码) | 小(只包含引用) |
| 内存使用 | 每个进程独立副本 | 多进程共享 |
| 库更新 | 需要重新编译 | 无需重新编译 |
| 启动速度 | 快(无需加载库) | 慢(需要加载和链接库) |
| 运行依赖 | 无(独立运行) | 有(需要.so文件存在) |
| 地址确定 | 编译时确定 | 运行时确定 |
2.3 动态链接器
查看可执行文件的动态链接器:
bash
readelf -l a.out | grep interpreter
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
动态链接器的作用:
- 加载程序依赖的所有动态库
- 解析符号引用
- 进行重定位
- 将控制权交给程序
启动流程:
bash
用户执行:./a.out
↓
内核加载a.out
↓
发现需要动态链接器
↓
加载 /lib64/ld-linux-x86-64.so.2
↓
跳转到动态链接器的入口点
↓
动态链接器工作:
1. 读取a.out的动态段(.dynamic)
2. 加载依赖的.so文件
3. 解析符号
4. 重定位
↓
跳转到a.out的入口点(_start)
↓
程序开始执行
三、位置无关码(PIC)
3.1 问题的提出
动态库要被多个进程共享,但每个进程的地址空间布局可能不同!
bash
进程A的地址空间:
┌─────────────────┐
│ 程序A代码 │ 0x400000
├─────────────────┤
│ libc.so │ 0x7f8917335000
└─────────────────┘
进程B的地址空间:
┌─────────────────┐
│ 程序B代码 │ 0x400000
├─────────────────┤
│ libc.so │ 0x7f9628446000 ← 地址不同!
└─────────────────┘
如果库的代码中有绝对地址:
asm
mov $0x7f8917335010, %rax # 绝对地址
在进程B中就错了!
解决方案:位置无关码(PIC - Position Independent Code)
3.2 什么是位置无关码
位置无关码:代码可以被加载到任意地址运行,不需要修改代码本身。
实现原理:使用相对地址
asm
# 绝对地址(位置相关)
mov $0x7f8917335010, %rax # 硬编码地址
# 相对地址(位置无关)
lea 0x2010(%rip), %rax # 相对于PC的偏移
3.3 编译动态库时的-fPIC选项
bash
# 不使用-fPIC
gcc -c my_lib.c -o my_lib_nopic.o
# 使用-fPIC
gcc -fPIC -c my_lib.c -o my_lib_pic.o
对比反汇编:
c
// my_lib.c
int global_var = 42;
int get_global() {
return global_var;
}
不使用-fPIC:
bash
objdump -d my_lib_nopic.o
0000000000000000 <get_global>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # a <get_global+0xa>
a: 5d pop %rbp
b: c3 retq
使用-fPIC:
bash
objdump -d my_lib_pic.o
0000000000000000 <get_global>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # b <get_global+0xb>
b: 8b 00 mov (%rax),%eax
d: 5d pop %rbp
e: c3 retq
区别:
- 不使用-fPIC:直接访问变量地址(需要重定位)
- 使用-fPIC:通过GOT表间接访问(后面详解)
3.4 验证动态库的加载地址
编写测试程序:
c
// test_pic.c
#include <stdio.h>
#include <dlfcn.h>
int main() {
void *handle = dlopen("./libmystdio.so", RTLD_NOW);
if (!handle) {
fprintf(stderr, "%s\n", dlerror());
return 1;
}
void *func = dlsym(handle, "mfopen");
printf("Library loaded at: %p\n", handle);
printf("Function mfopen at: %p\n", func);
dlclose(handle);
return 0;
}
bash
gcc test_pic.c -ldl -o test_pic
# 运行多次,观察地址
./test_pic
Library loaded at: 0x7f8917335000
Function mfopen at: 0x7f89173356a0
./test_pic
Library loaded at: 0x7f9628446000 # 地址变了!
Function mfopen at: 0x7f96284467a0
但程序仍然正常工作!这就是PIC的威力!
四、全局偏移表(GOT)
4.1 问题:如何访问全局变量
场景:
c
// lib.c (动态库代码)
int global_var = 100;
int get_global() {
return global_var; // 如何访问?
}
问题:
global_var在运行时才知道具体地址- 代码段是只读的,不能修改
- 怎么办?
解决方案:GOT (Global Offset Table)
4.2 GOT表的原理
核心思想:增加一层间接访问
bash
代码段(只读) → GOT表(可写) → 实际变量
具体实现:
bash
┌─────────────────────────┐
│ 代码段 (.text) │ ← 只读
│ ┌──────────────────┐ │
│ │ get_global: │ │
│ │ mov GOT[n],%rax│───┼──┐
│ │ mov (%rax),%eax│ │ │
│ │ ret │ │ │
│ └──────────────────┘ │ │
└─────────────────────────┘ │
│
┌────────────────────┘
↓
┌─────────────────────────┐
│ GOT表 (.got) │ ← 可写
│ ┌──────────────────┐ │
│ │ GOT[n] = │ │
│ │ &global_var │───┼──┐
│ └──────────────────┘ │ │
└─────────────────────────┘ │
│
┌────────────────────┘
↓
┌─────────────────────────┐
│ .data段 │
│ ┌──────────────────┐ │
│ │ global_var = 100 │ │
│ └──────────────────┘ │
└─────────────────────────┘
关键点:
- 代码段中存储的是GOT表项的相对偏移(编译时确定)
- GOT表项中存储的是变量的绝对地址(运行时填入)
- GOT表是可写的,动态链接器在加载时填入正确地址
4.3 查看GOT表
编译动态库:
c
// my_lib.c
int global_var = 42;
int get_global() {
return global_var;
}
bash
gcc -fPIC -shared my_lib.c -o libmy.so
查看GOT段:
bash
readelf -S libmy.so | grep got
[20] .got PROGBITS 0000000000003fd8 00002fd8
0000000000000028 0000000000000008 WA 0 0 8
[21] .got.plt PROGBITS 0000000000004000 00003000
0000000000000020 0000000000000008 WA 0 0 8
注意:
.got:存储全局变量的地址.got.plt:存储函数的地址(与PLT配合,后面讲解)
反汇编查看访问方式:
bash
objdump -d libmy.so | grep -A 10 "<get_global>"
0000000000001119 <get_global>:
1119: 55 push %rbp
111a: 48 89 e5 mov %rsp,%rbp
111d: 48 8b 05 a4 2e 00 00 mov 0x2ea4(%rip),%rax # 3fc8 <global_var>
1124: 8b 00 mov (%rax),%eax
1126: 5d pop %rbp
1127: c3 retq
分析:
mov 0x2ea4(%rip),%rax:从GOT表中取出地址mov (%rax),%eax:间接访问变量
4.4 运行时GOT表的填充
动态链接器的工作:
bash
1. 加载动态库到内存(假设地址0x7f8917335000)
2. 解析符号
global_var的符号定义在.data段,偏移0x4010
实际地址 = 0x7f8917335000 + 0x4010 = 0x7f8917339010
3. 填充GOT表
GOT[n] = 0x7f8917339010
4. 之后代码访问:
mov 0x2ea4(%rip), %rax → rax = 0x7f8917339010
mov (%rax), %eax → eax = *0x7f8917339010 = 42
五、过程链接表(PLT)
5.1 问题:如何调用函数
与全局变量类似,函数的地址也是运行时才知道。
但函数调用有特殊性:
c
// 程序中可能调用printf数百次
for (int i = 0; i < 1000; i++) {
printf("%d\n", i);
}
如果每次都查找和绑定函数地址,性能太差!
优化方案:延迟绑定(Lazy Binding) + PLT
5.2 PLT的原理
核心思想:第一次调用时绑定,之后直接跳转
bash
第一次调用printf:
程序 → PLT桩代码 → 动态链接器 → 解析符号 → 填GOT → 跳转printf
第二次调用printf:
程序 → PLT桩代码 → 直接从GOT跳转printf (快!)
5.3 PLT的结构
PLT由多个表项组成:
bash
┌─────────────────────────────┐
│ PLT[0] (PLT header) │ ← 公共代码,调用动态链接器
├─────────────────────────────┤
│ PLT[1] (printf@plt) │ ← printf的桩代码
├─────────────────────────────┤
│ PLT[2] (malloc@plt) │ ← malloc的桩代码
├─────────────────────────────┤
│ PLT[3] (...) │
└─────────────────────────────┘
每个PLT表项对应一个GOT表项:
bash
PLT[1] (printf@plt) ←→ GOT[3] (printf的地址)
PLT[2] (malloc@plt) ←→ GOT[4] (malloc的地址)
5.4 PLT桩代码详解
编写测试程序:
c
// test.c
#include <stdio.h>
int main() {
printf("Hello\n");
printf("World\n");
return 0;
}
bash
gcc test.c -o test
查看PLT:
bash
objdump -d test | grep -A 20 "<printf@plt>"
0000000000401030 <printf@plt>:
401030: ff 25 e2 2f 00 00 jmpq *0x2fe2(%rip) # 404018 <printf@GLIBC_2.2.5>
401036: 68 00 00 00 00 pushq $0x0
40103b: e9 e0 ff ff ff jmpq 401020 <.plt>
分析:
第1条指令:jmpq *0x2fe2(%rip)
- 跳转到GOT[3]指向的地址
- 第一次调用时,GOT[3]指向下一条指令(40103 6)
第2条指令:pushq $0x0
- 压入索引号0(printf是第0个动态符号)
第3条指令:jmpq 401020
- 跳转到PLT[0],调用动态链接器
PLT[0]的代码:
bash
objdump -d test | grep -A 10 "^0000000000401020"
0000000000401020 <.plt>:
401020: ff 35 e2 2f 00 00 pushq 0x2fe2(%rip) # 404008 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 e4 2f 00 00 jmpq *0x2fe4(%rip) # 404010 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nopl 0x0(%rax)
分析:
- 压入GOT[1](链接器标识)
- 跳转到GOT[2](动态链接器的入口)
5.5 完整的调用流程
5.5.1 第一次调用printf
bash
1. main中: call 401030 <printf@plt>
↓
2. PLT[1]: jmpq *GOT[3]
GOT[3]初始值 = 401036(下一条指令)
↓
3. PLT[1]: pushq $0x0 (压入printf的索引)
↓
4. PLT[1]: jmpq PLT[0]
↓
5. PLT[0]: pushq GOT[1] (压入链接器标识)
↓
6. PLT[0]: jmpq *GOT[2] (跳转到动态链接器)
↓
7. 动态链接器:
- 根据索引0查找printf
- 解析符号,得到printf地址(假设0x7f8917335abc)
- 填充GOT[3] = 0x7f8917335abc
- 跳转到printf
↓
8. printf执行
↓
9. printf返回到main
5.5.2 第二次调用printf
bash
1. main中: call 401030 <printf@plt>
↓
2. PLT[1]: jmpq *GOT[3]
GOT[3]现在 = 0x7f8917335abc(第一次已填充)
↓
3. 直接跳转到printf!(快速!)
↓
4. printf执行
↓
5. printf返回到main
流程图:
bash
第一次调用(慢):
main → PLT[1] → GOT[3] → PLT[1]下一条 → PLT[0] → 动态链接器 → 解析 → printf
第二次调用(快):
main → PLT[1] → GOT[3] → printf
5.6 查看GOT表内容
查看初始状态的GOT:
bash
readelf -x .got.plt test
Hex dump of section '.got.plt':
0x00404000 e03f4000 00000000 00000000 00000000 .?@.............
0x00404010 00000000 00000000 36104000 00000000 ........6.@.....
↑
初始指向PLT[1]的第2条指令
使用gdb动态观察:
bash
gdb test
(gdb) break main
Breakpoint 1 at 0x401136
(gdb) run
Starting program: /home/user/test
Breakpoint 1, 0x0000000000401136 in main ()
# 查看GOT[3]的内容(printf的GOT表项)
(gdb) x/x 0x404018
0x404018: 0x00401036 # 初始指向PLT[1]的第2条指令
# 单步执行到第一次调用printf之后
(gdb) next
# 再次查看GOT[3]
(gdb) x/x 0x404018
0x404018: 0x7ffff7a65550 # 已经填充了printf的真实地址!
# 查看这个地址确实是printf
(gdb) info symbol 0x7ffff7a65550
printf in section .text of /lib/x86_64-linux-gnu/libc.so.6
验证成功!GOT表在第一次调用后被填充了!
六、动态链接的完整流程
6.1 编译时:生成动态链接信息
编译动态链接的程序:
bash
gcc -o main main.c -L. -lmystdio
生成的可执行文件包含:
bash
1. .interp段:指定动态链接器路径
2. .dynamic段:动态链接所需的信息
3. .dynsym段:动态符号表
4. .dynstr段:动态字符串表
5. .plt段:过程链接表
6. .got.plt段:全局偏移表(函数部分)
7. .got段:全局偏移表(数据部分)
查看动态段:
bash
readelf -d main
Dynamic section at offset 0xe28 contains 24 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libmystdio.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x401000
0x000000000000000d (FINI) 0x4011e8
0x0000000000000019 (INIT_ARRAY) 0x403e10
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x403e18
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x0000000000000004 (HASH) 0x400298
0x0000000000000005 (STRTAB) 0x400398
0x0000000000000006 (SYMTAB) 0x4002b8
0x000000000000000a (STRSZ) 137 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
关键信息:
NEEDED:依赖的动态库列表SYMTAB:动态符号表位置STRTAB:动态字符串表位置
6.2 加载时:动态链接器的工作
完整流程:
bash
1. 内核加载可执行文件
- 读取ELF头
- 创建进程地址空间
- 映射可执行文件到内存
2. 发现需要动态链接器
- 读取.interp段
- 加载动态链接器到内存
- 跳转到动态链接器入口
3. 动态链接器初始化
- 自我重定位(链接器也是动态库!)
- 建立内部数据结构
4. 加载依赖的动态库
- 读取.dynamic段的NEEDED项
- 按顺序加载每个.so文件
- 递归加载间接依赖
- 构建加载顺序列表
5. 符号解析
- 遍历所有.dynsym表
- 建立全局符号表
- 解析每个未定义符号
6. 重定位
- 处理.rel.dyn段(数据重定位)
填充.got中的全局变量地址
- 处理.rel.plt段(函数重定位)
设置.got.plt的初始值(延迟绑定)
7. 执行初始化函数
- 调用每个库的.init段
- 调用每个库的.init_array
8. 转移控制权
- 跳转到可执行文件的入口点
- 程序开始执行
6.3 运行时:延迟绑定
第一次调用函数:
bash
1. 调用PLT桩代码
2. GOT表项指向PLT内的下一条指令
3. 压入符号索引
4. 跳转到PLT[0]
5. 调用动态链接器的_dl_runtime_resolve
6. 动态链接器:
- 根据索引查找符号
- 在已加载的库中解析符号
- 获取函数真实地址
- 填充GOT表项
- 跳转到函数
7. 函数执行并返回
后续调用:
bash
1. 调用PLT桩代码
2. GOT表项已填充,直接跳转到函数
3. 函数执行并返回

七、动态库的查找路径
7.1 查找顺序
动态链接器查找.so文件的顺序:
bash
1. DT_RPATH (不推荐,已废弃)
可执行文件中指定的路径
2. LD_LIBRARY_PATH 环境变量
export LD_LIBRARY_PATH=/path/to/libs
3. DT_RUNPATH
可执行文件中指定的路径(新标准)
4. /etc/ld.so.cache
ldconfig生成的缓存
5. 默认系统路径
/lib, /lib64, /usr/lib, /usr/lib64
7.2 设置RPATH和RUNPATH
在编译时指定库路径:
bash
# 设置RPATH(立即解析)
gcc main.c -L. -lmystdio -Wl,-rpath,/home/user/mylib -o main
# 设置RUNPATH(允许LD_LIBRARY_PATH覆盖)
gcc main.c -L. -lmystdio -Wl,--enable-new-dtags,-rpath,/home/user/mylib -o main
查看RPATH/RUNPATH:
bash
readelf -d main | grep PATH
0x000000000000000f (RPATH) Library rpath: [/home/user/mylib]
# 或
0x000000000000001d (RUNPATH) Library runpath: [/home/user/mylib]
优势:
- 无需设置环境变量
- 无需修改系统配置
- 程序自带路径信息
7.3 使用$ORIGIN变量
$ORIGIN:代表可执行文件所在目录
bash
# 相对路径:在可执行文件同目录下的lib子目录查找
gcc main.c -L. -lmystdio -Wl,-rpath,'$ORIGIN/lib' -o main
# 验证
readelf -d main | grep PATH
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/lib]
目录结构:
bash
myapp/
├── main ← 可执行文件
└── lib/
└── libmystdio.so ← 动态库
好处:
- 可移植性好
- 整个目录可以随意移动
- 常用于软件打包发布
八、动态库版本管理
8.1 SO-NAME机制
动态库的命名规范:
bash
libname.so.x.y.z
│ │ │ │ │
│ │ │ │ └── 发布版本号(release)
│ │ │ └──── 次版本号(minor)
│ │ └────── 主版本号(major)
│ └───────── 扩展名
└──────────────── 库名前缀
例如:
libmystdio.so.1.2.3
三个文件名:
bash
# 1. 真实文件名(real name)
libmystdio.so.1.2.3 ← 实际的文件
# 2. SO-NAME(链接器使用)
libmystdio.so.1 ← 符号链接,指向真实文件
# 3. 链接器名(linker name)
libmystdio.so ← 符号链接,编译时使用
查看:
bash
ls -l libmystdio*
-rwxr-xr-x 1 user user 12345 Oct 28 10:00 libmystdio.so.1.2.3
lrwxrwxrwx 1 user user 19 Oct 28 10:00 libmystdio.so.1 -> libmystdio.so.1.2.3
lrwxrwxrwx 1 user user 15 Oct 28 10:00 libmystdio.so -> libmystdio.so.1
8.2 创建带版本的动态库
bash
# 编译
gcc -fPIC -shared my_stdio.c my_string.c -o libmystdio.so.1.2.3 \
-Wl,-soname,libmystdio.so.1
# 创建符号链接
ln -s libmystdio.so.1.2.3 libmystdio.so.1
ln -s libmystdio.so.1 libmystdio.so
# 查看SO-NAME
readelf -d libmystdio.so.1.2.3 | grep SONAME
0x000000000000000e (SONAME) Library soname: [libmystdio.so.1]
8.3 版本兼容性
主版本号(major):
- 不兼容的API变化
- 程序需要重新编译
次版本号(minor):
- 增加新功能,保持向后兼容
- 程序无需重新编译
发布版本号(release):
- Bug修复,不改变API
- 程序无需重新编译
示例:
bash
# 应用程序链接 libmystdio.so.1
gcc main.c -lmystdio -o main
readelf -d main | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libmystdio.so.1]
# 可以使用以下任意版本:
libmystdio.so.1.0.0
libmystdio.so.1.1.0 ← 增加了新功能
libmystdio.so.1.2.3 ← 修复了bug
# 但不能使用:
libmystdio.so.2.0.0 ← 主版本号改变,不兼容
九、符号的可见性控制
9.1 为什么需要控制符号可见性
问题:
- 默认情况下,动态库中的所有全局符号都可见
- 可能与其他库的符号冲突
- 暴露内部实现细节
解决方案:符号可见性控制
9.2 使用__attribute__控制
c
// my_lib.c
// 公开接口
__attribute__((visibility("default")))
int public_function() {
return internal_function();
}
// 内部函数
__attribute__((visibility("hidden")))
int internal_function() {
return 42;
}
bash
gcc -fPIC -shared my_lib.c -o libmy.so
# 查看符号
nm -D libmy.so
000000000000068a T public_function
# internal_function不可见!
9.3 使用-fvisibility编译选项
bash
# 默认隐藏所有符号
gcc -fPIC -shared -fvisibility=hidden my_lib.c -o libmy.so
# 只导出标记为default的符号
推荐做法:
c
// my_lib.h
#ifdef BUILDING_MY_LIB
#define MY_LIB_API __attribute__((visibility("default")))
#else
#define MY_LIB_API
#endif
// 公开API
MY_LIB_API int public_function();
// 内部函数(不声明在头文件中)
static int internal_helper();
十、动态加载(dlopen)
10.1 dlopen系列函数
除了自动加载,还可以手动控制加载:
c
#include <dlfcn.h>
// 打开动态库
void *dlopen(const char *filename, int flag);
// 查找符号
void *dlsym(void *handle, const char *symbol);
// 获取错误信息
char *dlerror(void);
// 关闭动态库
int dlclose(void *handle);
10.2 实战:动态加载插件
插件接口:
c
// plugin.h
typedef int (*plugin_init_func)(void);
typedef int (*plugin_run_func)(void);
typedef void (*plugin_cleanup_func)(void);
插件实现:
c
// plugin_hello.c
#include <stdio.h>
int plugin_init() {
printf("Hello plugin initializing...\n");
return 0;
}
int plugin_run() {
printf("Hello plugin running!\n");
return 0;
}
void plugin_cleanup() {
printf("Hello plugin cleanup.\n");
}
bash
gcc -fPIC -shared plugin_hello.c -o plugin_hello.so
主程序:
c
// main.c
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include "plugin.h"
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <plugin.so>\n", argv[0]);
return 1;
}
// 加载插件
void *handle = dlopen(argv[1], RTLD_NOW);
if (!handle) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
return 1;
}
// 查找符号
plugin_init_func init = dlsym(handle, "plugin_init");
plugin_run_func run = dlsym(handle, "plugin_run");
plugin_cleanup_func cleanup = dlsym(handle, "plugin_cleanup");
if (!init || !run || !cleanup) {
fprintf(stderr, "dlsym failed: %s\n", dlerror());
dlclose(handle);
return 1;
}
// 使用插件
if (init() != 0) {
fprintf(stderr, "Plugin init failed\n");
dlclose(handle);
return 1;
}
run();
cleanup();
// 卸载插件
dlclose(handle);
return 0;
}
bash
gcc main.c -ldl -o main
./main plugin_hello.so
Hello plugin initializing...
Hello plugin running!
Hello plugin cleanup.
10.3 dlopen的flag参数
c
// 何时解析符号
RTLD_NOW // 立即解析所有符号
RTLD_LAZY // 延迟解析(调用时才解析)
// 符号可见性
RTLD_GLOBAL // 该库的符号可用于后续加载的库
RTLD_LOCAL // 该库的符号不对外可见(默认)
// 组合使用
dlopen("lib.so", RTLD_NOW | RTLD_GLOBAL);
十一、常见问题与调试
11.1 undefined symbol错误
现象:
bash
./main
./main: symbol lookup error: ./libmy.so: undefined symbol: foo
原因:
- 动态库依赖的符号在运行时找不到
解决方法:
bash
# 1. 查看缺少的符号
nm -D libmy.so | grep foo
U foo # U表示未定义
# 2. 查找哪个库提供了foo
nm -D /usr/lib/*.so | grep " T foo"
# 3. 链接时加上依赖的库
gcc -shared -fPIC my_lib.c -o libmy.so -lfoo
11.2 版本冲突
现象:
bash
./main
./main: /lib/libc.so.6: version `GLIBC_2.34' not found
原因:
- 程序需要的库版本比系统提供的版本新
解决方法:
bash
# 1. 查看程序需要的版本
readelf -V main
# 2. 查看系统提供的版本
strings /lib/libc.so.6 | grep GLIBC_
# 3. 升级系统库或重新编译程序
11.3 使用LD_DEBUG调试
LD_DEBUG环境变量可以打印动态链接器的详细信息:
bash
# 显示所有可用选项
LD_DEBUG=help ./main
# 显示库的查找过程
LD_DEBUG=libs ./main
# 显示符号绑定过程
LD_DEBUG=bindings ./main
# 显示重定位过程
LD_DEBUG=reloc ./main
# 显示所有信息
LD_DEBUG=all ./main
# 输出到文件
LD_DEBUG=all LD_DEBUG_OUTPUT=debug.log ./main
示例输出:
bash
LD_DEBUG=libs ./main
150: find library=libmystdio.so [0]; searching
150: search cache=/etc/ld.so.cache
150: search path=/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu
150: trying file=/lib/x86_64-linux-gnu/libmystdio.so
150: trying file=/usr/lib/x86_64-linux-gnu/libmystdio.so
150: search path=./lib (RUNPATH from file ./main)
150: trying file=./lib/libmystdio.so
150:
150: find library=libc.so.6 [0]; searching
150: search cache=/etc/ld.so.cache
150: trying file=/lib/x86_64-linux-gnu/libc.so.6
十二、总结
本文深入讲解了动态链接的完整机制:
核心知识点:
-
位置无关码(PIC)
- 使用相对地址而不是绝对地址
- 编译时使用
-fPIC选项 - 使代码可以加载到任意地址
-
全局偏移表(GOT)
- 存储全局变量和函数的地址
- 运行时由动态链接器填充
- 实现数据访问的位置无关性
-
过程链接表(PLT)
- 函数调用的跳转桩代码
- 配合GOT实现延迟绑定
- 第一次调用时解析,后续直接跳转
-
动态链接流程
- 编译时:生成动态链接信息
- 加载时:动态链接器解析和重定位
- 运行时:延迟绑定优化性能
-
库的查找和版本管理
- LD_LIBRARY_PATH、RPATH、RUNPATH
- SO-NAME机制
- 版本号规范
-
动态加载(dlopen)
- 手动控制库的加载和卸载
- 实现插件系统
- 灵活的符号查找
完整的动态链接调用流程图:
bash
编译时:
源文件(.c)
↓ gcc -fPIC -shared
动态库(.so)
├→ .text (位置无关码)
├→ .got (全局偏移表,待填充)
├→ .plt (过程链接表)
└→ .dynsym (动态符号表)
加载时:
内核加载程序
↓
启动动态链接器 (/lib64/ld-linux-x86-64.so.2)
↓
动态链接器工作:
1. 加载依赖的.so
2. 解析符号
3. 填充GOT表
4. 设置PLT
↓
跳转到程序入口
运行时(第一次调用函数):
main: call printf@plt
↓
PLT[printf]: jmp *GOT[printf] → 指向PLT内部
↓
PLT[printf]: push index; jmp PLT[0]
↓
PLT[0]: 调用动态链接器
↓
动态链接器:解析printf,填充GOT[printf]
↓
跳转到printf
↓
printf执行
运行时(后续调用):
main: call printf@plt
↓
PLT[printf]: jmp *GOT[printf] → 直接跳转到printf(快!)
↓
printf执行
💡 思考题
- 为什么动态库必须使用
-fPIC编译?- GOT表为什么是可写的,而PLT是只读的?
- 延迟绑定的优势和劣势分别是什么?
- 如何禁用延迟绑定,强制立即绑定所有符号?(提示:LD_BIND_NOW)
至此,我们完整掌握了Linux下库的制作、静态链接和动态链接的全部原理!