CSAPP(lecture 13_02 Linking)

静态库(Static Libraries)

静态库的创建方法

前面我们已经了解,链接器可以将多个独立编译的目标文件组合成一个可执行程序。但在实际开发中,程序往往需要调用大量已经实现的通用函数,例如 printf、malloc、strlen、sqrt 等。

这些函数不可能每次都由程序员重新实现,因此需要一种机制,将它们统一组织起来,供不同程序重复使用。

最直接的方式是将所有通用函数放在同一个源文件中,再编译成一个大型目标文件。但这样存在明显的问题:即使程序只使用其中一个函数,链接时也可能引入大量不需要的代码和数据。

另一种方式是将每个函数分别编译为独立的目标文件,例如:

c 复制代码
printf.c   -> printf.o
scanf.c    -> scanf.o
strlen.c   -> strlen.o
memcpy.c   -> memcpy.o

这样可以根据实际需求 选择目标文件,但如果程序调用了几十个库函数,就需要在链接命令中指定大量 .o 文件。而且,一个库函数可能继续调用其他库函数,开发者还需要手动管理这些依赖关系,显然不适合大型项目。

因此,更合理的设计是:将多个可重定位目标文件集中管理,同时允许链接器根据程序的实际需求,自动选择需要的目标文件。

这就是静态库(Static Library)的基本思想。

在 Linux 中,静态库通常以 .a 为后缀,本质上是一种归档文件(Archive),由多个可重定位目标文件及相应的索引信息组成。例如:

c 复制代码
libvector.a
    |
    |-- addvec.o
    |
    |-- multvec.o

其中,addvec.o 实现向量相加,multvec.o 实现向量相乘。它们仍然是独立的可重定位目标文件,只是被打包进了同一个静态库中。

这里需要明确一个概念:静态库并不是已经完成链接的可执行程序,而是等待其他程序使用的一组目标文件。 库中的 .o 文件仍然保存机器指令、符号信息以及必要的重定位记录。最终是否将某个目标文件加入可执行程序,取决于链接器对符号引用的解析结果。

例如,程序只调用 addvec,并没有调用 multvec。链接器处理 libvector.a 时,可以根据未解析的 addvec 符号找到 addvec.o,并将这个目标文件提取出来参与链接,而不必同时提取 multvec.o。

这称为按需提取(Selective Extraction),也是静态库区别于直接链接一组普通目标文件的重要特点。

不过,静态库通常以目标文件成员 为基本提取单位,而不是以函数为单位。假设一个 math.o 中同时实现了 add、sub 和 mul,即使程序只调用 add,链接器也可能需要提取整个 math.o。

因此,静态库中目标文件的组织粒度,会直接影响最终可能引入多少代码。

接下来,我们自己实现一个简单的向量运算静态库,观察从函数封装、目标文件生成到最终链接的完整过程。

首先创建头文件 vector.h,声明两个向量运算函数:

c 复制代码
#ifndef VECTOR_H
#define VECTOR_H

void addvec(int *x, int *y, int *z, int n);
void multvec(int *x, int *y, int *z, int n);

#endif

其中,addvec 负责向量逐元素相加,multvec 负责向量逐元素相乘,参数 x 和 y 是输入数组,z 用于保存结果,n 表示数组的元素数量。

这里的头文件只提供函数接口,不包含函数实现。这样,使用库的程序只需要知道函数的名称、参数和返回类型,而不需要知道内部采用了怎样的算法。

接下来实现 addvec.c:

c 复制代码
#include "vector.h"

void addvec(int *x, int *y, int *z, int n)
{
    for (int i = 0; i < n; i++)
    {
        z[i] = x[i] + y[i];
    }
}

再实现 multvec.c:

c 复制代码
#include "vector.h"

void multvec(int *x, int *y, int *z, int n)
{
    for (int i = 0; i < n; i++)
    {
        z[i] = x[i] * y[i];
    }
}

两个函数分别对应:

zi=xi+yi z_i=x_i+y_i zi=xi+yi

以及:

zi=xiyi z_i=x_i y_i zi=xiyi

其中 0≤i<n0\leq i<n0≤i<n。由于两个函数的实现相互独立,我们可以分别编译:

c 复制代码
gcc -Og -c addvec.c -o addvec.o
gcc -Og -c multvec.c -o multvec.o

此时生成两个可重定位目标文件:

c 复制代码
addvec.o
multvec.o

这里使用 -c,表示只生成目标文件,不执行最终链接。因为我们希望将这两个目标文件封装成静态库,而不是直接生成可执行程序。

接下来使用 Linux 中的归档工具 ar:

c 复制代码
ar rcs libvector.a addvec.o multvec.o

其中,r (replace)表示插入或替换归档成员,c (create)表示在归档不存在时创建文件,s 表示创建或更新符号索引。生成的 libvector.a 就是我们自己的静态库。

为什么静态库需要符号索引?

因为链接器使用静态库时,真正关心的是某个未定义符号由哪个归档成员提供。如果一个静态库包含几千个目标文件,而没有便于查找的索引,链接器就需要进行更多的成员检查。归档符号索引记录了符号与成员之间的关系,使链接器能够更高效地查找提供某个符号的目标文件。

可以通过下面的命令查看归档中的成员:

c 复制代码
ar -t libvector.a

输出:

c 复制代码
addvec.o
multvec.o

也可以使用:

c 复制代码
ar -tv libvector.a

查看更详细的成员信息,包括文件大小、权限等。

如果希望查看静态库中的符号,可以使用:

c 复制代码
nm -A libvector.a

在普通 GNU/Linux 工具链下,可以观察到类似的结果:

c 复制代码
libvector.a:addvec.o:0000000000000000 T addvec
libvector.a:multvec.o:0000000000000000 T multvec

这里的地址只是各目标文件中的符号值,并不是函数在最终可执行程序中的运行地址。

T 表示符号定义位于代码节 .text 中,而 nm 中的 U 则表示目标文件引用了尚未定义的外部符号。

因此,链接器可以知道:

  • addvec.o 提供 addvec 的定义。
  • multvec.o 提供 multvec 的定义。

这样,当其他程序引用其中某个函数时,链接器就能够根据符号信息找到对应的目标文件成员。

静态库还支持增量更新。例如,如果我们修改了 addvec.c,只需要重新编译对应的源文件:

c 复制代码
gcc -Og -c addvec.c -o addvec.o

再更新静态库:

c 复制代码
ar rcs libvector.a addvec.o

这样就可以用新的 addvec.o 替换静态库中的旧成员,而不必重新编译没有发生变化的 multvec.c。

这种方式本质上仍然利用了前面介绍的分离编译机制,只不过多个目标文件现在由一个归档文件统一管理。

实际 Linux 系统中,静态库的规模可能远大于这个例子。例如,libc.a 通常包含大量与输入输出、字符串处理、内存管理等相关的目标文件,libm.a 则提供数学函数的静态实现。

一个程序可能只需要使用其中很少的一部分函数,因此按照符号需求提取目标文件,可以减少不必要的代码引入。

现在,我们已经创建了自己的 libvector.a,接下来需要让另一个程序使用其中的函数。

创建 main2.c:

c 复制代码
#include <stdio.h>
#include "vector.h"

int main()
{
    int x[2] = {1, 2};
    int y[2] = {3, 4};
    int z[2];

    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);

    return 0;
}

这里,程序只调用 addvec,没有调用 multvec。

首先将主程序编译为目标文件:

c 复制代码
gcc -Og -c main2.c -o main2.o

然后查看其中尚未定义的外部符号:

c 复制代码
nm -u main2.o

通常可以看到:

c 复制代码
U addvec
U __printf_chk

这两个符号分别来自我们自己创建的向量运算库,以及 C 标准库。

现在执行链接:

c 复制代码
gcc main2.o ./libvector.a -o prog2

这里直接指定 libvector.a 的路径,表示让链接器使用这个静态归档。

链接器处理 main2.o 时,发现当前程序引用了 addvec,但 main2.o 中并没有这个函数的定义。于是,它继续扫描 libvector.a,根据归档中的符号信息找到 addvec.o,并将其提取出来参与链接。

而 multvec.o 没有提供当前需要解析的符号,因此通常不会被提取。

至于 printf,则由 GCC 驱动所安排的 C 标准库依赖提供。这里需要区分:我们明确使用的是静态库 libvector.a,但程序并不一定整体采用静态链接,默认情况下 printf 等标准库函数通常仍然通过共享库提供。

因此,最终链接过程可以理解为:

c 复制代码
main2.o
   |
   | 引用 addvec
   | 引用 printf
   |
   v
libvector.a
   |
   | 提取 addvec.o
   | 不提取 multvec.o
   |
   v
C 标准库
   |
   | 满足 printf 的引用
   |
   v
Linker
   |
   | 符号解析
   | 重定位
   |
   v
prog2

运行程序:

c 复制代码
./prog2

输出:

c 复制代码
z = [4 6]

我们还可以通过 nm 进一步验证链接器究竟提取了哪些函数:

c 复制代码
nm prog2 | grep addvec
nm prog2 | grep multvec

在没有剥离符号的普通构建情况下,通常能够找到 addvec,而找不到 multvec。这说明虽然两个函数都位于同一个静态库中,但最终只有程序需要的目标文件成员被引入。

如果希望进一步观察链接器实际选择了哪些归档成员,可以生成链接映射文件(Linker Map):

c 复制代码
gcc main2.o ./libvector.a \
    -Wl,-Map=prog2.map \
    -o prog2

其中,-Wl 表示将后面的参数传递给链接器,-Map=prog2.map 则要求链接器输出映射信息。

打开 prog2.map,在 GNU 链接器生成的映射文件中,通常可以找到类似内容:

c 复制代码
Archive member included to satisfy reference by file (symbol)

./libvector.a(addvec.o)
                              main2.o (addvec)

它表明 addvec.o 被提取的原因,是 main2.o 中存在对 addvec 的引用。相比单纯检查最终符号表,这种方式更加直接地展示了静态库按需提取的过程。

至此,我们就能够区分普通目标文件与静态库在链接过程中的处理方式。

如果直接使用:

c 复制代码
gcc main2.o addvec.o multvec.o -o prog2

那么三个普通目标文件都作为输入参与链接,即使 main2.o 并没有调用 multvec。当然,如果启用了相应的节级垃圾回收优化,最终仍可能移除部分未引用代码,但这是另一个层次的机制。

而使用:

c 复制代码
gcc main2.o ./libvector.a -o prog2

链接器通常根据未解析符号按需提取归档成员,因此 multvec.o 可以从一开始就不参与最终链接。

这也意味着:将目标文件打包为静态库,不只是为了减少链接命令中的文件数量,更重要的是改变了链接器选择目标文件的方式。

静态库还具有一个重要的运行时特征。由于被提取的目标文件代码已经参与最终链接,生成的可执行程序不需要在运行时重新打开原来的 .a 文件。

我们可以验证这一点:

c 复制代码
mv libvector.a libvector.a.bak
./prog2

如果程序之前已经成功完成静态链接,那么即使把 libvector.a 临时移走,prog2 通常仍然能够运行。

不过,这不代表程序已经完全不依赖任何共享库。它仍然可能动态依赖 libc.so 等系统共享库,只是不再依赖原来的 libvector.a 归档文件。

最后,还需要掌握实际项目中使用静态库的常见方式。

对于一个准备提供给其他开发者使用的库,通常需要同时提供头文件和库文件。例如:

c 复制代码
vector/
├── include/
│   └── vector.h
└── lib/
    └── libvector.a

其中,include 保存函数声明,lib 保存编译后的静态库。使用者不需要获得 addvec.c 和 multvec.c 的源代码,也可以根据头文件提供的接口调用相应函数。

假设主程序位于当前目录,可以使用:

c 复制代码
gcc main2.c \
    -I./vector/include \
    -L./vector/lib \
    -lvector \
    -o prog2

这里涉及三个常见的 GCC 参数:

-I 指定头文件搜索路径,告诉编译器到哪里寻找 vector.h;

-L 指定库文件搜索路径,告诉链接器到哪里寻找指定的库;

-lvector 则表示按照库命名规则查找对应的库文件。

对于:

c 复制代码
libvector.a

指定库名时需要去掉前缀 lib 和后缀 .a,所以使用:

c 复制代码
-lvector

需要注意,如果同一搜索路径下同时存在 libvector.so 和 libvector.a,在常见的 GNU/Linux 默认动态链接模式下,链接器通常优先选择共享库。因此,专门验证静态链接行为时,直接指定 .a 文件路径更加明确。

这里也需要区分 -I 与 -L 的作用:前者解决编译器寻找声明(.h)的位置,后者解决链接器寻找库文件(.c)的位置。

头文件路径正确,并不意味着链接器一定能找到库;反过来,链接器能找到静态库,也不代表编译器能够找到对应的头文件。

这正是很多项目中会分别提供 include 和 lib 目录的原因。头文件描述接口,静态库提供已经编译完成的实现,而编译器和链接器在不同阶段分别使用它们。

但理解静态库的按需提取机制之后,还会出现一个非常重要的问题:如果链接器需要先发现某个未定义符号,才会从静态库中提取相应成员,那么静态库放在链接命令的前面还是后面,就可能直接影响最终结果。

因此,接下来需要进一步分析链接器扫描静态库时的符号解析算法,以及为什么仅仅交换两个链接参数的顺序,就可能产生 undefined reference 错误。

静态库的链接顺序

前面我们已经知道,链接器处理静态库时,并不会无条件地提取归档中的所有目标文件,而是根据当前尚未解析的符号引用,选择能够提供相应定义的目标文件成员。正是这种按需提取机制,使静态库能够在集中管理大量函数的同时,避免引入不必要的代码。

但这种机制也带来了一个容易被忽略的问题:静态库中明明存在某个函数的定义,链接器却仍然可能报告 undefined reference。 造成这种情况的原因,不一定是函数不存在或者库文件路径错误,也可能仅仅是链接命令中的文件顺序不正确。

继续使用前面创建的 libvector.a。我们已经知道,main2.o 引用了 addvec,而 libvector.a 中的 addvec.o 提供了该函数的定义。

因此,下面的链接命令能够成功:

c 复制代码
gcc main2.o ./libvector.a -o prog2

但如果交换两个输入文件的顺序:

c 复制代码
gcc ./libvector.a main2.o -o prog2

在传统的 GNU 静态归档链接规则下,就可能出现:

c 复制代码
undefined reference to `addvec'

两个命令使用的目标文件和静态库完全相同,为什么只是改变顺序,就会产生不同的链接结果?

原因在于,链接器通常按照命令行顺序处理目标文件和静态库,并在处理过程中不断维护已经发现的符号定义,以及尚未解析的符号引用。

为了理解这个过程,可以将链接器处理的符号抽象为三个集合:

  • EEE:当前需要参与链接的目标文件集合。
  • DDD:已经发现的符号定义集合(Defined Symbols)。
  • UUU:尚未解析的符号引用集合(Undefined Symbols)。

其中,EEE 用于记录哪些目标文件已经被纳入链接,DDD 保存这些文件提供的符号定义,而 UUU 则记录当前仍然需要从其他目标文件或库中寻找定义的符号。

需要注意,这只是对传统静态链接算法的简化描述。实际链接器还需要维护符号绑定属性、可见性、版本信息以及 Section 等数据,但不影响我们理解它的核心流程。

当链接器遇到一个普通目标文件 .o 时,通常会直接将它加入 EEE,再根据该目标文件的符号表更新 DDD 和 UUU。如果某个原本未解析的符号在新目标文件中找到了定义,就可以将它从 UUU 中移除;如果新目标文件又引用了其他尚未定义的符号,就需要将这些引用加入 UUU。

但当链接器遇到静态库 .a 时,处理方式不同。静态库中的目标文件不会全部自动加入 EEE,只有能够满足当前未解析符号需求的成员,才会被提取出来。

现在重新分析正确的链接命令:

c 复制代码
gcc main2.o ./libvector.a -o prog2

链接器首先读取 main2.o。根据前面观察过的符号表,当前文件定义了 main,同时引用了 addvec 和 printf。

因此,可以将当前符号状态简化为:

D={main} D=\{main\} D={main}

U={addvec,printf} U=\{addvec,printf\} U={addvec,printf}

随后,链接器开始扫描 libvector.a。由于未解析集合 UUU 中存在 addvec,而归档成员 addvec.o 恰好提供该符号的定义,链接器便将 addvec.o 提取出来,加入需要链接的目标文件集合 EEE。

此时,addvec 已经获得定义,可以从 UUU 中移除:

D={main,addvec} D=\{main,addvec\} D={main,addvec}

U={printf} U=\{printf\} U={printf}

如果 addvec.o 自身还引用了其他外部函数,那么这些新的未解析引用也需要加入 UUU,由后续目标文件或库继续提供定义。

这里还有一个细节:链接器并不是扫描静态库中的每个成员一次之后就立即结束,而是可以在当前归档内部继续提取由新引用所需要的其他成员,直到该归档无法再满足更多未解析符号

例如,假设 addvec.o 还调用了同一个静态库中 helper.o 提供的函数,那么提取 addvec.o 后产生的新引用,也可以使链接器继续提取 helper.o。

扫描静态库完成之后,链接器再处理后续输入及 GCC 安排的标准库依赖,最终解析 printf 等符号,并完成链接。

现在交换顺序:

c 复制代码
gcc ./libvector.a main2.o -o prog2

链接器首先遇到的是 libvector.a。但此时 main2.o 尚未被处理,因此链接器还不知道程序需要 addvec。

也就是说,在扫描该静态库时,当前未解析符号集合中并没有 addvec。因此,链接器没有理由提取 addvec.o,同样也不需要提取 multvec.o。

直到后面读取 main2.o,链接器才发现程序需要 addvec。然而,静态库已经在前面被扫描过了。对于普通的单次命令行归档扫描流程,链接器不会因为后面新出现了一个未解析符号,就自动返回重新扫描之前的所有静态库。

因此,即使 libvector.a 中确实存在 addvec 的定义,链接器最终仍然可能无法解析这个符号,从而产生链接错误。

这就解释了静态库链接中一个非常重要的原则:

依赖某个静态库的目标文件应该放在前面,而提供相应定义的静态库通常应该放在后面。

这个规则不仅适用于普通目标文件与静态库之间,同样适用于多个静态库之间的依赖关系。

假设我们现在有两个静态库:

c 复制代码
libA.a
libB.a

其中,libA.a 提供函数 funcA,但 funcA 的实现还需要调用 libB.a 中的 funcB。

例如,a.c 中定义:

c 复制代码
void funcB(void);
void funcA(void)
{
    funcB();
}

而 b.c 中定义:

c 复制代码
#include <stdio.h>

void funcB(void)
{
    printf("funcB\n");
}

分别编译并创建静态库:

c 复制代码
gcc -c a.c -o a.o
gcc -c b.c -o b.o

ar rcs libA.a a.o
ar rcs libB.a b.o

主程序 main.c 只调用 funcA:

c 复制代码
void funcA(void);

int main(void)
{
    funcA();
    return 0;
}

由于 main 直接依赖 funcA,而 funcA 又依赖 funcB,因此整个依赖关系为:

main⟶funcA⟶funcB main \longrightarrow funcA \longrightarrow funcB main⟶funcA⟶funcB

对应到静态库:

main.o⟶libA.a⟶libB.a main.o \longrightarrow libA.a \longrightarrow libB.a main.o⟶libA.a⟶libB.a

所以,合理的链接顺序是:

c 复制代码
gcc main.o -L. -lA -lB -o prog

链接器先处理 main.o,发现需要 funcA;随后扫描 libA.a,提取提供 funcA 的 a.o。但 a.o 又引入了对 funcB 的引用,因此链接器继续扫描 libB.a,提取提供 funcB 的 b.o。

整个过程可以理解为未解析符号集合的变化:

{funcA}⟶{funcB}⟶∅ \{funcA\} \longrightarrow \{funcB\} \longrightarrow \varnothing {funcA}⟶{funcB}⟶∅

这里省略了运行时启动代码和标准库的其他符号引用,只关注我们自己编写的函数。

如果把静态库顺序改成:

c 复制代码
gcc main.o -L. -lB -lA -o prog

那么链接器处理 main.o 后,首先需要的是 funcA。但它先扫描 libB.a,而该库只能提供 funcB。由于当前还没有发现对 funcB 的引用,因此 b.o 不会被提取。

随后,链接器扫描 libA.a,成功提取 a.o,但这时才发现 funcB 尚未解析。而提供 funcB 的 libB.a 已经在前面扫描过了,因此最终仍然可能产生链接错误。

可以发现,库文件的排列顺序实际上反映了程序中不同模块之间的依赖关系。 如果一个库依赖另一个库,那么通常应当将依赖者放在前面,被依赖者放在后面

不过,实际工程中的依赖关系并不一定都是简单的单向关系。有时两个静态库可能相互依赖,这会使普通的单次扫描无法完成所有符号解析。

例如,假设 libA.a 中包含两个目标文件:

c 复制代码
libA.a
    |
    |-- a1.o
    |
    |-- a2.o

而 libB.a 中包含:

c 复制代码
libB.a
    |
    |-- b1.o

其中,主程序调用 a1,a1 调用 b1,而 b1 又调用 a2。

它们之间的依赖关系为:

main⟶a1⟶b1⟶a2 main \longrightarrow a1 \longrightarrow b1 \longrightarrow a2 main⟶a1⟶b1⟶a2

虽然 a1 和 a2 都属于 libA.a,但它们分别位于不同的目标文件成员中。

现在使用:

c 复制代码
gcc main.o -lA -lB -o prog

链接器处理 main.o 后,发现需要 a1,于是扫描 libA.a 并提取 a1.o。

提取 a1.o 后,产生对 b1 的引用。随后扫描 libB.a,提取 b1.o。

但 b1.o 又引用了 a2,因此现在需要重新从 libA.a 中提取 a2.o。

问题在于,libA.a 已经完成了前面的扫描,而链接命令后面没有再次出现这个库,所以 a2 可能无法得到解析。

这说明,即使两个静态库的文件都存在,并且每个函数都具有对应的定义,只要依赖关系跨越了归档扫描顺序,仍然可能出现链接错误。

对于这种情况,一种简单的方法是重复指定静态库:

c 复制代码
gcc main.o -lA -lB -lA -o prog

第一次扫描 libA.a 时提取 a1.o,随后扫描 libB.a 时提取 b1.o,最后再次扫描 libA.a,便可以提取提供 a2 的成员。

另一种更通用的方法,是使用 GNU 链接器提供的库分组机制:

c 复制代码
gcc main.o \
    -Wl,--start-group \
    -lA -lB \
    -Wl,--end-group \
    -o prog

其中,-Wl 表示将后面的参数传递给链接器,--start-group 与 --end-group 则将多个静态库组织成一个分组。

链接器会对分组内的归档进行必要的重复搜索,直到无法再通过这些库解析新的符号引用。这样就可以解决部分静态库之间的循环依赖问题。

但重复扫描归档也会增加链接器的处理开销,因此在实际工程中,更应该优先通过合理的模块划分和依赖组织,减少不必要的循环依赖。

这里还可以进一步区分普通目标文件与静态库的处理方式。普通 .o 文件作为链接输入时,通常会直接参与链接,而静态归档中的成员则需要根据未解析符号进行选择。

因此,即使两个输入文件最终都能提供相同的函数定义,将它们作为普通目标文件直接链接,和将它们放入静态库后再链接,也可能产生不同的结果。

这也是为什么大型 C/C++ 项目在将多个目标文件封装为静态库之后,有时会突然出现此前没有发生过的 undefined reference 错误。

对于这种问题,可以通过以下命令观察目标文件与静态库中的符号:

c 复制代码
nm -u main.o

nm -A libA.a

nm -A libB.a

其中,nm -u 用于列出尚未定义的外部符号,nm -A 则可以展示符号所属的归档成员。

如果希望进一步观察链接器为什么提取某个静态库成员,可以生成链接映射文件:

c 复制代码
gcc main.o -lA -lB \
    -Wl,-Map=prog.map \
    -o prog

在 GNU 链接器生成的映射文件中,通常能够找到某个归档成员因哪个符号引用而被提取的信息。这对于定位复杂项目中的链接依赖问题非常有帮助。

因此,当实际工程中出现 undefined reference 时,除了检查函数是否已经实现、声明是否一致以及库文件路径是否正确,还应该检查静态库之间的依赖关系和链接顺序。特别是使用多个第三方静态库时,不能简单认为只要将所有 -lxxx 选项写进链接命令,链接器就一定能够自动处理所有依赖。

在 CMake 项目中,类似的依赖关系通常通过 target_link_libraries 表达。例如,某个目标需要链接 A 和 B,可以写成:

c 复制代码
target_link_libraries(app PRIVATE A B)

如果 A 本身是一个依赖 B 的静态库,更合理的做法是在定义 A 的构建规则中声明它对 B 的链接依赖,让 CMake 根据目标依赖关系组织最终链接命令,而不是在每个可执行程序中手动重复维护库顺序。

至此,我们已经理解了静态库的完整链接机制:链接器通过符号表识别尚未解析的引用,再根据命令行顺序扫描静态库,并以目标文件成员为单位提取所需实现。 这种设计避免了无条件引入整个库,但也使链接结果受到归档顺序和模块依赖关系的影响。

不过,静态库仍然存在一个局限。被提取的目标文件通常会在链接阶段成为可执行程序的一部分。

假设系统中有大量程序都使用相同的库函数,那么这些程序可能分别保存一份相同的机器代码;当库的实现发生更新时,已经静态链接完成的程序通常还需要重新链接,才能使用新的实现。

因此,为了进一步减少重复代码,并支持多个程序共享库的实现,现代 Linux 系统大量采用共享库(Shared Libraries)与动态链接(Dynamic Linking)

共享库(Shared Libraries)

前面我们已经了解了静态库的工作原理:链接器根据未解析的符号引用,从静态库中提取需要的目标文件,并将它们的代码和数据组织到最终的可执行文件中。这种方式解决了通用函数的组织和复用问题,但也意味着,一旦完成静态链接,所提取的库代码通常就成为可执行文件自身的一部分。

例如,假设系统中有 100100100 个程序,每个程序都需要使用标准 C 库中的某些函数。如果这些函数全部采用静态链接,那么相同的机器代码就可能分别保存在 100100100 个可执行文件中。这不仅增加了磁盘空间的占用,而且多个程序运行时,各自的进程映像中也可能包含重复的库代码。

此外,当某个库函数出现安全漏洞,需要修复时,已经完成静态链接的可执行文件通常不会因为系统中的静态库更新而自动改变。程序必须重新链接,才能将新的函数实现引入可执行文件。

因此,静态库虽然实现了源代码与目标文件的复用,却没有充分实现运行时机器代码的共享。

为了解决这些问题,现代操作系统大量采用共享库(Shared Library),也称为动态链接库。在 Linux 中,共享库通常以 .so 为后缀,例如:

C 复制代码
libc.so.6
libm.so.6
libstdc++.so.6

与静态库不同,共享库中的函数实现通常不会在构建可执行文件时直接复制进去,而是由动态链接器(Dynamic Linker)在程序加载或运行期间完成相关符号解析和地址绑定,使程序能够使用共享库中的代码。

例如,程序需要调用 printf 时,静态链接可以把提供该函数实现的相关目标文件成员引入可执行程序;而采用动态链接时,可执行文件可以保留对外部函数的动态符号引用,在运行过程中通过动态链接机制找到共享库中的实现。

因此,两种链接方式最根本的区别在于:静态链接主要在构建阶段引入库代码,而动态链接允许部分符号解析和地址绑定延迟到程序加载或运行期间。

不过,需要进一步理解的是,动态链接并不意味着共享库中的所有代码在程序启动时都必须被完整复制到内存中。

现代 Linux 使用虚拟内存和内存映射机制,将共享库的可加载段映射到进程虚拟地址空间。多个进程虽然拥有相互独立的虚拟地址空间,却可以将各自的虚拟页面映射到相同的只读物理页面。

例如,假设进程 AAA 和进程 BBB 都需要使用某个共享库:

C 复制代码
Process A                  Process B

Virtual Memory             Virtual Memory

+---------------+          +---------------+
| libvector.so  |          | libvector.so  |
| Code Mapping  |          | Code Mapping  |
+-------+-------+          +-------+-------+
        |                          |
        |                          |
        +------------+-------------+
                     |
                     v
             Physical Memory

             +---------------+
             | Shared Code   |
             | Pages         |
             +---------------+

两个进程中的共享库虚拟地址不一定相同,但它们可以共享底层的只读代码页面,因此不需要为每个进程都保存一份独立的物理代码副本。

这里需要区分共享代码和共享数据。共享库的只读代码页面通常可以在多个进程之间共享,但共享库中的普通可写全局变量通常仍然具有进程隔离性。

例如:

C 复制代码
int counter = 0;

void increment()
{
    counter++;
}

假设进程 AAA 和进程 BBB 都加载了包含上述代码的共享库,那么它们可以共享 increment 对应的只读机器代码页面,但普通情况下并不会共享同一个可写的 counter 对象。

进程 AAA 修改 counter,通常不会直接改变进程 BBB 中该变量的值。这是因为不同进程仍然具有独立的虚拟地址空间和相应的数据映射,必要时可以通过写时复制(Copy-on-Write)机制保持隔离。

因此,共享库所说的共享,主要是代码及其他可共享页面的共享,而不是让所有进程直接共享同一份可写全局状态

动态链接的时机

共享库可以在程序生命周期中的不同阶段参与链接,主要分为加载时动态链接(Load-time Dynamic Linking)和运行时动态链接(Run-time Dynamic Linking)。

加载时动态链接是最常见的情况。例如,我们编写一个调用 printf 的程序:

c 复制代码
#include <stdio.h>

int main()
{
    printf("Hello World\n");
    return 0;
}

在常见的 Linux 环境中,直接使用:

bash 复制代码
gcc main.c -o prog

生成的程序通常会动态依赖标准 C 库。

这意味着,链接器虽然已经生成了 prog,但程序并没有将 printf 的完整实现直接复制进自己的可执行文件,而是记录了必要的动态依赖和符号引用信息。

当程序被执行时,系统会依据 ELF 中的相关信息加载动态链接器,由动态链接器完成共享库的查找、加载以及必要的符号解析和重定位。

另一种方式是运行时动态链接,即程序已经开始执行之后,再根据实际需求主动加载某个共享库。例如,在 Linux 中可以使用:

c 复制代码
dlopen();
dlsym();
dlclose();

这几个接口实现共享库的显式加载、符号查找与引用释放。

这种方式常见于插件系统、可扩展服务器以及一些需要按需加载功能模块的应用。例如,一个服务器可以在启动后根据配置加载特定的功能插件,而不必在编译主程序时就确定所有插件实现。

两种方式都利用了共享库和动态链接器,但区别在于:加载时动态链接主要依据可执行文件预先记录的依赖关系自动完成,而运行时动态链接允许程序在执行期间主动选择加载哪些库。

接下来先分析最常见的加载时动态链接,理解共享库从生成到被程序使用的完整过程。

共享库的创建

继续使用前面实现的向量运算库,其中包含三个文件:

c 复制代码
vector.h
addvec.c
multvec.c

之前我们通过 gcc 将源文件分别编译为可重定位目标文件,再使用 ar 将其打包为静态库:

bash 复制代码
gcc -c addvec.c -o addvec.o
gcc -c multvec.c -o multvec.o

ar rcs libvector.a addvec.o multvec.o

静态库本质上是多个目标文件组成的归档。程序进行静态链接时,链接器会从静态库中提取需要的目标文件,将其中的代码和数据链接进可执行文件。

如果多个程序都需要调用 addvec(),那么这些程序的可执行文件中可能分别包含相同的函数实现。而共享库采用不同的方式:函数实现保存在独立的共享目标文件中,程序运行时由动态链接器将其映射到进程的虚拟地址空间。不同进程可以将同一共享库映射到各自的虚拟地址空间,并共享其只读代码对应的物理内存页面。

在 Linux 中,可以通过以下命令创建共享库:

bash 复制代码
gcc -fPIC -shared -o libvector.so addvec.c multvec.c

其中,-shared 表示生成共享目标文件,而 -fPIC 表示生成位置无关代码 (Position Independent Code, PIC)。

为什么创建共享库通常需要位置无关代码?这需要从共享库的加载方式和机器指令的寻址机制开始理解

我们已经知道,Linux 中每个进程都有独立的虚拟地址空间。共享库虽然可以被多个进程使用,但同一个共享库在不同进程中的虚拟加载地址不一定相同

例如,假设 libvector.so 在进程 A 中的加载基址为 0x1000,而在进程 B 中的加载基址为 0x5000。如果共享库内部某个函数相对于加载基址的偏移量为 0x200,那么该函数在两个进程中的虚拟地址分别为:

SA=0x1000+0x200=0x1200 S_A=0x1000+0x200=0x1200 SA=0x1000+0x200=0x1200

SB=0x5000+0x200=0x5200 S_B=0x5000+0x200=0x5200 SB=0x5000+0x200=0x5200

可以看到,同一个函数在不同进程中的虚拟地址可能不同。如果机器指令直接使用固定的绝对地址访问该函数,那么共享库加载到不同位置时,原先编码在指令中的地址就可能失效。

一种解决方法是由动态链接器进行重定位,修改机器指令中需要调整的地址。但是,如果共享库中存在大量此类绝对地址引用,加载时就可能需要修改大量指令。这不仅增加了动态重定位的开销,还可能导致原本可以在多个进程之间共享的只读代码页面发生写时复制 (Copy-on-Write),降低内存共享效率。

因此,我们希望共享库中的机器指令尽可能不依赖固定的加载基址,使同一段机器代码能够在不同虚拟地址处正确执行,而不需要因为加载位置变化就修改指令本身。

这就是位置无关代码的主要目的。

实现位置无关代码的一项重要技术是 PC 相对寻址 (PC-Relative Addressing)。

假设共享库内部存在两个函数:

c 复制代码
void funcB(void)
{
    // ...
}

void funcA(void)
{
    funcB();
}

在符合条件的直接调用中,funcA() 调用 funcB() 时,不需要将 funcB() 的绝对地址编码进机器指令,而是可以使用两个位置之间的相对位移。

在 x86-64 中,直接相对调用指令可以采用以下形式:

c 复制代码
call funcB

这类指令可以将相对于下一条指令地址的位移编码在机器指令中。CPU 执行时,根据当前 RIP 和该位移计算目标地址。

假设 funcA() 中调用指令的下一条指令地址为 0x1100,而 funcB() 的地址为 0x1300,那么相对位移为:

D=0x1300−0x1100=0x200 D=0x1300-0x1100=0x200 D=0x1300−0x1100=0x200

如果共享库整体移动,使调用指令的下一条指令地址变为 0x5100,而 funcB() 的地址变为 0x5300,那么:

D′=0x5300−0x5100=0x200 D'=0x5300-0x5100=0x200 D′=0x5300−0x5100=0x200

可以看到,虽然两个位置的绝对地址都发生了变化,但它们之间的相对位移始终不变。

更一般地,设调用指令使用的相对寻址基准地址为 PPP,目标函数地址为 SSS,则:

D=S−P D=S-P D=S−P

如果共享库整体移动了 Δ\DeltaΔ,那么:

P′=P+Δ,S′=S+Δ P'=P+\Delta,\qquad S'=S+\Delta P′=P+Δ,S′=S+Δ

因此:

D′=S′−P′ =(S+Δ)−(P+Δ) =S−P=D \begin{aligned} D'&=S'-P'\ &=(S+\Delta)-(P+\Delta)\ &=S-P=D \end{aligned} D′=S′−P′ =(S+Δ)−(P+Δ) =S−P=D

由此得到:

D′=D \boxed{D'=D} D′=D

这里需要注意一个关键前提:调用指令和目标函数必须保持固定的相对位置。

共享库加载时,并不是将内部每个函数单独随机分配到任意虚拟地址,而是根据 ELF 的可加载段和相对地址布局,将共享库映射到进程的虚拟地址空间。对于同一个共享库内部相对位置已经确定的代码,加载基址改变时,调用位置和目标位置会一起移动,因此它们之间的相对距离保持不变。

这也解释了为什么机器指令中的相对位移可以保持不变:虽然指令本身编码的位移没有变化,但 CPU 执行指令时使用的 RIP 已经变成当前进程中的实际虚拟地址。

因此,PC 相对寻址使共享库内部满足条件的地址引用能够在不同加载基址下正常工作,而不必修改机器指令。

PC 相对寻址解决了共享库内部相对位置已知的地址引用问题,但对于运行时才能确定目标地址的全局变量和外部函数,仅依靠相对寻址还不够。

前面的例子中,funcA() 和 funcB() 属于同一个共享库,并且假设两者可以直接绑定,相对位置在链接时已经确定。但如果共享库需要调用其他共享库中的函数,情况就有所不同。

例如:

c 复制代码
#include <stdio.h>

void funcA(void)
{
    printf("Hello World\n");
}

这里的 printf() 通常由 C 标准库提供,而不是由当前共享库实现。由于不同共享库可以分别加载到不同的虚拟地址,因此在编译或链接当前共享库时,通常无法提前确定 funcA() 与 printf() 的实际运行时地址之差。

也就是说,PC 相对寻址能够处理相对位置已经确定的地址引用,但无法凭空确定运行时才知道的目标地址 。

因此,位置无关代码还需要借助间接地址表,将运行时确定的地址与机器指令分离。具体实现涉及 GOT 和 PLT,我们将在分析动态链接器的符号绑定过程时进一步展开

理解了位置无关代码后,我们可以将共享库的创建过程拆分为两个阶段:

bash 复制代码
gcc -fPIC -c addvec.c -o addvec.o
gcc -fPIC -c multvec.c -o multvec.o

gcc -shared \
    -o libvector.so \
    addvec.o multvec.o

第一阶段生成适合共享库使用的可重定位目标文件

第二阶段将这些目标文件链接成共享目标文件。

注意,-shared 生成的 .so 与 ar 创建的 .a 并不是同一种文件结构。

静态库 .a 是包含多个目标文件成员的归档,而共享库 .so 通常是 ELF 类型为 ET_DYN 的共享目标文件,其内部具有相应的代码、数据、动态符号表、动态重定位信息以及加载所需的程序头表。

可以通过以下命令查看:

bash 复制代码
file libvector.so
readelf -h libvector.so

在 ELF Header 中通常可以看到:

C 复制代码
Type: DYN (Shared object file)

这说明它已经不是简单的目标文件归档,而是一个能够被动态链接器加载并参与运行时链接的 ELF 共享目标文件。

共享库的使用

创建 libvector.so 之后,我们可以直接使用之前的 main2.c:

c 复制代码
#include <stdio.h>
#include "vector.h"

int main()
{
    int x[2] = {1, 2};
    int y[2] = {3, 4};
    int z[2];

    addvec(x, y, z, 2);

    printf("z = [%d %d]\n", z[0], z[1]);

    return 0;
}

如果共享库和头文件都位于当前目录,可以使用:

bash 复制代码
gcc main2.c -L. -lvector -o prog2

这里:

-L. 指定链接阶段搜索库文件的目录为当前目录。

-lvector 表示查找名称对应于 libvector 的库文件。

注意,-L 只是告诉链接器在构建程序时到哪里寻找库,并不意味着程序运行时的动态链接器也一定会搜索这个目录。

如果当前目录中同时存在:

C 复制代码
libvector.a
libvector.so

在常见的 GNU/Linux 默认动态链接模式下,链接器通常优先选择共享库 libvector.so。

因此,当前生成的 prog2 可以动态依赖 libvector.so,而不必将 addvec 的完整机器代码复制进可执行文件。

为了进一步验证,可以执行:

C 复制代码
readelf -d prog2

其中,-d 用于查看 ELF 的 Dynamic Section(动态节)。

在典型的动态链接结果中,可以看到类似:

C 复制代码
0x0000000000000001 (NEEDED) Shared library: [libvector.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]

NEEDED 条目记录了可执行文件需要的共享库名称。

这意味着,虽然 prog2 已经成功构建,但它仍然需要在程序加载时找到相应的共享库。

我们还可以使用:

C 复制代码
nm -D prog2

查看动态符号表。

其中通常可以找到对 addvec 的未定义动态符号引用,例如:

C 复制代码
U addvec

这里的 U 并不表示程序的链接已经失败,而是说明:在采用动态链接的可执行文件中,某些外部符号引用可以保留为动态符号,由动态链接器在加载或运行过程中继续处理。

这与此前处理普通可重定位目标文件时的未定义符号有所区别。

对于普通静态链接,如果链接器最终无法满足必须解析的外部引用,通常会报告链接错误;而对于动态链接,链接器可以依据共享库提供的符号建立动态依赖关系,并生成相应的运行时链接信息。

动态链接的加载过程

前面已经创建了共享库 libvector.so,并将其链接到可执行文件 prog2。与静态链接不同,prog2 中并没有直接包含 addvec() 等函数的完整实现,而是记录了对共享库的依赖。

那么,当我们执行:

bash 复制代码
./prog2

Linux 如何找到 libvector.so,并让程序成功调用其中的函数?

首先,程序启动时,Linux 内核会通过 execve() 系统调用加载 ELF 可执行文件,建立进程的虚拟地址空间。但对于动态链接的程序,内核并不负责完成所有共享库的加载和符号解析,而是需要动态链接器的帮助。

为此,ELF 文件中通常包含一个 PT_INTERP 类型的程序头,用于指定动态链接器的位置。在常见的 x86-64 Linux 环境中,动态链接器通常为:

bash 复制代码
/lib64/ld-linux-x86-64.so.2

可以通过以下命令查看:

bash 复制代码
readelf -l prog2

当内核发现 PT_INTERP 时,会将指定的动态链接器加载到进程的虚拟地址空间,并将控制权交给它。

动态链接器的主要任务,就是在程序正式运行前,找到程序依赖的共享库,并建立正确的函数和数据地址引用。

具体来说,动态链接器首先读取 prog2 的动态段,确定它依赖哪些共享库。我们可以通过以下命令查看:

bash 复制代码
readelf -d prog2

其中可能包含:

bash 复制代码
NEEDED    Shared library: [libvector.so]
NEEDED    Shared library: [libc.so.6]

NEEDED 表示程序运行时需要的共享库。动态链接器根据这些信息查找对应的库文件,再将其可加载段映射到当前进程的虚拟地址空间。

需要注意,共享库加载到内存,并不意味着程序已经知道如何调用其中的函数。

例如,prog2 需要调用 addvec(),但共享库的实际加载地址可能随着进程的虚拟地址布局而变化。因此,动态链接器还需要完成必要的符号解析和重定位:

  • 符号解析: 确定 addvec() 对应哪个共享库中的函数实现。
  • 重定位: 根据函数或变量的实际运行时地址,修正需要调整的地址引用,例如填充 GOT 表中的地址槽位

完成必要的加载、重定位和初始化工作后,动态链接器将控制权交给程序的入口点,随后由程序启动代码调用 main()。

因此,整个动态链接的加载过程可以概括为:

c 复制代码
执行 ./prog2
    |
    v
Linux 内核加载 ELF
    |
    v
根据 PT_INTERP 启动动态链接器
    |
    v
读取 NEEDED,查找依赖的共享库
    |
    v
将共享库映射到进程的虚拟地址空间
    |
    v
完成必要的符号解析和重定位
    |
    v
执行程序启动代码
    |
    v
main() 调用 addvec()

不过,并不是所有外部函数都必须在进入 main() 前完成最终的符号绑定。对于采用延迟绑定 (Lazy Binding) 的函数调用,动态链接器可以等到函数第一次被调用时,再解析其实际地址。

至此,我们已经知道动态链接器如何加载共享库,并建立程序与共享库之间的联系。但还有一个问题:如果程序在编译时不知道外部函数的实际地址,那么 CPU 最终如何跳转到该函数?

这就需要进一步了解 GOT (全局偏移表) 和 PLT (过程链接表)。它们能够帮助程序通过间接寻址访问运行时确定的函数或变量地址,而不必将这些地址直接写入机器指令。

全局偏移表(GOT)与过程链接表(PLT)

前面介绍共享库时,我们使用 -fPIC 生成位置无关代码(Position Independent Code,PIC)。其核心思想是让共享库中的机器指令尽可能不依赖固定的加载地址,使同一份代码能够映射到不同进程的虚拟地址空间中

对于共享库内部 的普通相对跳转,这并不难实现。因为只要指令与目标位置一起移动相同的距离,它们之间的相对位移就不会改变。

但共享库不仅需要执行内部代码,还可能访问全局变量,以及调用其他共享库中定义的函数。对于这些目标,编译器并不总能在编译阶段确定最终地址,因此,仅依靠普通的 PC 相对寻址还不够。

考虑下面的代码:

c 复制代码
/* addvec.c */
int addcnt = 0;

void addvec(int *x, int *y, int *z, int n)
{
    for (int i = 0; i < n; i++)
        z[i] = x[i] + y[i];

    addcnt++;
}

这里的 addcnt 是一个全局变量,用于统计 addvec 的调用次数。

如果编译器能够确定 addcnt 与当前机器指令之间的相对距离,那么就可以直接使用 RIP 相对寻址。但对于具有默认可见性、允许符号抢先(Symbol Interposition)的全局变量,情况更加复杂。

虽然 addcnt 定义在当前共享库中,但 ELF 动态链接规则允许某些外部定义在运行时覆盖当前共享库中的同名定义。因此,实际引用的对象并不一定就是编译器当前看到的定义。对于这种可被抢先的符号,编译器不能简单地假设其最终地址必然位于当前共享库的固定相对位置。

这就需要引入全局偏移表(Global Offset Table,GOT)。

GOT与全局变量

GOT 本质上是存放在数据区域(可修改)中的一张地址表 。在 x86-64 ELF 中,每个需要通过 GOT 进行间接访问的符号,可以拥有对应的表项,表项中保存该符号运行时需要使用的地址。

对于上面的 addcnt,编译器不必将它的最终地址直接编码进每一条访存指令,而是可以先通过 RIP 相对寻址找到 GOT 表项,再从表项中取出 addcnt 的实际地址。

假设 addcnt 对应的地址保存在 GOT3 中:

C 复制代码
GOT
+---------------------------+
| GOT[0]                    |
+---------------------------+
| GOT[1]                    |
+---------------------------+
| GOT[2]                    |
+---------------------------+
| GOT[3] = &addcnt          |
+---------------------------+

这里要明确区分:GOT3 保存的是 addcnt 的地址,而不是 addcnt 变量的值。

假设动态链接器最终将 addcnt 定位在:

Address⁡(addcnt)=0x7ffff7fc4010 \operatorname{Address}(addcnt)=0x7ffff7fc4010 Address(addcnt)=0x7ffff7fc4010

那么 GOT3 中保存的就是这个地址。当程序需要修改 addcnt 时,首先从 GOT3 读取地址,再根据这个地址找到变量所在的内存位置。

在 x86-64 中,这个过程可以由类似下面的汇编指令完成:

bash 复制代码
mov 0x200b89(%rip), %rax
addl $0x1, (%rax)

第一条指令:

C 复制代码
mov 0x200b89(%rip), %rax

使用 RIP 相对寻址,找到 GOT3 所在的位置,并读取表项中的内容。

因此,执行之后:

%rax=GOT⁡3 \%rax=\operatorname{GOT}3 %rax=GOT3

而 GOT3 中保存的是 addcnt 的运行时地址,所以:

%rax=Address⁡(addcnt) \boxed{\%rax=\operatorname{Address}(addcnt)} %rax=Address(addcnt)

第二条指令:

C 复制代码
addl $0x1, (%rax)

将 %rax 保存的地址作为内存操作数,对该地址中的 32 位整数加一。

这就实现了 C 语言中的:

C 复制代码
addcnt++;

整个过程实际上包含两次不同的地址访问:

C 复制代码
RIP 相对寻址
      |
      v
找到 GOT[3]
      |
      v
读取 GOT[3] 中的地址
      |
      v
找到 addcnt
      |
      v
修改 addcnt 的值

这里可能会产生一个疑问:我们原本是希望避免依赖绝对地址,但 GOT3 中仍然保存了 addcnt 的绝对地址,这不是又回到了原来的问题吗?

关键区别在于,这个绝对地址保存在数据表中,而不是直接硬编码在共享库的机器指令中。

编译器可以确定当前指令与 GOT3 之间的相对距离,因为它们都属于当前共享对象内部的布局。即使整个共享库移动,这个距离也能够保持不变。

因此,机器指令本身通常不需要因为装载基址的变化而被修改。

而 GOT3 中的绝对地址,则可以由动态链接器在加载过程中根据实际符号解析结果进行初始化或重定位。

这样,就把原本需要修改机器指令的地址问题,转移成了修改数据表项的问题。

这就是 GOT 的核心价值:通过一张可在加载时更新的地址表,将机器代码与运行时绝对地址解耦,使共享库更容易实现位置无关。

需要注意,并非所有全局变量引用都必须经过 GOT。如果编译器能够确定符号不可被外部抢先,例如某些具有隐藏可见性(Hidden Visibility)的内部变量,就可能直接使用 RIP 相对寻址访问,而不必经过 GOT

因此,是否使用 GOT,既与目标架构有关,也受到符号可见性、编译器选项和 ELF 链接规则的影响。

PLT 与函数调用

前面已经介绍了 GOT 如何访问全局变量:当变量的实际运行时地址无法提前确定时,可以由动态链接器将其地址写入 GOT 表项,程序再通过 RIP 相对寻址找到 GOT,从中读取变量的实际地址。

那么,对于共享库中的函数,能不能采用同样的方法?

答案是可以的。例如,主程序需要调用 libvector.so 中的 addvec():

c 复制代码
addvec(x, y, z, 2);

我们完全可以在 GOT 中为 addvec() 保留一个表项,由动态链接器在程序启动时解析函数地址,再将其写入 GOT。之后,程序只需要通过 GOT 读取地址,就能够调用真正的 addvec()。

但这种方式意味着:程序在调用函数之前,GOT 中必须已经保存了正确的函数地址。

假设一个程序依赖多个共享库,其中包含大量外部函数,但实际运行时可能只调用其中一部分。如果每次启动都提前解析所有函数地址,就可能增加不必要的启动开销。

因此,传统 ELF 动态链接机制还提供了另一种方式:延迟绑定 (Lazy Binding),即函数的实际地址可以等到第一次调用时再解析,而不必全部在程序启动时确定。

为了实现这种机制,动态链接器需要与 GOT 和 PLT 配合工作。

PLT (Procedure Linkage Table,过程链接表) 是 ELF 中的一组函数跳转入口,可以将其理解为由多个机器指令片段组成的数组 。

与 GOT 保存地址数据不同,PLT 的每个表项包含一段可执行的跳转代码 ,通常位于可执行的代码区域

程序调用外部函数时,可以先进入对应的 PLT 表项,再通过 GOT 中保存的地址跳转到真正的函数,或者在首次调用时进入动态链接器完成延迟绑定。

这里需要区分两者的作用:

  • GOT: 保存函数或变量的运行时地址。
  • PLT: 提供外部函数调用的跳转入口,并支持延迟绑定过程。

最重要的一点是:第一次调用和后续调用可以执行相同的 PLT 跳转指令,但由于 GOT 中保存的地址发生了变化,最终的执行路径也不同。

下面结合图 7-19 分析这一过程

假设主程序需要调用 addvec(),对应的机器指令为:

c 复制代码
callq 0x4005c0

这里的 0x4005c0 并不是 addvec() 的实际函数地址,而是 addvec() 对应的 PLT 入口地址。

在示例中:

PLT⁡2=0x4005c0 \ \operatorname{PLT}2=0x4005c0\ PLT2=0x4005c0

因此,程序首先进入 PLT2,其中包含以下三条指令:

bash 复制代码
4005c0: jmpq *GOT[4]
4005c6: pushq $0x1
4005cb: jmpq 0x4005a0

其中,GOT4 是为 addvec() 准备的地址表项,而 PLT2 中的三条指令分别承担不同的任务。

第一条指令通过 GOT4 中保存的地址进行间接跳转;

第二条指令提供用于识别待解析符号的重定位信息;

第三条指令跳转到PLT0,进一步进入动态链接器的解析流程。

为什么需要这样设计?关键就在于 GOT4 在函数第一次调用前后保存的内容不同。

第一次调用 addvec

当程序第一次调用 addvec() 时,动态链接器还没有对该函数完成延迟绑定。

此时,GOT4 并没有保存 addvec() 的真实地址,而是暂时保存 PLT2 内部第二条指令的地址:

GOT⁡4=0x4005c6 \ \operatorname{GOT}4=0x4005c6\ GOT4=0x4005c6

首先执行:

bash 复制代码
callq 0x4005c0

进入 PLT2,执行第一条指令:

bash 复制代码
4005c0: jmpq *GOT[4]

这里的 * 表示间接跳转:CPU 先读取 GOT4 中保存的地址,再跳转到该地址执行,而不是跳转到 GOT 表项本身。

由于 GOT4 当前保存的是 0x4005c6,因此 CPU 会跳转到 PLT2 的第二条指令:

bash 复制代码
4005c6: pushq $0x1

这里的 0x1 并不是函数地址,而是与本次符号解析对应的重定位记录标识,供后续解析过程使用。

接着执行:

bash 复制代码
4005cb: jmpq 0x4005a0

跳转到 PLT0。PLT0 是传统 PLT 结构中的特殊入口,它会利用 GOT 中预留的信息,将控制权交给动态链接器的解析逻辑。

于是,第一次调用过程可以概括为:

c 复制代码
main() 调用 addvec()
        |
        v
进入 PLT[2]
        |
        v
读取 GOT[4]
        |
        v
GOT[4] 指向 PLT[2] 的第二条指令
        |
        v
执行 push,提供重定位标识
        |
        v
跳转到 PLT[0]
        |
        v
进入动态链接器
        |
        v
解析 addvec 的实际地址

动态链接器会根据重定位记录和动态符号表,查找 addvec() 对应的函数定义。

假设最终确定函数地址为 SSS,那么动态链接器就会将该地址写入 GOT4:

GOT⁡4=S \ \boxed{\operatorname{GOT}4=S}\ GOT4=S

随后,动态链接器将控制权转交给真正的 addvec(),完成本次函数调用。

因此,第一次调用不仅要执行函数,还需要先确定函数的真实地址,并将解析结果保存到 GOT 中。

后续调用 addvec

现在,假设程序再次调用 addvec():

c 复制代码
callq 0x4005c0

CPU 仍然进入 PLT2,执行相同的第一条指令:

bash 复制代码
4005c0: jmpq *GOT[4]

但是,此时 GOT4 已经被动态链接器更新。

第一次调用之前:

GOT⁡4=0x4005c6 \ \operatorname{GOT}4=0x4005c6\ GOT4=0x4005c6

第一次调用完成绑定之后:

GOT⁡4=Address⁡(addvec) \ \operatorname{GOT}4=\operatorname{Address}(addvec)\ GOT4=Address(addvec)

因此,CPU 读取 GOT4 后,就能够直接跳转到真正的 addvec(),不需要再次进入动态链接器。

后续调用过程就简化为:

bash 复制代码
main() 调用 addvec()
        |
        v
进入 PLT[2]
        |
        v
读取 GOT[4]
        |
        v
直接跳转到真正的 addvec()

可以发现,PLT 中的机器指令始终没有发生变化,真正改变的只有 GOT4 中保存的地址。

这与前面使用 GOT 访问全局变量的原理是一致的:机器代码不需要直接保存可能变化的函数绝对地址,而是通过一张可以由动态链接器更新的地址表完成访问。

因此,对于传统 PLT 延迟绑定机制,可以总结为:

第一次调用:PLT→动态链接器→目标函数 \ \boxed{\ \text{第一次调用:PLT}\rightarrow\text{动态链接器}\rightarrow\text{目标函数}\ }\ 第一次调用:PLT→动态链接器→目标函数

后续调用:PLT→目标函数 \ \boxed{\ \text{后续调用:PLT}\rightarrow\text{目标函数}\ }\ 后续调用:PLT→目标函数

第一次调用时完成函数地址解析,并将结果缓存到 GOT;后续调用直接使用 GOT 中已经解析好的函数地址。这就是延迟绑定的基本原理。

GOT 和 PLT 的区别

理解了整个调用过程后,还需要明确一个问题:既然 GOT 本身就能够保存函数地址,为什么还需要 PLT?

实际上,PLT 并不是调用共享库函数的必要条件。

如果程序选择立即绑定 (Eager Binding),可以在启动阶段就解析外部函数地址,并将地址写入 GOT。此后,机器指令可以直接通过 GOT 进行间接调用,而不必经过传统的 PLT 入口。

例如,在 x86-64 平台上,GCC 可以通过 -fno-plt 选项,让适用的外部函数调用直接使用 GOT:

bash 复制代码
gcc -fno-plt -o prog2 main.c -L. -lvector

因此,GOT 与 PLT 的本质区别并不是一个用于变量、另一个用于函数,而是:

GOT 是保存地址的数据表,而 PLT 是执行跳转的代码入口。

GOT 与 PLT 的实际观察

理解了理论之后,可以通过 Linux 工具观察共享库函数调用所对应的 ELF 结构。

首先,假设我们已经创建了 libvector.so,并且主程序通过动态链接调用 addvec:

c 复制代码
gcc -O0 -fplt -fno-pie -no-pie \
    main2.c -L. -lvector \
    -Wl,-rpath,'$ORIGIN' \
    -o prog2

这里使用 -fplt,是为了在支持该选项的 GCC 环境中尽量采用传统的 PLT 函数调用形式;关闭 PIE 则便于观察相对稳定的可执行文件地址。

接下来查看程序中的 PLT:

c 复制代码
objdump -d prog2

在某些 GNU/Linux 构建环境中,可以看到类似:

C 复制代码
call addvec@plt

这说明主程序并不是直接调用共享库中 addvec 的实际地址,而是先进入对应的 PLT 跳转入口。

如果希望进一步观察 PLT 和 GOT 对应的 ELF 节,可以使用:

C 复制代码
readelf -SW prog2

在相关输出中,可能出现:

C 复制代码
.plt
.plt.got
.plt.sec
.got
.got.plt

这些 Section 的具体存在情况和布局取决于工具链及链接选项,并非所有程序都会同时具有完全相同的节集合。

其中,.plt、.plt.sec 等通常保存与外部函数跳转有关的机器代码,而 .got、.got.plt 等通常保存动态链接所需要的地址表项。

还可以查看动态重定位记录:

C 复制代码
readelf -rW prog2

对于采用传统 PLT 动态调用的函数,通常可以观察到类似:

C 复制代码
R_X86_64_JUMP_SLOT  addvec

这种重定位类型用于表示动态链接器需要为相应的函数调用地址表项确定目标函数地址。

对于通过 GOT 访问的外部数据符号,还可能看到:

C 复制代码
R_X86_64_GLOB_DAT

这类重定位用于在运行时将解析得到的符号地址写入相应的地址表项。

因此,我们可以将此前学习的静态重定位与这里的动态重定位联系起来:

静态链接器在构建阶段完成必要的地址计算,而动态链接器可以根据实际加载位置和符号解析结果,在程序加载或运行期间修正 GOT 等位置中的地址。

对于传统的延迟绑定,第一次函数调用还可以触发相应的 JUMP_SLOT 绑定过程,并将解析结果保存到对应表项中。

如果希望关闭延迟绑定,可以在链接时使用:

C 复制代码
gcc main2.c -L. -lvector \
    -Wl,-z,now \
    -Wl,-rpath,'$ORIGIN' \
    -o prog2

-z now 要求动态链接器在程序加载阶段完成相关函数符号绑定,而不是等到第一次调用时再解析。

因此,延迟绑定并不是动态链接的必要条件,而是一种可以选择的符号解析策略。

至此,GOT 与 PLT 的设计动机就可以统一起来:GOT 通过可重定位的地址表,解决位置无关代码访问运行时地址的问题;PLT 则为外部函数调用提供跳转入口,并通过 GOT 与动态链接器协作,实现函数地址的动态绑定。

理解这两张表之后,前面介绍的 PIC、共享库加载和动态符号解析就不再是相互独立的概念,而是能够连接成完整的执行过程。

至此,我们已经从机器指令的角度理解了动态链接的地址解析机制。对于需要运行时确定地址的全局变量,程序可以通过 GOT 间接访问;对于外部函数,传统的 PLT/GOT 机制则可以在第一次调用时完成延迟绑定,并在后续调用中直接使用已经解析的函数地址。

但上述机制有一个基本前提:动态链接器必须能够找到提供目标符号的共享库。如果共享库文件本身无法定位,那么即使程序中的 GOT、PLT 和重定位信息都正确,动态链接仍然无法正常完成。因此,接下来需要进一步理解共享库在运行时的搜索规则。

共享库的搜索路径

前面我们使用:

bash 复制代码
gcc main2.c -L. -lvector -o prog2

成功完成了链接,但此时直接运行:

C 复制代码
./prog2

可能出现:

C 复制代码
error while loading shared libraries:
libvector.so: cannot open shared object file:
No such file or directory

这个错误经常让初学者感到疑惑:编译链接时明明已经找到了 libvector.so,为什么运行时却说找不到?

原因就在于,链接阶段与加载阶段使用的是两套不同的库查找机制。

编译链接阶段,-L. 告诉链接器到当前目录寻找 libvector.so;而程序运行时,动态链接器需要根据 ELF 中的相关路径信息、运行环境和系统库配置重新定位共享库。

在常见的 Linux 环境中,动态链接器不会仅仅因为共享库与可执行文件处于同一个目录,就自动搜索该目录。

可以通过:

C 复制代码
ldd ./prog2

观察程序依赖的共享库及其解析情况。

如果动态链接器无法定位 libvector.so,可能看到:

C 复制代码
libvector.so => not found

这里的 not found 并不意味着程序源代码中没有对应函数,也不意味着编译器没有找到头文件,而是运行时动态链接器无法找到满足依赖的共享库文件

需要注意,ldd 应只用于可信 的可执行文件;对于不可信程序,可以优先使用 readelf -d 查看其记录的动态依赖,而不直接执行 ldd。

对于我们自己创建的共享库,可以通过以下方式临时指定搜索路径:

C 复制代码
LD_LIBRARY_PATH=. ./prog2

其中,LD_LIBRARY_PATH 是动态链接器使用的环境变量之一,能够提供额外的共享库搜索目录。

这里的 . 表示当前工作目录,因此执行命令时,动态链接器可以在当前目录中 查找 libvector.so。

但这种方式更适合开发与调试,不一定适合软件正式部署,因为程序能否正确运行会依赖外部环境变量的配置。

对于需要随程序一起部署的共享库,一种更常用的方式是在链接时指定运行时搜索路径。

例如:

bash 复制代码
gcc main2.c -L. -lvector \
    -Wl,-rpath,'$ORIGIN' \
    -o prog2

其中,-Wl 用于向链接器传递参数,-rpath 用于设置运行时库搜索路径。

$ORIGIN 是动态链接器能够识别的特殊路径标记,表示包含相关 ELF 对象的目录。在当前可执行文件的运行时搜索路径中使用它,可以使程序在运行时从自身所在目录寻找相应的共享库。

这里使用单引号,是为了避免 Shell 提前展开 $ORIGIN。

可以执行:

C 复制代码
readelf -d prog2

查看是否存在类似:

C 复制代码
(RUNPATH) Library runpath: [$ORIGIN]

的动态节信息。

这样,只要将 prog2 和 libvector.so 放在同一目录中,在相关依赖能够被满足的前提下,程序就可以通过记录的运行时搜索路径找到共享库。

需要注意,RUNPATH 与旧式 RPATH 的搜索规则并不完全相同。例如,RUNPATH 通常只直接作用于该对象自身声明的依赖,不会像某些旧式 RPATH 行为那样传递给整个依赖树。因此,复杂程序如果还依赖其他共享库,需要分别处理相关对象的搜索路径。

这也解释了实际工程中经常同时出现的三个参数:

C 复制代码
-I
-L
-Wl,-rpath

它们分别作用于不同阶段:

参数 作用
-I 编译时寻找头文件
-L 链接时寻找库文件
-Wl,-rpath 为运行时动态链接器提供库搜索路径

不能把它们当成同一种路径配置。

对于已经安装在系统标准库目录中的共享库,动态链接器还可以根据系统默认路径及配置缓存进行查找。例如,系统可以通过 /etc/ld.so.conf、/etc/ld.so.conf.d/ 和 ldconfig 管理部分共享库搜索配置。

不过,在开发自己的项目时,通常不需要为了测试一个共享库就修改系统全局配置。使用合适的运行时路径,既便于部署,也能够避免不同项目之间发生库版本冲突。

至此,我们已经能够理解共享库与静态库在构建和运行方式上的本质差异:静态库通过目标文件的按需提取,在链接阶段将需要的实现纳入可执行程序;共享库则使程序能够保留动态依赖,在加载或运行期间通过动态链接器定位并使用对应的机器代码。

这种机制既支持多个进程共享库代码,也使程序能够在不重新构建主程序的情况下,使用 ABI 兼容的共享库更新版本。

但目前介绍的仍然是加载时动态链接 ,即程序在构建时已经声明了对 libvector.so 的依赖,动态链接器在启动过程中自动处理。

如果程序希望在执行到某个位置之后,再决定加载哪个共享库,甚至根据字符串名称动态查找函数,那么还需要另一种机制:运行时动态链接(Run-time Dynamic Linking)。

运行时动态链接(Run-time Dynamic Linking)

前面介绍的加载时动态链接,要求程序在构建阶段就确定需要依赖哪些共享库。例如,使用 -lvector 链接 libvector.so 后,生成的可执行文件会记录相应的动态库依赖,程序启动时再由动态链接器完成共享库的加载和必要的符号解析。

但在某些应用中,程序并不能提前确定需要使用哪些库。例如,一个支持插件的服务器可能根据配置文件选择不同的功能模块;一个图像处理程序可能需要根据输入文件的格式加载对应的解码器。如果将所有功能模块都作为启动时的依赖,不仅会增加不必要的资源开销,也会降低程序的扩展性。

更合理的方式是:程序先正常启动,等到真正需要某项功能时,再主动加载相应的共享库,并取得其中的函数地址。

这种机制称为运行时动态链接(Run-time Dynamic Linking)。在 Linux 中,可以通过 dlopen、dlsym 和 dlclose 等接口实现,它们声明在 <dlfcn.h> 中。

与加载时动态链接相比,运行时动态链接最重要的特点是:共享库的选择和加载可以由程序自身在运行过程中控制,而不是必须在构建可执行文件时就确定所有依赖。

动态加载共享库

继续使用前面创建的向量运算库:

C 复制代码
libvector.so

其中提供了两个函数:

c 复制代码
void addvec(int *x, int *y, int *z, int n);
void multvec(int *x, int *y, int *z, int n);

此前,我们在编译主程序时直接链接共享库:

bash 复制代码
gcc main2.c -L. -lvector -o prog2

这种方式会使可执行文件记录对 libvector.so 的动态依赖。如果运行时找不到这个共享库,程序通常无法正常完成启动。

现在我们希望改变这种关系:主程序在构建阶段不直接依赖 libvector.so,而是在执行过程中主动加载它。

Linux 提供的 dlopen 函数可以实现这一功能,其接口为:

c 复制代码
#include <dlfcn.h>
void *dlopen(const char *filename, int flags);

其中,filename 指定需要加载的共享库,flags 控制动态加载与符号解析的方式。

如果加载成功,dlopen 返回一个非空句柄(Handle),后续可以通过这个句柄查找库中的符号;

如果加载失败,则返回 NULL,可以通过 dlerror 获取错误信息。

例如:

c 复制代码
void *handle = dlopen("./libvector.so", RTLD_LAZY);

if (handle == NULL)
{
    fprintf(stderr, "%s\n", dlerror());
    exit(EXIT_FAILURE);
}

这里使用 ./libvector.so 明确指定当前工作目录中的共享库文件。由于路径中包含 /,动态链接器会按照指定路径定位该文件,而不需要通过 -L 搜索链接阶段的库目录。

需要注意,dlopen 不只是打开一个普通文件。动态链接器需要识别共享库的 ELF 格式,将必要的代码和数据段映射到进程虚拟地址空间,并处理其依赖关系及相应的动态重定位。共享库还可能包含初始化函数,这些初始化操作通常会在 dlopen 成功返回之前执行。

这里还有一个重要参数:

c 复制代码
RTLD_LAZY

它表示允许对某些函数符号采用延迟绑定(Lazy Binding),即不必在共享库加载时立即完成所有函数引用的最终地址解析,而可以将部分工作推迟到实际调用时。

与之对应的是:

C 复制代码
RTLD_NOW

它要求动态链接器在 dlopen 返回之前完成需要立即处理的符号解析,包括函数引用的相关绑定。如果存在无法解析的必要符号,加载过程就可能直接失败。

两者的主要区别在于函数符号绑定的时机,而不是共享库是否会被映射到内存中。即使使用 RTLD_LAZY,共享库仍然需要被加载,数据重定位及其他必要工作也不能简单推迟到任意时间。

因此,RTLD_LAZY 可以减少部分加载阶段的解析工作,而 RTLD_NOW 则更适合希望在加载阶段尽早发现符号依赖错误的情况。

动态符号查找

成功加载 libvector.so 后,程序已经获得了对应的共享库句柄,但还不能仅凭这个句柄调用 addvec。我们仍然需要取得函数在当前进程地址空间中的实际地址。

这里需要使用 dlsym:

c 复制代码
#include <dlfcn.h>
void *dlsym(void *handle, const char *symbol);

其中,handle 是之前通过 dlopen 获得的句柄,symbol 是需要查找的符号名称。

例如:

c 复制代码
void *symbol = dlsym(handle, "addvec");

动态链接器会按照相应的符号查找规则,在指定的共享对象及其适用的依赖查找范围内寻找 addvec,并返回对应的符号地址。

这里需要联系此前学习过的符号表知识。我们已经知道,ELF 中的 .symtab 可以保存链接器需要使用的符号信息。但对于运行时动态链接,更直接相关的是动态符号表 .dynsym。

普通符号表 .symtab 主要服务于静态链接和符号分析,程序运行并不一定需要保留它。而 .dynsym 保存动态链接所需要的符号信息,使动态链接器能够根据名称查找相关定义。

我们可以通过以下命令查看 libvector.so 对外提供的动态符号:

bash 复制代码
nm -D --defined-only libvector.so

其中,-D 表示查看动态符号,--defined-only 则只显示当前共享库已经定义的符号。

在没有特殊符号隐藏或优化的情况下,可以看到类似:

c 复制代码
00000000000010f9 T addvec
0000000000001140 T multvec

具体的符号值取决于编译器、链接器和共享库布局,并不代表这些函数每次运行时都会位于相同的绝对虚拟地址。

对于共享库而言,符号值可以表示其在共享对象中的相应位置。加载时结合实际装载基址及 ELF 的有关规则,就可以确定运行时的函数地址。

因此,dlsym 可以理解为:根据运行时的符号名称,在已经加载的共享对象中找到对应的定义,并取得当前进程能够使用的地址。

但这里还涉及一个 C 语言层面的细节。dlsym 返回的是 void *,而我们要调用的是一个函数,所以必须将它作为具有正确函数签名的函数指针来使用。

此前已经学习过函数指针,例如:

c 复制代码
void (*func)(int *, int *, int *, int);

这里的 func 是一个函数指针,可以指向返回类型为 void、具有四个指定参数的函数。

而 addvec 的定义正好是:

c 复制代码
void addvec(int *x, int *y, int *z, int n);

因此,可以使用相应的函数指针类型保存 dlsym 返回的地址:

c 复制代码
void (*addvec)(int *, int *, int *, int);

addvec = (void (*)(int *, int *, int *, int))
         dlsym(handle, "addvec");

这种从 dlsym 的返回值转换为函数指针的方式,是 POSIX 动态链接环境中常见的用法;不能将其不加区分地推广到所有 ISO C 实现。

这里最重要的是:函数指针的类型必须与实际函数的调用接口一致。 如果返回类型、参数数量或参数类型不匹配,即使成功找到了符号地址,实际调用时也可能因为调用约定或参数解释错误而产生未定义行为。

此外,查找符号之后应当使用 dlerror 判断是否发生错误,而不是仅仅依靠返回值是否为 NULL。因为动态符号查找的结果在某些合法情况下也可能是空地址,不能简单认为所有空返回值都必然代表查找失败。

比较规范的写法是:

c 复制代码
dlerror();  // 清除此前保存的错误状态

void (*addvec)(int *, int *, int *, int);

addvec = (void (*)(int *, int *, int *, int))
         dlsym(handle, "addvec");

const char *error = dlerror();

if (error != NULL)
{
    fprintf(stderr, "%s\n", error);
    dlclose(handle);
    exit(EXIT_FAILURE);
}

dlerror 会返回相关动态链接操作的错误描述;如果没有需要报告的错误,则返回 NULL。调用它读取错误状态后,也会清除当前保存的相应错误信息。

因此,在 dlsym 前先调用一次 dlerror,可以避免把之前操作留下的错误误认为是本次查找产生的错误。

运行时调用

现在,我们已经通过 dlopen 加载了共享库,并通过 dlsym 获得了 addvec 的函数地址,接下来就可以像调用普通函数一样,通过函数指针执行代码。

例如:

c 复制代码
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

addvec(x, y, z, 2);

printf("z = [%d %d]\n", z[0], z[1]);

执行结果仍然是:

c 复制代码
z = [4 6]

这里的调用形式看起来与之前没有区别,但底层机制已经发生变化。

在加载时动态链接的程序中,编译器已经知道存在一个外部函数 addvec,链接器会为其建立相应的动态符号引用,之后通过动态链接机制完成地址绑定。

而在当前程序中,addvec 是一个普通的函数指针变量。程序在运行过程中根据字符串 "addvec" 查找函数地址,然后通过该指针进行间接调用。

因此,从机器指令的角度来看,函数调用不再必须直接对应一个编译阶段就已经确定的外部函数符号,而是可以通过运行时获得的地址完成。

这也是插件系统能够成立的重要基础:程序不需要在构建时知道某个功能模块的全部具体实现,只要运行时能够加载模块,并且双方遵守约定的函数接口,就可以完成调用。

使用完共享库之后,可以通过 dlclose 释放句柄:

c 复制代码
int dlclose(void *handle);

例如:

c 复制代码
if (dlclose(handle) != 0)
{
    fprintf(stderr, "%s\n", dlerror());
    return 1;
}

需要注意,dlclose 的语义是释放当前程序持有的共享对象引用,并不意味着函数返回后,操作系统一定立即将该共享库的所有映射从内存中删除。

如果同一个共享对象仍然被其他地方引用,或者受到动态链接器其他机制的影响,那么它仍然可能保持加载状态。

同时,在共享库真正卸载之后,之前通过 dlsym 获得的函数指针就不能再被当作有效地址使用。因此,应当在完成所有相关函数调用之后,再释放共享库句柄。

完整实验

现在将上述过程组合成一个可以直接运行的程序,观察运行时动态链接的完整工作方式。

首先,确保当前目录中已经存在前面创建的 addvec.c 和 multvec.c,然后重新生成共享库:

bash 复制代码
gcc -fPIC -shared \
    -o libvector.so \
    addvec.c multvec.c

接着创建 dll.c:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int main(void)
{
    int x[2] = {1, 2};
    int y[2] = {3, 4};
    int z[2] = {0};

    // 运行时加载共享库
    void *handle = dlopen("./libvector.so", RTLD_NOW);

    if (handle == NULL)
    {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return EXIT_FAILURE;
    }

    // 清除之前的错误状态
    dlerror();

    // 根据符号名称获取函数地址
    void (*addvec)(int *, int *, int *, int);

    addvec = (void (*)(int *, int *, int *, int))
             dlsym(handle, "addvec");

    const char *error = dlerror();

    if (error != NULL)
    {
        fprintf(stderr, "dlsym: %s\n", error);
        dlclose(handle);
        return EXIT_FAILURE;
    }

    // 通过函数指针调用共享库中的函数
    addvec(x, y, z, 2);

    printf("z = [%d %d]\n", z[0], z[1]);

    // 释放共享库句柄
    if (dlclose(handle) != 0)
    {
        fprintf(stderr, "dlclose: %s\n", dlerror());
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

编译主程序:

bash 复制代码
gcc dll.c -o dll -ldl

其中,-ldl 是传统 Linux/glibc 工具链中用于链接 dlopen、dlsym 等动态装载接口的选项。对于 glibc 2.34 及之后的版本,这些接口已经整合到 libc 中,因此部分系统即使不显式指定 -ldl 也能完成链接;这里保留该选项以兼容较旧的工具链。

运行:

c 复制代码
./dll

输出:

c 复制代码
z = [4 6]

这个实验与此前通过 -lvector 构建程序的方式有一个本质区别:我们在编译 dll.c 时,没有将 libvector.so 作为直接链接依赖。

可以通过下面的命令验证:

bash 复制代码
readelf -d dll | grep NEEDED

在普通的 glibc 构建环境下,输出通常包含 libc.so.6,但不会直接包含 libvector.so:

bash 复制代码
Shared library: [libc.so.6]

这是因为主程序并没有在构建阶段建立对 libvector.so 的直接动态依赖,而是通过 dlopen 在执行过程中主动加载它。

这一点还可以通过另一个实验验证。将共享库暂时改名:

bash 复制代码
mv libvector.so libvector.so.bak

再次执行:

C 复制代码
./dll

程序可以进入 main,并执行到 dlopen,但由于指定路径下已经不存在共享库,加载操作会失败,并由我们编写的错误处理代码报告原因。

重新恢复共享库:

bash 复制代码
mv libvector.so.bak libvector.so

程序便可以再次正常执行。

因此,加载时动态链接和运行时动态链接,在依赖管理方面存在明显区别。前者通常要求程序启动时就满足预先记录的共享库依赖,而后者允许程序在运行过程中主动加载某些可选模块,并根据加载结果决定后续行为。

不过,运行时加载也不意味着程序完全不需要共享库。它只是将库的加载时机和选择权交给了程序自身;当相应功能确实需要执行时,共享库仍然必须存在,并满足当前平台、体系结构以及 ABI 等方面的要求。

动态链接的工程应用

理解了 dlopen 和 dlsym 之后,我们就能进一步理解很多软件为什么能够在不修改主程序的情况下增加新功能。

例如,一个服务器可以规定每个插件必须提供统一的初始化接口:

c 复制代码
int plugin_init(void);

不同插件分别编译为:

c 复制代码
libplugin_a.so
libplugin_b.so
libplugin_c.so

主程序不需要提前链接所有插件,只需要根据配置文件选择相应的 .so 文件,通过 dlopen 加载,再通过 dlsym 查找 plugin_init,最后调用对应的初始化函数。

这样,新增插件时,通常只需要提供符合接口约定的共享库,而不必重新编译服务器主程序。

当然,这种扩展能力也带来了新的约束。由于插件与主程序可能分别编译,双方必须保证函数签名、数据结构布局、调用约定以及相关 ABI 的一致性。如果接口不兼容,即使能够成功找到符号,也可能在调用时出现错误。

在 C++ 项目中,还需要特别考虑名称修饰(Name Mangling)。例如,C++ 编译器通常会在函数符号名称中编码参数类型等信息,使得源代码中的函数名称不一定等同于 ELF 中实际导出的符号名称。

因此,很多跨语言或者面向插件的 C++ 项目会通过 extern "C" 导出稳定的 C 风格接口,避免直接依赖编译器特定的名称修饰规则。但 extern "C" 主要解决语言链接属性与符号命名问题,并不能自动保证不同模块之间的 ABI 完全兼容。

这也说明,运行时动态链接的关键不仅是能够加载共享库,更是要求程序与共享库之间存在明确、稳定的二进制接口。

至此,链接机制已经覆盖了程序生命周期中的三个不同阶段:

  • 构建-过程中通过静态链接组合目标文件;
  • 程序加载时由动态链接器处理预先声明的共享库依赖;
  • 程序运行期间则可以通过 dlopen 和 dlsym 主动加载模块并解析符号。

但动态链接还有一个更加灵活的应用:如果程序原本调用的是某个库函数,我们是否能够在不修改该函数实现的情况下,拦截它的调用,并在真正执行前后添加自己的逻辑?

例如,程序调用 malloc 分配内存时,我们希望记录每次分配的大小和返回地址;调用 free 释放内存时,希望记录对应的指针。借助链接阶段和动态符号解析机制,就可以在不同阶段改变函数调用最终绑定到的实现。

这就引出了 库打桩(Library Interpositioning),它也是 CSAPP Linking 部分最后一个重要的应用。

库打桩(Library Interpositioning)

前面我们已经学习了运行时动态链接,知道程序可以通过 dlopen 加载共享库,再利用 dlsym 根据符号名称获取函数地址。这说明,程序中的函数调用并不一定要在编译阶段就确定最终的实现,函数名与机器代码之间的对应关系,可以在不同阶段建立。

既然如此,我们就可以进一步利用链接机制:如果程序原本需要调用某个库函数,能否在不修改该函数实现的情况下,让它先执行我们自己编写的函数,再决定是否调用原来的库函数?

例如,我们希望分析一个程序的动态内存使用情况。程序通过 malloc 申请内存,再通过 free 释放内存,但仅仅观察程序最终使用了多少内存,并不能准确了解每一次内存分配和释放发生在什么位置。如果能够拦截这些函数调用,就可以记录每次申请的大小、返回的地址,以及内存释放时对应的指针。

一种直观的方法是在每次调用 malloc 和 free 的位置手动添加日志。但如果项目中有成百上千处动态内存分配,逐一修改代码不仅工作量大,还可能影响程序原有的逻辑。对于第三方库或者已经编译完成的程序,这种方法甚至不可行。

更合理的方式是,在原函数外面增加一层包装函数(Wrapper Function),让程序先调用包装函数,由包装函数完成日志记录、参数检查或其他操作,再调用原来的函数。

例如,原来的函数调用关系为:

C 复制代码
main()
  |
  v
malloc(32)
  |
  v
libc 中的 malloc

加入包装函数之后:

C 复制代码
main()
  |
  v
mymalloc(32)
  |
  | 记录申请大小
  |
  v
libc 中的 malloc
  |
  | 返回内存地址
  |
  v
mymalloc()
  |
  | 记录返回地址
  |
  v
main()

这种通过改变函数调用的解析目标,在原函数与调用者之间插入自定义实现的技术,称为库打桩(Library Interpositioning),也经常称为函数拦截。

它的核心并不是修改原函数的机器代码,而是利用符号引用与函数定义之间的绑定关系,将原本指向库函数的调用引导到自定义的包装函数

这种技术具有广泛的工程应用。例如,通过拦截 malloc 和 free,可以统计动态内存分配次数、记录内存地址变化,辅助排查内存泄漏;通过拦截 read、write 等 I/O 函数,可以记录程序的文件读写行为;通过拦截网络函数,还可以观察连接建立、数据发送及相关错误信息。

库打桩同样可以用于性能分析。例如,在包装函数的入口记录时间戳,在原函数返回后再次记录时间戳,就可以统计函数调用耗时。不过,这种方式本身会带来额外开销,测量结果还需要考虑包装函数及日志操作的影响。

需要注意,库打桩通常只能观察或替换经过相应符号解析路径的调用,不能保证拦截程序中的每一次相关操作。例如,经过编译器内联的函数调用、某些直接系统调用,以及共享库内部已经完成绑定的调用,可能不会经过我们设置的包装函数。

从程序构建和执行的过程来看,库打桩可以发生在三个不同阶段:编译时、链接时以及加载或运行时。虽然它们最终都能改变函数调用的目标,但实现机制完全不同。编译时可以借助预处理器改变源代码中的函数引用;链接时可以利用链接器的符号解析规则;加载或运行时则可以利用动态链接器的符号查找顺序。

为了观察这三种机制的区别,我们始终使用同一个测试程序:

c 复制代码
/* int.c */

#include <stdlib.h>

int main()
{
    int *p = malloc(32);
    free(p);
    return 0;
}

这个程序调用 malloc 申请 323232 字节内存,然后使用 free 释放。我们的目标是在不改变其原有分配与释放逻辑的情况下,输出每次调用的参数和地址。

例如:

C 复制代码
malloc(32) = 0x55a2c0
free(0x55a2c0)

其中,具体内存地址取决于运行环境,每次执行都可能不同。

编译时打桩(Compile-time Interpositioning)

首先考虑最容易实现的方式:在编译阶段,将程序中对 malloc 和 free 的调用替换成对自定义函数的调用。

这里利用的是 C 语言预处理器的宏替换机制。

例如:

c 复制代码
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

经过预处理之后,源代码中的:

c 复制代码
int *p = malloc(32);
free(p);

会变为:

c 复制代码
int *p = mymalloc(32);
myfree(p);

注意,这种替换发生在预处理阶段,而不是链接器已经生成可执行文件之后再去修改机器代码。

也就是说,编译器实际看到的函数调用已经发生变化。之后生成的目标文件引用的是 mymalloc 和 myfree,链接器只需要按照普通符号解析规则找到它们的定义即可。

这实际上将函数调用目标的改变提前到了编译阶段。

现在创建头文件 trace.h,统一声明包装函数,并定义相应的宏:

c 复制代码
/* trace.h */
#ifndef TRACE_H
#define TRACE_H

#include <stdlib.h>

void *mymalloc(size_t size);
void myfree(void *ptr);

#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

#endif

这里首先包含 <stdlib.h>,确保标准库中的 malloc、free 以及 size_t 已经得到正确声明,然后再定义宏,避免宏影响标准库头文件中的函数声明。

接下来实现包装函数。

trace.c:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

void *mymalloc(size_t size)
{
    void *ptr = malloc(size);

    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);

    return ptr;
}

void myfree(void *ptr)
{
    fprintf(stderr, "free(%p)\n", ptr);

    free(ptr);
}

这里的 mymalloc 并没有重新实现内存分配算法,而是调用标准库中的 malloc 完成真正的内存分配,再记录申请大小和返回地址。

同样,myfree 先记录需要释放的指针,再调用标准库中的 free。

为什么 mymalloc 内部仍然能够调用真正的 malloc,而不会再次进入 mymalloc,造成无限递归?

关键在于,trace.c 并没有包含定义宏替换规则的 trace.h。因此,编译 trace.c 时,源代码中的:

C 复制代码
malloc(size);

仍然是对标准库函数 malloc 的调用,而不会被预处理器替换为 mymalloc。

反过来,如果在实现包装函数的源文件中也启用了相同的宏,那么:

c 复制代码
void *mymalloc(size_t size)
{
    return malloc(size);
}

就可能经过预处理变成:

c 复制代码
void *mymalloc(size_t size)
{
    return mymalloc(size);
}

函数将不断调用自身,直到发生栈溢出或其他运行时错误。

因此,编译时打桩必须明确控制宏替换的作用范围,避免包装函数内部的真实调用再次被替换。

现在需要让测试程序 int.c 使用这些宏。我们可以直接在源文件中包含 trace.h,但为了不修改原来的测试程序,这里使用 GCC 的 -include 选项:

bash 复制代码
gcc -O0 -fno-builtin-malloc -fno-builtin-free \
    -include trace.h \
    -c int.c -o int.o

-include trace.h 告诉预处理器,在处理 int.c 之前先包含 trace.h,因此,即使原始代码没有显式写出这个头文件,也能够应用其中定义的宏替换规则

另外,这里使用 -O0 关闭优化,并通过 -fno-builtin-malloc、-fno-builtin-free 禁止 GCC 将对应函数按照内建函数进行特殊优化,方便观察实际的函数调用关系。

需要注意,-include 只用于编译需要被拦截的测试程序,不应该同时用于编译包装函数所在的 trace.c,否则可能再次出现递归替换问题。

接下来编译包装函数:

bash 复制代码
gcc -O0 -c trace.c -o trace.o

然后链接:

bash 复制代码
gcc int.o trace.o -o intc

运行:

bash 复制代码
./intc

可以看到类似:

bash 复制代码
malloc(32) = 0x55a2c0
free(0x55a2c0)

此时,我们可以通过查看目标文件的符号表,进一步确认宏替换究竟发生在什么阶段。

执行:

C 复制代码
nm -u int.o

通常能够看到:

C 复制代码
U mymalloc
U myfree

而不是原来的:

C 复制代码
U malloc
U free

为什么会这样?

因为宏替换发生在生成机器代码之前,编译器最终生成的目标文件已经引用了 mymalloc 和 myfree。因此,链接器根本不需要知道原始代码曾经写过 malloc 和 free,它只负责把当前目标文件中的引用关联到 trace.o 提供的对应定义。

整个过程可以表示为:

C 复制代码
int.c
  |
  | 预处理
  | malloc -> mymalloc
  | free   -> myfree
  v
编译器
  |
  v
int.o
  |
  | 引用 mymalloc、myfree
  v
链接器
  |
  | 查找 trace.o 中的定义
  v
intc

由此可见,编译时打桩并没有改变链接器的符号解析规则,只是在编译前利用宏替换改变了程序需要引用的函数名称。

这种方式实现简单,也便于调试,但存在一个明显限制:必须能够控制相关源文件的编译过程,并让宏定义在函数调用被编译之前生效。

对于已经编译好的目标文件,宏替换无法再改变其中的符号引用。因此,如果我们希望不重新编译 int.o,直接在链接阶段改变其函数调用目标,就需要使用另一种方式。

前面介绍符号解析时,我们已经知道,编译器在生成目标文件时,可以保留对外部函数的未定义符号引用。例如,原始测试程序调用 malloc 和 free,如果没有进行编译时宏替换,那么目标文件中通常会保留对这两个外部符号的引用。

可以重新编译原始程序:

bash 复制代码
gcc -O0 -fno-builtin-malloc -fno-builtin-free \
    -c int.c -o int.o

这里不再使用 -include trace.h,因此预处理阶段不会将 malloc 和 free 替换成其他函数名称。

执行:

C 复制代码
nm -u int.o

可以看到类似:

C 复制代码
U malloc
U free

现在的问题是:既然目标文件已经生成,并且符号名称仍然是 malloc 和 free,能否让链接器在解析这两个符号时,主动选择我们编写的包装函数?

GNU 链接器提供了 --wrap 选项,允许在链接阶段重定向指定的符号引用。

对于 malloc,可以使用:

C 复制代码
-Wl,--wrap=malloc

这里的 -Wl 表示将后面的选项传递给链接器,而 --wrap=malloc 告诉链接器按照特殊规则解析 malloc 的引用。

其核心规则是:

c 复制代码
malloc        -> __wrap_malloc
__real_malloc -> malloc

第一条规则表示:当链接器遇到需要重定向的 malloc 未定义符号引用时,将其解析为 __wrap_malloc。

第二条规则表示:在包装函数中,可以通过 __real_malloc 表达对原始 malloc 的调用。链接器会将这个引用重新解析为真正的 malloc。

这样,程序既能够先调用我们编写的包装函数,又不会失去调用标准库原函数的能力。

需要注意,__real_malloc 并不是 C 标准库本身提供的一个新内存分配函数,而是 GNU 链接器为 --wrap 机制约定的特殊符号名称。真正完成内存分配的仍然是标准库中的 malloc

现在创建 linkwrap.c:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

// 声明真实函数的链接器包装别名
void *__real_malloc(size_t size);
void __real_free(void *ptr);

// malloc 的包装函数
void *__wrap_malloc(size_t size)
{
    void *ptr = __real_malloc(size);

    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);

    return ptr;
}

// free 的包装函数
void __wrap_free(void *ptr)
{
    fprintf(stderr, "free(%p)\n", ptr);

    __real_free(ptr);
}

注意,这里的包装函数没有直接使用原来的函数名称,而是使用了 __wrap_malloc 和 __wrap_free。

它们内部调用的也不是普通的 malloc 和 free,而是通过 __real_malloc、__real_free 告诉链接器需要调用原始函数。

因此,函数调用过程可以理解为:

C 复制代码
main()
  |
  | 调用 malloc
  v
链接器重定向
  |
  v
__wrap_malloc()
  |
  | 通过 __real_malloc 调用原始函数
  v
libc malloc()
  |
  v
返回包装函数
  |
  | 记录申请结果
  v
返回 main()

这与编译时打桩有一个明显区别:测试程序中的函数调用名称没有发生改变。int.o 依然引用 malloc 和 free,只是链接器在处理这些引用时,将它们引导到了包装函数。

现在编译包装函数:

bash 复制代码
gcc -O0 -c linkwrap.c -o linkwrap.o

然后执行链接:

bash 复制代码
gcc int.o linkwrap.o \
    -Wl,--wrap=malloc \
    -Wl,--wrap=free \
    -o intl

这里的两个 --wrap 选项分别处理 malloc 和 free。

执行:

C 复制代码
./intl

可以得到类似:

C 复制代码
malloc(32) = 0x55a2c0
free(0x55a2c0)

虽然输出与编译时打桩相同,但底层机制已经不同。

我们可以再次查看原始目标文件:

bash 复制代码
nm -u int.o

它仍然包含:

bash 复制代码
U malloc
U free

说明编译器并没有把源代码中的函数名称改成包装函数名称。

接下来查看包装函数的目标文件:

C 复制代码
nm linkwrap.o

其中可以找到 __wrap_malloc、__wrap_free 的定义,以及 __real_malloc、__real_free 的未定义引用。

因此,链接器实际上完成了两组相互配合的符号重定向:

malloc⟶__wrap_malloc \boxed{ malloc \longrightarrow \_\_wrap\_malloc } malloc⟶__wrap_malloc

__real_malloc⟶malloc \boxed{ \_\_real\_malloc \longrightarrow malloc } __real_malloc⟶malloc

对于 free 也采用相同的规则。

这里可以进一步联系之前的重定位知识。原始 int.o 中调用 malloc 的机器指令,可能包含一个尚未确定目标地址的重定位记录。

在普通链接过程中,链接器会将这个符号引用关联到标准库中的 malloc,并根据相应的链接规则完成地址处理。

但启用 --wrap=malloc 后,链接器在符号解析阶段改变了目标定义,使原本需要解析到 malloc 的引用转而解析到 __wrap_malloc。

因此,链接时打桩的关键不是修改源代码,也不是运行时修改函数的机器指令,而是在链接器处理未定义符号引用时改变其解析目标。

不过,这种机制同样有适用范围。GNU 链接器的 --wrap 主要处理未定义符号引用,如果某个函数调用在同一个目标文件内部已经完成绑定,或者被编译器内联、优化消除 ,那么该调用就不一定能够被 --wrap 拦截。

例如,假设某个源文件中既定义了函数 foo,又在另一个函数中直接调用它,编译器或汇编器可能已经能够确定两者之间的关系,而不再保留一个需要链接器解析的外部未定义引用。此时即使指定 --wrap=foo,也不能保证这个调用会被重定向。

因此,如果希望利用 --wrap 分析某个函数,应该检查实际生成的目标文件中是否存在对应的未定义符号引用,而不能仅根据源代码中出现了某个函数名称,就认为它必然能够被链接器拦截。

与编译时打桩相比,链接时打桩不需要修改被拦截函数调用处的源代码,甚至可以用于已经生成的目标文件。这对于拥有目标文件但不方便重新编译全部源代码的项目尤其有用。

例如,在测试某个库的内存分配行为时,我们可以通过 --wrap 为指定函数添加日志、参数检查或故障注入逻辑,而无需直接修改原来的函数实现。

但它仍然存在一个限制:必须重新执行链接,生成包含相应符号重定向关系的可执行文件。

如果程序已经构建完成,我们既无法修改源代码,也不希望重新链接,而只是希望在运行程序时临时拦截某些共享库函数,那么编译时和链接时打桩就不再适用。

这就需要利用动态链接器的符号查找机制,在程序加载阶段改变外部符号的解析顺序。Linux 提供的 LD_PRELOAD 正是实现这种机制的重要手段。

下一部分将进一步分析加载时打桩(Load-time Interpositioning) ,重点理解 LD_PRELOAD 如何改变共享库的符号查找顺序,以及 dlsym(RTLD_NEXT, ...) 为什么能够在拦截函数之后继续调用原始函数。

加载时打桩(Load-time Interpositioning)

前面介绍的两种库打桩方式,分别利用预处理器的宏替换 和链接器的特殊符号解析规则 ,将程序中对 malloc、free 的调用引导到自定义的包装函数。但它们都需要参与程序的构建过程:编译时打桩需要重新编译相关源文件,而链接时打桩虽然可以使用已经生成的目标文件,却仍然需要重新执行链接。

如果现在只有一个已经编译完成的可执行程序,既不准备修改源代码,也不希望重新链接,是否还能改变它对共享库函数的调用?

答案是可以。因为对于采用动态链接的程序,部分外部函数的地址并不是在生成可执行文件时就完全固定,而是需要由动态链接器在加载或运行阶段完成符号绑定。只要能够影响动态链接器的符号查找顺序,就有机会让原本指向标准库函数的引用,优先解析到我们自己提供的函数实现。

这就是加载时打桩(Load-time Interpositioning)的基本原理。在 Linux 中,可以通过环境变量 LD_PRELOAD 实现这种机制。

LD_PRELOAD 与符号查找

继续使用前面的测试程序:

c 复制代码
/* int.c */

#include <stdlib.h>

int main()
{
    int *p = malloc(32);
    free(p);
    return 0;
}

正常情况下,程序调用 malloc 和 free 时,相关外部符号通常会通过动态链接机制关联到标准 C 库中的对应实现。

假设我们另外创建一个共享库 mymalloc.so,其中也定义了两个名称完全相同的函数:

c 复制代码
void *malloc(size_t size);
void free(void *ptr);

如果能够让动态链接器优先使用 mymalloc.so 中的定义,那么程序原本对 malloc 和 free 的外部调用就可能首先进入我们的包装函数。

在 Linux 中,可以使用:

C 复制代码
LD_PRELOAD=./mymalloc.so ./intr

其中,LD_PRELOAD 告诉动态链接器,在正常依赖库之外预先加载指定的共享库,并使其符号在适用的查找范围内具有较高的查找优先级。

因此,当程序需要解析 malloc 时,动态链接器可以先找到 mymalloc.so 中的同名符号,而不是直接使用 libc 中的定义。

整个过程可以理解为:

C 复制代码
普通动态链接:

main()
  |
  | malloc(32)
  v
libc.so 中的 malloc()


使用 LD_PRELOAD:

main()
  |
  | malloc(32)
  v
mymalloc.so 中的 malloc()
  |
  | 记录函数调用
  |
  v
libc.so 中的 malloc()

这里有一个重要区别:加载时打桩不是将可执行文件中所有名为 malloc 的机器指令直接替换掉,而是利用动态链接器的符号查找与绑定机制,使符合条件的外部函数引用解析到自定义实现。

因此,它主要作用于经过动态符号解析的调用。对于静态链接的函数、已经内联的代码、某些内部绑定的函数调用,以及直接执行系统调用的代码,LD_PRELOAD 并不能保证实现拦截。

同样,如果目标程序处于特殊的安全执行模式,例如某些具有特权属性的程序,动态链接器还可能限制或忽略普通用户指定的 LD_PRELOAD,避免任意共享库被注入特权进程。

这些限制也说明,LD_PRELOAD 更适合作为动态链接程序的调试、监控和扩展机制,而不能将其视为能够拦截所有程序行为的通用手段。

RTLD_NEXT 与原函数调用

现在假设我们已经成功拦截了 malloc,让程序调用进入自定义共享库中的同名函数:

c 复制代码
void *malloc(size_t size)
{
    // 记录调用信息

    return malloc(size);
}

这段代码显然存在问题。

因为当前函数本身就叫 malloc,如果在函数内部再次调用 malloc,就可能重新进入当前包装函数,形成无限递归。

因此,加载时打桩需要解决一个问题:包装函数已经占据了原函数的名称,如何继续调用真正的库函数?

此前介绍运行时动态链接时,我们已经使用过 dlsym:

C 复制代码
void *dlsym(void *handle, const char *symbol);

它可以根据符号名称查找对应的地址。

在 GNU/Linux 的动态链接环境中,dlsym 还支持一个特殊的伪句柄:

C 复制代码
RTLD_NEXT

例如:

C 复制代码
dlsym(RTLD_NEXT, "malloc");

这里的 RTLD_NEXT 表示:从当前调用 dlsym 的共享对象之后,继续按照相应的符号查找顺序,寻找下一个名为 malloc 的定义。

假设当前符号查找关系为:

C 复制代码
mymalloc.so
    |
    | 定义 malloc
    |
    v
libc.so
    |
    | 定义 malloc
    |
    v
其他共享对象

当程序第一次解析 malloc 时,可以优先找到 mymalloc.so 中的包装函数。

而包装函数内部使用:

c 复制代码
dlsym(RTLD_NEXT, "malloc");

就可以跳过当前对象中的 malloc 定义,继续向后查找,从而取得后续的同名函数实现。

在我们这个简单的实验中,后续实现通常就是 libc 中真正的 malloc。

需要注意,RTLD_NEXT 并不专门表示"查找 libc 中的函数"。如果程序加载了多个拦截库,那么查找结果可能是下一个拦截库中的包装函数,而不一定直接是标准库函数。

因此,多个 LD_PRELOAD 库还可以形成一条包装调用链。

例如:

C 复制代码
main()
  |
  v
Preload A: malloc()
  |
  | RTLD_NEXT
  v
Preload B: malloc()
  |
  | RTLD_NEXT
  v
libc: malloc()

这也是 RTLD_NEXT 与普通 dlsym(handle, "malloc") 的重要区别:它支持包装函数沿当前符号查找链继续寻找后续定义,而不是简单地在某个指定句柄中查找符号。

malloc 与 free 的拦截实现

现在我们可以编写自己的共享库。它对外提供与标准库同名的 malloc 和 free,内部再通过 RTLD_NEXT 调用后续的真正实现。

不过,拦截内存分配函数时需要格外小心,因为 malloc 是很多标准库函数依赖的底层接口。

例如,如果直接在自定义 malloc 中调用 printf,就可能出现以下调用关系:

C 复制代码
malloc()
  |
  v
printf()
  |
  | printf 的内部实现可能申请内存
  v
malloc()
  |
  v
printf()
  |
  v
...

虽然不是每次 printf 都会触发动态内存分配,但在不同的标准库实现和运行状态下,确实可能出现这种重入问题。

更复杂的是,dlsym 自身也参与动态链接器的内部工作。如果包装函数尚未完成原函数地址的初始化,就因为某种内部操作再次进入 malloc,同样可能发生递归。

因此,不能简单认为在 malloc 中调用任意标准库函数都是安全的。

下面给出一个适合在 Linux/glibc 环境中学习和验证的实现。它使用 RTLD_NEXT 查找真实函数,缓存相应的函数指针,并通过线程局部标志减少日志操作产生的重复记录。

为降低日志导致递归的风险,这里不使用 printf 直接向标准输出打印,而是先将日志格式化到固定大小的栈缓冲区,再通过 write 输出到标准错误。

创建 mymalloc.c:

c 复制代码
#define _GNU_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <dlfcn.h>
#include <unistd.h>

// 保存真实的函数地址
static void *(*real_malloc)(size_t) = NULL;
static void (*real_free)(void *) = NULL;

// 避免日志操作反复进入日志逻辑
static _Thread_local int in_hook = 0;


// 自定义 malloc
void *malloc(size_t size)
{
    // 获取后续 malloc 的地址
    if (real_malloc == NULL)
    {
        real_malloc = (void *(*)(size_t))
                      dlsym(RTLD_NEXT, "malloc");

        if (real_malloc == NULL)
            _exit(127);
    }

    // 调用真实 malloc
    void *ptr = real_malloc(size);

    // 记录分配信息
    if (!in_hook)
    {
        in_hook = 1;

        char buffer[128];

        int n = snprintf(
            buffer,
            sizeof(buffer),
            "malloc(%zu) = %p\n",
            size,
            ptr
        );

        if (n > 0)
        {
            size_t len = (size_t)n;

            if (len >= sizeof(buffer))
                len = sizeof(buffer) - 1;

            write(STDERR_FILENO, buffer, len);
        }

        in_hook = 0;
    }

    return ptr;
}


// 自定义 free
void free(void *ptr)
{
    // 获取后续 free 的地址
    if (real_free == NULL)
    {
        real_free = (void (*)(void *))
                    dlsym(RTLD_NEXT, "free");

        if (real_free == NULL)
            _exit(127);
    }

    // 记录释放信息
    if (!in_hook)
    {
        in_hook = 1;

        char buffer[128];

        int n = snprintf(
            buffer,
            sizeof(buffer),
            "free(%p)\n",
            ptr
        );

        if (n > 0)
        {
            size_t len = (size_t)n;

            if (len >= sizeof(buffer))
                len = sizeof(buffer) - 1;

            write(STDERR_FILENO, buffer, len);
        }

        in_hook = 0;
    }

    // 调用真实 free
    real_free(ptr);
}

这里需要注意,以上代码是为了展示动态符号拦截机制的教学实验,并不是生产级的内存分配拦截器。

其中,_Thread_local 只用于防止当前线程的日志逻辑重复执行,并不能彻底解决动态链接器初始化期间的重入问题;对 real_malloc 和 real_free 的首次解析也没有实现完整的多线程同步保护。snprintf 在复杂运行环境下同样不能被普遍视为完全安全的内存分配拦截器内部操作。

因此,生产级内存追踪器还需要处理早期初始化、原子状态、递归回退、信号安全和线程并发等问题。这里只在普通 Linux/glibc 测试程序中验证符号拦截原理。

现在编译共享库:

bash 复制代码
gcc -fPIC -shared \
    -o mymalloc.so \
    mymalloc.c \
    -ldl

这里使用了此前介绍过的两个重要选项:-fPIC 用于生成位置无关代码,-shared 用于生成共享目标文件。

接下来重新编译原始测试程序:

bash 复制代码
gcc -O0 \
    -fno-builtin-malloc \
    -fno-builtin-free \
    int.c -o intr

我们没有修改 int.c 的源代码,也没有像链接时打桩那样使用 --wrap,更没有将 mymalloc.so 直接作为链接依赖。

因此,正常执行:

C 复制代码
./intr

时,程序仍然按照普通动态链接机制调用相应的标准库函数,不会输出我们自己定义的内存追踪日志。

现在使用:

C 复制代码
LD_PRELOAD=./mymalloc.so ./intr

就可以得到类似:

C 复制代码
malloc(32) = 0x55c2b8a622a0
free(0x55c2b8a622a0)

可以发现,程序中的 malloc 和 free 调用已经进入我们编写的共享库。

这里最重要的现象是:同一个可执行文件,在不重新编译和链接的情况下,仅仅改变程序启动时的环境变量,就能够改变某些库函数调用所使用的实现。

这正是动态链接机制提供的灵活性。

动态符号的绑定过程

为了进一步理解上述实验,可以直接观察测试程序的动态符号表。

首先执行:

C 复制代码
nm -D intr | grep -E 'malloc|free'

通常可以看到程序对 malloc 和 free 的动态符号引用。

再执行:

C 复制代码
nm -D --defined-only mymalloc.so

可以观察到 mymalloc.so 中导出了同名函数的定义。

因此,测试程序和我们自定义的共享库之间,已经具备了动态符号解析所需要的基本条件。

但是,intr 在构建时并没有记录对 mymalloc.so 的直接依赖。我们可以使用:

C 复制代码
readelf -d intr | grep NEEDED

验证这一点。

在正常的 glibc 构建环境中,输出通常会包含 libc.so.6,而不会包含 mymalloc.so。

这说明 mymalloc.so 并不是程序通过 -l 选项链接进来的普通动态依赖,而是在程序启动时通过 LD_PRELOAD 额外引入的共享对象。

动态链接器加载程序时,会根据适用的符号查找规则处理这些共享对象,使预加载库中的可见符号能够优先满足某些动态符号引用。

如果我们希望观察动态链接器实际进行的符号绑定,还可以使用:

C 复制代码
LD_DEBUG=bindings \
LD_PRELOAD=./mymalloc.so \
./intr 2> bindings.log

其中,LD_DEBUG=bindings 可以让 glibc 动态链接器输出符号绑定相关的诊断信息。

然后执行:

C 复制代码
grep -E 'malloc|free' bindings.log

在输出中,可以寻找涉及 intr、mymalloc.so 和 libc 的绑定关系。由于动态链接器和标准库在启动过程中还会进行其他符号处理,日志可能包含大量额外内容,需要重点关注目标函数的实际绑定结果。

这里也可以进一步联系之前介绍的 GOT 和 PLT。

在传统的 ELF 动态函数调用模型中,外部函数调用可以通过 PLT 提供的跳转入口以及 GOT 中保存的地址间接完成。当动态链接器解析相应符号时,就可以使后续调用最终到达预加载库中的实现。

例如:

C 复制代码
main()
  |
  v
malloc@plt
  |
  | 通过动态绑定结果确定目标
  v
mymalloc.so: malloc()
  |
  | dlsym(RTLD_NEXT, "malloc")
  v
libc.so: malloc()

这只是典型 PLT/GOT 调用模式的示意。现代编译器也可能通过 -fno-plt 等方式采用不同的外部函数调用形式,但动态符号绑定的基本原理仍然成立。

因此,LD_PRELOAD 并不是一种独立于链接器之外的特殊魔法,而是利用了 ELF 动态链接器原本就具有的符号查找和地址绑定能力。

三种打桩方式的区别

现在可以将编译时、链接时和加载时三种库打桩机制放在同一条程序构建流程中理解。

编译时打桩发生在生成目标文件之前,利用预处理宏将原来的函数调用替换为包装函数调用。此时,编译器看到的符号名称已经发生变化。

链接时打桩则保留目标文件中的原始符号引用,在链接阶段通过 --wrap 等机制改变其解析目标,因此不需要重新编译原始源文件,但仍然需要重新链接。

加载时打桩更进一步,利用动态链接器的符号查找规则,在程序启动时通过 LD_PRELOAD 引入额外共享库,使符合条件的外部函数引用优先绑定到包装函数。因此,对于已经构建完成的动态链接程序,可以在不修改可执行文件的情况下启用相应的拦截。

对比 编译时打桩 链接时打桩 加载时打桩
主要机制 宏替换 --wrap 符号重定向 LD_PRELOAD 动态符号抢先解析
发生阶段 预处理、编译阶段 静态链接阶段 加载、动态绑定阶段
是否需要重新编译目标代码 需要 通常不需要 不需要
是否需要重新链接可执行文件 需要 需要 通常不需要
典型用途 可控源码的测试与日志 已有目标文件的调用拦截 动态链接程序的运行时追踪

这三种实现虽然操作方式不同,但背后遵循同一个思想:函数调用最终执行哪个实现,并不完全取决于源代码中写下的函数名称,还取决于编译、链接和加载过程中如何建立符号引用与符号定义的对应关系。

从最初的符号解析和重定位,到静态库的按需提取,再到共享库的动态加载与库打桩,我们实际上是在不断研究同一个问题:一个函数名称,如何最终对应到 CPU 实际执行的机器指令。

在静态链接中,这种对应关系主要由链接器在构建阶段确定;在动态链接中,部分对应关系可以延迟到加载或运行期间处理;而库打桩则进一步利用这些机制,使程序能够在不改变原函数实现的情况下,插入监控、调试或其他功能。

对实际 Linux 系统开发而言,这些知识可以帮助我们理解 undefined reference、multiple definition、共享库找不到、符号版本不兼容等问题,也能帮助我们分析第三方库的链接关系,并利用动态链接机制构建可扩展的插件系统和函数追踪工具。

理解链接的真正价值,不只是掌握几条 GCC 命令,而是能够从目标文件、ELF 符号表、机器指令和运行时加载机制出发,解释程序中的一次函数调用究竟是如何找到并执行对应实现的。

相关推荐
旖旎夜光1 小时前
【C++ 基础入门】C++ 多态(上):虚函数、重写、override/final 与抽象类
开发语言·c++·笔记·学习
青少儿编程课堂3 小时前
蓝桥杯青少组图形化 STEMA 省考(报名倒计时·11/22开考)+ 全国青少年信息素养大赛 2026—2027赛季 + 电子学会图形化考级:校园华容道大闯关
c++·python·算法·bfs·信息学竞赛
白杨尚青3 小时前
C++进阶篇(五):红黑树——四条规则、三种插入情况与旋转变色
开发语言·数据结构·c++·stl·红黑树·平衡二叉树
旖旎夜光4 小时前
【C++ 基础入门】C++ 继承(上):概念、切片、隐藏、默认成员函数与友元
c++·笔记·学习
apcipot_rain5 小时前
C++转Java日记(05)
java·开发语言·c++
apcipot_rain13 小时前
C++转Java日记(04)
java·开发语言·c++
wuyk55513 小时前
Keil工具链-第05讲-启动文件与__main到底做了什么
c语言·stm32·单片机·嵌入式硬件·物联网
WL学习笔记16 小时前
vector模拟实现
c++
蜗牛一步步往上爬18 小时前
第十六章 C++语言在深度学习中的应用
开发语言·c++·深度学习