动态链接与动态库


动态库

动态库概念的出现要远远晚于静态库,它是在现代多任务操作系统成熟只后才出现的。多任务的操作系统带来了资源的共享,比如PC上的键盘鼠标驱动程序,你的应用只需要调用这些内置驱动程序的API接口即可,而不需要将驱动程序也一并打入到你的二进制中。此外,使用动态库可以减少link的时间,相比于静态库每次修改都需要都需重新编译来建立地址绑定,动态库可以做到只编译自己,从而减少链接时间。

我们可以将动态库简单的理解为一个没有main函数的binary。这说明动态库并不是像静态库那种目标文件的集合,构建一个动态库也同样需要完整的编译和链接过程

> clang++ -shared -fPIC a.cpp -o a.so
> clang++ main.cpp -b.cpp -Wl,a.so

上面的第一条命令用来将a.cpp编译为一个动态库,在linux系统中,一般以.so结尾,第二条指令则用来链接该动态库到一个可执行文件中。这里面最重要的参数是-fPIC,它可以让编译器生成地址无关的代码,这是动态库可以被多个应用程序共享的前提。我们后面会详细介绍其工作原理。

Naming Convention

在基于UNIX的系统中,动态库文件的表示方法为

lib + <library name> + .so + <library version information>

注意以lib开头的表示动态库文件,而不是动态库的名字,上面提到的版本信息可以表示为

<M>.<m>.<p>

其中M表示大版本,m表示小版本,p表示小改动。如果是soname表示,则格式为

lib + <libraray name> + .so + <Major Version>

例如libz.so.1.2.3.4的sonamelibz.so.1。 在编译链接的时候,如果使用gcc或者clang,也需要准寻某些特定的convention,比如用-L表示库所在的路径,-l表示该路径下的动态库名称。如果将编译和链接的命令写在一起,则需要使用-Wl,告诉编译器后面的flag是linker的flag。一个好的习惯是同时使用-L-l,尽量避免直接使用-l加绝对路径的方式

Load-Time Relocation

动态链接要解决的问题是如何让代码在进程间共享。为了达到这个目的,我们需要将动态库中的内容加载到目标进程中去。这就涉及到对动态中代码地址进行重定位的问题。实际上在fPIC被发明之前,早期的动态库加载用的是所谓的Loader-Time Relocation (LTR)。这种方法需要loader根据目标进程来修改动态库中.text段指令的地址。显然,由于动态库中指令地址被修改了,因此,它只能被用于第一个加载它的进程,如果有其它进程想要使用,则需要再拷贝一份,并让loader再修改一遍指令地址以适应当前的目标进程。显然,这种方式效率很低,而且没有达到被各进程共享的目的。

如果要验证LTR,需要使用32bit的linux,64bit机器上生成的动态库默认是fpic格式

Position Independent Code

在具体详细介绍-fPIC是如何工作的之前,我们先来想想如何让动态库的代码被多个process共用,显然,虚拟内存能帮我们做到这一点。我们可以将动态库加载到物理内存中,然后通过虚拟内存将其映射到不同的process中。注意,我们映射的只是text段,而对于data段,由于它是read-write的,我们需要为每个进程copy一份。如下图所示

由于text段可以被映射到不同进程的不动位置,因此,它里面的代码必须是地址无关的。接下来我们需要思考的问题是动态库中的符号是如何被引用的。这又包含两部分

  1. 动态库中的外部symbol的调用
  2. 动态库中的内部symbol的调用

第二个问题其实很好回答,内部的函数调用可以直接使用offset来定位,调用者不需要知道其在虚拟内存中绝对的地址。而回答第一个问题则比较复杂, 我们还是用一个例子说明,假设我们有两动态库libx.soliby.so,其中libx.sofoo函数调用了liby.so中的bar函数。如下图所示

此时,对于进程#1来说,libx.sofoo函数被映射到0x5000的位置,liby.sobar函数被映射到0x3000的位置,此时,如果让编译器生成foo函数的汇编指令,则是

call -0x2000(%rip) #bar

而对于进程#2来说,libx.sofoo函数被映射到0x8000的位置,liby.sobar函数被映射到0x1000的位置,此时,如果让编译生成foo的汇编指令,则是

call -0x7000(%rip) #bar

显然,编译器是无法对foo生成不同汇编代码的,换一个角度说,这相当于让编译器生成地址相关的代码,这违背了fPIC的设计初衷。

如果想让编译器生成地址无关的代码,那么对于bar的调用应该是和具体地址无关的,也就是说基于(%rip)的偏移必须是一个固定值,这样编译器产生的foo的汇编指定才是确定的。推而广之,对于动态库中外部符号的引用必须是地址无关的。那么如何做到这一点呢?我们可以引入一个中间层,让动态库内对外部符号的调用都指向这个中间层,再由这个中间层来查找真实的符号地址,而动态库和这个中间层的offset是固定的,这就确保了编译器可以为符号调用生成确定的代码。这个中间层叫做Global Offset Table,简称GOT。我们接着看上面的例子

上图中,两个进程的地址空间都有各自的GOT表,此时,对于libx.so中对于bar的调用转成了对GOT中bar的寻址。而由于在linking期间,libx.soliby.so相对于GOT的offset是可以被确定的(具体来说,对于进程#1和进程#2,相对于GOT中bar符号的offset为-0x1000),因此,编译器可以为foo生成确定的汇编代码。

call -0x1000(%rip) #bar

然后,进程#1 通过访问自己的GOT表,查到bar函数的地址是0x3000,它就能真正地调用到bar函数了。进程#2访问自己的GOT表,查到bar函数的地址是0x1000,它也能顺利地调用bar函数。这样我们就通过引入了 GOT 这个间接层,解决了 call 指令和 bar 函数定义之间的偏移不固定的问题。

了解GOT之后,我们回顾一下动态库的编译链接过程,为了生成地址无关代码,linker会为外部的符号建立基于GOT的间接跳转。到这一步linker的工作就完成了,当动态库被加载到进程空间时,loade需要根据offset的值来创建GOT符号表,然后对client binary中符号进行修正,使他们全部指向GOT中的符号。从client的角度看,进程的内存空间如下所示

Demonstration of PIC Code

如果上面的描述太过抽象的话,接下来我们看通过实际的程序来演示-fPIC是如何工作的,我们用下面的例子


// mylib.c

unsigned long mylib_int;
static int y = 3;
void set_mylib_int(unsigned long x){
    mylib_int = x+y;
}

unsigned long get_mylib_int() {
    return mylib_int;
}


// main.c

extern 
void set_mylib_int(unsigned long x);
extern long get_mylib_int();
unsigned long glob = 5555;

int main() {
    set_mylib_int(100);
}

我们先把mylib.c编译成动态库,并用objdump -S来看它反汇编的代码

> gcc mylib.c -fPIC -shared -fno-plt -o mylib.so
> objdump -S mylib.so

...
0000000000000609 \<set_mylib_int\>:
 609:	55                   	push   %rbp
 60a:	48 89 e5             	mov    %rsp,%rbp
 60d:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
 611:	8b 05 09 0a 20 00    	mov    0x200a09(%rip),%eax
 617:	48 63 d0             	movslq %eax,%rdx
 61a:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
 61e:	48 01 c2             	add    %rax,%rdx
 621:	48 8b 05 b8 09 20 00 	mov    0x2009b8(%rip),%rax
 628:	48 89 10             	mov    %rdx,(%rax)
 62b:	90                   	nop
 62c:	5d                   	pop    %rbp
 62d:	c3                   	retq

000000000000062e \<get_mylib_int\>:
 62e:	55                   	push   %rbp
 62f:	48 89 e5             	mov    %rsp,%rbp
 632:	48 8b 05 a7 09 20 00 	mov    0x2009a7(%rip),%rax
 639:	48 8b 00             	mov    (%rax),%rax
 63c:	5d                   	pop    %rbp
 63d:	c3                   	retq
 ...

分析汇编代码之前,我们先来看下mylib.c都有哪些符号。首先,他有一个external符号mylib_int和一个内部符号y。他们在符号表中的位置为

0000000000201030 B mylib_int
...
0000000000201020 d y

我们知道对于内部符号寻址,只需要用内部的offset即可,因此对于y来说,它的寻址很简单

611:	8b 05 09 0a 20 00    	mov    0x200a09(%rip),%eax 
617:	48 63 d0             	movslq %eax,%rdx

上面代码的意思是将y放到%eax里,根据偏移量,我们可以计算y在动态库中的实际位置为

0x200a09 + 0x617 = 0x201020

和符号表中的位置一致,因此y可以做到于地址无关。我们再来看mylib_int,对它的访问对应下面两行汇编代码

 621:	48 8b 05 b8 09 20 00 	mov    0x2009b8(%rip),%rax
 628:	48 89 10             	mov    %rdx,(%rax)

由于mylib_int是一个外部符号,因此对它的寻址需要借助GOT,而0x2009b8则是相对于GOT的offset,我们计算mylib_int在GOT中地址如下

0x0x2009b8 + 0x628 = 0x200fe0

我们再来查看GOT表的位置

> objdump -D mylib.so

...
Disassembly of section .got:

0000000000200fd8 \<.got\>:
	...

Runtime Behavior

上面的静态分析我们看到了动态库是如何寻址符号的,这一小节,我们可以通过GDB来观察进程运行时的frame,我们首先编译可执行文件,然后用GDB运行,并在set_mylib_int处添加断点

> export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
> gcc main.c -Wl,mylib.so -o a.out
> gdb a.out

(gdb)b set_mylib_int
(gdb)r

当断点触发时,我们来看set_mylib_int的汇编代码

(gdb) disassemble
Dump of assembler code for function set_mylib_int:
   0x00007ffff7bcd609 <+0>:	push   %rbp
   0x00007ffff7bcd60a <+1>:	mov    %rsp,%rbp
=> 0x00007ffff7bcd60d <+4>:	mov    %rdi,-0x8(%rbp)
   0x00007ffff7bcd611 <+8>:	mov    0x200a09(%rip),%eax
   0x00007ffff7bcd617 <+14>:	movslq %eax,%rdx
   0x00007ffff7bcd61a <+17>:	mov    -0x8(%rbp),%rax
   0x00007ffff7bcd61e <+21>:	add    %rax,%rdx
   0x00007ffff7bcd621 <+24>:	mov    0x2009b8(%rip),%rax
   0x00007ffff7bcd628 <+31>:	mov    %rdx,(%rax)
   0x00007ffff7bcd62b <+34>:	nop
   0x00007ffff7bcd62c <+35>:	pop    %rbp
   0x00007ffff7bcd62d <+36>:	retq
End of assembler dump.

我们可以看到汇编代码没有变,这说明loader并没有改变动态库中的.text段,但是这些指令在进程中的位置发生了变了,我们还是来计算一下mylib_init在GOT中的位置

0x2009b8 + 0x00007ffff7bcd628 =  0x7ffff7dcdfe0

我们再来看下0x7ffff7dcdfe0指向哪里

(gdb) x/x 0x7ffff7dcdfe0
0x7ffff7dcdfe0:	0xf7dce030

理论上,0xf7dce030就应该是mylib_init的真实位置,我们再来查看其地址

(gdb) p/x &mylib_int
$3 = 0xf7dce030

Resources