【Linux】库制作与原理(三):动态链接与加载机制

文章目录

    • 库制作与原理(三):动态链接与加载机制
    • 一、进程地址空间与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]

动态链接器的作用:

  1. 加载程序依赖的所有动态库
  2. 解析符号引用
  3. 进行重定位
  4. 将控制权交给程序

启动流程:

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)

分析:

  1. 压入GOT[1](链接器标识)
  2. 跳转到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

十二、总结

本文深入讲解了动态链接的完整机制:

核心知识点:

  1. 位置无关码(PIC)

    • 使用相对地址而不是绝对地址
    • 编译时使用-fPIC选项
    • 使代码可以加载到任意地址
  2. 全局偏移表(GOT)

    • 存储全局变量和函数的地址
    • 运行时由动态链接器填充
    • 实现数据访问的位置无关性
  3. 过程链接表(PLT)

    • 函数调用的跳转桩代码
    • 配合GOT实现延迟绑定
    • 第一次调用时解析,后续直接跳转
  4. 动态链接流程

    • 编译时:生成动态链接信息
    • 加载时:动态链接器解析和重定位
    • 运行时:延迟绑定优化性能
  5. 库的查找和版本管理

    • LD_LIBRARY_PATH、RPATH、RUNPATH
    • SO-NAME机制
    • 版本号规范
  6. 动态加载(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执行

💡 思考题

  1. 为什么动态库必须使用-fPIC编译?
  2. GOT表为什么是可写的,而PLT是只读的?
  3. 延迟绑定的优势和劣势分别是什么?
  4. 如何禁用延迟绑定,强制立即绑定所有符号?(提示:LD_BIND_NOW)

至此,我们完整掌握了Linux下库的制作、静态链接和动态链接的全部原理!

相关推荐
tokepson3 小时前
Mysql下载部署方法备份(Windows/Linux)
linux·服务器·windows·mysql
zz_nj5 小时前
工作的环境
linux·运维·服务器
极客先躯6 小时前
如何自动提取Git指定时间段的修改文件?Win/Linux双平台解决方案
linux·git·elasticsearch
suijishengchengde7 小时前
****LINUX时间同步配置*****
linux·运维
qiuqyue7 小时前
基于虹软Linux Pro SDK的多路RTSP流并发接入、解码与帧级处理实践
linux·运维·网络
切糕师学AI7 小时前
Linux 操作系统简介
linux
南烟斋..8 小时前
GDB调试核心指南
linux·服务器
爱跑马的程序员8 小时前
Linux 如何查看文件夹的大小(du、df、ls、find)
linux·运维·ubuntu
oMcLin10 小时前
如何在 Ubuntu 22.04 LTS 上部署并优化 Magento 电商平台,提升高并发请求的响应速度与稳定性?
linux·运维·ubuntu
Qinti_mm10 小时前
Linux io_uring:高性能异步I/O革命
linux·i/o·io_uring