• 静态链接与动态链接


    目录

    静态链接

    地址空间分配

    静态链接的详细过程

    静态链接库

    动态链接

    位置无关代码

    延迟绑定机制


    本篇会重点介绍静态链接,动态链接,延迟绑定机制

    问:两个或者多个不同的目标文件是如何组成一个可执行文件的呢?

    答:这就需要进行链接( linking )。链接由链接器( linker )完成,根据发生的时间不同,可分为编译时链接( compile time)、加载时链接( load time )和运行时链接(runtime )。

    静态链接

    测试代码

    main.c

    1. extern int shared;
    2. extern void func(int * a, int * b);
    3. int main()
    4. {
    5. int a = 100;
    6. func(&a, &shared);
    7. return 0;
    8. }

    func.c

    1. int shared = 1;
    2. int tmp = 0;
    3. void func(int * a, int * b)
    4. {
    5. tmp = *a;
    6. *a = *b;
    7. *b = tmp;
    8. }

    地址空间分配

    把两个目标文件链接成一个可执行文件

    gcc -static -fno-stack-protector main.c func.c -save-temps --verbose -o func.ELF

    在将main.o和func.o这两个目标文件链接成一个可执行文件时,最简单的方法是按序叠加这种方案的弊端是,如果参与链接的目标文件过多,那么输出的可执行文件会非常零散。而段的装载地址和空间以页为单位对齐,不足一页的代码节或数据节也要占用一页,这样就造成了内存空间的浪费。

    另一种方案是相似节合并,将不同目标文件相同属性的节合并为一个节,如将main.o与func.o的.text节合并为新的.text 节,将main.o与 func.o中的.data节合并为新的.data节,这种方案被当前的链接器所采用,首先对各个节的长度、属性和偏移进行分析,然后将输入目标文件中符号表的符号定义与符号引用统一生成全局符号表,最后读取输入文件的各类信息对符号进行解析、重定位等操作。相似节的合并就发生在重定位时。完成后,程序中的每条指令和全局变量就都有唯一的运行时内存地址了。

    静态链接的详细过程

    为了构造可执行文件,链接器必须完成两个重要工作:符号解析( symbol resolution )和重定位( relocation )。

    • 符号解析是将每个符号(函数、全局变量、静态变量)的引用与其定义进行关联。
    • 重定位则是将每个符号的定义与一个内存地址进行关联,然后修改这些符号的引用,使其指向这个内存地址。

    对比一下静态链接可执行文件 func.ELF和中间产物main.o的区别。使用objdump可以查看文件各个节的详细信息,这里我们重点关注.text、.data和.bss节。

    1. objdump -h main.o
    2. objdump -h func.ELF

    5 .text         0008f480  00000000004004d0  00000000004004d0  000004d0  2**4
                      CONTENTS, ALLOC, LOAD, READONLY, CODE
    20 .data         00001af0  00000000006b90e0  00000000006b90e0  000b90e0  2**5
                      CONTENTS, ALLOC, LOAD, DATA
    25 .bss          000016f8  00000000006bb2e0  00000000006bb2e0  000bb2d8  2**5
                      ALLOC

    • VMA( Virtual Memory Address )是虚拟地址
    • LMA( Load Memory Address )是加载地址,一般情况下两者是相同的。

    尚未进行链接的目标文件 main.o的VMA都是0。而在链接完成后的 func.ELF中,相似节被合并,且完成了虚拟地址的分配。
    使用objdump查看main.o的反汇编代码,参数“-mi386:intel”表示以intel格式输出。

    objdump -d -M intel --section=.text main.o
    

    main()函数的地址从0开始。其中,对func()函数的调用在偏移0x20处,0xe8是CALL指令的操作码,后四个字节是被调用函数相对于调用指令的下一条指令的偏移量。

    由于还没有重定位,编译器并不知道func函数的位置以及变量shared的位置,所以其地址用0x0代替,之后的地址替换工作是交给链接器完成
    查看func.ELF的符号地址

    objdump -d -M intel --section=.text func.ELF | grep -A 16 "
    "

    调用func()函数的指令CALL位于0x4009c9,其下一条指令MOV位于0x4009ce,因此相对于MOV指令偏移量为0x07的地址为0x4009ce+0x07=0x4009d5,刚好就是func()函数的地址。同时,0x4009c1处也已经改成了shared 的地址0x6ca090。

    查看main.o里的可重定位表

    objdump -r main.o

    可重定位文件中最重要的就是要包含重定位表,用于告诉链接器如何修改节的内容。每一个重定位表对应一个需要被重定位的节。

    例如名为.rel.text的节用于保存.text节的重定位表。.rel.text包含两个重定位入口:

    • shared 的类型R_X86_64_32用于绝对寻址,CPU 将直接使用在指令中编码的32位值作为有效地址。
    • func的类型R_X86_64_PC32用于相对寻址,CPU将指令中编码的32位值加上PC (下一条指令地址)的值得到有效地址。需要注意的是,func-0x0000000000000004 中的-0x4是r_addend域的值,是对偏移的调整
       


    静态链接库

    后缀名为.a的文件是静态链接库文件,如常见的libc.a。一个静态链接库可以视为一组目标文件
    经过压缩打包后形成的集合。执行各种编译任务时,需要许多个同的目标文件,比如输入输出有printf.o、scanf.o,内存管理有malloc.o等。为了方便管理,人们使用ar 工具将这些目标文件进行了压缩、编号和索引,就形成了libc.a。

    动态链接

    静态链接产生问题:

    • 内存空间的浪费,大部分可执行文件都需要glibc,在静态链接时需要把libc.a和编写的代码链接进去。多个可执行程序在内存中都包含这一部分,相同的库被多次链接,内存空间浪费。
    • 标准函数只要有改动,就需要重新编译整个源文件

    动态链接:系统库和自己写的代码先不链接在一起,都是独立的模块,等到程序执行时在内存中完成链接。而且内存一个系统库可以被多个程序一起使用。这些被共享的库被称作共享库,或者共享对象,这个过程由动态链接器完成。

    • 当运行func1.ELF时,系统将func1.o和依赖的testLib.o装载入内存,然后进行动态链接。完成后系统将控制权交给程序人口点,程序开始执行。
    • 当运行func2.ELF想要执行时,由于内存中已经有testLib.o,因此不再重复加载,直接进行链接即可。

    GCC默认使用动态链接编译,通过下面的命令我们将func.c编译为共享库,然后使用这个库编译main.c。

    1. gcc -shared -fpic -o func.so func.c
    2. gcc -fno-stack-protector -o func.ELF2 main.c ./func.so

    参数-shared表示生成共享库, -fpic 表示生成与位置无关的代码。这样可执行文件 func.ELF2就会在加载时与func.so进行动态链接。另外动态加载器ld-linux.so本身就是一个共享库,因此加载器会加载并运行动态加载器,并由动态加载器来完成其他共享库以及符号的重定位。
     

    位置无关代码

    可以加载而不需要重定位的代码被称为位置无关代码(PIC),这个共享库的基本属性,通过给gcc传递 -fpic 参数可以生成 PIC 。这样一个共享库就可以被所有进程使用。

    一个程序(或共享库)的数据段和代码段的相对距离不变,与绝对内存地址无关。于是就由了全局偏移量表(GOT),位于数据段的开头,用于保存全局变量和库函数的引用,每个条目占8个字节,加载时会进行重定位并填入符号的绝对地址。

    因为引入了RELRO保护机制,GOT被拆分为 .got 和 .got.plt节两个部分:

    1. 前者不需要延迟绑定机制用于保存全局变量引用,加载内存后标记为只读;
    2. 后者需要延迟绑定用于保存函数引用,具有读写权限。

    看一下 func.so 

    objdump -h func.so

    readelf -r func.so | grep tmp

    objdump -d -M intel --section=.text func.so | grep -A 20 ""

     全局变量 tmp 位于GOT上,R_X86_64_GLOB_DAT 表示需要动态链接器找到 tmp 的值并填充到0x200fd8。在func()函数需要取出 tmp时,计算符号相对PC的偏移 rip+0x2009e5 ,也就是0x6c9+0x2009e5=0x200fd8。

    延迟绑定机制

    由于动态链接是由动态链接器在程序加载时进行的,如果有很多个程序需要加载,会影响到动态链接器的性能。延迟绑定,其思想是当函数第一次被调用时,动态链接器才进行符号查找,重定位等操作,没被调用就不进行绑定。

    ELF文件通过过程链接表(Procedure Linkage Table,PLT )和GOT的配合来实现延迟绑定,每个被调用的库函数都有一组对应的PLT和GOT。

    位于代码段.plt节的PLT是一个数组,每个条目占16个字节。其中 PLT[0]用于跳转到动态链接器,PLT[1]用于调用系统启动函数_libc_start_main(),我们熟悉的main()函数就是在这里面调用的,从PLT[2]开始就是被调用的各个函数条目。

    位于数据段.got.plt节的GOT也是一个数组,每个条目占8个字节。其中 GOT[0]和 GOT[1]包含动态链接器在解析函数地址时所需要的两个地址(.dynamic和 relor条目),GOT[2]是动态链接器ld-linux.so 的人口点,从GOT[3]开始就是被调用的各个函数条目,这些条目默认指向对应PLT条目的第二条指令,完成绑定后才会被修改为函数的实际地址。

    当func.ELF2调用库函数 func()为例

    1. 当main调用func时,会进入0x4005b0这个地址,也就是PLT[2];
    2. jmp指令跳会找到0x601020这个地址,也就是GOT[4],取地址中的值,跳转到0x4005b6,也就是PLT[2],把0x1压入栈(func在.rel.plt中的下标)压栈;
    3. 之后jmp跳转到0x400590,也就是PLT[0],把GOT[1]压入栈;
    4. 调用GOT[2],也就是动态链接器的_dl_runtime_resolve()函数,完成符号解析和重定位工作,并把func的真实地址填入func@got.plt,也就是GOT[4],最后程序控制权给func(),延迟绑定完成。
    5. 之后如果需要调用func(),直接跳转到func@got.plt,控制去哪交给func()

  • 相关阅读:
    再度盈利后提“冷静增长”,爱奇艺守住长视频初心
    双目3D感知(一):双目初步认识
    学习pytorch7 神经网络的基本骨架--nn,module的使用
    【机器学习13】生成对抗网络
    vue使用json-server 模拟数据
    对比鸿蒙,Google 的 Fuchsia 当前进度如何?
    智能合约概述
    GC过程初步
    KNN——水果分类
    Spring Cloud 微服务入门
  • 原文地址:https://blog.csdn.net/qq_61553520/article/details/133318509