前言
我们在制作动态库的时候:
gcc -fPIC -o math.o math.c
通常需要以选项 -fPIC
来说明我们编译的源文件是用来制作动态库的,可是为什么需要这个PIC(Position - Independent Code)选项。
下面小编会一步一步带大家了解这个 PIC 的作用。
从中我们可以体会两点:
- 体会什么是虚拟地址/虚拟地址来自哪里?
- 什么是
PIC
?
1. 汇编文件中的地址
在了解.o文件之前,我们先来了解一个可执行程序中形成的地址
1.1 可执行程序中的虚拟地址
可执行程序就是众多.o
文件,通过一个连接器(ld)形成的可执行程序。
- 了解:平坦模式 VS 分段模式。
现代的C/C++可执行程序都是以平坦模式(Flat Memory Model)进行编译的。也就是意味着:程序都以一个平坦模型来看待内存,即单一的、连续的、线性的地址。
- 我们可以知道:实际一个可执行程序是已经有了地址的概念了!!
如何证明呢?下面我们写一个简单C语言的程序
/*** main.c******/
#include <stdio.h>
int g_val = 10;
int add(int x, int y)
{
return x + y;
}
int main()
{
int a = 0;
printf("%d\n", a);
return 0;
}
我们使用gcc编译器将它形成一个可执行程序
gcc -o main.exe main.c
然后我们使用工具objdump来查看该可执行程序的反汇编:
(文件较大,我们重定向到一个文本文件)
objdump -d main.exe > disassembly.txt
-
来看文件:disassembly.txt
被标记的位置就是代码执行的地址!!
可是我们的代码并没有被加载到内存之中,为什么会有地址的概念了呢?说明:在程序加载到物理内存之前,就有了虚拟地址的概念。
1.2 汇编形成的.o文件
上面谈到,一个可执行程序有自己对应的虚拟地址。那么我们往前看,在形成可执行程序之前的汇编结束形成的.o文件(目标文件)
在其内部也有对应的虚拟地址:
gcc -c main.o main.c;objdump -d main.o > tmp.txt
-
来看结果:
-
现象:
add
函数的起始地址是00,main
函数的起始地址是18。显然是事实上不可能的。这是可重定位目标文件(.o 文件)中代码段(.text 段)内的相对偏移地址。这是为了告诉连接器:“我希望我能得到一个合适的地址,你连接器在合适的时候给我一个合适的地址(这个地址就是虚拟地址)”
来看形成可执行之后的该程序:
明显:add
和main
的地址发生了变化!! -
结论:
- 汇编形成的.o文件中含有地址。.o文件中的地址是可重定位的,即需要连接器为其分配真正需要的虚拟地址。
- 所以我们的.o文件要求:连接器在连接.o文件的时候需要对我内部的代码的地址(函数的地址……)进行分配一个合理的地址!!!
2. PIC
一个动态库可能是多个.o文件链接而成
gcc -shared libxx.so a.o b.o
这段命令:就是将a.o文件和b.o文件链接形成一个动态库libxx.so
。
既然是链接过程,那么必然地,我们的动态库连接器一定需要为两个目标文件的代码分配对应虚拟地址。因为我们.o文件是要求:链接器在链接的时候需要给我的代码一个合适的地址。
- 一个事实:动态库的链接器是无法完成这个工作的。因为他不知道这个动态库未来会被加载到进程地址空间的哪一个位置,他无法提供一个有效的绝对地址(而第二次程序与库进行链接的静态链接器拥有绝对的上帝视角,可以对地址进行分配)
所以,如果我们就采用普通的方式(不带选项-fPIC
)来编译汇编我们的源文件,这样形成的.o文件,对于我们的动态库链接器来说:是无法满足.o文件中地址确定的需求的。
-
为什么静态链接和动态链接会有这样的差异?
主要原因是因为:OS的综合考虑(资源、效率、目的……)!动态库链接形成.so文件和最后.o文件链接形成可执行程序的时候链接器的身份和地位不同。
- 动态库链接器和静态链接器设计的理念不同,我们从如下几个方面来说明:
特性 | 静态链接 | 动态链接 |
---|---|---|
主要目的 | 创建一个独立、高效、可移植的可执行程序 | 共享代码、节省内存 |
发生时机 | 编译时 | 运行时 |
可用信息 | 全部信息:所有源代码、所有库代码 | 局部信息:只知道当前要加载的库 |
关键约束 | 结果是一个完全封闭、完整的整体 | 必须和未来未知组件整合 |
然而有了编译选项fPIC
就不一样了:
-
PIC选项就是在编译的时候告诉编译器:“你要用GOT来解决地址重定位问题哦”。编译器就通过全局偏移表(GOT) 将绝大多数对绝对地址的依赖从代码段转移到了数据段。
- 现在,访问一个全局变量
global_var
的指令不再是直接访问某个内存地址,而是变成了:- 计算GOT表本身的起始地址(如何找到GOT表项,细节就不说了)
- 找到变量
global_val
在表中的表项,取出该变量的真实地址,存入寄存器 - 最后通过寄存器中的的真实地址对数值进行访问
- 现在,访问一个全局变量
-
在创建动态库时(即使用
gcc -shared
),链接器可以看到global_var@GOTPCREL
也是一个需要重定位的项。但这次,这个重定位项是针对GOT这个数据结构的某个表项,而不是针对代码段中的指令。链接器可以安心地完成这个重定位:它在GOT表中为
global_var
预留一个位置,并生成一个重定位项(可以简单理解为偏移量),记录:“运行时加载器,当你把这个库加载到某个基址X后,请计算一下global_var
的实际地址(X + 它的偏移量),然后把这个地址值填到GOT表的这个位置里。”
所以,-fPIC
对于.o文件中重定位的要求进行了其它处理:
- 将大量对代码段的重定位,转变为了对数据段(GOT表)的重定位。
- 代码段本身因为使用相对寻址,变得非常“干净”,几乎不需要修改。
- 数据段(GOT)是可写的,在运行时被修改是预期之内的行为,不影响代码段的只读共享特性。
简单来说:以前需要的代码所需要的一个绝对定址,现在只需要一个偏移量。
简单总结一下:
- 因为动态库是共享的等等设计因素,决定了动态链接器特性就无法像静态链接器那样拥有地址分配的能力。其次由于.o文件要求对其中代码段的地址进行重定位,而动态库链接器无法完成这样的工作所以就需要PIC。通过GOT对重定位进行转化,间接实现重定位。
那么最后加载器在加载程序的时候,我们的动态库就可以被加载到进程地址空间的任意位置,我们加载器填充GOT表项信息的时候,只需要将动态库加载的起始地址 + 偏移量(偏移量我们也可以直接存储在GOT表项中,之后加上起始地址即可。但是具体实现肯定有所差异)就可以得到代码的虚拟地址了。最后CPU执行指令的时候就执行这些虚拟地址,就会通过页表转化找到物理地址下的动态库。(至于如何找到GOT表的细节我们就不再说了)