基本修养实战篇(八) 动态链接

动态链接技术的初衷是用来解决大量重复静态库被load到内存造成的浪费问题。

在linux中,常用的c语言运行库glibc,对应的文件名是libc.so.整个系统只保留一份libc.so, 所有程序装载的时候,系统的动态链接器,都会将程序运行所需要的所有动态链接库(libc.so是最基本的)装载到进程的地址空间,并将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。

在一个动态链接的例子中,如下图所示:

 看到没有,即使是动态库,还是会在一定程度上参与到linker的链接的过程,那么这个地方linker在干什么呢?答案是,需要这里的静态链接器标注一下这里的符号是动态链接的符号,不在当前链接环节进行重定位,而是留到装载的时候进行。所以这里是静态链接器和动态链接器有一个协作的过程。

cat /proc/<pid>/maps 看一下进程的虚拟地址空间分布:

 这里看到了程序运行所依赖的C库libc-2.24.so,以及ld-2.24.so, 这个玩意就是linux的动态链接器。注意哦,注意我的措辞,linux的动态链接器,说明,这个玩意是针对linux的,也是依托于linux操作系统而存在的。没听说裸机上或者rtos上有这个玩意。

动态链接器ld-2.24.so和普通的共享对象一样被映射到了进程的地址空间,在系统开始运行我们的可执行文件之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后,再把控制权交给program本身开始执行。

下面我们将触及到地址无关码的概念。

首先回顾一下,之前编译的so, 有的加上了-fPIC选项,有的没有加这个选项,只有-shared选项。-shared就表示,输出的共享对象使用的是装载时重定位。-fPIC表示地址无关码。

装载时重定位可以有效解决动态模块中有绝对地址引用的情况,因为操作系统会在装载时,对程序中指令和数据的绝对地址的引用进行重定位,但是使用的策略很简单,是按照整体加载来搞的,装载地址加上so里面的对应函数的偏移量就行了。这种装载时重定位的一个大问题就是没有办法节省内存。因为假如一开始一个so被加载到了0x10000000的位置,然后所有的指令和数据中对绝对地址的索引都被重定位了,这个时候这些个指令只是针对这个程序是ok的,假设换了一个程序,so的加载地址(操作系统映射的)有变化,那岂不是就gg了。

我们的想法是,最好的情况是,指令需要修改的部分独立出来,和数据部分放在一起,这样指令部分就可以保持不变,数据部分就可以每个进程一个副本。这就是地址无关码的技术。那么地址无关码是如何做到的呢?

首先,我们把一个模块的地址引用枚举成下面4类:

  1. 模块内部的函数调用,跳转
  2. 模块内部的数据访问,比如模块内部的全局,静态变量等(局部变量在栈上,不用考虑,肯定每个进程都互不影响)
  3. 模块外部的函数调用,跳转
  4. 模块外部的数据访问,比如引用其他模块中定义的全局变量

对于情况1,可以通过相对地址调用指令来完成,也就是说指令的后几个字节的是目的地址相对当前指令下一条指令的偏移。

对于情况2,也是利用指令相对内部数据的偏移来完成。

光说不练不行,我们还是看代码吧:

static int a;
extern int b;
extern void ext();

void bar()
{
    a = 1;
    b = 2;
}

void foo()
{
    bar();
    ext();
}

我们使用以下的编译命令把它编译成共享目标文件:

gcc pic.c -shared -fPIC -o libpic.so

然后objdump -SD libpic.so, 看下bar反汇编的结果:

00000600 <bar>:
 600:   e52db004        push    {fp}            ; (str fp, [sp, #-4]!)
 604:   e28db000        add     fp, sp, #0
 608:   e59f2030        ldr     r2, [pc, #48]   ; 640 <bar+0x40>
 60c:   e08f2002        add     r2, pc, r2
 610:   e59f302c        ldr     r3, [pc, #44]   ; 644 <bar+0x44>
 614:   e08f3003        add     r3, pc, r3
 618:   e3a01001        mov     r1, #1
 61c:   e5831000        str     r1, [r3]
 620:   e59f3020        ldr     r3, [pc, #32]   ; 648 <bar+0x48>
 624:   e7923003        ldr     r3, [r2, r3]
 628:   e3a02002        mov     r2, #2
 62c:   e5832000        str     r2, [r3]
 630:   e1a00000        nop                     ; (mov r0, r0)
 634:   e28bd000        add     sp, fp, #0
 638:   e49db004        pop     {fp}            ; (ldr fp, [sp], #4)
 63c:   e12fff1e        bx      lr
 640:   000109ec        andeq   r0, r1, ip, ror #19
 644:   00010a20        andeq   r0, r1, r0, lsr #20
 648:   00000024        andeq   r0, r0, r4, lsr #32

看到这里面的ldr r2, [pc, #48]了吗,这就是用pc的偏移去寻址模块内部数据,这样的话,不管模块被加载到什么位置,都可以正常访问到。

那么andeq r0, r1, ip, ror # 19又是什么意思呢?

下面是情况3,模块间数据访问,很明显,这些对其它模块的全局变量的引用,肯定和模块的装载地址有关。也就是没有装载的时候,肯定不知道到哪里去寻址这个变量。ELF的做法是在数据段中建立一个指向这些外部全局变量的指针数组 , 也就是全局偏移表(Global Offset Table).

当代码需要用到某个外部全局变量b时,程序会先找到GOT, 然后找b的具体位置,链接器在装载模块时会查找GOT表中每个变量的所在地址,然后进行GOT表的填充。由于GOT本身是放在数据段的,所以他可以在模块装载时被修改,这样每个进程就有了自己独立的副本,相互不会影响。

 我们可以使用objdump -h libpic.so 查看GOT的位置

 可以看到got表在文件中的偏移为0x11000

objdump -R libpic.so 查看pic.so需要在动态链接时的重定位项

 可以看到b的文件内的offset是11024,相对于GOT表的位置11000,它偏移了24字节,也就是GOT表中第9项。

到了这个地方,我突然发现了一点端倪,上面有个地方我没有看懂,原因就在于我没能真正联系起来这里提到的几个概念,pc怎么偏移的,偏移多少,好好看看吧,是这样联系起来的。首先我们看到GOT表的位置是0x11000, b的位置是0x11000 + 24.

在函数bar()中,ldr r2, [pc, #48] ; 这句执行后r2 = 0x640;当然因为是相对PC的偏移,所以不怕这个模块被加载到任意地址,执行后总能跳到对应的位置把数据109ec拿出来给r2, 这个地方的指令我估计是反汇编的时候强行翻译出来的,其实这个地方是个数据。所以现在r2 = 109ec.

然后是r2 = pc + r2; 这个时候r2 = 614 + 109ec = 11000. 好家伙,这不正好就是GOT表的位置吗。好,再然后是ldr r3, [pc, #44], 也就是把644这个位置的内容(10a20)给r3, 然后是r3 = pc + r3 = 10a20 + 61c = 1103c;然后就是很简单的把1复制给这个地址,所以这个地址就是a的地址吗,我们来验证一下,objdump -SD libpic.so | grep 1103c -C 10

 果然如此,真相大白!

嗯,上面只看到了模块内部数据的访问,现在继续往下看: ldr r3, [pc, #32]; 也就是说r3 = 24. 然后是r3 = r2 + r3; 之前算的r2 = 614 + 109ec = 11000,也就是位于GOT表的位置,所以现在 r3 = 11000 + 24 = 11024, 好家伙,正好就是b的地址。

我们乘胜追击,继续往下看内部函数和外部函数的访问,看下foo的反汇编结果

0000064c <foo>:
 64c:   e92d4800        push    {fp, lr}
 650:   e28db004        add     fp, sp, #4
 654:   ebffff83        bl      468 <bar@plt>
 658:   ebffff88        bl      480 <ext@plt>
 65c:   e1a00000        nop                     ; (mov r0, r0)
 660:   e8bd8800        pop     {fp, pc}

首先是bl 468 <bar@plt>, 然后是bl 480<ext@plt>, 可以看到这里和书上说的不太一样,也就是说对模块内部和外部函数都是用的plt。好的,下面来说一下plt(Procedure Linkage Table), elf使用plt技术来实现延迟绑定,所谓延迟绑定就是函数在第一次用到时才进行绑定,这样可以避免动态链接器上来就一通绑定,(因为有的函数可能压根就用不到,还去查找和重定位它们就是浪费时间),加快启动速度。

每个外部函数在PLT中都有一个响应的项,比如bar@plt, ext@plt, 调用函数并不直接通过GOT跳转,而是先通过PLT跳转,类似下图:

我们看一下这是个啥,objdump -SD libpic.so | grep bar@plt -C 10

00000468 <bar@plt>:
 468:   e28fc600        add     ip, pc, #0, 12
 46c:   e28cca10        add     ip, ip, #16, 20 ; 0x10000
 470:   e5bcfba0        ldr     pc, [ip, #2976]!        ; 0xba0

首先add ip, pc, #0, 12 简直令我困惑,于是我决定,人肉翻译一下这条指令,看看到底是什么意思。只能大概猜测一下后面的12是移位的意思,但是和结果对不上。这里我想到一个办法,那就是利用C内嵌汇编来测试一条指令的执行结果,然后打印出来。

上代码:

 int c_pc =  0x470;
     int c_out = 0;
     __asm__ __volatile__(
                    "add %[out], %[in], #16, 20
"
                    :[out] "=r" (c_out)
                    :[in] "r"(c_pc)
                    :
          );
     printf("c_out = %x
", c_out); //0x470

    int c_pc2;
    int c_ip = 0x10470;
    __asm__ __volatile__(
                   "ldr %[out], [%[in], #2976]!
"
                   :[out] "=r"(c_pc2)
                   :[in] "r"(c_ip)
                   :
         );  // 段错误,因为[%[in], #2976] 试图非法访问一个地址
    printf("c_pc2 = %x
", c_pc2);
    printf("c_ip = %x
", c_ip);

经过上面一番折腾,我算是知道了bar最后的执行结果,pc = *(0x11010); 好家伙11010 不就是前面那个重定位项里的东东吗:

 这里bar@@Base 前面有个type是R_ARM_JUMP_SLOT, 当然下面不远处就是ext函数。

11010这个地址的内容会被给pc, 它的内容是什么呢:

 就是bar的地址了。附近还可以看到ext也在这里,而且符号值为0,那就很明显了,当前压根找不到ext的符号,所以标记一下,在装载的时候让动态链接器去找好了。这里bar的值是600,我忙猜,动态链接器看到不是0,应该是直接把这个值修正成相对该模块首地址的偏移就行,就不用去别的地方找符号了。

这里不禁令人疑惑,bar明明是个模块内部的函数,为啥却被放在了动态重定位段?以及plt是怎么到got的,这里还是有些不是很清晰。

我们不妨先看看so里面到底有哪些段,先有一个总览,再去研究这个细节。

但是这里还是没有看到plt真正大显神威的地方。以及.plt的效果是什么,这里也是没有看到,同时也没有看到ld.so 是如何获取被执行权限的。所以一定是哪里少了一环,把这些全部联系起来才算是真正了解了plt技术。

为了实现这个目的,我们需要gdb的帮助。我先是写了一个main.c, 然后和libpic.so 链接生成了一个可执行文件test_pic.

随后gdb test_pic, 在foo函数处打上端点,然后disassemble 查看

 这里就是运行时环境了。objdump 反汇编一下test_pic, 0x104d0的确就在plt段。

 而且plt段的加载地址也指定了要在104a4的位置

 这样我们就大可放心的跳转到这里了,看看接下来会发生什么

执行 si,单步执行到104d8, 执行完后,info  registers, 查看当前的寄存器,发现

 好嘛,可算是让我逮着了。这不就是plt段的起始地址吗。所以接下来就要跑到这里去了。

接下来我将尽可能详细地描述这个过程,看看这里面到底有什么奥妙。plt放了一段精致的代码:

   0x104a4:     push    {lr}            ; (str lr, [sp, #-4]!)
   0x104a8:     ldr     lr, [pc, #4]    ; 0x104b4
   0x104ac:     add     lr, pc, lr
   0x104b0:     ldr     pc, [lr, #8]!
   0x104b4:     andeq   r0, r1, r12, asr #22

我们si 单步执行过去,走到104a4的时候,执行了push {lr}, 什么意思呢,我们执行x/8i $lr, 看看lr是什么内容,在main+12的位置,还是不是很明确,我们稍微把前面的内容也打印下,于是,一下子就看出来,0x10640就是foo函数后的下一条指令,从foo函数返回后要走到这里。

 好的,push完之后,看第二条指令,ldr lr, [pc, #4], 这个比较简单pc + 4 是104b4, 然后外面是一个[],所以要把104b4的值给lr, 那么104b4又是什么内容呢,是10b4c.

 add lr, pc, lr 这里就是lr = 104b4 + 10b4c = 21000.

ldr pc, [lr, #8]! ; 执行完后pc = *(21008)

那么21008的位置的值又是多少呢:答案是0x76fe53e4

 所以待会PC就要跑到这个位置去了,这个位置是个什么玩意呢?

 WTF, 这不就是一直只闻其名,不见其人的dl_runtime_resolve吗,终于现身了啊!!

在这里面,就开始要resolve foo这个符号了,对它进行绑定和链接。

我试着用单步调试,然后发现这里干的活真是不少,这里放些截图感受一下:

 注意到里面的lookup_symbol了没,这里就在搜索外部符号,fix_up应该就是在完成重定位。

 resolve完成之后,开始执行bar(). 然后后面遇到了ext(), 又是一顿找啊

 我们要重点关注的事情是,foo以及里面的bar, ext,经过首次resolve后,后面是否还会resolve呢。

我们修改一下源码,在main里面再加一个foo(), 然后把断点设置在第二个foo的位置。看下第二次的foo的调用是怎么走的。

 可以看到foo@plt段的内容压根没变,但是第二次调用foo的确很快就跳到了真正的foo,中间也没有再去resolve, 符合我们的认知。

其中最为关键的一步,我认为就是[r12, #2876] 这个地址的内容,应该是在resolve完成后被改掉了。前后对比一下这个位置,果然如此!!!

 第二次执行到foo之前的时候,内存0x21014这个地方存储的内容,就已经变成了foo的真正的首地址了:

 拼图还剩最后一块,那就是0x21014在哪里,它是什么段呢?

我们用readelf -a test_pic看一下就知道了;

 这个.rel.plt段,就是main中依赖的外部模块的符号,本来以为万事大吉了来着。但是回想到之前说的每个段的VMA和LMA,保险起见,我还是要确认一下,这么一看,好家伙,出问题了。

 

 .plt段的确被load在104a4的位置,既然是这样的话,0x21014这个位置就是.got段。

objdump -SD test_pic

 可见,0x21014的确在GOT中,且值就是plt的首地址,rel.plt 中的21014也不是巧合,而是就是要ld重定位的时候去这个(GOT中的)位置修改。

 这么一来,事情才变得比较清晰了。

这下面有一段摘录,相互映证了一下。

.dynsym:为了完成动态链接,最关键的还是所依赖的符号和相关文件的信息。为了表示动态链接这些模块之间的符号导入导出关系,ELF有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息。
.rel.dyn:实际上是对数据引用的修正,它所修正的位置位于.got以及数据段。
.rel.plt:是对函数引用的修正,它所修正的位置位于.got。
.plt:程序链接表(Procedure Link Table),外部调用的跳板。
.text:为代码段,也是反汇编处理的部分,以机器码的形式存储。
.dynamic:描述了模块动态链接相关的信息。
.got:全局偏移表(Global Offset Table),用于记录外部调用的入口地址。
.data: 数据段,保存的那些已经初始化了的全局静态变量和局部静态变量。

最后总结一下PLT的作用;

foo@plt --> 公共plt --> _dl_runtime_resolve (reslove 符号foo)  --> 根据.rel.plt foo的信息 --> 修改got中的值

后面再次调用foo@plt --> GOT--> foo

=================================

原文地址:https://www.cnblogs.com/Arnold-Zhang/p/15430263.html