当前位置: 首页 > 科技观察

Linux动态链接过程中【重定位】的底层原理

时间:2023-03-12 08:51:39 科技观察

大家好,我是道兄,是你们技术修炼路上的敲门砖。在上一篇文章中,我们了解了Linux系统中的GCC编译器在编译可执行程序时如何在静态链接时重定位符号。为了完整起见,让我们在本文中一起探讨:如何在动态链接期间重定位符号。和往常一样,本文大量使用【代码+图片】来真实感受实际的内存模型。本文大量使用图片,建议您在电脑上阅读本文。至于为什么要用动态链接,这里不展开讨论,只说几点:节省物理内存。可以动态更新。动态链接解决什么问题?静态链接得到的可执行程序被操作系统加载后即可执行。因为在链接的时候,链接器已经将所有目标文件中的代码、数据等段组装成了可执行文件。并且代码中使用到的所有外部符号(变量、函数)都被重定位(即在代码段中需要重定位的地方填入变量和函数的地址),所以可执行程序在执行的时候,它可以在不依赖其他外部模块的情况下运行。详细的静态链接过程可以参考之前的文章:【图片+代码】:GCC链接过程中的【Relocation】过程解析。也就是说:符号重定位的过程就是直接修改可执行文件。但对于动态链接,在编译阶段,可执行文件或动态库中只记录一些必要的信息。真正的重定位过程在这个时间点完成:可执行程序和动态库加载完成后,调用可执行程序的入口函数之前。只有解析完所有需要重定位的符号后,程序才能执行。既然也是重定位,那么和静态链接过程一样:同样需要把符号的目标地址填到代码段中需要重定位的地方。矛盾:代码段不可写!我们知道,在现代操作系统中,对内存的访问是由权限控制的。一般来说:代码段:可读可执行。数据段:可读可写。如果进行符号重定位,需要修改代码(填写符号的地址),但是代码段没有写权限,矛盾!解决这个矛盾就是Linux系统中动态链接器的核心工作!解决矛盾:加一层间接DavidWheeler有一句名言:“计算机科学中的大多数问题都可以通过加一层间接来解决”。解决动态链接中的代码重定位问题也可以通过加一层间接来解决。由于代码段加载到内存后不可写,数据段是。对于代码段中引用的外部符号,可以在数据段中加入一个跳板:让代码段先引用数据段中的内容,然后将外部符号的地址填入数据中的相应位置搬迁期间的部分。这个矛盾你解决了吗?!如下图所示:理解了上图的解决方案,你就基本理解了动态链接过程中重定位的核心思想。示例代码我们需要3个源文件来讨论动态链接中的重定位过程:main.c、a.c、b.c,其中a.c和b.c被编译成一个动态库,然后main.c与这两个动态库动态链接成一个可执行程序。它们之间的依赖关系是:b.c代码如下:#includeintb=30;voidfunc_b(void){printf("infunc_b.b=%d\n",b);}代码说明:定义一个全局变量和一个全局函数,供a.c调用。a.c代码如下(稍微复杂一点,主要是探索:不同类型的符号如何处理重定位):#include//内部定义[static]globalvariablestaticinta1=10;//internaldefinition[Non-static]globalvariableinta2=20;//declareexternalvariableexternintb;//declareexternalfunctionexternvoidfunc_b(void);//内部定义的[static]函数staticvoidfunc_a2(void){printf("infunc_a2\n");}//内部定义的[非静态]函数voidfunc_a3(void){printf("infunc_a3\n");}//由main调用“在func_a1\n”);//操作内部变量a1=11;a2=21;//操作外部变量b=31;//调用内部函数func_a2();func_a3();//调用外部函数func_b();}代码说明:1、定义了两个全局变量:一个static,一个non-static;2、定义了三个函数:func_a2是静态函数,只能在本文件中调用。func_a1和func_a3是可以被外部调用的全局函数。3.func_a1将在main.c中被调用。main.c代码如下:#include#include#include//声明外部变量externinta2;externvoidfunc_a1();typedefvoid(*pfunc)(void);intmain(void){printf("inmain\n");//打印这个进程的全局符号表void*handle=dlopen(0,RTLD_NOW);if(NULL==handle){printf("dlopen失败!\n");返回-1;}printf("\n------------主要----------------\n");//打印main中变量symbol的地址pfuncaddr_main=dlsym(handle,"main");if(NULL!=addr_main)printf("addr_main=0x%x\n",(unsignedint)addr_main);elseprintf("获取main地址失败!\n");printf("\n------------liba.so--------------\n");//打印liba.so中变量symbol的地址unsignedint*addr_a1=dlsym(handle,"a1");if(NULL!=addr_a1)printf("addr_a1=0x%x\n",*addr_a1);elseprintf("获取a1地址失败!\n");unsignedint*addr_a2=dlsym(handle,"a2");如果(NULL!=addr_a2)printf(“addr_a2=0x%x\n",*addr_a2);elseprintf("getaddressofa2failed!\n");//打印liba.so中函号码的地址pfuncaddr_func_a1=dlsym(handle,"func_a1");if(NULL!=addr_func_a1)printf("addr_func_a1=0x%x\n",(unsignedint)addr_func_a1);elseprintf("getaddressoffunc_a1failed!\n");pfuncaddr_func_a2=dlsym(handle,"func_a2");if(NULL!=addr_func_a2)printf("addr_func_a2=0x%x\n",(unsignedint)addr_func_a2);elseprintf("getaddressoffunc_a2failed!\n");pfuncaddr_func_a3=dlsym(handle,"func_a3");if(NULL!=addr_func_a3)printf("addr_func_a3=0x%x\n",(unsignedint)addr_func_a3);elseprintf("获取func_a3地址失败!\n");printf("\n----------libb.so------------\n");//打印libb.so中变量号的地址unsignedint*addr_b=dlsym(handle,"b");if(NULL!=addr_b)printf("addr_b=0x%x\n",*addr_b);elseprintf("获取b地址笑死了!\n");//打印libb.so中函数符号的地址pfuncaddr_func_b=dlsym(handle,"func_b");if(NULL!=addr_func_b)printf("addr_func_b=0x%x\n",(unsignedint)addr_func_b);elseprintf("getaddressoffunc_bfailed!\n");dlclose(handle);//操作外部变量a2=100;//调用外部函数func_a1();退出查看地址信息在虚拟空间中while(1)sleep(5);return0;}更正:代码本来想打印变量的地址,不小心加了*来打印变量值最后检查的时候才发现,所以懒得修改了代码说明:这个过程中使用dlopen函数(第一个参数传入NULL)打印一些符号信息(变量和函数),赋值给so中的liba.变量a2,然后调用liba.so中的func_a1函数,编译成动态链接库,将上述源文件编译成动态库和可执行程序上午:$gcc-m32-fPIC--sharedb.c-olibb。so$gcc-m32-fPIC--shareda.c-oliba.so-lb-L./$gcc-m32-fPICmain.c-omain-ldl-la-lb-L./有几个要点说明:-fPIC参数的意思是:GeneratePositionIndependentCode(位置无关代码),这也是动态链接的关键。既然动态库是在运行时加载的,为什么还要在编译时指定呢?因为在编译时,你需要知道每个动态库中提供了哪些符号。Windows中动态库的显式导出和导入标识符更能体现这个概念(__declspec(dllexport),__declspec(dllimport))。此时得到如下文件:对于静态链接的可执行程序,动态库的依赖可以认为是直接从可执行程序的入口函数(即ELF文件头中)加载后开始的操作系统。指定e_entry的地址),执行其中的指令代码。但是对于一个动态链接程序来说,在入口函数的指令执行之前,必须先将程序所依赖的动态库加载到内存中,然后才能开始执行。对于我们的示例代码:主程序依赖liba.so库,liba.so库依赖libb.so库。可以使用ldd工具查看动态库之间的依赖关系:可以看到:在liba.so动态库中,记录了信息:它依赖于libb.so。在主要的可执行文件中,记录了信息:依赖于liba.so、libb.so。您还可以使用另一个工具patchelf来查看可执行程序或动态库依赖于哪些其他模块。例如:那么,谁负责加载动态库呢?动态链接器!动态链接器在动态库的加载过程中加载动态库。执行主程序时,操作系统首先将主程序加载到内存中,然后将.Interp段信息查看文件依赖了哪些动态库:上图中字符串/lib/ld-linux.so.2表示main依赖动态链接库。ld-linux.so.2也是一个动态链接库。大多数情况下,动态链接库已经加载到内存中(动态链接库是为了共享)。这时,操作系统只需要将物理内存映射到主进程的虚拟地址空间,然后将控制权交给动态链接器即可。动态链接器发现main依赖于liba.so,于是在虚拟地址空间中找到一块空闲空间可以容纳liba.so,然后加载liba.so中需要加载到内存中的代码段和数据段.加载进去。当然加载liba.so的时候会发现它依赖于libb.so,所以在虚拟地址空间中找一块空闲的空间可以放libb.so,把代码段和数据段放上去在libb.so等待加载到内存中,示意图如下:动态链接器本身也是一个动态库,而且是一个特殊的动态库:它不依赖任何其他动态库,因为当它被loaded,没有人帮它加载依赖的动态库,否则会造成先有鸡还是先有蛋的问题。动态库的加载地址一个进程在运行时的实际加载地址(或虚拟内存区域)可以通过命令读取:$cat/proc/[进程的pid]/maps。例如:当主程序在我的虚拟机中执行时,我看到的地址信息是:黄色部分是main、liba.so、libb.so这三个模块的加载信息。此外,还可以看到c库(libc-2.23.so)、动态链接器(ld-2.23.so)和动态加载库libdl-2.23.so的虚拟地址区。布局如下:可以看出:maincan执行程序位于低地址,所有动态库位于4G内存空间的最后1G空间。还有一条指令也可以和$pmap[进程的pid]配合使用,同样可以打印出各个模块的内存地址:symbolrelocation全局符号表是在前面的静态链接中学习的,链接器扫描每个target文件(.o文件),每个目标文件中的符号将被提取出来,形成一个全局符号表。然后在第二次扫描的时候,在每个目标文件中查看需要重定位的符号,然后在全局符号表中查找该符号排列的地址,然后将这个地址填入引用的地方,这个就是静态链接时间重定位。但是,动态链接时的重定位与静态链接有很大不同,因为每个符号的地址只有在运行时才能知道。例如:liba.so是指libb.so中的变量和函数,libb.so中这两个符号被加载到哪里,直到主程序准备执行时,才可以加载到内存中的某个地方链接器的一个随机位置。也就是说:动态链接器知道每个动态库中的代码段和数据段加载的内存地址,所以动态链接器还会维护一个全局符号表,存储每个动态库中导出的符号及其内存地址信息。在示例代码的main.c函数中,我们使用dlopen返回的句柄来打印进程中一些全局符号的地址信息。输出如下:上面已经更正:本来想打印变量的地址信息,但是printf语句不小心加了model打印变量值。可以看到,在全局符号表中,并没有找到liba.so中的变量a1和函数func_a2这两个符号,因为它们都是static类型的,在导出的时候并没有导出到符号表中编译成动态库。.说到符号表,我们再来看看这三个ELF文件中的动态符号表信息:1、动态链接库中保护了两个符号表:.dynsym(动态符号表:表示导出导入关系模块中的符号)和.symtab(符号表:表示模块中的所有符号)。.dynsym包含在.symtab中。2、由于图片太大,这里只贴出.dynsym动态符号表。绿色矩形前面的Ndx列是一个数字,表示该符号位于当前文件的哪个段(即:段索引)。红色矩形前的Ndx列为UND,表示未找到该符号,为外部符号(需要重定位)。全局偏移表GOT在我们的示例代码中,liba.so比较特殊,它同时依赖于主执行程序和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表),在重定位时,填入GOT表中b的地址。在liba.so的代码段中,在引用b的地方填入了GOT表的地址,因为GOT表在编译阶段就可以确定,使用的是相对地址。这样就可以在不修改liba.so代码段的情况下动态重定位符号b了!实际上,动态库中有两张GOT表,分别用于重定位变量符号(节名:.got)和函数符号(节名:.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表。另外,从加载的角度来看,加载器并不是对这些section分别进行处理,而是根据读写属性的不同,将多个section看成一个segment。再次使用命令readelf-lliba.so查看段信息:也就是说:28个段中(重点关注绿线):0~16段都是可读可执行权限,视为一个段.第17~24段都是可读可写权限,算是另外一个段。我们重点关注.got和.got.plt这两个section(关注黄色矩形):可见:.got和.got.plt和datasection一样,都是可读可写的,所以认为是一样的段被加载到内存中。通过上面两张图(红色矩形框)可以得到liba.so动态库文件的内部结构如下:liba.so动态库的虚拟地址继续观察段信息中的AirtAddr列liba.so文件,表示是加载到虚拟内存的地址,重新映射如下:因为编译动态库时使用了代码位置独立参数(-fPIC),所以这里的虚拟地址从0x0000_0000开始.当liba.so的代码段和数据段加载到内存中时,动态链接器找到一块空闲空间,这块空间的起始地址就相当于一个基地址。liba.so中代码段和数据段的所有虚拟地址信息,只要加上这个基地址,就可以得到实际的虚拟地址。我们还是利用上图中的输出信息来画出详细的内存模型图,如下图: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。动态部分。liba.so引用了外部符号func_b,类型为R_386_JUMP_SLOT,该符号的重定位描述信息在.rel.plt段。从左边的红色矩形框可以看出,每个需要重定位的表项对应的虚拟地址绘制成内存模型图如下:暂时只关注表项红色部分:b在.got表中,func_b在.got.plt表中,这两个符号都是从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函数的反汇编代码:第一个绿色矩形(call490<__x86.get_pc_thunk.bx>)的函数是:把下一条指令(add)存入%ebx,即:%ebx=0x622然后执行:add$0x19de,%ebx,让%ebxadd0x19de,结果为:%ebx=0x2000。0x2000正是.got.plt表的起始地址!再看第二个绿色矩形:mov-0x18(%ebx),%eax:先将%ebx减去0x18的结果存入%eax,结果为:%eax=0x1fe8,这个地址就是.got表中变量b的虚拟地址。movl$0x1f,(%eax):将0x1f(十进制为31)存入0x1fe8表项(libb.so数据段中的某个位置)所存地址对应的内存单元中。因此,链接器进行重定位后,变量b的真实地址被存储在0x1fe8表项中,以上两步将值31赋值给变量b。第三个绿色矩形框是调用函数func_b,稍微复杂一点。跳转到符号func_b@plt,看反汇编代码:jmp指令调用了上面%ebx+0xc处的函数指针。从got.plt布局图中可以看出,重定位后,func_b函数的地址存放在这个入口(libb.so中代码段的某个位置),所以正确跳转到了这个函数。