【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下库的制作、静态链接和动态链接的全部原理!

相关推荐
一个不知名程序员www2 小时前
算法学习入门---C/C++输入输出
c语言·c++
APIshop2 小时前
高性能采集方案:淘宝商品 API 的并发调用与数据实时处理
linux·网络·算法
松涛和鸣2 小时前
DAY38 TCP Network Programming
linux·网络·数据库·网络协议·tcp/ip·算法
川212 小时前
ZooKeeper配置+失误
linux·分布式·zookeeper
向日葵.2 小时前
中间件交接文档
linux·运维·服务器
Ghost Face...3 小时前
U-Boot与PMON:配置与设备树解析对比
linux·单片机·嵌入式硬件
技术摆渡人3 小时前
Android 全栈架构终极指南:从 Linux 内核、Binder 驱动到 Framework 源码实战
android·linux·架构
qq_254617773 小时前
Linux创建VLAN虚拟网卡的命令
linux·网络协议
wdfk_prog3 小时前
[Linux]学习笔记系列 -- [fs][fs_parser]
linux·笔记·学习