前言

本文主要参考《程序员的自我修养》。

本文主要介绍了静态链接和动态链接的过程,以及链接需要用到的重要手段,如重定位表、符号表、GOT、PLT等。又从文件结构上简要分析了可重定位文件、静态链接可执行文件、共享目标文件和动态链接可执行文件之间的区别。

链接过程

静态链接

以如下的C程序为例:

Main.c:

/* Main.c */
extern int shared;

int main() {
  int a = 100;
  swap(&a, &shared);
}

Lib.c:

/* Lib.c */
int shared = 1;

void swap(int *a, int *b) {
  *a ^= *b ^= *a ^= *b;
}

文件过程

法一
gcc -static Main.c Lib.c -o out

./out

这种方法隐含编译和链接过程,out为静态链接后的可执行文件。

法二(有潜在风险)
gcc -c Main.c Lib.c
ld Main.o Lib.o -e main -o out

./out

这种方法将编译和链接过程分开,静态链接过程就是ld进行链接。但这样生成的可执行文件out运行时往往出错,因为可执行文件ELF结构不完整。

前置知识

在链接中,我们将函数和变量统称为符号Symbol);将在本目标文件中使用,而又没有在本目标文件中定义的全局符号,称为外部符号External Symbol)。

重定位表

由于外部符号在编译后并不能确定其位置(链接后才能确定),因此需要在目标文件中标记出链接时需要修改的位置,在链接时获知外部符号位置后进行修改,称为重定位Relocation)。在目标文件中使用重定位表Relocation Table)的结构来保存这些与重定位相关的信息。链接时只需要查看重定位表,将对应符号记录的需要重定位的位置进行修改即可。

如Main.o中sharedswap()为外部符号,且仅出现一次,其重定位表为:

偏移量          信息           类型           符号值        符号名称 + 加数
000000000023  00090000000a R_X86_64_32       0000000000000000 shared + 0
000000000030  000a00000002 R_X86_64_PC32     0000000000000000 swap - 4

而Lib.o中没有外部符号,因此其重定位表为空。

符号表

目标文件使用符号表Symbol Table)来记录本目标文件中的全局符号,包括自身定义的全局符号和外部符号等;在链接时只需要查看符号表,就能得知此目标文件定义了哪些符号可以链接给其他目标文件,以及需要哪些外部符号链接到自身。

如Main.o的符号表:

Symbol table '.symtab' contains 12 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS Main.c
     ...
     8: 0000000000000000    79 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
    ...

Main.o定义了符号main,使用到了外部符号sharedswap

Lib.o的符号表:

Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS Lib.c
     ...
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    2 shared
     9: 0000000000000000    75 FUNC    GLOBAL DEFAULT    1 swap

Lib.o定义了符号sharedswap,没有使用到外部符号。

详细过程

静态链接的主要目的就是将多个目标文件合并,并处理各目标文件用到的外部符号,对外部符号重定位(调整地址),使程序能够正常执行。

静态链接一般采用两步链接Two-pass Linking)的方法。

第一步,空间与地址分配。扫描所有的输入目标文件,获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。连接器获取所有输入目标文件的段长度后,将它们合并。

第二步,符号解析与重定位。使用上一步收集到的所有信息,读取输入目标文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。

以Main.o和Lib.o为例介绍重定位的大体过程。第一步收集到全局符号表

符号名状态所在目标文件...
main定义Main...
shared引用Main...
swap引用Main...
shared定义Lib...
swap定义Lib...

第二步,首先查看Main.o的符号表(或全局符号表),发现shared需要重定位;查看全局符号表发现Lib.o定义了shared,查看Lib.o的符号表以及第一步的段合并信息,确定shared的地址;再查看Main.o的重定位表,找到所有shared需要重定位的地址,修改为shared的真实地址。对swap的处理也是类似。然后查看Lib.o的符号表(或全局符号表),发现Lib.o没有需要重定位的符号,跳过。最后处理完所有的输入目标文件,静态链接过程结束。

此时生成的可执行文件中所有的符号引用都修正为了真实的地址,就可以顺利运行啦!

动态链接

以如下的C程序为例:

Program.c:

/* Program.c */
#include "Lib.h"

int main() {
  foobar(1);
  return 0;
}

Lib.c:

/* Lib.c */
#include <stdio.h>

void foobar(int i) {
  printf("Printing from Lib.so %d\n", i);
}

Lib.h:

/* Lib.h */
void foobar(int i);

文件过程

gcc -fPIC -shared Lib.c -o Lib.so

gcc Program.c ./Lib.so -o Program

./Program

首先将Lib.c编译得到共享对象文件Lib.so,然后编译链接Program.c得到可执行文件Program。

注意:编译链接Program.c时需要用到Lib.so,但仅仅是用到Lib.so的符号信息,链接时并没有将Lib.so合并到Program,两者相对独立。

前置知识

静态链接存在两个主要的问题:

  1. 空间浪费。拿静态链接介绍的例子来说,如果有多个程序用到了Lib.o,那么在生成可执行程序时每个程序都会独自链接Lib.o;如果这些程序同时运行在操作系统中,那么每个程序都有一份几乎完全相同的Lib.o的代码(因为静态链接会重定位,代码不会完全相同),造成空间浪费。
  2. 更新困难。如果对Lib.c进行了更新,那么必须对整个程序进行重新编译链接才能应用此更新;如果程序包含许多模块,或模块来源不同,则任一模块有更新,整个程序都必须重新链接、发布给用户,造成极大开销。

而动态链接的基本思想就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。

地址无关代码,GOT

我们说让多个程序共享同一个模块,指的是共享这个模块的代码段,而数据段为每个程序私有。但代码段并不能直接拿来共享,因为代码段中对外部符号的引用还需要重定位(回忆静态链接);又因为每个程序中外部符号的位置都不相同,因此不能直接对代码段进行修改(不然只有一个程序能正常运行,其他程序都会出错)。

怎么解决共享代码段的问题?这就要用到地址无关代码PIC,Position-independent Code)的技术。地址无关代码的原理简单来说就是将重定位信息由代码段转移到数据段,对代码段中需要重定位的地方,直接修改为转到数据段中固定位置,即真正的重定位信息;由于数据段由每个程序私有,因此不会相互造成影响。

我们在数据段中建立一个全局偏移表GOT,Global Offset Table),此表中依次记录外部符号的真实地址,当然这个真实地址不能在编译时确定(也确定不了),需要在动态链接时才能知道外部符号的真实地址(加载外部符号所在的模块后);但总的来说,在动态链接完成后,GOT中记录了此模块用到的外部符号的真实地址。

在编译生成动态共享对象DSO,Dynamic Shared Objects),即.so文件时,首先根据符号表建立GOT(此时GOT表项为各外部符号,但值为空);再通过重定位表和GOT,将需要重定位的地址修改为转跳到GOT中对应表项,实际是相对地址转跳(可以写死相对地址转跳的原因是,同一模块代码段和数据段的相对位置是固定不变的,但运行中得到GOT中对应表项的绝对地址还是需要一些计算的,参考如何理解DLL不是地址无关的?DLL与ELF的对比分析)。

简单模拟一下运行时的过程:

  1. 在动态链接完成后,GOT表项的值修改为外部符号真实地址;
  2. 当执行代码段,遇到外部符号时,直接转跳到GOT中对应表项;
  3. 从GOT对应表项获得外部符号的真实地址;
  4. 以此地址继续执行。

通俗来说就是拿GOT作跳板,因为GOT相对代码段位置固定,代码段可以保持完全一样;因为GOT在数据段中,不同程序可以自定义GOT表项的值。

延迟绑定,PLT

动态链接比静态链接慢的其中一个原因就是每次程序装载时都要对所有模块进行符号查找和地址重定位(填充GOT等)等,这些工作势必减慢程序的启动速度。

但可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者一些用户很少用到的功能模块等,如果一开始就把所有的函数都链接好实际上是一种浪费。所以延迟绑定Lazy Binding)技术被用到,其基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。

首先考虑一个问题,当遇到外部符号时,需要告诉动态链接器哪些信息才能找到此外部符号的地址?答案是只需要此外部符号名,和发生地址绑定的模块(就是本模块,用来在动态链接器找到地址后返回修改本模块的值)。

这里在数据段中建立一个过程链接表PLT,Procedure Linkage Table),此表中表项对应各外部符号(和GOT类型),且初始时标记为空。当程序运行时访问PLT某一表项时,如果为空(即第一次访问),则触发动态链接器查找对应外部符号地址(告知外部符号名和本模块ID),等待动态链接器找到外部符号地址;动态连接器成功找到地址后,通过模块ID(即本模块),修改GOT中对应外部符号项地址为外部符号真实地址,并将LPT中对应表项标记为GOT中对应表项地址;当第二次或以后访问PTL中某一表项时,则直接得到GOT中对应表项,从GOT中得到外部符号真实地址。从而实现了延迟绑定。

通过上述步骤可以发现,PLT实际上就是GOT之上的另一个跳板,程序运行时遇到外部符号首先跳转到PLT触发延迟绑定,再跳转到GOT获取外部符号真实地址。所以引入延迟绑定后,在编译生成动态共享对象时,首先根据符号表建立GOT和PLT,再通过重定位表和PLT,将需要重定位的地址修改为跳转到PTL中对应的表项

简单模拟一下运行时的过程:

  1. 在动态链接完成后,GOT和PLT均为空;
  2. 当执行代码段,遇到外部符号时,直接转跳到PLT中对应表项;
  3. 若PLT中对应表项为空,则触发延迟绑定,告知动态链接器外部符号名和本模块ID,动态连接器成功找到地址后,通过模块ID(即本模块),修改GOT中对应外部符号项地址为外部符号真实地址,并将LPT中对应表项标记为GOT中对应表项地址,转跳到GOT中对应表项,得到外部符号真实地址;否则(若PLT中对应表项有转跳地址),则转跳到对应GOT表项,得到外部符号真实地址。
  4. 以此地址继续执行。

注意:这里只是大体介绍PLT工作原理,实际情况与这里的介绍还是有些区别的,如PLT只会处理函数而不会处理变量。

详细过程

上面的介绍实际上已经把动态链接的过程介绍地差不多了,这里再系统地介绍一下。

动态共享对象

需要注意动态共享对象(即.so文件)与可执行文件差别不大,与目标文件差别很大。

在编译生成.so文件时,除包含有一般目标文件的信息外,还主要增加或修改了如下信息:

  • ELF头部标记为共享目标文件;
  • 增加.dynamic段,添加依赖共享对象(如本例中需要libc.so,因用到printf())等;
  • 增加动态符号表,仅保存动态链接相关符号;
  • 增加GOT和PLT,表项与外部符号一一对应,初始为空;
  • 增加初始化段(如构造和析构);
可执行文件

动态链接的可执行文件与静态链接可执行文件相比,除多了动态共享对象的一些信息(可执行文件也需要动态链接)之外,主要还有如下不同:

  • 增加.interp段,用来指定动态连接器的位置,如/lib64/ld-linux-x86-64.so
装载和动态链接

动态链接基本上可以分为3步:

  1. 启动动态连接器本身
  2. 装载所有需要的共享对象
  3. 重定位和初始化

在可执行文件装载完成后,操作系统读取.interp段获取动态链接器路径(如/lib64/ld-linux-x86-64.so—),再从操作系统此路径下加载动态链接器;随后操作系统将控制权转交给动态连接器入口地址(而非可执行文件)。在动态链接器得到控制权后,开始进行自举等初始化操作。

动态链接器初始化完成后,开始读取可执行文件的.dynamic段,获取该可执行文件所依赖的共享对象(如Lib.solibc.so),然后将这些共享对象的名字放入一个装载集合中按顺序开始装载。连接器在将共享对象装载后,读取其.dynamic段,获取该共享对象所依赖的其他共享对象(如Lib.so依赖libc.so),如此往复直到所有需要的共享对象都被装载。这个过程中还进行了全局符号表的构建,和静态链接过程类似,但读取的是动态符号表)。

如果没有使用延迟绑定技术,则链接器开始重新遍历可执行文件和每个共享对象的动态符号表和GOT,对GOT进行填充;因为此时动态链接器已经拥有了全局符号表,所以这个修正过程很容易。如果使用了延迟绑定技术,则不再需要重新遍历,等待运行时再对GOT和PLT进行修正。

重定位完成后,如果某个共享对象有.init段,那么动态链接器会执行.init段中的代码实现共享对象特有的初始化过程;相应地,如果共享对象有.finit段,当进程退出时会执行.finit段中的代码。如果可执行文件中也有.init段或.finit段,那么动态链接器不会执行它,因为这部分代码由程序初始化部分代码负责执行。

这样所有准备工作全部结束,动态链接器将控制权转交给程序的入口并开始执行。

文件结构

这里以可重定位文件(目标文件)、静态链接可执行文件、共享目标文件、动态链接可执行文件的顺序大概介绍文件的主要结构,分析区别。

上面说到的4种文件都是ELF格式文件。

可重定位文件

可重定位文件(Relocatable File)即.o文件。

可重定位文件包含一些基础的、常见的段,如:

  • .text 代码段
  • .data、.rodata、.bss 数据段
  • .strtab 字符串表
  • .symtab 符号表
  • .rel.text、.rel.data 重定位表

可重定位文件不包含程序头,因可重定位文件不能也不需要执行。

可重定位文件包含的信息最为完整,其中重定位表为其后的链接过程提供便利。

静态链接可执行文件

静态链接得到的可执行文件(Executable File)中,相对于可重定位文件,段的变化主要是:

  • 因为可执行文件已经进行链接,不再需要重定位表.rel.text、.rel.data;
  • 增加.init、.fini,程序初始化与终结代码段;
  • 一些C语言运行时所需的段。

增加了程序头,描述准备程序执行所需的段和其他信息,可以执行。

注意:现在静态链接得到的可执行文件也可能具有.plt、.got段等动态链接特有的段,但这并不影响此可执行文件是静态链接的本质。

共享目标文件

共享目标文件(Shared Object File)即.so文件。

经常存在的一个误解是认为共享目标文件和可重定位文件类似,而实际上共享目标文件和可重定位文件根本不在统一水平,应该说和可执行文件非常相似。

共享目标文件同静态链接得到的可执行文件相比,段的区别主要是:

  • 增加.dynstr,动态字符串表,仅保存动态链接相关字符串;
  • 增加.dynsym,动态符号表,仅保存动态链接相关符号;
  • 增加.dynamic,保存动态链接器所需基本信息,如依赖的共享对象等;
  • 增加.got,保存变量的全局偏移表GOT;
  • 增加.got.plt,保存函数的全局偏移表GOT;
  • 增加.plt,过程链接表PLT;
  • 增加.rel,dyn、.rel.plt,动态重定位表。

共享目标文件也具有程序头,具备可执行文件属性。

动态链接可执行文件

注意实际上来说,并不存在“静态链接可执行文件”和“动态链接可执行文件”,其统一为“可执行文件”。

动态链接得到的可执行文件与共享目标文件相比,主要差异很小:

  • 增加.interp,保存动态链接器路径。

同时程序头会标记.interp段,使得可执行程序执行时,动态链接器会先于可执行文件获得控制权。

总结

静态链接和动态链接从某个角度上来看就是把链接过程放在装载前和装载后的区别,静态链接的过程很简单,就是重定位;但动态链接想要实现这样的重定位就需要和运行环境妥协,为了实现代码共享,引入GOT,为了加快链接过程,引入PLT,为了实现重定位,引入动态链接器进行控制。

本文尽可能简单地介绍了静态链接和动态链接,所以也忽略了实际存在的许多细节和要注意的问题。本文只能拿来作为参考,若想准确深入了解,还请参阅其他资料。

参考