链接与加载 动态链接与静态链接

转自http://pananq.com/


链接与加载(上) — 静态链接

引子:

之前营销平台的API被咨询较多的一个问题,是下面这样一个错误:

dns

注:libdnscli.a是一个容灾逻辑的公共库,libmgwapi.a是营销平台业务逻辑的库,其中libmgwapi.a是依赖于libdnscli.a的。

上面报的错误是一些函数undefined,但是这些函数在libdnscli.a里面是定义了的(可用nm确认),那编译器为何会报这个错误呢?

类似于这类编译链接上的问题,日常工作中经常遇到,往往这类错误的提示信息相当不友好,如果对其背后原理不理解,很难知道问题出在哪里。本文将分上下篇讨论一下链接与加载的原理,上篇主要是一些基本知识点,与静态链接,下篇讲动态链接与加载。

示例程序

我们先看一个简单的示例程序,代码如下:

/*main.c*/
int u = 333;

int sum(int, int);

int main(int argc, char* argv[])
{
    sum(argc, 1);
    return 0;
}

/*sum.c*/
#define pi 3

int printf(const char*, ...);

int x = 111;
int y;
const int w = 222;
extern int u;
int *ptr = &u;

static int add(int a)
{
    static int base = 99999;
    base--;
    return a + base;
}

int sum(int a, int b)
{
    static int base = 88888;
    static int xx;
    base++;
    printf("%d\n", base);
    int t = add(a);
    return a + b + x + y + w + *ptr + t + base + pi;
}

编译过程

通过一行命令(gcc -static sum.c main.c),即可编译出一个可执行程序a.out,看似简单,其实背后隐藏了一个复杂的过程:*.c文件经过预处理器(cpp)、编译器(cc1)、汇编器(as)生成可重定位目标文件*.o,然后多个可重定位目标文件经过链接器(ld),生成一个可执行目标文件a.out,如下图:

xxxxxx

我们将集中讨论一下链接器所做的事情:链接。其主要需要解决下面两个问题:

1、符号解析:将符号引用与符号定义联系起来。

2、重定位:将符号定义与虚拟地址关联起来,并修改所有对这些符号的引用。

可重定位目标文件

一般来说,我们称*.o这种文件为可重定位目标文件,a.out称为可执行目标文件,在静态链接的情况下,我们可以认为可重定位目标文件为链接器的输入,可执行目标文件为输出。实际上,各种目标文件在不同平台上,有不同的格式,例如Windows的PE格式、Linux下的ELF格式。接下来通过分析一个典型ELF可重定位目标文件,来看看链接器是如何解决上面提到的两个问题的。使用readelf -a sum.o及objdump -d sum.o可以看到sum.o的详细信息。

一个ELF可重定位目标文件,一般包含下面这些关键section:

yyy

像.text, .rodata, .data, .bss这些段,都比较好理解。链接器将多个可重定位目标文件合并为可以可执行程序时,首先要做的就是要合并.text, .rodata, .data, .bss这些段,如下图:

merge relocation obj

然后根据.symtab, .rel.text, .rel.data, .strtab等section的信息,进行符号解析及重定位的关键,下面详解说明一下这几个段。

字符串表(.strtab)

一个字符串表,包括.symtab中的符号表的符号名,字符串表即以null结束得字符串序列,类似如下格式(可使用readelf -x获取):

strtab

符号表(.symtab)

存放了程序中被定义或引用的函数和全局变量(包含局部静态变量)的信息,包括符号的名字(记录的是.strtab中的偏移量),地址、大小、类型(数据、函数、段、源文件)、绑定域(本地、全局)、所属section等,示例程序sum.o的.symtab如下:

xxxzzz

可以看到:

1、static修饰的局部变量,例如sum::z,add::base存放在.data section,而sum::xx放在.bss section,但是其符号名后面增加了一个数字后缀,已解决不同函数里使用相同名字的static变量,且其绑定域是LOCAL,即表示该符号是本地的,非全局可见。

2、TYPE字段,一般常见的是FUNC、OBJECT、NOTYPE,分别表示函数、变量、未知类型(例如引用的外部变量或函数,如extern int u;以及在libc.a中定义的printf函数)。

3、Ndx即为该符号所在的section 索引值,但有3个特殊值:ABS代表不应该被重定位的符号;UND表示未定义的符号(例如extern int u);COMMOM表示未被分配位置的未初始化数据(例如int y)。

重定位表(.rel.xxx)与重定位

重定位表,如rel.text为代码段的重定位表,rel.data为数据段对应的重定位表,一般而言,任何调用外部函数或全局变量的指令均需要修改。例如sum函数调用了printf函数,在未链接之前,sum.o是不知道printf函数最终真实地址的,需经过链接重定位,将printf函数关联到一个地址后,再用该地址,修改call指令。下图即为sum.o的.rel.text及.rel.data信息,为了更容易理解,将源代码及汇编代码放在一起。可以看到,sum.c编译成sum.o,里面包括全局变量(含局部静态变量)、外部函数等地址均需要重定位。

xxx

.rel.xxx的offset表示需要进行重定位的位置(即在该section中的偏移量),info由两部分组成:后8位表示类型,例如0x01表示R_386_32,0x02表示R_386_PC32;前24位为其在.symtab中的索引值。以sum中对全局变量y的引用为例,在汇编代码中,看到在.text section的0x5d偏移处引用了y,则在.rel.text有一条对应的记录,其info信息为0x00001201,即其类型为R_386_32,前24位为0x000012,对应是y在.symtab中的索引值。静态局部变量略有不同,其在.symtab中的索引值均指向的是.data section。

R_386_32:重定位一个使用32位绝对地址的引用。其地址计算方法为.symtab中对应的value值加上原始值,以.rel.text的第一条记录为例,其计算方法是重定位后.data section地址加上0x00000008,即add函数里的static basic地址。

R_386_PC32:重定位一个使用32位PC相关的地址引用。其地址计算方法为用被重定位的符号的实际运行时地址,加上原始值,减去重定位所影响到的地址单元的实际运行时地址,最终算得的结果即得相对地址。例如重定位后,printf的地址是0x08048cc0,sum的地址是0x0804824c,需要重定位的地址在sum内的偏移量为0x38-0x18 = 0x20,则计算后应得的地址为0x08048cc0 - 0x0804824c - 0x20 + 0xfffffffc = 0x00000a50。sum函数中printf经过重定位后,该语句将变成 call 50 0a 00 00。

静态库解析符号引用

接下来,我们看看链接器是如何使用静态库来解析引用的。

在符号解析阶段,链接器从左至右,依次扫描可重定位目标文件(*.o)和静态库(*.a)。在这个过程中,链接器将维持三个集合:

集合E:可重定位目标文件的集合。

集合U:未解析的符号集,即符号表中UNDEF的符号。

集合D:已定义的符号集。

初始情况下,E、U、D均为空。

1、对于每个输入文件f,如果是目标文件,则将f加入E,并用f中的符号表修改U、D,然后继续下个文件。

2、如果f是一个静态库,那么链接器将尝试匹配U中未解析符号与静态库成员定义的符号。如果静态库中某个成员m,定义了一个符号来解析U中引用,那么将m加入E中,同时使用m的符号表,来更新U、D。对静态库中所有成员目标文件反复进行该过程,直至U和D不再发生变化。此时,任何不包含在E中的成员目标文件都将丢弃,链接器将继续下一个文件。

3、当所有输入文件完成后,如果U非空,链接器则会报错,否则合并和重定位E中目标文件,构建出可执行文件。

对此,我们再回到文章开头的那么问题,就比较清晰了,因为libmgwapi.a以来于libdnscli.a,但是libdnscli.a放在libmgwapi.a的左边,导致libdnscli.a里的目标文件根本就没有加入集合E中。其解决办法就是交换二者顺序,当然类似与gcc demo.c -ldnscli -lmgwapi -ldnscli也是可以的。

至此,静态链接部分大致就这些内容,下篇讲介绍动态链接与程序加载原理。

分析工具

1、readelf:显示目标文件的完整结构。

2、objdump:显示一个目标文件中所有信息,可以反汇编.text。

3、nm:列出目标文件的符号表中定义的符号。

参考资料

1、《深入理解计算机系统》。

2、《程序员的自我修养》。

3、《ELF format.pdf》等。





链接与加载(下) — 动态链接

上篇我们讨论了一下静态链接的基本原理,静态库解决了程序模块化、分离编译、提升编译效率等问题,但是也有一些明显的缺点:

1、更新及维护困难。例如需更新静态库版本,则需要将应用程序与之重新链接。如果是一个基础类库,被几十上百个程序使用,其更新工作将是极其繁琐的。

2、空间浪费。几乎每个进程都会使用的标准I/O函数,例如printf等,这些函数代码将被复制到每个进程的代码段中,这将会造成内存及磁盘空间的极大浪费。

对此,动态链接共享库应运而生。

在正式讨论动态链接之前,先留一个小问题:下面这两个编译命令有何区别?为何?

   1:  gcc -shared -o libxyz.so xyz.c
   2:  gcc -shared -fPIC -o libxyz.so xyz.c

示例程序

   1:  //xyz.c
   2:  //gcc -shared -fPIC -o libxyz.so xyz.c
   3:  int printf(const char*, ...);
   4:  extern int errOffset;
   5:  int errBase = 1;
   6:   
   7:  int setErr()
   8:  {
   9:      errOffset = 0x888;
  10:      errBase = 0x999;
  11:      printf("setErr\n");
  12:      return 0;
  13:  }


动态共享库的难点

要想模块的更新及维护更加方便,则必须更加彻底的模块化,将程序的各个模块合理分割,形成独立的文件,并且在应用程序加载执行时,才进行链接操作。如此,只需模块与模块之间的接口保持兼容,则可以很方便的更新任意一个模块,而不需要对应用程序做任何操作。同时也能解决静态库磁盘空间浪费的问题,因为应用程序不需要再将库复制一份。

但是内存空间的浪费,则不是那么好解决。我们先看看一下共享库中的全局变量,例如在某个共享库中,申请了一个全局变量,同时A进程与B进程都链接了该库。我们的实际使用过程中知道,A进程与B进程中这个全局变量是不会互相影响的。那么可以推断,共享库中的数据段,每个进程都有一个副本,是不能共享的,示意图如下:

image

一个共享库,最核心的两个段是代码段和数据段,既然数据段不能共享(这个是理所当然的),那代码段必须能共享,否则就节省不了任何内存空间了。

地址无关代码(PIC)

为何需要PIC技术?

上篇我们讲到,链接要解决的一个重要问题是重定位:即修改代码段,将全局变量或外部函数的地址替换成运行时的真实地址。如果我们仅仅是将链接过程推迟到运行时,那么这就有一个问题:例如共享库libxyz.so中有对errOffset变量的引用,且errOffset是在其他模块定义的,假设进程A中errOffset的地址是AddrA,进程B的地址是AddrB,在进程A和B的加载和重定位时,需将代码段中对errOffset的地址修改为进程对应的实际地址,而二个进程中地址不一样,则会导致二者代码段不一样,这会导致每个进程均需要拷贝一个代码段副本出来(注:如果编译共享库时没有带上-fPIC选项,就是这种效果,跟静态库的差异仅仅是将链接延迟到加载)。这样就达不到节省内存空间的效果。而要让代码段能在多个进程间共享,那必须保持代码段在重定位时,不需要被修改。于是,地址无关代码(PIC,Position-independent Code)技术诞生。

PIC原理

该方案的主要思想是:把代码段中跟地址相关的部分放到数据段中,使得重定位时,代码段不需要被修改。主要依赖下面两个事实:

1、无论在何处加载一个共享库,其数据段总是紧跟在代码段之后的,即代码段中任何指令和数据段中任何变量之间的距离都是一个常量,与代码段和数据段的绝对地址无关。

2、目标模块的数据段,在各个进程中都有对应的副本,是可以被修改的。

于是编译器在数据段开始的地方,创建了一个表,叫做全局偏移量表(GOT,global offset table)。记录了该模块所有外部函数或全局变量的表目,同时为GOT中每个表目生成一个重定位记录。在加载时,动态链接器更新GOT中表目的值,使得其值为该符号的运行时绝对地址。

全局数据访问的位置无关

我们先看看全局变量是如何通过GOT技术实现位置无关代码的。还是以示例程序来分析,先看一下汇编代码。

   1:  0000055c <setErr>:
   2:   55c:   55                      push   %ebp
   3:   55d:   89 e5                   mov    %esp,%ebp
   4:   55f:   53                      push   %ebx
   5:   560:   83 ec 04                sub    $0x4,%esp
   6:   563:   e8 00 00 00 00          call   568 <setErr+0xc>
   7:   568:   5b                      pop    %ebx
   8:   569:   81 c3 94 11 00 00       add    $0x1194,%ebx
   9:   56f:   8b 83 f8 ff ff ff       mov    0xfffffff8(%ebx),%eax
  10:   575:   c7 00 88 08 00 00       movl   $0x888,(%eax)
  11:   57b:   8b 83 ec ff ff ff       mov    0xffffffec(%ebx),%eax
  12:   581:   c7 00 99 09 00 00       movl   $0x999,(%eax)
  13:   587:   83 ec 0c                sub    $0xc,%esp
  14:   58a:   8d 83 0f ef ff ff       lea    0xffffef0f(%ebx),%eax
  15:   590:   50                      push   %eax
  16:   591:   e8 c2 fe ff ff          call   458 <puts@plt>
  17:   596:   83 c4 10                add    $0x10,%esp
  18:   599:   b8 00 00 00 00          mov    $0x0,%eax
  19:   59e:   8b 5d fc                mov    0xfffffffc(%ebp),%ebx
  20:   5a1:   c9                      leave  
  21:   5a2:   c3                      ret    

从汇编代码看,第6、7行的作用是获得当前PC值,并将其值存入寄存器ebx,第8、9行,则是计算errOffset的GOT地址,不妨假设libxyz.so映射到地址0x40018000,则执行完第7行指令后,ebx的值为0x40018000 + 0x568,执行完第9行时,eax的值为 0x40018000 + 0x568 + 0x1194 – 0x8 = 0x400196f4 = 0x40018000 + 0x16f4,而这正好就是GOT中errOffset所对应的表目地址。同理可以计算全局变量errBase所对应的GOT条目偏移量。通过下面指令,可以验证GOT中各个条目与计算是否一致:

注:call指令的效果即将下一条指令压栈,并跳转。上面的第6行汇编,获得的效果是,0x40018000 + 0x568 被压栈,并跳转至第7行汇编(0x40018000 + 0x568),第7行的效果是,将0x40018000 + 0x568弹出,并赋给寄存器ebx,此时即完成了当前PC值的获取。

$ objdump –R libxyz.so

   1:  DYNAMIC RELOCATION RECORDS
   2:  OFFSET   TYPE              VALUE 
   3:  ...
   4:  000016e8 R_386_GLOB_DAT    errBase
   5:  ...
   6:  000016f4 R_386_GLOB_DAT    errOffset
   7:  ...
   8:  00001708 R_386_JUMP_SLOT   puts
   9:  ...

函数的延迟绑定

为何需要延迟绑定技术?

位置无关代码,在性能上,比静态链接要差一些,要访问一个全局变量,需要先定位到GOT地址,然后间接寻址。另外一个降低程序性能的因素是,动态链接的工作是在加载时完成的,即程序启动后,动态链接库先要完成链接过程,即需寻找并加载所需的共享库,进行符号搜索和重定位,这无疑会影响程序的启动速度,于是就有延迟绑定技术(PLT,Procedure Linkage Table)。

在动态链接下,程序各个模块之间包含了大量的函数调用(全局变量较少,否则大量全局变量会增加模块之间的耦合度,范围了模块之间松耦合的原则)。但是往往有很多函数,在整个程序执行过程中,都不会被调用,例如一些错误处理函数,如果一开始全部都在程序启动时进行链接,则会造成浪费。PLT技术,则是当函数第一次被使用时才进行链接(符号解析、重定位),如果没有用到则不进行链接。

延迟绑定原理

参考上面汇编代码中,对printf函数的调用,实际上是跳到puts@plt的地址,其汇编代码如下:

   1:  Disassembly of section .plt:
   2:          ...
   3:  00000458 <puts@plt>:
   4:   458:   ff a3 0c 00 00 00       jmp    *0xc(%ebx)
   5:   45e:   68 00 00 00 00          push   $0x0
   6:   463:   e9 e0 ff ff ff          jmp    448 <_init+0x18>
   7:          ...

在上面对全局变量的分析中,已知ebx的值为0x40018000 + 0x568 + 0x1194,上面的第4行汇编,即是跳转至0x40018000 + 0x568 + 0x1194 + 0xc = 0x40018000 + 0x1708处(此即puts对应的GOT地址)所存储的地址。如果该函数还未被调用过,则该GOT条目的值被初始化为0x40018000 + 0x45e,即上面的第5行汇编代码地址,此时第4行的作用,就是跳转到第5行。其中0x0表示puts这个符号在.rel.plt中的索引值,以此作为一个参数,调用动态链接器,完成符号解析和重定位操作,并将puts函数的真实地址填入puts@GOT中。后续再次调用puts函数时,通过第4行即可直接跳转到puts函数的入口。

总体来说,动态链接的示意图如下:

virtualprocessspace-so

同名全局符号覆盖(global symbol interpose)

在使用动态链接库时,必须小心该问题。例如下面这个main.c与libxyz.so编译(gcc -o b.out -g main.c -L./ -lxyz)时,不会报任何错误。最终main函数中setErr函数调用的是main.c文件中定义的,而不是libxyz.so中定义的。

   1:  int errOffset = 333;
   2:  extern int errBase;
   3:   
   4:  int setErr()
   5:  {
   6:      errOffset = 888;
   7:      errBase = 999;
   8:      return 0;
   9:  }
  10:   
  11:  int main(int argc, char* argv[]) 
  12:  {
  13:      setErr();
  14:      printf("%d, %d\n", errOffset, errBase);
  15:      return 0;
  16:  }

对于这个问题,linux下动态链接库采用如下处理方式:当一个符号需求被加入全局符号表时,如果同名符号已存在,则忽略后加入的符号。一般动态链接器的加载顺序,是按广度优先顺序进行加载,首先是main,然后是libxyz.so,然后是libc.so等。

总结

现在我们简单回顾一下,链接与编译等过程的分离,使得代码的分离编译,模块化成为可能。而链接过程解决的两个核心问题是符号解析与重定位:符号解析即相当于将符号与运行时地址关联起来,而重定位则是修正代码段或数据段中的全局变量或函数的地址。静态链接库在链接时,将模块中相应目标文件的代码段和数据段直接修正后,拷贝到可执行目标文件中;而动态链接共享库,与应用程序进一步分离,并将链接过程延迟到程序加载时进行,同时通过PIC技术,使得重定位时,仅需要修改数据段,而无需修改代码段,于是代码段可以被多个进程共享,以达到节省内存空间效果。

无论是静态链接还是动态链接,抛开这些技术细节本身,还有很多其他思想是值得借鉴的:

1、坚决的模块化。小到代码级函数,大到大规模系统的功能Service化,无一不是模块化的体现。将C语言代码转换成可执行程序,就要经过预处理、编译、汇编、链接等模块的依此处理,这几个模块之间功能清晰,职责明确,使得这个复杂的过程变得清晰简洁,实为模块化设计的典范。至于如何才能做到这种高内聚松耦合的模块化,可以参阅《Unix编程艺术》一书,该书中有部分章节详细谈了模块化的几大原则:正交性、紧凑性等。

2、Don't Repeat yourself。不重复造轮子在不同层面有上不同的体现,总的来说就是不要让相同或相似的东西重复出现,如果几行代码经常重复出现,就应该写成函数;如果一个文件在不同程序中重复出现,那就应该编译成库文件。动态共享库相对于静态库,在复用上更进一步:不要让类似的二进制代码重复出现在内存里。

3、只有必须要做的时候才去做。动态库的延迟绑定技术、被广泛应用的copy on write(COW)技术都是这种思路,如果在合适的场景使用,不仅能提升效率,还能节省资源。

参考资料

1、《深入理解计算机系统》。

2、《程序员的自我修养》。

  • 1
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值