深入理解计算机系统篇之链接(5):PIC位置无关代码
1.摘要
本章讲解PIC位置无关代码的引入动机,使用方法及原理。
2.动机
引入PIC的动机是共享库(动态库)的使用方式决定的,共享库只有一份被放入Flash中,每个应用程序如果要链接共享库,就需要讲其搬移到RAM中链接,可是每个应用程序在RAM中的地址不同,引入的共享库地址也就不一样,不能再采用静态的固定的地址获取方式去调用,所以引入位置无关代码的概念。
3.介绍
只需要知道共享库内部的相对偏移而不需要实际地址,在加载到RAM中自行计算最终地址,这就是位置无关代码(PIC)。
如何开启PIC呢?只需要在编译时加上-fpic即可,例如:
$ g++ -fpic -shared -g -o libsub_debug.so sub.cpp
或者在cmake构建工具中加上一行:
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
加入编译选项后,编译器把指令改成相对访问,链接器不写死地址,运行时再确定绝对位置,这就是位置无关代码做的事情。
4.原理
为什么可以做到位置无关?首先这里分两部分,第一部分是自己模块调用自己的全局变量,像动态库里自己调用自己的变量;第二部分是别的模块调用动态库里的函数与变量,需要通过间接跳转才能做到位置无关。
4.1当前目标模块下的全局变量PIC
在非PIC下全局变量就是存储在.data\.bss段中的,是一个固定地址,任何函数访问都只能通过固定地址来访问。开启PIC后,如何用相对地址呢?首先编译器运用以下事实来生成全局变量PIC的引用,即无论目标模块被搬移到哪里执行,.text与.data之间的距离是不变的。
这时执行的代码段与数据段的变量偏移就是固定的,不会随着搬移而变化,于是PIC利用这一点对当前目标访问的全局变量的地址进行了修改。首先编译器会建立一张全局偏移量表(GOT),在GOT中,存放着被当前模块引用的全局变量,当编译器遇到调用全局变量的地方时,将地址转换为GOT表的偏移。程序最终运行时先跳转到GOT表寻找对应条目,再找到最终变量地址,最终引用到全局变量。
4.2引用其他目标模块下的全局变量或函数
调用其他目标模块或者动态库中的函数时如何做到位置无关的呢?这里编译系统用到一个延迟绑定的技术来解决位置无关的问题,它需要用到两个简单的数据结构GOT与过程链接表(PLT),但是使用的流程有些复杂。首先,需要了解一下这两个数据结构:
- 过程链接表(PLT)。PLT是一个关于函数调用的数组。其中PLT[0]是一个特殊的条目,它表示跳转到动态链接器中,每个被可执行程序调用的库函数都有自己的PLT条目。
PLT[1]表示调⽤系统启动函数(__libc_start_main)。从PLT[2]开始表示用户调用的函数,在下图示例中代表addvec函数。 - 全局偏移量表(GOT)。GOT之前讲是存放全局变量的地方,但是与PLT联合使用时,
GOT[0]与GOT[1]就表示动态链接器在解析函数地址时会用到的信息。GOT[2]表示动态链接器的入口地址,其他每个条目表示一个被调用的函数地址,需要在第一次运行时才能被解析,后续可以直接调用。并且每个条目都有一个对应的PLT条目。

延迟绑定技术如上图所示,在第一次调用函数时会进行以下四步:
- 代码段执行到函数调用后跳转到PLT中相关函数的条目(因为每个被可执行程序调用的库函数都有自己的PLT条目,所以不用担心无法跳转),如图中的
PLT[2] - PLT中的每个条目都有几条指令,图中第一条指令是通过
GOT[4]间接跳转。因为每个GOT条目初始化时都指向它的PLT条目的第二条指令,所以又跳转回PLT的第二条指令。(这个为什么要跳转一次,直接向下执行不就可以了吗?) - 然后将函数ID压入栈中,图中的0x1为addvec函数的ID,之后从
PLT[2]跳转到PLT[0]。 - 最后
PLT[0]通过GOT[1]间接的把动态链接器的参数压栈,然后通过GOT[2]间接跳转到动态链接器中。然后动态链接器用压入栈中的两个栈条目确定函数addvec的运行时位置,用这个地址重写GOT[4]。
后续调用时因为GOT[4]已经有了运行时地址所以流程相对简单:
- 同样代码段执行跳转到
PLT[2]中。 - 这时跳到
GOT[4]不需要再跳回来,因为有了函数运行时地址,直接跳转到函数即可。