位置无关代码的动态链接过程

共享库,在Linux称为shared object,即.so文件,在Windows中成为
dynamic linked library,即.dll文件。使用共享库的主要目的就是“共享”,
即内存中只有一份代码,而多个进程都可以使用它,当然使用时涉及到内存的分页机制,
这里不作展开。共享库的一种很常用的实现方式就是位置无关代码(position independent
code),该方式生成的共享库,其代码部分在动态载入并链接到内存中时是不会有任何修改的,
这些代码放到内存的任何位置都可以正常工作,所以叫做与位置无关的代码

这里我们关注位置无关代码(PIC)是如何实现的。毕竟共享库映射到进程内存空间时,其
位置是不固定的,程序代码是如何知道共享库中函数的具体地址的,共享库中函数之间
相互调用时又是如何确定具体内存地址的,共享库中的全局变量(或者static)变量地址
又是如何确定的?

这些问题其实很简单,魔力之源就是全局偏移表(Global Offset Table),它被放置
.so文件的.GOT section中。当调用函数或者引用数据(.so中的数据)时,不是
直接使用函数或者数据的绝对地址,而是使用GOT中相关索引中保存的地址。也就是说所有
函数、全局变量的绝对内存地址都放到GOT中,GOT其实就是个unsigned int数组。

.so文件载入内存之后的样子大致是这个样子的,

Layout of shared library in memory

图片来源

在链接阶段,code section的大小是可以知道的,而每条指令在code section中的偏移
也是已知的,所以从某一条指令到GOT的偏移量也就是可知的了,例如

1
; 1. 取得GOT的真实地址,也就是当前指令的真实地址加上偏移量
lea ebx, ADDR_OF_GOT

; 2. 取得变量的真实地址,0x10为数据在GOT中索引,这在链接时即可确定
mov edx, DWORD PTR [ebx + 0x10]

; 3. 取得变量的值
mov edx, DWORD PTR [edx]

代码参考

上面是对变量的重定位,对于函数呢,函数之间的相对地址早在链接阶段就是已知的了,
所以.so内部的函数调用不需要重定位,至于外部调用.so内部函数,同样只需查阅
GOT即可。但是GOT中的真实地址又是怎么来的呢?答案是动态连接器,在Linux中就是
ld.xxx.so,它在加载.so文件到内存时会知道.so代码在内存中的真实地址,然后
根据重定位表(.dynamic)即可知道每个函数、全局变量的偏移量及其在GOT中的索引,
进而就可以填充GOT表了。

看似很完美,但是实际上函数的处理方式并非如此,函数采用一种叫做延迟加载的技术,
即在函数未被调用之前,其在GOT中的真实地址并没有得出,而是在第一次调用时,调用
动态加载器计算函数的真实地址,并将真实地址填入GOT的相关条目中,下次再调用该函数
时就直接使用GOT中记录的真实地址,而无需再次调用动态加载器了。

延迟加载技术其实也蛮简单,关键点就是过程链接表(procedure linkage table, PLT),
编译阶段,编译器会为每个函数生成一个代码片段,叫做stub,不论什么函数,其对应
的stub都是16字节,而且这个stub的格式也都是一样的,这些stub构成过程链接表,

1
PLT[0]:
    call 'linker'
; ...
PLT[n]:
    jmp *GOT[m]
    push 'index of func'
    jmp PLT[0]

而在GOT中,函数对应的条目默认值为该函数的stub中push指令的地址,所以第一次
调用某一个函数的流程大致是这样的:

  1. call ‘plt item’,这个是在链接时就被修改了的,PLT中的item其实也是一个小函数,
    其相对地址在链接阶段就是已知的(链接器创建了这些小函数)
  2. jmp *GOT[m],m是该函数在GOT中对应的索引,链接阶段已知,这句话就是跳转到
    该函数在GOT中的对应项保存的地址处,而GOT默认保存的是该函数对应的PLT项的push指令
    地址,所以这一句的意思其实就是接着执行push指令
  3. push ‘index of func’,把该函数在动态链接表(.dynamic)中的索引入栈,它其实
    就是调用linker的参数,看下一条
  4. jmp PLT[0],PLT[0]不同于其他项,它保存的指令就是调用链接器,根据3中给出参数
    计算函数的真实地址,然后修改函数对应的GOT项,(即GOT[m])

以后再次调用该函数时,情况发生了戏剧性的变化:

  1. 同第一次调用一样,仍然执行其对应的PLT项
  2. 同第一次调用一样,仍然跳转到其对应的GOT项保存的地址处,但是由于第一次调用时
    动态链接器已经修改了该GOT项(上面的步骤4),所以这次直接跳转到该函数的真实地址处,
    不再调用linker了

于是,延迟加载过程就这样完成了。

以上就是位置无关代码的实现方式,只是描述思想,不必计较细节。

(over)