分享

通过elf各种重定位类型,理解不同场合的链接过程

 astrotycoon 2017-12-04
 本帖最后由 _nosay 于 2016-11-24 18:12 编辑

    最近为了理解elf格式规范中的各种重定位类型,晕了。跑出去玩了几天,终于为每种重定位类型,找到了对应的case。elf规范总共定义了10种重定位类型,之所以需要这么多种不同类型的重定位信息,是由于如下原因:
    ① 硬件对变量和函数的寻址方式不同,寻找变量要求绝对地址,寻找函数要求相对地址;
    ② 不同场合下,程序员对最终可执行文件或动态库的期望不一样(位置无关、动态库函数重定位延迟),从而加了不同的编译选项(比如-fPIC、-Ox等);
    ③ C语言的static、extern特性,导致不同特性的变量或函数地址可以被确定的时机不同;
    ④ 内核加载可执行文件,约定从固定地址0x80480000开始,但加载.so的起始地址无法约定(一个可执行程序只有一个main(),但可能依赖多个动态库)。
    疑问:那整个系统中,可执行程序也不只一个呀,都约定从相同的起始地址加载,不会冲突吗?
    因为每个进程访问的都是虚拟地址,由内核在背后负责将不同进程的相同虚拟地址,映射到不同的实际物理地址(属于内核范畴,不理解没关系,不影响对本贴关键内容的理解)。

  • 静态链接/动态链接简单理解
    .c文件中的代码最终被执行,需要经历如下过程:
    ① 编译:词法解析 → 语法解析 → 静态链接
    ② 加载:加载可执行文件 → 可执行文件启动或执行时,加载依赖的.so文件 → 动态链接

    本帖仅关注静态链接、动态链接过程,静态链接与动态链接区别:
    ① 静态链接处于将1个或多个.o文件“拼凑”成可执行文件阶段,处理对象是文件,文件中的代码区没有只读属性,链接过程中可以直接修改;动态链接处于可执行文件或.so文件已被加载到内存阶段,处理对象是内存,内核为代码区所在的内存区域设置了只读属性,如果代码区有内容需要重定位,需要在编译或静态链接时,事先准备一个间接位置(加载到内存不会被设置只读属性),动态链接是对该间接位置进行重定位。
    ② 通过下图可以看出,静态链接将.o的各个节“撕开”,属性相同的节“拼凑”为可执行文件的段;动态链接是将“整个”.so文件安排在与可执行文件镜像相独立的位置(图中最简化了.o、.so、可执行文件的内容,用于说明静态链接与动态链接的区别,它们的内容远远不止.data、.text)。
    另外,.so文件还涉及到位置无关(-fPIC)、延迟加载的选择(应该是跟优化级别有关),接下来即将详细总结。
   
    编译阶段,.o文件的全局变量位置不确定,因为这时无法确定还有其它哪些.o文件,以及链接器将来会按什么顺序“排列”这些.o文件(见静态链接示意图),链接阶段,.so文件的全局变量位置也不确定,是因为这时不知道.so文件将来被加载到进程空间的什么位置(见动态链接示意图)。既然不能确定,只能瞎写,但计算机世界一是一、二是二,瞎写完一定要留下一个“交待”(重定位项),等将来时机成熟时,再补上正确的值。

  • R_386_32
   
    R_386_32计算公式:S+A。
    S:对于.o文件,表示全局变量被链接器安排的位置或其所在xx节的起始位置;对于.so文件,表示全局变量被加载到的虚拟地址。
    A:被重定位处的原始值(4字节,由重定位项offset指向)。

    晕了,没关系,先看具体的case(看完R_386_32,再理解其它重定位类型就有感觉了):
    6处的指令“a1 00 00 00 00”,由于此时无法预测g1经过加载后的地址,就先写一个假地址0,16处指令“a1 04 00 00 00”,由于此时无法预测g3经过链接后的位置,就先写一个假地址4。但0、4都不是编译器随意写上的,因为随后做重定位计算时要将它们作为A值。
    另外,g4是未初始化的static全局变量,所以被“放在”.bss段,所以重定位项指定用.bss节位置作为S值(“放在”加了引号,是因为.bss在文件中不占空间,只有一个结构用于指示加载到内存时,它应该处于的位置以及大小,比如一万个0,在程序要执行时,才需要为这些0分配内存为它们所代表的变量占据位置,而在文件中不需要分配一万个字节,它只需要能提供一个简短的信息,提供加载时能正确的分配一万个字节并填充为0即可)。
   

    为了验证图中提出的问题,继续分析链接该.o文件得到的.so文件:
   
    可以看出,对.o留下的不同类型重定位项,处理结果不一样,暂时只关注g1、g3,链接器(ld)在.so文件中为g1仍然留了一条R_386_32重定位项,而为g3留了一条R_386_RELATIVE重定位项,再次处理这两条重定位项,就已经是加载到内存后由动态链接器完成了(ld-linux.so.2),计算公式中的成员含义也会改变,比如S表示符号加载到的虚拟地址。
    不是说内核会为代码对应的内存区域设置只读属性么,加载到内存后还能修改的了吗?
    准确的说,内核将代码加载到内存,需要做很多处理,设置只读只是其中一个,并且在完成重定位之后:“加载→重定位→设置只读→执行”。


    上述这种不加-fPIC选项编译得到的.so文件,只是单纯将一个完整的二进制文件分隔成可执行文件和.so文件,将重定位操作延迟到加载时了,并没有发挥.so关键设计意图:.so共享。
    因为各个进程的虚拟空间使用情况不一样,会导致这种情况:进程A的100地址空闲,并将libc.so加载到100开始的虚拟空间,将.so镜像中各个重定位处依据100地址计算重定位值,进程B的200地址空闲,如果直接将内存中已经存在的libc.so镜像映射到200位置,之前按100地址修改的重定位处,就不满足进程B的要求了,所以只能再加载一份libc.so并映射到自己的虚拟空间(稍后说明的位置无关就是为了解决这个问题)。

  • R_386_PC32
   
    R_386_PC32计算公式:S+A-P。
    S:对于.o文件,表示链接阶段全局变量被安排的位置或其所在xx节起始位置;
         对于.so文件,表示全局变量被加载到的虚拟地址。
    A:被重定位处的原始值(4字节,由重定位项offset指向)。
    P:重定位项中的offset值。对于.o、.so文件,表示被重定位处在.o、.so文件中的偏移;对于可执行文件,表示被重定位处被加载到的虚拟地址。

    10处的指令“e8 fc ff ff ff”,由于此时无法预测f1()经过加载后的地址,就先写一个假地址-4,15处指令“e8 fc ff ff ff”,由于此时无法预测f2()经过链接后的位置,就先写一个假地址-4。
    -4-P解释:首先P代表被重定位处位置(对于10处指令,P=11,对于15处指令,P=16),则P+4分别代表下一条指令位置15、1a(运行时就是下一条指令的虚拟地址),而硬件在处理机器码e8(call指令)时,不是将紧接着后面的4字节直接作为跳转地址,而是要加上下一条指令的地址,所以重定位时只好事先-(P+4)。
    对比说明R_386_32的case,g1的A值为0,g3的A值为4,而这里两条重定位项的A值都是-4,因为它们的差距体现在这两处,P值本身就不同。
   
    为什么f1()、f2()都有重定位项,fun()没有?
    重定位项的生成是根据调用,而不是定义。

    另外,有了对R_386_32的理解基础,可以想象将该.o文件链接成.so文件,链接器会如何处理重定位项,并动手验证一下(注意:有些电脑编译.so文件必须加-fPIC选项,一方面可能跟系统是32/64位有关,另一方面可能跟编译器版本有关)
   
    非static函数fun()调用static函数f3(),为什么直接使用相对偏移即可
    当前.so加载位置确定,f3()加载位置就确定了,不能确定fun()位置(同链接阶段无法通过R_386_32重定位类型确定g1位置一个道理),那么此时计算的相对位置不也失效了吗?

  • R_386_GOT32
   
    R_386_GOT32计算公式:G+A-P。
    G:链接阶段生成的GOT表位置。
    A:被重定位处的原始值(4字节,由重定位项offset指向)。
    P:重定位项中的offset值,表示被重定位处在.o文件中的偏移。

    对于G的解释,提到GOT表,什么是GOT表?
    通过R_386_32、R_386_PC32的说明可知,不加-fPIC选项编译得到的.so文件,并没有给计算机带来什么实惠,因为它的代码根据前一个进程对它的加载地址完成重定位后,之后其它进程不能直接将它映射到自己的虚拟空间使用,因为它们对重定位计算的基准不一样。
    got表正是用于将需要重定位的内容剥离出来,从大范围(整个.so的代码区域)汇聚到小范围(.got表),即将加载地址对代码区域的影响,转移到对.got表的影响。所以说,.so共享并不是完全的共享,各个进程仍然有一个.got表的副本,而.got表往往很小。

    主要利用两个技巧:
    ① 在程序编写阶段,虽然不知道以下两条指令真正执行后ebx寄存会得到什么值,但能确定它的含义是当时eip寄存器的值,那么跟这条指令相对位置固定的运行时地址,在逻辑上都能在编译阶段“获知”:
        call L1
        L1: pop ebx
    ② 那么,在.so文件中相对于指令区域确定位置生成一个.got表,.so被执行时.got表的绝对地址也是可以“获知”的。这样,就可以用.got表项的绝对地址,覆盖原本在指令区域的重定位处,而.got表中存放将来才能确定的最终重定位的符号地址。

     一份代码区域,多份.got表:
   

    理解mmap()函数,有助于更深入理解上图,在此只大概说明(涉及内核的内存管理和文件系统):
   
    ① 假设进程A先将libc.so映射到自己的一块虚拟空间,当首次访问这块区间时发生缺页异常,分配物理页面并读入内容,然后建立映射。接着,进程B也将libc.so映射到自己的一块虚拟空间,首次访问这块区间仍然会发生缺页异常,但与其建立映射的物理页面,就不用再重新分配读入了。从而,物理内存只需要一份.so的内容,就可以供A、B两个进程使用。
    ② 思维敏锐的可能会发现一个问题:.so文件中如果有全局变量,被多个进程共享,不是会相互干扰吗?
        COW(写时复制):内核为虚拟页面、物理页面都设置了一些属性,比如如果对某个虚拟页面进行写操作,就重新分配一个物理页面,复制内容并重新建立映射(为.so数据区分配的页面,就具有这样的属性)。
    ③ 各个进程将.so文件映射到自己的虚拟空间,数据区、代码区的相对位置,仍然保持和刚链接过后一致,所以在代码区向.got的重定位计算仍然有效,只不过动态链接器为不同进程向.got表初始化全局变量的地址时,要向.got表进行写操作,导致每个进程有一个.got副本。

    编译说明R_386_32时使用的case代码,加上-fPIC选项,就能看到编译器为g1、g2变量在.o文件中生成了R_386_GOT32重定位项以及为g3、g4变量在.o文件中生成了R_386_GOTOFF重定位项,稍后说明)
   
    6、b处两条指令执行后,ecx寄存器会得到.got表加载地址,为什么?
    ① 前面已经说明过R_386_PC32重定位类型,7处经过这种类型重定位后,执行时会跳转到__x86.get_pc_thunk.cx,得到b处指令的加载地址(CPU没有提供直接获取当前ip的指令,所以利用call会将返回地址压栈的特点);
    ② R_386_GOTPC,提示链接器创建.got表,并修改d处的值,保证执行时用它加ecx寄存器可以得到.got表地址(可以通过R_386_GLOB_DAT类型分析过程,编译得到的.so验证):
        通过①可能确定,执行过6处指令后ecx得到的b处指令的加载地址,拿什么和它相加可以得到.got表位置呢?
        +A:从ecx所指位置往后推2字节(机器码“81 c1”),就到了被重定位处(重定位项中的offset/规范文档中的P);
        +G-P:再向后推.got表相对此处的距离,就到.got表了。
        注意:$0x2只是作为链接器计算重定位值的A,在执行时就被G-P-2覆盖了,不要疑惑为什么要从ecx减2,它的含义根本就不是减数。

  • R_386_GLOB_DAT
   
    R_386_GLOB_DAT计算公式:S。

    对比R_386_32、R_386_GOT32,就是在编译.o文件时,加了-fPIC选项,R_386_GOT32重定位类型就是希望将重定位处从代码区域转移到.got表,链接阶段创建.got表,完成代码区域重定位,添加对.got表项的重定位项(转移≠消除)。
    将说明R_386_GOT32时编译得到的.o文件,链接成.so文件:
   
    ① 532、537处(对应.o文件中6、b处)指令,确实可以将.got表位置计算到ecx寄存器中(不过是结束位置,后面指令取.got表项地址时,用的是负偏移,可能不同编译器不一样吧,用开始位置、结束位置计算,道理是一样的)
    ② g1、g2的重定位类型变成R_386_GLOB_DAT,它是用于告诉动态链接器,在确定g1、g2地址时,放到它们的.got表项里(0x1fe8、0x1ff4)。

  • R_386_PLT32
   
    R_386_PLT32计算公式:L+A-P

    与R_386_GOT32、R_386_GLOB_DAT道理相似,R_386_PLT32、R_386_JUMP_SLOT也是为支持PIC定义的重定位类型,前者面向的是全局变量,后者面向函数。
    不过由于为了支持“动态库函数重定位延迟”,在.got.plt表跳转前,还多了一层.plt表跳转,.plt表里是一些链接器为重定位函数生成的跳转代码片段,计算公式中的L表示为重定位函数生成的跳转代码片段在.so文件中的位置(可以通过R_386_JUMP_SLOT类型分析过程,编译得到的.so验证)。
   

  • R_386_JUMP_SLOT
   
    R_386_JUMP_SLOT计算公式:S

    相比链接器对R_386_GLOB_DAT重定位项的处理,R_386_JUMP_SLOT重定位项的处理过程更复杂,不光在.so文件中创建.got.plt表,还为f1()、f2()对应生成了f1@pltf2@plt代码片段,并将代码中对f1()、f2()的调用,分别替换成对f1@pltf2@plt的调用,同时对f1()、f2()的重定位类型变成了R_386_JUMP_SLOT,这个重定位类型不会在.so文件一加载时就被动态链接器处理,而是调用f1()、f2()的指令首次被执行时,才进行重定位操作(比如我喜欢学习内核,但我目前的工作跟内核并没有多大关系,聪明的人都是等工作需要的时候,再学)。

    不防看一下f1()首次被调用的过程:
    ① 562地址处的指令执行后,ebx寄存器指向.got.plt表开始位置0x2000;
    ② 568、56d处原本对f1()、f2()的调用,被替换为对f1@pltf2@plt的调用:
        <f1@plt>:
        410: jmp  *0x18(%ebx)    ;0x2018,f1()在.got.plt表中占据的表项位置,表项内容初始值为0x416,即首次执行这条指令时,相当于“jmp 0x416”
        416: push $0x18
        41b: jmp  3d0 <_init+0x2c>

        有点奇怪,首次从410→416干嘛要特意绕个圈?
        这个圈只会在第一次执行f1@plt时才会绕,当416、41b处指令调用动态链接器,将f1()地址填到.got.plt表项0x2018后,以后再执行410处指令时,就相当于“jmp f1”了。即:
        第一次执行f1@plt:410 → 416、41b → f1()
        第二次执行f1@plt:410 → f1()
    而且链接器将所有调用f1()的地方都替换成调用f1@plt了,所以410→416这个圈,只会绕一次,不过即使这样,每次调用f1(),也要先“call <fl@plt>”,所以延迟加载虽然“聪明”,但有得也有失。
   

  • R_386_COPY
   
    R_386_COPY计算公式:冇

    初始化的全局变量和未初始化全局变量,被安排的位置是不同的,分别在.data、.bss,那么,如果某个模块通过extern引用了另外一个模块中定义的全局变量,编译阶段是不知道这个变量是否被初始化了,因为它根本看不到其它.c文件,所以就暂时把该变量安排在.bss,等链接阶段得知它如果有初始值的话,就依据此重定位项,修改一下.bss相应位置的值。
   

  • R_386_RELATIVE
   
    R_386_RELATIVE计算公式:B+A
    B:.so文件加载地址。

    分析R_386_32类型时,已经说明过R_386_RELATIVE,静态变量位置在链接阶段可以确定在.so文件中的偏移,那么计算它的加载地址,自然是加上.so文件的加载地址B就可以了。

  • R_386_GOTOFF
   
    R_386_GOTOFF计算公式:S+A-GOT

    编译说明R_386_32时使用的case代码,加上-fPIC选项,就能看到编译器为g3、g4变量在.o文件中生成了R_386_GOTOFF重定位项(在分析R_386_GOT32类型时提到过,基于静态变量相对于.so文件加载地址,距离固定,利用GOT表位置进行重定位)
   

  • R_386_GOTPC
   
    R_386_GOTPC计算公式:GOT+A-P

    分析R_386_GOT32类型时,已经说明过R_386_GOTPC,它与R_386_32类似,只不过一个用于重定位变量位置,一个用于重定位.got表位置。

  • 总结
    把对各种重定位类型的理解放在同一张表格对比,它们之间的区别就暴露的赤裸裸,再也不扑朔迷离了:
  (为了xx,需要在xx阶段xx,前一阶段提供准备,后一阶段完成计算,或变换为另一种计算方式)
     

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多