也就是说:符号重定位的过程,是直接对可执行文件进行更改。

并且对于动态链接来说,在编译阶段,仅仅是在可执行文件或则动态库中记录了一些必要的信息。

真正的重定位过程,是在这个时间点来完成的:可执行程序、动态库被加载以后,调用可执行程序的入口函数之前。

只有当所有须要被重定位的符号被解决了以后,就能开始执行程序。

既然也是重定位,与静态链接过程一样:也须要把符号的目标地址填写到代码段中须要重定位的地方。

矛盾:代码段不可写

问题来了!

我们晓得,在现代操作系统中,对于显存的访问是有权限控制的,通常来说:

代码段:可读、可执行;

数据段:可读、可写;

假如进行符号重定位,就须要对代码进行更改(填写符号的地址),并且代码段又没有可写的权限,这是一个矛盾!

图片系统文件错误怎么办_linux系统 图片_图片系统打印

解决这个矛盾的方案,就是Linux系统中动态链接器的核心工作!

解决矛盾:降低一层间接性

DavidWheeler有一句格言:“计算机科学中的大多数问题,都可以通过降低一层间接性来解决。”

解决动态链接中的代码重定位问题,同样也可以通过降低一层间接性来解决。

既然代码段在被加载到显存中以后不可写,而且数据段是可写的。

在代码段中引用的外部符号,可以在数据段中降低一个跳板:让代码段先引用数据段中的内容,之后在重定位时linux 输入法,把外部符号的地址填写到数据段中对应的位置,不就解决这个矛盾了吗?!

如右图所示:

图片系统打印_linux系统 图片_图片系统文件错误怎么办

理解了上图的解决思路,基本上就理解了动态链接过程中重定位的核心思想。

示例代码

我们须要3个源文件来讨论动态链接中重定位的过程:main.c、a.c、b.c,其中的a.c和b.c被编译成动态库,之后main.c与这两个动态库一起动态链接成可执行程序。

它们之间的依赖关系是:

图片系统打印_linux系统 图片_图片系统文件错误怎么办

b.c

代码如下:

#include 

int b = 30;

void func_b(void)
{
printf("in func_b. b = %d n", b);
}

代码说明:

定义一个全局变量和一个全局函数,被a.c调用。

a.c

代码如下(稍稍复杂一些,主要是为了探求:不同类型的符号怎样处理重定位):

#include 

// 内部定义【静态】全局变量
static int a1 = 10;

// 内部定义【非静态】全局变量
int a2 = 20;

// 声明外部变量
extern int b;

// 声明外部函数
extern void func_b(void);

// 内部定义的【静态】函数
static void func_a2(void)
{
printf("in func_a2 n");
}

// 内部定义的【非静态】函数
void func_a3(void)
{
printf("in func_a3 n");
}

// 被 main 调用
void func_a1(void)
{
printf("in func_a1 n");

// 操作内部变量
a1 = 11;
a2 = 21;

// 操作外部变量
b = 31;

// 调用内部函数
func_a2();
func_a3();

// 调用外部函数
func_b();
}

代码说明:

定义了2个全局变量:一个静态,一个非静态;

定义了3个函数:

func_a2是静态函数,只能在本文件中调用;

func_a1和func_a3是全局函数,可以被外部调用;

在main.c中会调用func_a1。

main.c

代码如下:

#include 
#include
#include

// 声明外部变量
extern int a2;
extern void func_a1();

typedef void (*pfunc)(void);

int main(void)
{
printf("in main n");

// 打印此进程的全局符号表
void *handle = dlopen(0, RTLD_NOW);
if (NULL == handle)
{
printf("dlopen failed! n");
return -1;
}

printf("n------------ main ---------------n");
// 打印 main 中变量符号的地址
pfunc addr_main = dlsym(handle, "main");
if (NULL != addr_main)
printf("addr_main = 0x%x n", (unsigned int)addr_main);
else
printf("get address of main failed! n");

printf("n------------ liba.so ---------------n");
// 打印 liba.so 中变量符号的地址
unsigned int *addr_a1 = dlsym(handle, "a1");
if (NULL != addr_a1)
printf("addr_a1 = 0x%x n", *addr_a1);
else
printf("get address of a1 failed! n");

unsigned int *addr_a2 = dlsym(handle, "a2");
if (NULL != addr_a2)
printf("addr_a2 = 0x%x n", *addr_a2);
else
printf("get address of a2 failed! n");

// 打印 liba.so 中函数符号的地址
pfunc addr_func_a1 = dlsym(handle, "func_a1");
if (NULL != addr_func_a1)
printf("addr_func_a1 = 0x%x n", (unsigned int)addr_func_a1);
else
printf("get address of func_a1 failed! n");

pfunc addr_func_a2 = dlsym(handle, "func_a2");
if (NULL != addr_func_a2)
printf("addr_func_a2 = 0x%x n", (unsigned int)addr_func_a2);
else
printf("get address of func_a2 failed! n");

pfunc addr_func_a3 = dlsym(handle, "func_a3");
if (NULL != addr_func_a3)
printf("addr_func_a3 = 0x%x n", (unsigned int)addr_func_a3);
else
printf("get address of func_a3 failed! n");


printf("n------------ libb.so ---------------n");
// 打印 libb.so 中变量符号的地址
unsigned int *addr_b = dlsym(handle, "b");
if (NULL != addr_b)
printf("addr_b = 0x%x n", *addr_b);
else
printf("get address of b failed! n");

// 打印 libb.so 中函数符号的地址
pfunc addr_func_b = dlsym(handle, "func_b");
if (NULL != addr_func_b)
printf("addr_func_b = 0x%x n", (unsigned int)addr_func_b);
else
printf("get address of func_b failed! n");

dlclose(handle);

// 操作外部变量
a2 = 100;

// 调用外部函数
func_a1();

// 为了让进程不退出,方便查看虚拟空间中的地址信息
while(1) sleep(5);
return 0;
}

纠正:代码中原本是想复印变量的地址的,而且不留神加上了*,弄成了复印变量值。最后检测的时侯才发觉,所以就懒得再去更改了。

代码说明:

借助dlopen函数(第一个参数传入NULL),来复印此进程中的一些符号信息(变量和函数);

形参给liba.so中的变量a2,之后调用liba.so中的func_a1函数;

编译成动态链接库

把以上几个源文件编译成动态库以及可执行程序:

$ gcc -m32 -fPIC --shared b.c -o libb.so
$ gcc -m32 -fPIC --shared a.c -o liba.so -lb -L./
$ gcc -m32 -fPIC main.c -o main -ldl -la -lb -L./

有几点内容说明一下:

-fPIC参数意思是:生成位置无关代码(PositionIndependentCode),这也是动态链接中的关键;

既然动态库是在运行时加载,那为何在编译的时侯还须要指明?

由于在编译的时侯,须要晓得每一个动态库中提供了什么符号。Windows中的动态库的显性的导入和导出标示,更能彰显这个概念(__declspec(dllexport),__declspec(dllimport))。

此时,就得到了如下几个文件:

图片系统文件错误怎么办_图片系统打印_linux系统 图片

动态库的依赖关系

对于静态链接的可执行程序来说,被操作系统加载以后,可以觉得直接从可执行程序的入口函数开始(也就是ELF文件头手指定的e_entry这个地址),执行其中的指令码。

并且对于动态链接的程序来说,在执行入口函数的指令之前,必须把该程序所依赖的动态库加载到显存中,之后才会开始执行。

对于我们的实例代码来说:main程序依赖于liba.so库,而liba.so库又依赖于libb.so库。

可以用ldd工具来分别看一下动态库之间的依赖关系:

图片系统打印_linux系统 图片_图片系统文件错误怎么办

图片系统文件错误怎么办_图片系统打印_linux系统 图片

图片系统文件错误怎么办_linux系统 图片_图片系统打印

可以看出:

在liba.so动态库中,记录了信息:依赖于libb.so;

在main可执行文件中,记录了信息:依赖于liba.so,libb.so;

也可以使用另一个工具patchelf来查看一个可执行程序或则动态库,依赖于其他什么模块。诸如:

图片系统打印_linux系统 图片_图片系统文件错误怎么办

这么,动态库的加载是由谁来完成的呢?动态链接器!

动态库的加载过程动态链接器加载动态库

当执行main程序的时侯,操作系统首先把main加载到显存,之后通过.interp段信息来查看该文件依赖什么动态库:

linux系统 图片_图片系统文件错误怎么办_图片系统打印

上图中的字符串/lib/ld-linux.so.2,就表示main依赖动态链接库。

ld-linux.so.2也是一个动态链接库,在大部份情况下动态链接库早已被加载到显存中了(动态链接库就是为了共享),操作系统此时只须要把动态链接库所在的数学显存,映射到main进程的虚拟地址空间中就可以了,之后再把控制权交给动态链接器。

动态链接器发觉:main依赖liba.so,于是它就在虚拟地址空间中找一块能放得下liba.so的空闲空间,之后把liba.so中须要加载到显存中的代码段、数据段都加载进来。

其实,在加载liba.so时,又会发觉它依赖libb.so,于是又把在虚拟地址空间中找一块能放得下libb.so的空闲空间,把libb.so中的代码段、数据段等加载到显存中linux系统 图片,示意图如下所示:

图片系统文件错误怎么办_图片系统打印_linux系统 图片

动态链接器自身也是一个动态库,并且是一个特殊的动态库:它不依赖于其他的任何动态库,由于当它被加载的时侯,没有人帮它去加载依赖的动态库,否则就产生鸡生蛋、蛋生鸡的问题了。

动态库的加载地址

一个进程在运行时的实际加载地址(或则说虚拟显存区域),可以通过指令:$cat/proc/[进程的pid]/maps读取下来。

比如:我的虚拟机中执行main程序时,看见的地址信息是:

linux系统 图片_图片系统打印_图片系统文件错误怎么办

黑色部份分别是:main,liba.so,libb.so这3个模块的加载信息。

另外,还可以见到c库(libc-2.23.so)、动态链接器(ld-2.23.so)以及动态加载库libdl-2.23.so的虚拟地址区域,布局如下:

图片系统文件错误怎么办_linux系统 图片_图片系统打印

可以看出下来:main可执行程序是坐落低地址,所有的动态库都坐落4G显存空间的最后1G空间中。

还有另外一个指令也挺好用$pmap[进程的pid],也可以复印出每位模块的显存地址:

符号重定位全局符号表

在之前的静态链接小学习过,链接器在扫描每一个目标文件(.o文件)的时侯,会把每位目标文件中的符号提取下来,构成一个全局符号表。

之后在第二遍扫描的时侯,查看每位目标文件中须要重定位的符号,之后在全局符号表中查找该符号被安排在哪些地址,之后把这个地址填写到引用的地方,这就是静态链接时的重定位。

并且动态链接过程中的重定位,与静态链接的处理方法差异就大好多了,由于每位符号的地址只有在运行的时侯才会晓得它们的地址。

比如:liba.so引用了libb.so中的变量和函数,而libb.so中的这两个符号被加载到哪些位置,直至main程序打算执行的时侯,能够被链接器加载到显存中的某个随机的位置。

也就是说:动态链接器晓得每位动态库中的代码段、数据段被加载的显存地址,因而动态链接器也会维护一个全局符号表,其中储存着每一个动态库中导入的符号以及它们的显存地址信息。

在示例代码main.c函数中,我们通过dlopen返回的句柄来复印进程中的一些全局符号的地址信息,输出内容如下:

linux系统 图片_图片系统文件错误怎么办_图片系统打印

上文早已纠错过:原本是想复印变量的地址信息,并且printf句子中不留神加上了机型,弄成了复印变量值。

可以看见:在全局符号表中,没有找到liba.so中的变量a1和函数func_a2这两个符号,由于它俩都是static类型的,在编译成动态库的时侯,没有导入到符号表中。

既然提及了符号表,就来瞧瞧这3个ELF文件中的动态符号表信息:

动态链接库中保护两个符号表:.dynsym(动态符号表:表示模块中符号的导入、导入关系)和.symtab(符号表:表示模块中的所有符号);

.symtab中包含了.dynsym;

因为图片太大,这儿只贴出.dynsym动态符号表。

红色圆形框后面的Ndx列是数字,表示该符号坐落当前文件的哪一个段中(即:段索引);

绿色圆形框后面的Ndx列是UND,表示这个符号没有找到,是一个外部符号(须要重定位);

linux系统 图片_图片系统文件错误怎么办_图片系统打印

linux系统 图片_图片系统打印_图片系统文件错误怎么办

图片系统文件错误怎么办_linux系统 图片_图片系统打印

全局偏斜表GOT

在我们的示例代码中,liba.so是比较特殊的,它既被main可执行程序所依赖,又依赖于libb.so。

但是,在liba.so中,定义了静态、动态的全局变量和函数,可以挺好的概况好多种情况,因而这部份内容就主要来剖析liba.so这个动态库。

前文说过:代码重定位须要更改代码段中的符号引用,而代码段被加载到显存中又没有可写的权限,动态链接解决这个矛盾的方案是:降低一层间接性。

比如:liba.so的代码中引用了libb.so中的变量b,在liba.so的代码段,并不是在引用的地方直接指向libb.so数据段中变量b的地址,而是指向了liba.so自己的数据段中的某个位置,在重定位阶段,链接器再把libb.so中变量b的地址填写到这个位置。

由于liba.so自己的代码段和数据段位置是相对固定的,这样的话,liba.so的代码段被加载到显存以后,就再也不用更改了。

而数据段中这个间接跳转的位置,就叫做:全局偏斜表(GOT:GlobalOffsetTable)。

划重点:

liba.so的代码段中引用了libb.so中的符号b,既然b的地址须要在重定位时才会确定,这么就在数据段中开辟一块空间(亦称:GOT表),重定位时把b的地址填写到GOT表中。

而liba.so的代码段中,把GOT表的地址填写到引用b的地方,由于GOT表在编译阶段是可以确定的,使用的是相对地址。

这样,就可以在不更改liba.so代码段的前提下,动态的对符号b进行了重定位!

虽然,在一个动态库中存在2个GOT表,分别用于重定位变量符号(section名称:.got)和函数符号(section名称:.got.plt)。

也就是说:所有变量类型的符号重定位信息都坐落.got中,所有函数类型的符号重定位信息都坐落.got.plt中。

但是,在一个动态库文件中,有两个特殊的段(.rel.dyn和.rel.plt)来告诉链接器:.got和.got.plt这两个表中,有什么符号须要进行重定位,这个问题下边会深入讨论。

liba.so动态库文件的布局

为了更深刻的理解.got和.got.plt这两个表,有必要来拆解一下liba.so动态库文件的内部结构。

通过readelf-Sliba.so指令来看一下这个ELF文件中都有什么section:

可以看见:一共有28个section,其中的21、22就是两个GOT表。

另外,从装载的角度来看,装载器并不是把这种sections分开来处理,而是按照不同的读写属性,把多个section看做一个segment。

再度通过指令readelf-lliba.so,来查看一下segment信息:

图片系统打印_图片系统文件错误怎么办_linux系统 图片

也就是说:

这28个section中(关注红色腰线):

section0~16都是可读、可执行权限,被当作一个segment;

section17~24都是可读、可写的权限,被动作另一个segment;

再来重点看一下.got和.got.plt这两个section(关注红色圆形框):

可见:.got和.got.plt与数据段一样,都是可读、可写的,所以被当作同一个segment被加载到显存中。

通过以上这2张图(白色方形框),可以得到liba.so动态库文件的内部结构如下:

liba.so动态库的虚拟地址

来继续观察liba.so文件segment信息中的AirtAddr列,它表示的是被加载到虚拟显存中的地址,重新贴图如下:

图片系统打印_图片系统文件错误怎么办_linux系统 图片

由于编译动态库时,使用了代码位置无关参数(-fPIC),这儿的虚拟地址从0x0000_0000开始。

当liba.so的代码段、数据段被加载到显存中时,动态链接器找到一块空闲空间,这个空间的开始地址,就相当于一个基地址。

liba.so中的代码段和数据段中所有的虚拟地址信息,只要加上这个基地址,就得到了实际虚拟地址。

我们还是把上图中的输出信息,画出详尽的显存模型图,如下所示:

linux系统 图片_图片系统打印_图片系统文件错误怎么办

GOT表的内部结构

如今,我们早已晓得了liba.so库的文件布局,也晓得了它的虚拟地址,此时就可以来进一步的看一下.got和.got.plt这两个表的内部结构了。

从昨天的图片中看出:

.got表的宽度是0x1c,说明有7个表项(每位表项占4个字节);

.got.plt表的宽度是0x18,说明有6个表项;

上文早已说过,这两个表是拿来重定位所有的变量和函数等符号的。

这么:liba.so通过哪些方法来告诉动态链接器:须要对.got和.got.plt这两个表中的表项进行地址重定位呢?

在静态链接的时侯,目标文件是通过两个重定位表.rel.text和.rel.data这两个段信息来告诉链接器的。

对于动态链接来说,也是通过两个重定位表来传递须要重定位的符号信息的,只不过名子有些不同:.rel.dyn和.rel.plt。

通过指令readelf-rliba.so来查看重定位信息:

从蓝色和红色的圆形框中可以看出:

liba.so引用了外部符号b,类型是R_386_GLOB_DAT,这个符号的重定位描述信息在.rel.dyn段中;

liba.so引用了外部符号func_b,类型是R_386_JUMP_SLOT,这个符号的重定位描述信息在.rel.plt段中;

从左边绿色的圆形框可以看出:每一个须要重定位的表项所对应的虚拟地址,画成显存模型图就是下边这样:

图片系统打印_linux系统 图片_图片系统文件错误怎么办

暂时只专注表项中的绿色部份:.got表中的b,.got.plt表中的func_b,这两个符号都是libb.so中导入的。

也就是说:

liba.so的代码中在操作变量b的时侯,就到.got表中的0x0000_1fe8这个地址处来获取变量b的真正地址;

liba.so的代码中在调用func_b函数的时侯,就到.got.plt表中的0x0000_200c这个地址处来获取函数的真正地址;

反汇编liba.so代码

下边就来反汇编一下liba.so,看一下指令码中是怎样对这两个表项进行轮询的。

执行反汇编指令:$objdump-dliba.so,这儿只贴出func_a1函数的反汇编代码:

图片系统打印_linux系统 图片_图片系统文件错误怎么办

第一个红色圆形框(call490)的功能是:把下一条指令(add)的地址储存到%ebx中,也就是:

%ebx = 0x622

之后执行:add$0x19de,%ebx,让%ebx加上0xx1919de,结果就是:%ebx=0x2000。

0x2000正是.got.plt表的开始地址!

看一下第2个红色圆形框:

mov-0x18(%ebx),%eax:先用%ebx除以0x18的结果,储存到%eax中linux系统 图片,结果是:%eax=0x1fe8,这个地址正是变量b在.got表中的虚拟地址。

movl$0x1f,(%eax):在把0x1f(十补码就是31),储存到0x1fe8表项中储存的地址所对应的显存单元中(libb.so的数据段中的某个位置)。

因而,当链接器进行重定位以后,0x1fe8表项中储存的就是变量b的真正地址adobe air linux,而前面这两步操作,就把数值31形参给变量b了。

第3个红色圆形框,是调用函数func_b,稍稍复杂一些,跳转到符号func_b@plt的地方,看一下反汇编代码:

图片系统打印_图片系统文件错误怎么办_linux系统 图片

jmp指令调用了%ebx+0xc处的哪个函数表针,从前面的.got.plt布局图中可以看出,重定位以后这个表项中储存的正是func_b函数的地址(libb.so中代码段的某个位置),所以就正确的跳转到该函数中了。

end

一口Linux

Tagged:
Author

这篇优质的内容由TA贡献而来

刘遄

《Linux就该这么学》书籍作者,RHCA认证架构师,教育学(计算机专业硕士)。

发表回复