Posts 聊聊动态库
Post
Cancel

聊聊动态库

1. 装载时重定位的理解

1. 程序内存地址的不可预测性

在现代操作系统中,每个程序和共享库(如动态链接库)在启动时并不知道它们将被加载到哪一块内存区域。操作系统通过虚拟内存技术为每个进程分配独立的内存空间,虚拟地址空间对于每个进程来说是隔离的。

然而,程序代码和数据中很多地方都需要依赖内存地址来进行访问。例如,程序中的函数调用和全局变量都使用具体的内存地址。

但是,问题在于:

  • 如果程序和共享库的内存地址是固定的(比如编译时就已经确定了),这意味着每次运行程序时,操作系统必须确保每个程序都能够加载到相同的内存地址。
  • 问题:这不仅会造成内存空间的浪费(如果多个程序和库总是希望占用相同的地址空间),还可能会导致内存地址冲突。

2. 共享库的灵活性

在动态链接的情况下,程序通常会依赖于共享库(如 libc.so)。共享库的代码和数据并不是与程序在编译时就绑定的,而是直到程序运行时才会被加载。

如果每个进程都在运行时加载到共享库的相同内存地址,这样就可以节省内存空间。但是,这种做法也存在问题:在加载到不同的地址时,程序和共享库中的代码依赖于内存地址的地方需要调整。比如:

  • 程序的指令或数据如果需要访问某个全局变量或函数,它会使用某个特定的内存地址。
  • 如果共享库加载的地址与预期不同,那么这些地址就不再有效。

3. 装载时重定位的必要性

装载时重定位的目的就是解决上述问题,确保程序在不同的运行环境中都能正确运行。它的作用就是:

  • 修正地址引用:当程序或共享库被加载到内存时,操作系统会调整所有需要访问内存地址的地方。无论程序中某个函数或全局变量被引用了多少次,都需要根据加载地址来调整相应的内存地址。

举个简单的例子:

  • 假设一个函数 foo() 被定义在 libfoo.so 中。编译时,程序的代码可能包含对 foo() 函数的调用,但是 foo() 的具体内存地址在编译时并不确定,编译时它只是一个符号(名字)。
  • 当程序启动时,操作系统将加载 libfoo.so 并可能将它加载到某个内存地址(比如 0x10000000)。此时,程序中的调用 foo() 就会引用到该内存地址(比如 0x10000010)。如果程序中原本有一个指向 foo() 的地址是基于其他内存布局(比如 0x20000000),那么操作系统需要“重定位”程序中的指令,使得它们指向正确的内存地址。

4. 如何进行装载时重定位?

  • 符号解析:程序中的每个函数调用或全局变量引用,都包含了一个符号,它代表了某个函数或变量。装载时,操作系统会解析这些符号,并查找它们的实际内存地址。
  • 地址修正:通过将程序和库中引用的地址与实际加载的地址进行匹配,操作系统会修正这些地址,确保程序中的每个引用都指向正确的内存位置。

5. 为什么不提前做重定位?

有些程序在编译时并不确定所有的依赖库在哪些内存地址,因此不能在编译时做出具体的地址绑定。动态链接提供了灵活性,允许程序和库共享内存区域,不需要每个进程都加载一个独立的副本,减少内存浪费。

  • 静态链接:将库和程序的所有代码直接绑定到一个可执行文件中。程序在运行时不需要再进行符号解析,所有地址已经确定。
  • 动态链接:库的代码是独立的,程序运行时会动态加载它们。在这种情况下,程序不能在编译时确定共享库的内存地址,因此在加载时需要进行重定位。

总结

装载时重定位的根本目的是:

  • 让程序和共享库能够在运行时被加载到任意位置,而不依赖于固定的内存地址。
  • 确保程序中所有的地址引用(无论是函数调用还是数据访问)都能指向正确的内存位置。

重定位确保了程序在不同的运行环境和不同的内存分配情况下都能够正确运行,避免了固定地址带来的内存冲突和浪费问题。

2. 示例:动态库装载过程

假设程序 main 动态链接到 libfoo.so,并调用了 foo() 函数,动态库装载过程如下:

  1. 程序启动时
    • 程序启动后,操作系统加载动态链接器 ld-linux.so,并通过动态链接器来加载程序所依赖的共享库 libfoo.so
  2. 动态链接器加载库
    • 动态链接器 ld-linux.so 会读取程序的 ELF 格式文件,分析其中的依赖关系,发现程序需要加载 libfoo.so
  3. 检查重定位表
    • 动态链接器检查 libfoo.so 的重定位表,通常为 .rel.dyn 节,里面包含了程序在运行时需要重定位的符号(例如 foo() 函数)。
  4. 计算实际地址
    • 动态链接器根据加载地址计算 foo() 函数在 libfoo.so 中的实际内存地址。这个地址会被填入 全局偏移表(GOT) 中。
  5. 调用函数时的跳转
    • 当程序调用 foo() 函数时,程序会跳转到 过程链接表(PLT)
    • PLT 会通过 GOT 中存储的地址,找到 foo() 的实际地址。
  6. 执行函数
    • 通过 GOT 和 PLT,程序最终会跳转到 libfoo.so 中的 foo() 函数并执行。

总结

  • GOT (Global Offset Table):存储共享库函数的实际内存地址,程序运行时动态加载时更新。
  • PLT (Procedure Linkage Table):一个跳板,用于函数调用时跳转到正确的地址,PLT 中的代码会使用 GOT 中的地址找到函数并执行。
  • ld-linux.so:动态链接器,负责加载共享库和处理符号解析及重定位。

通过这种方式,程序可以在运行时动态链接共享库,避免了程序在编译时就固定链接到共享库的内存地址。

3. 地址无关代码

1 例子概览

我们有一个源程序 main.c,调用了动态库 libfoo.so 中的函数 foo(),动态库中还定义了一个全局变量 global_var

1.1 源程序中的重定位

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

// 声明动态库中的函数和变量
extern void foo();
extern int global_var;

int main() {
    printf("Before calling foo, global_var = %d\n", global_var);
    foo();
    printf("After calling foo, global_var = %d\n", global_var);
    return 0;
}

编译和链接

1
gcc -o main main.c -L. -lfoo

生成可执行文件 main。

重定位内容:

  1. 对 foo() 的调用
    • 在编译时,foo 的地址未知,编译器只生成一个调用 foo 的指令,链接器在 .plt 表中添加一个入口,指向动态链接器。
    • 动态链接器在运行时会将 foo 的实际地址填入 .got 表中。
  2. 对 global_var 的访问:
    • 类似 foo(),global_var 的地址在编译时未知,程序通过 .got 表间接访问其地址。
    • 动态链接器在运行时解析 global_var 的地址,并填入 .got 表中。

1.2 动态库中的重定位

动态库 libfoo.so 的代码如下:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

// 定义全局变量
int global_var = 10;

// 定义函数
void foo() {
    printf("Inside foo()\n");
    global_var += 5;
}

编译

1
gcc -shared -fPIC -o libfoo.so libfoo.c

生成动态库 libfoo.so。

重定位内容

  1. 动态库的装载地址:

    • 动态库的装载地址在运行时由操作系统决定(启用 ASLR 的情况下会随机分配)。
    • 动态库中的指令需要适应任意装载地址,因此编译时必须使用 -fPIC,生成位置无关代码(PIC)。
  2. 全局变量 global_var 的重定位:

    • 即使使用 -fPIC,global_var 的地址仍需要在装载时重定位,因为它是全局的,位于 .data 段。
    • 动态链接器在装载动态库时,会将 global_var 的实际地址填入 .got 表中。

重定位过程

  1. 假设系统分配了以下地址:

    • main 的虚拟地址:0x400000
    • libfoo.so 的装载地址:0x7f8000000000
  2. 对函数 foo 的调用

    • 源程序中调用 foo(),指令是通过 .plt 表间接调用。
    • .plt 表中的入口会访问 .got 表,动态链接器在 .got 表中填入 foo 的实际地址。
    • 程序通过 .plt 表找到动态库中 foo 的地址,并跳转执行。
  3. 对变量 global_var 的访问

    • 源程序中的 global_var 访问通过 .got 表完成。
    • 动态链接器在 .got 表中填入 global_var 在 libfoo.so 中的实际地址。
    • 每次访问 global_var 时,程序间接通过 .got 表访问其实际地址。
  4. 地址变化的完整流程

    • 编译时,源程序中的 foo 和 global_var 的地址是未解析的,使用 .plt 和 .got 表进行间接访问。
    • 链接时,动态链接器负责将 libfoo.so 加载到内存,并填充 .got 表,使源程序可以找到动态库中的符号地址。
    • 动态库的指令使用相对地址访问变量,代码段无需重定位;数据段中的全局变量会被动态链接器重定位到实际内存地址。
  5. 结果分析

  • 动态库装载地址的变化:动态库每次装载地址可能不同,但位置无关代码(PIC)无需修改指令,只需调整数据段。
  • 源程序中的重定位:主要针对动态库的符号解析,通过 .plt 和 .got 实现。

4. 再聊地址无关代码 fPIC

1
2
3
4
5
6
7
   // -shared 共享对象使用装载时重定位
   // -fPIC 使用地址无关代码
   gcc -shared -fPIC -o libfoo.so libfoo.c  


   // 查看 .so 二进制是否是否 -fPIC
   xm@xm:~/xx/complier$ readelf -d SimpleSection  | grep TEXTREL

4.1 为什么非要使用 fPIC

动态库的特点

动态库可能会被多个程序或线程同时加载,而加载时库的代码段需要在虚拟地址空间中放置。由于不同进程的地址空间可能存在冲突,动态库的加载地址是不可预测的。

动态库的加载方式

  • 动态库的代码段可以被多个进程共享(只读,节省内存)。
  • 如果动态库的代码中包含硬编码的绝对地址,加载时就需要修改代码段,导致无法共享。
  • 使用 -fPIC 编译后,代码中引用数据的地址是相对的,避免了这种问题。

为什么需要 -fPIC

避免绝对地址:

  1. 默认情况下,编译器会生成包含硬编码地址的指令,比如直接访问全局变量的绝对地址。
    • fPIC 生成的代码通过相对地址或表间接访问全局变量,避免了硬编码的绝对地址。
    • 提高动态库的可重定位性:
  2. 动态库可能被加载到任何地址。使用 -fPIC 生成的代码不依赖于具体的加载地址,可以在多个进程中共享同一份代码段
    • 减少加载时的重定位开销:
  3. 如果不使用 -fPIC,动态库的每次加载都需要修改其代码段中的硬编码地址,增加了重定位时间和内存开销。
    • 使用 -fPIC 时,运行时只需调整 GOT(全局偏移表)或 PLT(过程链接表),而不修改代码段

举例说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 不使用 iPIC
// lib_no_pic.c
int global_var = 42;

int get_global_var() {
    return global_var;
}

gcc -shared -o lib_no_pic.so lib_no_pic.c


// 使用 iPIC
// lib_pic.c
int global_var = 42;

int get_global_var() {
    return global_var;
}

gcc -shared -fPIC -o lib_pic.so lib_pic.c

  1. 不使用: 动态库中可能包含硬编码地址,例如直接引用 global_var 的地址 0x00405000。如果另一个程序将此库加载到不同的地址,例如 0x00600000,就需要修改代码段中对 global_var 的所有引用。

  2. 使用: 动态库中不会硬编码 global_var 的绝对地址,而是通过 GOT 表间接引用。加载库时,动态链接器只需要调整 GOT 表,而无需修改代码段。

4.2 动态库中的函数调用和数据访问机制

动态库的运作需要处理模块内部和外部的函数调用与数据访问问题。以下是对四种典型情况的分析:

1.动态库模块内部函数调用/跳转

描述
  • 动态库内部的函数调用,比如 funcA 调用 funcB,两者都在同一动态库中。
实现机制
  • 直接跳转:编译时,生成指令直接跳转到目标函数地址(相对跳转或固定偏移)。
  • 无需重定位:模块内的地址关系是固定的,无需动态链接器调整。
特点
  • 高效:不需要访问 GOT 或 PLT 表。
  • 不涉及动态链接器。

2. 动态库模块内部数据访问(全局变量、静态变量等)

描述
  • 动态库内部的全局变量或静态变量被同一模块内的代码访问。
实现机制
  • 直接访问(非位置无关代码)
    • 变量地址为绝对地址,编译时确定。
    • 加载时需要重定位,修正变量的绝对地址。
  • 间接访问(位置无关代码,PIC)
    • 使用 -fPIC 编译时,变量地址通过 GOT 表间接访问。
    • 加载时,动态链接器填充 GOT 表,指向实际变量地址。
特点
  • PIC:减少加载时的重定位开销。
  • 非 PIC:需要修改代码段,无法实现代码共享。

3. 动态库模块外部函数调用/跳转

描述
  • 动态库调用其他模块(如另一个动态库或主程序)中的函数。
实现机制
  • 通过 PLT 实现
    • 函数调用指向 PLT 表的入口。
    • 加载时,动态链接器将 PLT 表项指向实际函数地址。
    • 调用流程:
      1. 首次调用时,PLT 跳转到动态链接器完成地址解析,并写入 GOT 表。
      2. 随后调用直接通过 GOT 表找到实际地址。
特点
  • 首次调用有解析开销,之后效率较高。
  • 实现动态解析,支持灵活加载。

4. 动态库模块外部数据访问(其他模块中的全局变量)

描述
  • 动态库访问其他模块(如主程序或其他动态库)中的全局变量。
实现机制
  • 通过 GOT 表实现
    • 编译时,外部变量的访问指向 GOT 表。
    • 加载时,动态链接器填充 GOT 表项,指向实际变量地址。
    • 访问时,通过 GOT 表间接定位到目标变量。
特点
  • 避免硬编码绝对地址,提高灵活性。
  • 存在间接寻址的开销。

对比总结

情况实现方式是否需动态链接器参与特点
模块内部函数调用/跳转直接跳转(相对地址或固定偏移)高效,无需动态链接器参与。
模块内部数据访问直接访问(绝对地址或 GOT 表)可能(取决于是否使用 PIC)使用 PIC 时无须重定位;非 PIC 时需要重定位。
模块外部函数调用/跳转通过 PLT 表首次调用需要解析开销,之后高效。
模块外部数据访问通过 GOT 表动态链接器填充 GOT 表,访问需要间接寻址开销。

4.3 共享模块引用了一个全局变量的时候, 如何解决?

并不知道他是在共享库的其他目标文件中,还是其他的共享库。


背景

  1. 问题描述
    • 共享模块中引用了一个全局变量,但编译时无法确定该变量的具体位置:
      • 它可能在同一共享库的其他目标文件中。
      • 它可能定义在其他共享库中。
      • 它可能定义在主程序中。
  2. 挑战
    • 如何在不确定具体位置的情况下正确解析变量地址?
    • 如何支持动态加载,避免硬编码地址?

解决方案机制

1. 使用 GOT(全局偏移表)
  • GOT 的作用
    • 所有全局变量的访问都通过 GOT 表完成。
    • 每个全局变量对应 GOT 表中的一个条目。
    • 编译时,变量的访问指向 GOT 表,运行时动态链接器填充 GOT 表,指向实际变量地址。
  • 工作流程
    1. 编译时
      • 变量访问生成对 GOT 表的间接访问指令。
      • 例如,mov rax, [GOT + offset],从 GOT 表中获取变量地址。
    2. 链接时
      • 链接器为变量分配 GOT 表的条目。
      • 如果变量在当前共享库中,填充当前库内的地址。
      • 如果变量在其他模块中,等待动态链接器解析。
    3. 加载时
      • 动态链接器解析所有外部变量引用,将变量的真实地址写入 GOT 表。

5.聊聊延迟绑定

延迟绑定是动态链接的一种优化策略,主要用于共享库的函数调用。与传统的“立即绑定”(eager binding)不同,延迟绑定不会在程序加载时解析所有的动态符号,而是推迟到符号第一次被调用时才进行解析。这种策略通过减少启动时的开销来提升程序的初始加载性能。

6. 聊聊显示调用动态库

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <dlfcn.h>
#include <stdio.h>

int main() {
    // 1. 定义一个指向共享库中函数的指针
    // 这里我们假设加载一个数学库(libm.so),并调用其中的 cos 函数
    void *handle;           // 用于保存共享库句柄
    double (*cos_func)(double);  // 声明一个指向 cos 函数的指针

    // 2. 使用 dlopen 加载共享库
    // RTLD_LAZY 表示懒加载,函数符号将在使用时解析
    handle = dlopen("libm.so", RTLD_LAZY);  
    if (!handle) {  // 如果加载失败
        fprintf(stderr, "dlopen error: %s\n", dlerror());  // 输出错误信息
        return 1;
    }

    // 3. 使用 dlsym 查找共享库中的符号(函数或变量)
    cos_func = dlsym(handle, "cos");  // 查找 cos 函数
    if (!cos_func) {  // 如果查找失败
        fprintf(stderr, "dlsym error: %s\n", dlerror());  // 输出错误信息
        dlclose(handle);  // 卸载库
        return 1;
    }

    // 4. 调用函数(这里是调用 cos 函数)
    printf("cos(1) = %f\n", cos_func(1));  // 调用 cos 函数并打印结果

    // 5. 使用 dlclose 卸载共享库
    // **注意:** dlclose 并不是立即释放资源,而是减少共享库的引用计数
    // 如果引用计数为零,才会真正卸载库
    // 如果多个调用 `dlopen` 加载同一个库,每调用一次 `dlclose`,引用计数会减少一次
    // 只有当所有对该库的引用计数都减少为零时,才会卸载并释放资源
    dlclose(handle);  // 卸载库

    // 如果我们再次调用 `dlclose`,并且共享库的引用计数还未归零
    // 库不会立即被卸载,因此调用第二次 `dlclose` 不会有任何影响
    dlclose(handle);  // 再次调用 dlclose,但共享库仍然保持加载状态,引用计数未归零

    return 0;
}

多线程 动态库 变量安全问题

动态库中的 全局变量 是在进程的所有线程之间共享的,可能会导致并发访问冲突,需要通过线程同步机制来解决。

This post is licensed under CC BY 4.0 by the author.

Contents

Trending Tags