跳转至

05-动态链接与共享库

重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐⭐ 学习时间:1.5天 必须掌握:是


为什么学这一章?

现代程序几乎都依赖动态链接库(.so / .dll)。打开任何程序,它都会加载libc、libm、libpthread等共享库。理解动态链接是排查"找不到库"、"符号未定义"等运行时问题的关键。

学完这一章,你将能够: - ✅ 理解动态链接器的工作原理 - ✅ 掌握GOT/PLT机制实现延迟绑定 - ✅ 使用dlopen/dlsym实现运行时加载 - ✅ 解决常见的动态链接问题


📖 核心概念

1. 动态链接概述

Text Only
┌─────────────────────────────────────────────────────────────┐
│              动态链接 vs 静态链接                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  静态链接(编译时确定)                                      │
│  ┌─────────┐                                               │
│  │ 可执行文件 │ ← 包含所有代码(自包含、体积大)              │
│  │ (10 MB)  │                                               │
│  └─────────┘                                               │
│                                                             │
│  动态链接(运行时确定)                                      │
│  ┌─────────┐    加载时链接    ┌──────────┐                 │
│  │ 可执行文件 │ ───────────→ │ libc.so  │                  │
│  │ (100 KB) │ ───────────→ │ libm.so  │                  │
│  └─────────┘    运行时链接   │ libssl.so│                  │
│                              └──────────┘                   │
│  多个程序共享同一份库代码(节省内存和磁盘)                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

2. 动态链接器的工作

Text Only
┌─────────────────────────────────────────────────────────────┐
│          程序启动时的动态链接过程                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  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. 跳转到程序入口点开始执行                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
Bash
# 查看程序依赖的动态库
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机制

Text Only
┌─────────────────────────────────────────────────────────────┐
│              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

C
// 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;
}
Bash
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(位置无关代码)

C
// 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;
}
Bash
# 编译为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 运行时加载

C
// 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;
}
Bash
gcc runtime_loading.c -o runtime_load -ldl
./runtime_load

插件系统示例

C
// === 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;
}
Bash
# 编译插件
gcc -fPIC -shared my_plugin.c -o my_plugin.so

# 编译宿主
gcc host.c -o host -ldl

# 运行
./host ./my_plugin.so

6. 常见动态链接问题排查

Bash
# 问题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尽早发现问题。


📝 本章小结

Text Only
┌─────────────────────────────────────────────┐
│              本章核心知识点                    │
├─────────────────────────────────────────────┤
│                                             │
│  1. 动态链接在运行时加载和解析共享库        │
│  2. GOT存地址、PLT做跳转桩                 │
│  3. 延迟绑定:首次调用时才解析             │
│  4. PIC使库可加载到任意地址                 │
│  5. dlopen/dlsym实现运行时插件机制         │
│  6. ldd/LD_DEBUG/LD_PRELOAD排查问题        │
│                                             │
└─────────────────────────────────────────────┘

回到目录README.md