2.1.4 链接

链接是编译过程的最后一个阶段,链接器将一个或者多个目标文件和库,包括动态库和静态库,链接为一个单独的文件(通常为可执行文件、动态库或者静态库)。链接器的工作可以分为两个阶段:

❑第一阶段是将多个文件合并为一个单独的文件。对于可执行文件,还需要为指令及符号分配运行时地址。

❑第二阶段进行符号重定位。

1.合并目标文件

合并多个目标文件其实就是将多个目标文件的相同类型的段合并到一个段中,如图2-5所示。

2.1.4 链接 - 图1

图 2-5 合并目标文件

我们来看一下目标文件和链接后的可执行文件的".text"段。下面分别列出了目标文件hello.o、foo1.o、foo2.o以及可执行文件hello的段表中的".text"段的相关信息。由于篇幅限制,我们删除了输出的后面几列。

2.1.4 链接 - 图2

2.1.4 链接 - 图3

根据上面的输出结果可见,对于目标文件,并没有为目标文件中的机器指令及符号分配运行时地址。而对于可执行文件hello,链接器已经为其机器指令及符号分配了运行时地址,如对于可执行文件hello的".text"段,其在进程地址空间中起始地址为"0x080482f0",占据了0x1b8字节。

按照前面我们提到的目标文件合并理论,理论上三个目标文件hello.o、foo1.o、foo2.o的".text"段的尺寸加起来应该与可执行文件hello的".text"段的尺寸大小相等。但是,通过readelf的输出可见,三个目标文件的".text"段的尺寸加起来是0x46(0x26+0x10+0x10)字节,远小于可执行文件hello的".text"段的大小0x1b8。如果读者在编译时向gcc传递了参数-v,仔细观察gcc的输出可以发现,实际上在链接时链接器自作主张地链接了一些特别的文件,包括crt1.o、crti.o、crtn.o、crtbegin.o及crtend.o等,其实就是我们前面提到的启动文件。所以多出来的尺寸都是合并这些文件的".text"导致的。

下面我们手动调用ld,不链接这些启动文件,再来对比一下".text"段的尺寸。在默认情况下,链接器将使用函数"_start"作为可执行文件的入口,但是这个函数的实现在启动文件(crt1.o)中,因此,在这里我们通过给链接器ld传递参数"-e main",明确告诉链接器不使用默认的"_start"了,否则链接器会找不到符号"_start",而直接使用函数main作为可执行文件的入口。当然main函数中并没有实现启动代码的功能,在这里我们只是为了查看".text"段的尺寸。具体如下:

2.1.4 链接 - 图4

我们看到,如果不链接那些特殊的文件,按照上面的链接方法,可执行文件的".text"段的大小是0x48字节,依然不是0x46字节,为什么还是差了2字节?我们尝试更换一下链接时目标文件的次序:

2.1.4 链接 - 图5

这次我们看到,最终可执行文件".text"段的尺寸与目标文件的".text"段的尺寸和完全相同了。为什么呢?原因是在32位机器上,包括".text"、".data"等段有4字节对齐的要求。hello.o的".text"段是0x26,如果按照4字节对齐,需要填充2字节。而foo1.o和foo2.o的".text"段本身长度都是4字节对齐的,所以在合并时,如果hello.o在前面,那么其".text"段需要使用0填充两字节,使其对齐到0x28。所以,最终".text"的长度就是0x28+0x10+0x10,为0x48字节。而如果hello在最后,那么合并后的".text"的长度就是0x10+0x10+0x26,即0x46字节。

2.符号重定位

链接时,在第一阶段完成后,目标文件已经合并完成,并且已经为符号分配了运行时地址,链接器将进行符号重定位。

模块hello.o中有两处需要重定位,一处是偏移0xb处的变量foo2,另外一处是偏移0x1b处的函数foo2_func。汇编器已经将这两处需要重定位的符号记录在了重定位表中。

2.1.4 链接 - 图6

符号foo2的重定位类型是R_386_32,ELF标准规定的计算修订值的公式是:

2.1.4 链接 - 图7

其中,S表示符号的运行时地址,A就是汇编器填充在引用外部符号处的Addend。

符号foo2_func的重定位类型是R_386_PC32,ELF标准规定的计算修订值的公式是:

2.1.4 链接 - 图8

其中S、A的意义与前面完全相同,P为修订处的运行时地址或者偏移。对于目标文件,P为修订处在段内的偏移。对于可执行文件和动态库,P为修订处的运行时地址。

首先我们先来确定S。运行时地址在链接时才分配,因此,变量foo2和函数foo2_func的运行时地址在链接后的可执行文件hello的符号表中:

2.1.4 链接 - 图9

可见,符号foo2的运行时地址为0x0804a020,符号foo2_func的运行时地址是0x08048414。

接下来,我们再来看看汇编器为这两个符号填充的Addend是多少。我们使用工具objdump反汇编hello.o,其中黑体标识的分别是汇编器在引用foo2和foo2_func的地址处填充的Addend:

2.1.4 链接 - 图10

根据输出可见,汇编器在引用符号foo2处填充的Addend是0,在引用符号foo2_func处填充的Addend是-4。

于是,可执行文件hello中引用符号foo2的位置的修订值为:

2.1.4 链接 - 图11

我们反汇编可执行文件hello,来验证一下引用符号foo2处的值是否修订为我们计算的这个值:

2.1.4 链接 - 图12

2.1.4 链接 - 图13

注意偏移0x1b处,确实已经被链接器修订为0x0804a020了。

对于符号foo2_func的修订值,还需要变量P,即引用符号foo2_func处的运行时地址。根据可执行文件hello的反汇编代码可见,引用符号foo2_func的指令的地址是:

2.1.4 链接 - 图14

所以,可执行文件hello中引用符号foo2_func的位置的修订值为:

2.1.4 链接 - 图15

观察hello的反汇编代码,从地址0x80483f7开始处的4字节,确实也已经被链接器修订为0x19。

这里提醒一下读者,如果foo2_func占据的运行时地址小于main函数,那么这里foo2_func与PC的相对地址将是负数。在机器指令中,使用的是数的补码形式,所以一定要注意,以免造成困惑。

事实上,对于符号foo2使用的重定位类型R_386_32,是绝对地址重定位,链接器只要解析符号foo2的运行时地址替换修订处即可。而对于符号foo2_func,其使用的重定位类型是R_386_PC32,这是一个PC相对地址重定位。而当执行当前指令时,PC中已经加载了下一条指令的地址,并不是当前指令的地址,这就是在引用符号foo2_func处填充“-4”的原因。

我们看到,在链接时,链接器在需要重定位的符号所在的偏移处直接进行了编辑修订,所以人们通常也将链接器形象地称为"link editor"。

3.链接静态库

如果在链接过程中有静态库,那么链接是如何进行的呢?静态库其实就是多个目标文件的打包,因此,与合并多个目标文件并无本质差别。但是有一点需要特别说明,在链接静态库时,并不是将整个静态库中包含的目标文件全部复制一份到最终的可执行文件中,而是仅仅链接库中使用的目标文件。如图2-6所示,在对可执行文件链接时,只使用了静态库中的"Object File 2",所以链接器仅将"Object File 2"复制了一份到可执行文件中。

2.1.4 链接 - 图16

图 2-6 链接静态库

我们使用如下命令先将foo1.c和foo2.c编译为静态库libfoo.a。然后将静态库libfoo.a链接到可执行程序hello。

2.1.4 链接 - 图17

我们来看一下静态库libfoo.a的符号表:

2.1.4 链接 - 图18

2.1.4 链接 - 图19

我们看到,与代码中完全吻合,libfoo.a的符号表中包含4个全局符号,分别是变量foo1和foo2、函数foo1_func和foo2_func。如果最终创建的可执行文件hello包含了整个libfoo.a的副本,那么hello的符号表中也应该包含这4个全局符号。但是,实际上hello.c中仅使用了目标文件foo2.o中的函数foo2_func,所以按照我们前面的理论,hello中应该仅仅包含foo2.o的副本,而不必包含没有使用的foo1.o。我们查看一下hello的符号表:

2.1.4 链接 - 图20

以上hello的符号表仅包含了foo2和foo2_fiunc,显然,可执行文件hello中确实没有包含目标文件foo1.o。至于链接静态库中的目标文件的方法,与我们前面讨论的目标文件的合并完全相同。

4.链接动态库

我们知道,与静态库不同,动态库不会在可执行文件中有任何副本,那么为什么编译链接时依然需要指定动态库呢?原因包括下面几点:

1)动态加载器需要知道可执行程序依赖的动态库,这样在加载可执行程序时才能加载其依赖的动态库。所以,在链接时,链接器将根据可执行程序引用的动态库中的符号的情况在dynamic段中记录可执行程序依赖的动态库。我们使用如下命令将foo1.c和foo2.c编译为动态库,并将hello链接到动态库libfoo.so。

2.1.4 链接 - 图21

我们来查看hello中的dynamic段:

2.1.4 链接 - 图22

显然,在dynamic段中,记录了hello依赖的动态链接库libfoo.so。

2)链接器需要在重定位表中创建重定位记录(Relocation Record),这样当动态链接器加载hello时,将依据重定位记录重定位hello引用的这些外部符号。重定位记录存储在ELF文件的重定位段(Relocation)中,ELF文件中可能有多个段包含需要重定位的符号,所以可能会包含多个重定位段。以hello的重定位段为例:

2.1.4 链接 - 图23

2.1.4 链接 - 图24

根据输出可见,可执行文件hello包含两个重定位段,".rel.dyn"段中记录的是加载时需要重定位的变量,".rel.plt"段中记录的是需要重定位的函数。

因此,虽然编译时不需要链接共享库,但是可执行文件中需要记录其依赖的共享库以及加载/运行时需要重定位的条目,在加载程序时,动态加载器需要这些信息来完成加载时重定位。

最后我们再来关注一下在hello中的全局符号foo2和foo2_func。

2.1.4 链接 - 图25

在符号表中,我们看到,foo2_func是Undefined的,这没错,因为其确实不在hello中定义。但是注意变量foo2,理论上它也应该是Undefined的,但是我们看到其在hello中是有定义的,而且其还在BSS段中。换句话说,虽然我们在hello中没有定义一个未初始化的全局变量,但是链接器却偷偷在hello中定义了一个未初始化的变量foo2。那么,这个foo2与libfoo.so中的全局变量foo2是什么关系呢?为什么编译器要这样做?这也是和重定位有关的,事实上,这种重定位方式称为"Copy relocation",后面我们在讨论用户进程的加载时将会进一步介绍。