打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
linux平台的链接与加载

 

  •  

    一.静态链接

    示例程序

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

    1. /*main.c*/  
    2. int u = 333;  
    3.   
    4. int sum(int, int);  
    5.   
    6. int main(int argc, char* argv[])  
    7. {  
    8.     sum(argc, 1);  
    9.     return 0;  
    10. }  


     

    1. /*sum.c*/  
    2. #define pi 3  
    3.   
    4. int printf(const char*, ...);  
    5.   
    6. int x = 111;  
    7. int y;  
    8. const int w = 222;  
    9. extern int u;  
    10. int *ptr = &u;  
    11.   
    12. static int add(int a)  
    13. {  
    14.     static int base = 99999;  
    15.     base--;  
    16.     return a + base;  
    17. }  
    18.   
    19. int sum(int a, int b)  
    20. {  
    21.     static int base = 88888;  
    22.     static int xx;  
    23.     base++;  
    24.     printf("%d\n", base);  
    25.     int t = add(a);  
    26.     return a + b + x + y + w + *ptr + t + base + pi;  
    27. }  


     

    编译过程

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

    链接器所做的事情:链接。其主要需要解决下面两个问题:

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

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

    可重定位目标文件

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

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

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

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

    字符串表(.strtab)

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

    符号表(.symtab)

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

    可以看到:

    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,里面包括全局变量(含局部静态变量)、外部函数等地址均需要重定位。

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

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

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

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

     

    示例程序
    1. <pre class="cpp" name="code">??  //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.   }  
    14. </pre>  
    15. <pre></pre>  
    16. <br>  
    17. <p><span style="font-family:微软雅黑; font-size:14px"><strong>动态共享库的难点</strong></span></p>  
    18. <p>要想模块的更新及维护更加方便,则必须更加彻底的模块化,将程序的各个模块合理分割,形成独立的文件,并且在应用程序加载执行时,才进行链接操作。如此,只需模块与模块之间的接口保持兼容,则可以很方便的更新任意一个模块,而不需要对应用程序做任何操作。同时也能解决静态库磁盘空间浪费的问题,因为应用程序不需要再将库复制一份。</p>  
    19. <p>但是内存空间的浪费,则不是那么好解决。我们先看看一下共享库中的全局变量,例如在某个共享库中,申请了一个全局变量,同时A进程与B进程都链接了该库。我们的实际使用过程中知道,A进程与B进程中这个全局变量是不会互相影响的。那么可以推断,共享库中的数据段,每个进程都有一个副本,是不能共享的,示意图如下:</p>  
    20. <img src="//img.my.csdn.net/uploads/201301/14/1358174712_2807.png" alt="">  

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

    地址无关代码(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函数的入口。

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

    同名全局符号覆盖(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、《程序员的自我修养》。

      

  • 本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
    打开APP,阅读全文并永久保存 查看更多类似文章
    猜你喜欢
    类似文章
    【热】打开小程序,算一算2024你的财运
    深入理解GOT表覆写技术
    【图片 代码】:Linux 动态链接过程中的【重定位】底层原理
    ELF文件格式与程序的编译链接
    ARM开发各种烧写文件格式说明(ELF、HEX、BIN)
    计算机大佬让你彻底了解深入理解计算机系统
    深入理解计算机系统:第7章
    更多类似文章 >>
    生活服务
    热点新闻
    分享 收藏 导长图 关注 下载文章
    绑定账号成功
    后续可登录账号畅享VIP特权!
    如果VIP功能使用有故障,
    可点击这里联系客服!

    联系客服