静态和动态链接

引言
即使是最简单的HelloWorld的程序,它也要依赖于别人已经写好的成熟的软件库,这就是引出了一个问题,我们写的代码怎么和别人写的库集成在一起,也就是链接所要解决的问题。

首先看HelloWorld这个例子:
[cpp] view plain copy
  1. // main.c  
  2.   1 #include <stdio.h>  
  3.   2  
  4.   3 int main(int argc, char** argv)  
  5.   4 {  
  6.   5         printf("Hello World! argc=%d ", argc);  
  7.   6         return 0;  
  8.   7 }  

HelloWorld的main函数中引用了标准库提供的printf函数。链接所要解决的问题就是要让我们的程序能正确地找到printf这个函数。
解决这个问题有两个办法:一种方式是在生成可执行文件的时候,把printf函数相关的二进制指令和数据包含在最终的可执行文件中,这就是静态链接;另外一种方式是在程序运行的时候,再去加载printf函数相关的二进制指令和数据,这就是动态链接。
每个源文件都会首先被编译成目标文件,每个目标文件都提供一些别的目标文件需要的函数或者数据,同时又从别的目标文件中获得一些函数或者数据。因此,链接的过程就是目标文件间互通有无的过程。本文根据《程序员的自我修养》一书中关于静态和动态链接总结而成,欢迎指正并推荐阅读原书。

静态链接
静态链接就是在生成可执行文件的时候,把所有需要的函数的二进制代码都包含到可执行文件中去。因此,链接器需要知道参与链接的目标文件需要哪些函数,同时也要知道每个目标文件都能提供什么函数,这样链接器才能知道是不是每个目标文件所需要的函数都能正确地链接。如果某个目标文件需要的函数在参与链接的目标文件中都找不到的话,链接器就报错了。
目标文件中有两个重要的接口来提供这些信息:一个是符号表,另外一个是重定位表。利用Linux中的readelf工具就可以查看这些信息。
首先我们用命令gcc -c -o main.o main.c 来编译上面main.c文件来生成目标文件main.o。然后我们用命令readelf -s main.o来查看main.o中的符号表:
[plain] view plain copy
  1. Symbol table '.symtab' contains 11 entries:  
  2.    Num:    Value  Size Type    Bind   Vis      Ndx Name  
  3.      0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND  
  4.      1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c  
  5.      2: 00000000     0 SECTION LOCAL  DEFAULT    1  
  6.      3: 00000000     0 SECTION LOCAL  DEFAULT    3  
  7.      4: 00000000     0 SECTION LOCAL  DEFAULT    4  
  8.      5: 00000000     0 SECTION LOCAL  DEFAULT    5  
  9.      6: 00000000     0 SECTION LOCAL  DEFAULT    7  
  10.      7: 00000000     0 SECTION LOCAL  DEFAULT    8  
  11.      8: 00000000     0 SECTION LOCAL  DEFAULT    6  
  12. <strong>     9: 00000000    36 FUNC    GLOBAL DEFAULT    1 main  
  13.     10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND printf</strong>  

我们重点关注最后两行,从中可以看到main.o中提供main函数(Type列为FUNC,Ndx为1表示它是在本目标文件中第1个Section中),同时依赖于printf函数(Ndx列为UND)。

因为在编译main.c的时候,编译器还不知道printf函数的地址,所以在编译阶段只是将一个“临时地址”放到目标文件中,在链接阶段,这个“临时地址”将被修正为正确的地址,这个过程叫重定位。所以链接器还要知道该目标文件中哪些符号需要重定位,这些信息是放在了重定位表中。很明显,在main.o这个目标文件中,printf的地址需要重定位,我们还是用命令readelf -r main.o来验证一下,这些信息是保存在.rel.textSection中:
[plain] view plain copy
  1. Relocation section '.rel.text' at offset 0x400 contains 2 entries:  
  2.  Offset     Info    Type            Sym.Value  Sym. Name  
  3. 0000000a  00000501 R_386_32          00000000   .rodata  
  4. 00000019  00000a02 R_386_PC32        00000000   printf  
那么既然main.o依赖于printf函数,你可能会问,printf是在哪个目标文件里面?printf函数是标准库的一部分,在Linux下静态的标准库libc.a位于/usr/lib/i386-linux-gnu/中。你可以认为标准库就是把一些常用的函数的目标文件打包在一起,用命令ar -t libc.a可以查看libc.a中的内容,其中你就可以发现printf.o这个目标文件。在链接的时候,我们需要告诉链接器需要链接的目标文件和库文件(默认gcc会把标准库作为链接器输入的一部分)。链接器会根据输入的目标文件从库文件中提取需要目标文件。比如,链接器发现main.o会需要printf这个函数,在处理标准库文件的时候,链接器就会把printf.o从库文件中提取处理。当然printf.o依赖的目标文件也很被一起提取出来。库中其他目标文件就被舍弃掉,从而减小了最终生成的可执行文件的大小。

知道了这些信息后,链接器就可以开始工作了,分为两个步骤:1)合并相似段,把所有需要链接的目标文件的相似段放在可执行文件的对应段中。2)重定位符号使得目标文件能正确调用到其他目标文件提供的函数。
用命令gcc -static -o helloworld.static main.c来编译并做静态链接,生成可执行文件helloworld.static。因为可执行文件helloworld.static已经是链接好了的,所以里面就不会有重定位表了。命令 readelf -S helloworld.static | grep .rel.text将不会有任何输出(注:-S是打印出ELF文件中的Sections)。经过静态链接生成的可执行文件,只要装载到了内存中,就可以开始运行了。

动态链接
静态链接看起来很简单,但是有些不足。其中之一就对磁盘空间和内存空间的浪费。标准库中那些函数会被放到每个静态链接的可执行文件中,在运行的时候,这些重复的内容也会被不同的可执行文件加载到内存中去。同时,如果静态库有更新的话,所有可执行文件都得重新链接才能用上新的静态库。动态链接就是为了解决这个问题而出现的。所谓动态链接就是在运行的时候再去链接。理解动态链接需要从两个角度来看,一是从动态库的角度,而是从使用动态库的可执行文件的角度。

从动态库的角度来看,动态库像普通的可执行文件一样,有其代码段和数据段。为了使得动态库在内存中只有一份,需要做到不管动态库装载到什么位置,都不需要修改动态库中代码段的内容,从而实现动态库中代码段的共享。而数据段中的内容需要做到进程间的隔离,因此必须是私有的,也就是每个进程都有一份。因此,动态库的做法是把代码段中变化的部分放到数据段中去,这样代码段中剩下的就是不变的内容,就可以装载到虚拟内存的任何位置。那代码段中变化的内容是什么,主要包括了对外部函数和变量的引用。
我们来看一个简单的例子吧,假设我们要把下面的代码做成一个动态库:
[plain] view plain copy
  1.  1 #include <stdio.h>  
  2.  2 extern int shared;  
  3.  3 extern void bar();  
  4.  4 void foo(int i)  
  5.  5 {  
  6.  6   printf("Printing from Lib.so %d ", i);  
  7.  7   printf("Printing from Lib.so, shared %d ", shared);  
  8.  8  
  9.  9   bar();  
  10. 10   sleep(-1);  
  11. 11 }  
用命令gcc -shared -fPIC -o Lib.so Lib.c将生成一个动态库Lib.so(-shared是生成共享对象,-fPIC是生成地址无关的代码)。该动态库提供(导出)一个函数foo,依赖(导入)一个函数bar,和一个变量shared。
这里我们需要解决的问题是如何让foo这个函数能正确地引用到外部的函数bar和shared变量?程序装载有个特性,代码段和数据段的相对位置是固定的,因此我们把这些外部函数和外部变量的地址放到数据段的某个位置,这样代码就能根据其当前的地址从数据段中找到对应外部函数的地址(前提是谁能帮忙在数据段中填上这个外部函数的正确地址,下面会讲)。动态库中外部变量的地址是放在.got(global offset table)中,外部函数的地址是放在了.got.plt段中。
如果你用命令readelf -S Lib.so | grep got将会看到Lib.so中有这样两个Section。他们就是分别存放外部变量和函数地址的地方。
[plain] view plain copy
  1. [20] .got              PROGBITS        00001fe4 000fe4 000010 04  WA  0   0  4  
  2. [21] .got.plt          PROGBITS        00001ff4 000ff4 000020 04  WA  0   0  4  
到此为止,我们知道了动态库是把地址相关的内容放到了数据段中来实现地址无关的代码,从而使得动态库能被多个进程共享。那么接着的问题就谁来帮助动态库来修正.got和.got.plt中的地址。

那么我们就从动态链接器的角度来看看吧!

静态链接的可执行文件在装载进入内存后就可以开始运行了,因为所有的外部函数都已经包含在可执行文件中。而动态链接的可执行文件中对外部函数的引用地址在生成可执行文件的时候是未知的,所以在这些地址被修正前是动态链接生成的可执行文件是不能运行的。因此,动态链接生成的可执行文件运行前,系统会首先将动态链接库加载到内存中,动态链接器所在的路径在可执行文件可以查到的。

还是以前面的helloworld为例,用命令gcc -o helloworld.dyn main.c来以动态链接的方式生成可执行文件。然后用命令readelf -l helloworld.dyn | grep interpreter可以看到动态链接器在系统中的路径。
[plain] view plain copy
  1. [Requesting program interpreter: /lib/ld-linux.so.2]  
当动态链接器被加载进来后,它首先做的事情就是先找到该可执行文件依赖的动态库,这部分信息也是在可执行文件中可以查到的。用命令readelf -d helloworld.dyn,可以看到如下输出:
[plain] view plain copy
  1. Dynamic section at offset 0xf28 contains 20 entries:  
  2.   Tag        Type                         Name/Value  
  3.  0x00000001 (NEEDED)                     Shared library: [libc.so.6]  
或者用命令ldd helloworld.dyn,可以看到如下输出:
[plain] view plain copy
  1. linux-gate.so.1 =>  (0x008cd000)  
  2. libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0x00a7a000)  
  3. /lib/ld-linux.so.2 (0x0035d000)  
都表明该可执行文件依赖于libc.so.6这个动态库,也就是C语言标准库的动态链接版本。如果某个库依赖于别的动态库,它们也会被加载进来直到所有依赖的库都被加载进来。

当所有的库都被加载进来以后,类似于静态链接,动态链接器从各个动态库中可以知道每个库都提供什么函数(符号表)和哪些函数引用需要重定位(重定位表),然后修正.got和.got.plt中的符号到正确的地址,完成之后就可以将控制权交给可执行文件的入口地址,从而开始执行我们编写的代码了。
可见,动态链接器在程序运行前需要做大量的工作(修正符号地址),为了提高效率,一般采用的是延迟绑定,也就是只有用到某个函数才去修正.got.plt中地址,具体是如何做到延迟绑定的,推荐看《程序员的自我修养》一书。
小结
链接解决我们写的程序是如何和别的库组合在一起这个问题。每个参与链接的目标文件中都提供了这样的信息:我有什么符号(变量或者函数),我需要什么符号,这样链接器才能确定参与链接的目标文件和库是否能组合在一起。静态链接是在生成可执行文件的时候把需要的所有内容都包含在了可执行文件中,这导致的问题是可执行文件大,浪费磁盘和内存空间以及静态库升级的问题。动态链接是在程序运行的时候完成链接的,首先是动态链接器被加载到内存中,然后动态链接器再完成类似于静态链接器的所做的事情。

原文地址:https://www.cnblogs.com/zhoug2020/p/5700160.html