通过链接编译复杂的软件(二)—— 静态链接库、动态链接库和打桩

在分离编译和链接技术的帮助下,修改源代码后的重新构建工作不再那么耗时耗力。更重要的是,分离编译和链接这一个过程为我们提供了一套合作协议。
在这套协议下,编译不仅可以是不同时的,甚至也可以是不同地的——我们可以编译修改后的源文件 A 再链接以前编译的模块 B,也可以编译自己电脑上的源文件 A 再链接其他开发者编译给我们的模块 B。这种特性非常适合使用第三库的场景,它使第三方库容易分发和使用(以及一定程度上的保密)。根据第三方库被链接的时机,发展出了静态链接库(编译时链接)和动态链接库(运行时链接)技术。
此外,符号解析这一步骤使得我们能够插手其中做一些干涉(interposition, 打桩),比如把对库函数 A 的引用解析到自己的函数 B ,在 B 里面做一些处理后再调用库函数 A,从而在无法/不需要修改库的情况下方便快速地实现定制功能。
这篇文章将分析静态链接、动态链接库和打桩技术的实现原理。

一、静态链接库

通过链接而生成 ELF 文件的过程称为静态链接, 而静态链接库主要应用于静态链接过程中。
静态链接库文件本身和可执行文件一样,也是通过编译和链接 ELF 文件得来的。但是由于静态库本身也只是作为一个被链接的对象,所以生成它的过程更简单一点:链接器省去了合并相同节和重定位符号这两个步骤。换句话说,链接器只是简单地把所有的模块字节拼接在一起形成了一个存档(Archive)文件。
这么做的好处是加快了链接生成静态库的速度和节省了磁盘空间。之所以说加快了速度,是因为避免了一次无用的重定位步骤——既然静态库被链接时总是需要重定位的,那链接生成静态库时的重定位就显得多此一举了。那节省空间的原因是什么呢?回想一下,链接器在进行符号解析时维护着三个集合,分别是 ELF 集合 E、已定义符号集合 D 和未定义符号引用 U。最终文件的大小由 E 集合中的所有文件的大小决定。而链接器在链接静态库时,不像处理普通的 ELF 文件一样把整个文件加入到 E 中,而是只把静态库中被使用到的 ELF 模块加入到了 E 中。
我们重新梳理一下链接器进行符号解析的步骤:

  • 链接器开始处理一个文件 f,判断它是普通 ELF 文件还是静态库文件
    • 对于 ELF 文件,第 1 步把 f 中定义的符号逐个拿去和 D 中的符号进行匹配。如果找到了并且两个都是强符号,则报错 redefinition of xxx 并中止链接过程。 如果没找到,就把 f 定义的所有符号加到 D,并从 U 移除这些符号(如果存在的话);
    • 对于 ELF 文件,第 2 步把 f 中引用的未定义符号逐个拿去和 D 中的符号匹配。如果没找到就把它加入到 U;
  • 如果是静态文件库,就把它当做一系列的模块 m 来处理
    • 对于模块 m,把 m 中定义的符号逐个拿去和 U 中的符号进行匹配。如果找到了,就把这个文件当做 ELF 文件来处理,加入 E 并修改 D 和 U。如果没找到,就跳过这个模块。
    • 当处理完所有模块后,如何 E、D 和 U 被修改了,重新从第一个模块开始用相同的步骤处理。只有当 E、D 和 U 没有产生任何变化时,才结束对静态文件库的处理

链接器链接处理静态库的过程会导致一些诡异现象,比如链接生成静态库时没问题,但是静态库被链接时就出问题了,而且有时没问题,有时有问题
原因 1,包含相同强符号的 ELF 文件被链接成一个静态库。由于链接生成静态库时没有符号解析和重定位步骤,所有这个是完全允许的。当这个静态库被链接时,只要两个模块没有被同时需要,那就不会出错。如果同时被需要了,那就会出现 redefinition 错误。
原因 2,静态库依赖其它静态库。比如 main.o 依赖 b.a, b.a 依赖 a.a。如果链接时编译器先处理 a.a 再处理 b.a 最后处理 main.o,导致 a.a 和 b.a 里面所有的模块都被放弃,引起 main.o 里面的 undefined reference 错误。
不管是何种原因,静态库本身是 ok 的,而出错与否完全由使用者的模块所决定,所以会时好时坏

二、动态链接库

静态链接库是一种非常好的分发库的方式,不仅方便了链接库的方式,也减少了对磁盘空间的消耗。但是当库被修改后,使用者还是需要重新编译链接一次。而且如果同一台电脑上 N 个程序使用同一个静态库的所有模块,我们还是要拷贝 N 个静态库到这几个程序中去。这时候,动态链接库就派上用场了。
动态链接库需要在链接时被提供给链接器做参数,但是只有在程序加载时或者运行时才会被真正链接,而不会在编译时就被合并到可执行文件中去。即使动态库被修改后,我们也不需要重新编译链接。
这意味着程序在运行时必须找得到共享库的文件。听起来麻烦一点,但是任何事情总是有代价的不是吗?而且通过虚拟内存共享技术,同一个动态链接库加载到内存后可以同时被 N 个程序共享。就冲这点好处也完全值回了本了(也再次证明“分层”是和多么美妙的设计)。
说了这么多好处,动态链接库又是如何做到的呢?
最朴素的做法就是在链接动态库的时候,同样执行重定位操作,去修改代码中的符号引用的内存位置。
但是这样是不可行的,因为可执行文件的代码节是不能在加载时和运行时被修改的(不然那还得了)。修改动态链接也不行,因为共享属性和内存空间独立属性互相矛盾。
内存位置是每个程序独立的虚拟内存空间的位置。如果程序 A 链接库 S 时把其 S 中的地址改成了 A 虚拟内存空间内的地址,那程序 B 链接时是用 A 所设置的地址还是改成 B 自己的?用 A 的地址,就违反了虚拟内存空间的独立性,用 B 自己的,那 A 就面临和 B 一样的选择。虽然 A 和 B 可以各自拥有一个修改了的内存块的副本彼此不干扰,但是修改的地方越多,每个进程独享的内存空间就越多,最后就和每个程序持有一份动态链接库差不了多少了。这样就违背了动态链接库共享的初衷。
关于计算机系统的设计,有人说过:没有什么是分层解决不了的问题。分层的思想体现在计算机世界从宏观到微观的各个领域,包括在动态链接库的设计上。
直接修改代码不行,一是不允许,二是内存消耗太大。但是我们能使用分层把修改的地方集中到少量的可以被修改的字节(内存段)上来。比如说,把每个对符号的引用都集中收集到一个存在数据节的表里面来,访问符号时,先访问这张表,由这张表再找到符号的地址。这样的话,程序中的代码不用被修改,每个共享库继续被共享。
实际上,动态链接技术正是这么干的。而且它不仅用了表,而且是两张表,分辨是 GOT(global offset table) 和 PLT (procedure linking table)。

2.1 使用 GOT 表访问数据符号

GOT 表在 编译->静态链接 时生成,保存在可执行文件数据节之前。这说明它是可以被修改的。
GOT 表的使用方式就像刚才所说的那样,把每个符号引用的内存地址指向 GOT 表中的具体某项 t,再由 t 找到符号真正的地址。而 t 的值在链接时的重定位步骤中被大胆地修改到正确的位置。
这种策略中,t 的值是可以被修改的,但是代码找到 t 的方式是不能被修改的,否则就回到了老路子上。如何不使用 t 的位置找到 t 呢?答案是,使用相对寻址,用当前指令的地址加一个偏移量。而这个偏移量——得益于 ELF 文件格式的结构和操作系统加载可执行文件到内存时的行为——是在编译期间就能确定且运行时不变的一个常量:

1
2
addq 0x4040(%rip), %rax     // %rip + 0x4040 得到 t 的位置
addq $0x1, (%rax) // t 的位置保存数据符号的内存地址,把它加 1

这种使用偏移量寻找 t 的方法,是 GOT 表被称之为 global offset table 的原因。而不依赖 t 的地址找到 t 的代码,叫 PIC(position independent code) 代码。为了生成 PIC 代码,我们要在在编译时使用 -fPIC(flag=PIC) 标志。显然,在编译动态链接库时必须要使用 -fPIC flag。

但我们不会每次都使用 PIC 选项。考虑这种非 PIC代码 引用 PIC符号 的情况:我们编译了一个常规的 ELF 文件 main.o,其中对某个数据符号 x 的引用只可能是 movq 0xffffff, %rax 形式。在动态链接时,发现 x 是共享库里的符号。此时要怎么办?
此时再修改地址 0xffffff 已经不被允许了,而 x 也没有使用到 GOT。因此无能为力。
所有我们只能在 编译->链接动态库 时就做好准备工作。在那个时候,链接器发现 x 是对动态库中的引用,虽然它同样不能修改 movq 0xffffff, %rax 指令为两条指令加 GOT 的形式,但是它可以为 x 确定内存位置 0x112358 并重定位 0xffffff0x112358
等等,x 不是定义在共享库中的吗?怎么能此时就确认它的位置和呢?没关系,链接器可以在动态链接动态库时把 x 也重定位到 0x112358 并把它的值复制过来。

2.2 PLT 表和 GOT 表访问函数符号

访问函数和访问数据没啥区别,不过用的指令是 call 罢了:

1
2
addq 0x4040(%rip), %rax     // %rip + 0x4040 得到 t 的位置
callq (%rax) // t 的位置保存函数符号的内存地址,调用它

那为什么不直接使用 GOT 表来访问函数符号,而是要多加一个 PLT 表呢?
对 PIC 代码来说,没问题。但是,考虑刚才说到的非 PIC 引用 PIC 的情况呢?
调用共享库中函数 sum 的指令 call 0xffffff 不能改成 GOT 式的调用了,所以 0xffffff 的值必须在 编译->静态链接 时就被重定位。
这时候,PLT 表就派上用场了。0xffffff 重定位的目标就是 PLT 表中的一个条目,其地址指向一段辅助代码,用于帮助我们在运行时找到 sum 的地址。
PLT 表保存在 ELF 文件的代码节之前,说明它是不能被修改的。
在 编译->链接 时,如果发现函数 sum 符号的引用定义在动态链接库中,就在 PLT 表中生成一段代码指令,把 0xffffff 设置为这段指令的开始位置 0x112358。如果是 PIC 代码,当然不会用绝对值, 可能会用 0x4040(%rip) 的形式。
调用函数时,就会调用链接器插入在 PLT 里面的代码,其指令大致类似于:

1
2
3
jmpq    *GOT[4]
pushq $0x121
jmpq *PLT[0]

在这里,*GOT[4] 一开始只是简单地保存着 jmpq 下一条指令的地址,这是一个在链接时可以确认的值。因此,jmpq *GOT[4] 相当于执行 pushq $0x121。 而 $0x121sum 函数独特的 ID,这也是一个在静态链接时可以确认的值。把它压入了栈中,接着跳转到 *PLT[0]*PLT[0] 是两个链接器专门插入用来寻找符号地址的指令:

1
2
pushq   *GOT[1]
jmpq *GOT[2]

*GOT[1]保存的是重定位条目的地址,GOT[2] 保存的是动态链接器的地址。把重定位条目地址也压入栈后,加上刚才压入栈的 sum 的 ID 一共有了两个参数,之后跳转到动态链接器函数中使用这两个参数得到 sum 函数的地址 0x112358。动态链接器把 0x112358 保存到 GOT[4] 处,然后 popq 刚才压入栈的两个参数,最后跳转到 sum 函数地址处,开始执行 sum 函数。
整个过程中没有执行任何 call 指令,纯粹就是在用 pushjumppop 找到了 sum 函数的地址,从调用者来看,效果和 call sum 是没有区别的。
当第二次调用 sum 函数的时候,callq (0x4040(%rip)) 仍然会跳转到 jmpq *GOT[4],但此时 *GOT[4] 的值已经包含了 sum 函数正确的地址了。

使用 PLT 还有一个好处,就是在运行时链接开始的时候,所有函数都不需要重定位,加快了链接速度。这种只有在第一次调用函数时才重定位的技术叫**延迟绑定(lazy binding)**。

2.3 再次更新链接时解析符号的步骤

之前我们讨论了链接器遇到普通 ELF 文件和静态链接库文件时如何处理的 E、D 和 U 的步骤,现在我们要最后再更新一次这个步骤,考虑编译器遇到了动态链接库的情况。
假如链接器遇到了一个不需要的动态链接库 a,它是会把 a 加入到 E 还是抛弃 a 呢?可以做一个实验来检查。把 main.o 同时动态链接到 a.so 和 b.so,后两者都包含 main.o 依赖的符号。如果不需要的符号也会被加入 E,那么此时会报错。然而在实际链接过程中并没有报错,说明不需要的动态链接库是被抛弃了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//main.c
#include <iostream>
#include <algorithm>
#include <malloc.h>

using namespace std;

int sum (int a, int b);

int main(int argc, char **argv) {
cout << sum(1, 2) << endl;
return 0;
}

// a.c
int sum(int a, int b) { return a + b; }

// b.c
int sum(int a, int b) { return a + b; }

其实动态链接库即使被需要也不会被加入到 E 中然后被合并,但是对动态链接库中符号的引用会拿来生成 GOT 条目和 PLT 条目。只有到了加载或者运行时,才会从动态链接库中解析符号并执行重定位。

三、打桩技术

说了这么多,最后再来说说打桩。
打桩这个词其实翻译得不太正确,其英语单词是 Inter-Positioning,说是“拦截”可能更贴近本意。它的作用和设计模式中的装饰器模式很像,允许在不介入原函数的情况下,拦截对原函数的调用,实现定制的需求,再执行原函数。
打桩技术之所以得以实现,是因为链接过程中的符号解析步骤为我们提供了一个解析到其它符号的破绽。什么时候可以解析符号,什么时候就可以打桩。

3.1 编译时打桩

编译时打桩的实现机制是通过添加命令行参数 -I(path) 来调整编译器搜寻 include 时的优先级。
一般来说,如果 include 了库的头文件声明,并调用了其中的函数,而开发者自己的模块中又没有提供该函数的定义,则链接器会自动链接库的定义。
如果我们想拦截该库函数,该怎么办呢?我们可以自己实现一个同名的函数。但这不是一个好办法,因为这会给后期维护造成困扰,别人会认不清哪个是我们的实现,哪个是库的实现。
合理的做法是我们定义一个不同名的函数,在里面实现客制的功能,然后调用并返回库函数。为了让编译器把对库函数的调用链接到我们定义的不同名函数上,我们有两种选择:

  • 1 直接修改源码,使用类似 #define lib_func(arg) my_func(arg) 的宏,这样在预处理阶段就会把所有 lib_func 替换成 my_func,缺点是要修改源码
  • 2 自定义 lib.h 文件,修改源文件,把 #include <lib.h> 替换成 #include "/path/to/lib.h", 在 lib.h 里面使用宏,对源码的修改更少了,但还是要修改
  • 3 自定义 lib.h 文件,使用 -Ipath_to_dir_of_lib.h 这个 flag 来告诉编译器使用 path_to_dir_of_lib.h/lib.h 替代库 lib.h,在自定义的 lib.h 里面使用宏,不用修改源码

综合来看,当然是第三种方式更好。但其实第三种方案的实现原理还是基于第一种方案。严格说来,这种编译时打桩更像是预处理打桩,因为打桩发生在预处理步骤中,还没到链接时的符号解析那一步。

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
// main.c
#include <malloc.h>

using namespace std;

int main(int argc, char **argv) {
int* p = (int*)malloc(32);
free(p);
return 0;
}

// malloc.h
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

void* mymalloc(size_t t);

void myfree(void * ptr);

// mymalloc.c
#include <malloc.h>
#include <iostream>

using namespace std;

void *malloc(size_t t) {
void *p;
cout << "mymalloc" << endl;
return p;
}

void free(void *p) {
cout << "myfree" << endl;
}

上述程序中,可以使用 -I 参数告诉编译器以 "malloc.h" 代替 <myalloc.h>

3.2 静态链接时打桩

静态链接时打桩的机制是通过 -- wrap f 把所有对符号 f 的引用解析到 __wrap_f, 把对符号 __real_f 的引用解析到 f
如果要使用静态链接时打桩技术的话,我们需要在自己的模块中实现 __wrap_f,这样,原本对 f 的引用就都被我们拦截了。同时,我们要在 __wrap_f 中调用 __real_f 以调用原来真正的 f 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// main.c
#include <malloc.h>

using namespace std;

int main(int argc, char **argv) {
int* p = (int*)malloc(32);
free(p);
return 0;
}

// mymalloc.c
void * __real_malloc(size_t t);
void __real_free(void* ptr);

void *__wrap_malloc(size_t t) {
void *p;
cout << "mymalloc" << endl;
return p;
}

void __wrap_free(void *p) {
cout << "myfree" << endl;
}

假如 myalloc.c 被编译成 myalloc.o,则使用如下命令进行链接时打桩:

1
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o main.c myalloc.o

3.3 运行时打桩

运行时打桩的机制是通过 LD_PRELOAD 环境变量设置一个动态链接库作为优先的未定义符号解析来源。
链接器原本应该要在静态链接时设定的动态链接库中去解析符号,我们把它改为优先到另指定的其它链接库中去寻找符号。如果找到了,就不再去原本设定的库中匹配了。
如果要使用运行时打桩技术,我们要自己实现同名的函数,把其打包成动态链接库。然后在程序运行时,通过设置 LD_PRELOAD 环境变量来实现对符号定义的拦截。

使用打桩技术可以实现非常强大的功能,常见的性能测试和内存泄漏检查插件,都可以通过对系统函数的打桩来实现。

四、总结

链接过程主要解决的问题是分离编译情况下的符号解析和重定位两个大问题。符号解析必须在编译期完成,但是重定位可以推迟到加载时或者运行时。
静态链接库可以理解成一筐 ELF 文件的集合,根据当前符号需求进行选择,要则留,不要则扔。选完了马上就要开始链接,重定位符号。
动态链接库可以理解成一个向导,它承诺提供哪些符号,需要哪些符号。动态链接库也是根据当前符号需求进行选择,但是选完了不会马上开始链接,而是等需要时才链接。需要时,先找到向导,再根据向导的位置去重定位符号。
打桩其实就是对符号解析进行干涉,把符号解析到我们定义的符号上来。打桩可以执行在预处理时、静态链接时和运行时。