05-动态链接与共享库¶
重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐⭐ 学习时间:1.5天 必须掌握:是
为什么学这一章?¶
现代程序几乎都依赖动态链接库(.so / .dll)。打开任何程序,它都会加载libc、libm、libpthread等共享库。理解动态链接是排查"找不到库"、"符号未定义"等运行时问题的关键。
学完这一章,你将能够: - ✅ 理解动态链接器的工作原理 - ✅ 掌握GOT/PLT机制实现延迟绑定 - ✅ 使用dlopen/dlsym实现运行时加载 - ✅ 解决常见的动态链接问题
📖 核心概念¶
1. 动态链接概述¶
┌─────────────────────────────────────────────────────────────┐
│ 动态链接 vs 静态链接 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 静态链接(编译时确定) │
│ ┌─────────┐ │
│ │ 可执行文件 │ ← 包含所有代码(自包含、体积大) │
│ │ (10 MB) │ │
│ └─────────┘ │
│ │
│ 动态链接(运行时确定) │
│ ┌─────────┐ 加载时链接 ┌──────────┐ │
│ │ 可执行文件 │ ───────────→ │ libc.so │ │
│ │ (100 KB) │ ───────────→ │ libm.so │ │
│ └─────────┘ 运行时链接 │ libssl.so│ │
│ └──────────┘ │
│ 多个程序共享同一份库代码(节省内存和磁盘) │
│ │
└─────────────────────────────────────────────────────────────┘
2. 动态链接器的工作¶
┌─────────────────────────────────────────────────────────────┐
│ 程序启动时的动态链接过程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 内核加载可执行文件 │
│ 2. 内核检查ELF头中的PT_INTERP段 │
│ → 找到动态链接器路径 /lib64/ld-linux-x86-64.so.2 │
│ 3. 内核加载动态链接器到内存 │
│ 4. 动态链接器开始工作: │
│ a. 读取可执行文件的.dynamic段 │
│ b. 找到所有依赖的共享库(DT_NEEDED) │
│ c. 递归加载所有依赖库到内存 │
│ d. 处理符号解析和重定位 │
│ e. 执行各库的初始化函数(.init/.init_array) │
│ 5. 跳转到程序入口点开始执行 │
│ │
└─────────────────────────────────────────────────────────────┘
# 查看程序依赖的动态库
ldd /bin/ls
# 查看动态链接器信息
readelf -l /bin/ls | grep interpreter
# 查看.dynamic段
readelf -d /bin/ls
# 运行时设置库搜索路径
LD_LIBRARY_PATH=/my/lib ./my_program
# 查看库搜索顺序
ldconfig -p | grep libc
3. GOT和PLT机制¶
┌─────────────────────────────────────────────────────────────┐
│ GOT/PLT 延迟绑定机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 第一次调用 printf() 时: │
│ │
│ 代码段 PLT GOT │
│ ┌──────┐ ┌──────────┐ ┌───────────┐ │
│ │ call │───①───→│PLT[printf]│──②──→ │GOT[printf]│ │
│ │printf│ │ jmp *GOT │ │ = PLT+6 │←初始 │
│ └──────┘ │ push idx │←③──────│ │ │
│ │ jmp PLT0 │ └───────────┘ │
│ └────┬─────┘ │
│ ④ │
│ ┌──────────┐ │
│ │ PLT[0] │──⑤──→ 动态链接器 │
│ │ 调用ld.so│ _dl_runtime_resolve │
│ └──────────┘ ↓ │
│ 找到printf真实地址 │
│ 写入GOT表 ──⑥──→ │
│ │
│ 第二次调用 printf() 时: │
│ call → PLT[printf] → jmp *GOT → 直接跳到printf │
│ (GOT已被填入真实地址,不再经过链接器) │
│ │
└─────────────────────────────────────────────────────────────┘
观察GOT/PLT¶
// got_plt_demo.c
#include <stdio.h>
#include <string.h>
int main() {
// 第一次调用printf - 触发PLT解析
printf("第一次调用\n");
// 第二次调用printf - GOT已填充,直接跳转
printf("第二次调用\n");
// 查看strlen的GOT地址
size_t len = strlen("hello");
printf("len = %zu\n", len);
return 0;
}
gcc -g -no-pie got_plt_demo.c -o got_demo
# 查看PLT段
objdump -d -j .plt got_demo
# 查看GOT段
objdump -R got_demo
# 用GDB观察延迟绑定
gdb ./got_demo
(gdb) break main
(gdb) run
(gdb) x/gx 0x404018 # printf的GOT条目地址(根据实际值)
# 第一次:指向PLT中的桩代码
(gdb) call (int)printf("test\n")
(gdb) x/gx 0x404018
# 第二次:指向printf的真实地址
4. PIC(位置无关代码)¶
// pic_example.c - 位置无关代码原理
#include <stdio.h>
int global_var = 42;
int get_global() {
// PIC模式下,global_var的访问通过GOT间接寻址:
// 1. 获取当前指令的地址(RIP相对寻址)
// 2. 加上GOT偏移找到GOT条目
// 3. 从GOT读取global_var的实际地址
// 4. 通过该地址访问数据
return global_var;
}
void set_global(int val) {
global_var = val;
}
# 编译为PIC代码
gcc -fPIC -S -masm=intel pic_example.c -o pic_example.s
# 观察global_var的访问方式:
# mov rax, QWORD PTR global_var@GOTPCREL[rip]
# mov eax, DWORD PTR [rax]
# 编译为非PIC对比
gcc -fno-PIC -S -masm=intel pic_example.c -o nopic_example.s
# 直接访问:mov eax, DWORD PTR global_var[rip]
5. dlopen/dlsym 运行时加载¶
// runtime_loading.c - 运行时动态加载共享库
#include <stdio.h>
#include <dlfcn.h>
int main() {
// 运行时加载数学库
void *handle = dlopen("libm.so.6", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "加载失败: %s\n", dlerror());
return 1;
}
// 查找sin函数符号
typedef double (*math_func)(double);
math_func my_sin = (math_func)dlsym(handle, "sin");
char *error = dlerror();
if (error) {
fprintf(stderr, "符号查找失败: %s\n", error);
dlclose(handle);
return 1;
}
// 调用动态加载的函数
double result = my_sin(3.14159265 / 2.0);
printf("sin(π/2) = %f\n", result);
// 查找cos函数
math_func my_cos = (math_func)dlsym(handle, "cos");
printf("cos(0) = %f\n", my_cos(0.0));
// 卸载库
dlclose(handle);
return 0;
}
插件系统示例¶
// === plugin.h ===
#ifndef PLUGIN_H
#define PLUGIN_H
typedef struct {
const char *name; // 指针:存储变量的内存地址
int (*init)(void);
int (*process)(int input);
void (*cleanup)(void);
} Plugin;
Plugin *get_plugin(void);
#endif
// === my_plugin.c === (编译为.so)
#include "plugin.h" // 引入头文件
#include <stdio.h>
static int init() {
printf("[MyPlugin] 初始化\n");
return 0;
}
static int process(int input) {
printf("[MyPlugin] 处理: %d → %d\n", input, input * 2);
return input * 2;
}
static void cleanup() {
printf("[MyPlugin] 清理\n");
}
static Plugin plugin = {
.name = "MyPlugin",
.init = init,
.process = process,
.cleanup = cleanup
};
Plugin *get_plugin(void) { return &plugin; }
// === host.c === (宿主程序)
#include <stdio.h>
#include <dlfcn.h>
#include "plugin.h"
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("用法: %s <plugin.so>\n", argv[0]);
return 1;
}
void *handle = dlopen(argv[1], RTLD_NOW);
if (!handle) {
fprintf(stderr, "加载插件失败: %s\n", dlerror());
return 1;
}
typedef Plugin *(*GetPluginFunc)(void);
GetPluginFunc get = (GetPluginFunc)dlsym(handle, "get_plugin");
if (!get) {
fprintf(stderr, "找不到get_plugin: %s\n", dlerror());
dlclose(handle);
return 1;
}
Plugin *p = get();
printf("加载插件: %s\n", p->name);
p->init();
int result = p->process(21);
printf("结果: %d\n", result);
p->cleanup();
dlclose(handle);
return 0;
}
# 编译插件
gcc -fPIC -shared my_plugin.c -o my_plugin.so
# 编译宿主
gcc host.c -o host -ldl
# 运行
./host ./my_plugin.so
6. 常见动态链接问题排查¶
# 问题1:找不到共享库
# error: libfoo.so: cannot open shared object file
# 解决方法:
LD_LIBRARY_PATH=/path/to/lib ./program # 临时
sudo ldconfig /path/to/lib # 永久
echo "/path/to/lib" | sudo tee /etc/ld.so.conf.d/foo.conf # |管道:将前一命令的输出作为后一命令的输入
sudo ldconfig
# 问题2:符号版本不匹配
# 查看库提供的符号版本
objdump -T /usr/lib/libc.so.6 | grep printf # grep文本搜索:按模式匹配行
# 问题3:RPATH设置
gcc -Wl,-rpath,/my/lib main.c -L/my/lib -lfoo -o main
readelf -d main | grep RPATH
# 问题4:查看符号绑定过程
LD_DEBUG=bindings ./program 2>&1 | head -50
# 问题5:禁用延迟绑定(调试用)
LD_BIND_NOW=1 ./program
💡 面试常见问题¶
Q1:动态链接的优缺点?¶
答:优点:①节省磁盘和内存(共享库只需一份);②可独立更新库(不需重编译应用);③支持插件机制(dlopen运行时加载)。缺点:①运行时依赖(部署需同时提供.so);②首次调用有延迟绑定开销;③版本兼容性问题(DLL Hell / SO Hell)。
Q2:GOT和PLT分别的作用?为什么需要两个表?¶
答:GOT(全局偏移表)存储全局数据和函数的实际地址;PLT(过程链接表)是函数调用的跳转桩。分开的原因:GOT在数据段(可写),PLT在代码段(可执行但只读)。PLT代码跳转到GOT中存储的地址,延迟绑定时由链接器更新GOT条目。
Q3:什么是符号版本控制?如何解决ABI兼容性问题?¶
答:Linux使用符号版本控制(如printf@GLIBC_2.2.5)让同一个.so文件提供不同版本的同名函数。旧程序链接旧版本符号,新程序链接新版本。同时使用soname(如libc.so.6)区分不兼容的大版本。
Q4:LD_PRELOAD有什么用?有什么安全风险?¶
答:LD_PRELOAD指定一个共享库在所有库之前加载,可以覆盖(hook)任何库函数。用途:①调试(拦截malloc统计内存分配);②修复bug(替换有问题的函数)。安全风险:恶意替换关键函数(如密码验证),因此setuid程序会忽略LD_PRELOAD。
Q5:dlopen的RTLD_LAZY和RTLD_NOW有什么区别?¶
答:RTLD_LAZY延迟绑定,符号在首次使用时才解析,加载更快但可能运行时才发现缺失符号;RTLD_NOW立即绑定,加载时解析所有符号,如果有缺失符号立即报错。生产环境建议用RTLD_NOW尽早发现问题。
📝 本章小结¶
┌─────────────────────────────────────────────┐
│ 本章核心知识点 │
├─────────────────────────────────────────────┤
│ │
│ 1. 动态链接在运行时加载和解析共享库 │
│ 2. GOT存地址、PLT做跳转桩 │
│ 3. 延迟绑定:首次调用时才解析 │
│ 4. PIC使库可加载到任意地址 │
│ 5. dlopen/dlsym实现运行时插件机制 │
│ 6. ldd/LD_DEBUG/LD_PRELOAD排查问题 │
│ │
└─────────────────────────────────────────────┘
回到目录:README.md