深入理解Android:Java虚拟机ART
上QQ阅读APP看书,第一时间看更新

4.2.4 实例分析:调用动态库中的函数

本案例很简单,就是可执行程序如何调用一个动态库所提供的函数。在分析这个案例前,先来了解下面一个问题和它的解决办法。

源码编译成ELF文件后,代码就被翻译成了机器指令。而函数调用对应的指令就是指示CPU先跳到该函数所在的内存地址,然后执行后面的指令。所以,对于函数调用而言,最关键之处莫过于确定该函数的入口在内存中的地址了。那么,如何确定这个函数的地址 此处的地址指该函数入口在内存中的虚拟地址。呢?

一种很直观的方法是编译时确定。如果编译时就能计算出某个函数的地址,这个问题就非常简单了。比如main函数调用test函数,如果test函数在编译时得到其入口地址为0x00009000(虚拟地址)的话。那么,对应的调用指令可能就是"call 0x00009000"。但现实中这种做法很不实用,原因有很多,比如:

·如果一个程序使用多个动态库,编译器很难为所有函数都确定一个绝对地址。

·出于安全考虑,操作系统加载动态库到内存的时候并不会使用固定的位置,而是会基于一个随机数来计算最终的加载位置。如此,0x00009000这个地址不太可能是test函数在内存后的真正地址。并且,test的真实地址每次随着动态库加载都可能不一样。

那么该如何解决此问题呢?不用担心,ELF规范早就设计好了。解决方法也不算复杂,只不过需要借助一些辅助手段。先来认识第一个辅助手段,GOT。

4.2.4.1 Global Offset Table

GOT是Global Offset Table的缩写,它是一个表,其格式非常简单,如图4-22所示。

图4-22 Global Offset Table

图4-22所示为Global Offset Table(简写为GOT),其中:

·GOT对应的section名为.got,每一项存储的是该ELF文件用到的符号(函数或变量)的地址。

·GOT第2项和第3项内容由interpreter程序设置,即GOT[1]由runtime linker设置,GOT[2]为runtime linker对应的处理函数用于处理符号的解析,一般称之为Resolver。

·GOT其余表项存储符号(函数或变量)的地址。特别注意:其余表项中的值将由Resolver动态填写。即,当调用者第一次访问这些符号的时候,将触发Interpreter的Resolver函数被调用,该函数将计算符号的最终地址,然后填写到GOT对应项中。而符号地址的计算方法依赖于ELF文件中重定位和符号表中的一些信息。

由上述内容可知,GOT是一个表,存储的是符号的绝对地址。但绝对地址不可能由编译器决定,那么这些符号地址是怎么计算而来的呢?

·首先,GOT[1]和GOT[2]这两项内容存储的是解释器的信息和符号解析处理函数的入口地址。何为解释器?请读者回顾图4-18所述的Program Header中索引号为1的元素,其类型为PT_INTERP,值为/linux64/ld-linux-x86-64.so.2。这个东西其实就是链接器ld。当可执行程序使用动态库的时候,编译器会将ld的信息放到ELF对应位置上。

·当可执行程序被操作系统加载和准备执行之时,操作系统发现该程序有PT_INTERP类型的segment,就会先跳转到ld的entry point执行(注意,ld也是一个ELF文件)。也就是说,对于使用动态库的可执行程序,操作系统首先执行的是ld,而不是可执行程序本身。当然,操作系统还会把和待执行的目标程序信息一起告诉ld。

·ld在整个过程中起什么作用呢?很简单,就是加载那些执行时需要的动态库(是根据可执行程序ELF文件里的.dynsym表等信息来处理),设置好GOT等对应项。最后,系统的控制权将交还给可执行程序。这时,我们的程序才真正运行起来。

4.2.4.2 符号地址什么时候计算?

回到本案例中来,此时我们知道GOT表会存储符号的绝对地址。并且,这个绝对地址的计算将由ld来完成。那么,是否call指令写成"call*(symbol_index@got)"(symbol_index@got表示目标符号在GOT中的索引,*号表示取该索引对应元素的值)就解决问题了呢?

确实,如果GOT表中已经有符号的最终地址,那么该问题就解决了。但是我们无法绕开最基础的一个问题,那就是该符号的地址是在什么时候计算出来?一般而言,符号地址计算有两个时机:

·ld将控制权交给可执行程序之前。此时,ld已经加载了依赖的动态库,而且也知道这些动态库加载到内存的虚拟地址,这样就可以计算出所有需要的符号的地址。如果要使用这种方法的话,需要设置环境变量LD_BIND_NOW(export LD_BIND_NOW=1)。对于运行中调用dlopen来加载so文件的程序而言,就需设置dlopen的flag参数为RTLOAD_NOW。这种做法的一个主要缺点在于它使得那些大量依赖动态库的程序的加载时间变长 笔者以前在windows上做影视行业非线编程序(类似会声会影这样的程序)时,一个应用程序依赖几十甚至上百个dll文件,启动速度花1~2分钟都很常见。

·用的时候再计算。相比上面一种方式而言,这种方式可以让ld尽快把控制权交给可执行程序本身,从而提升程序启动速度。如果要使用这种方式,需要设置环境变量LD_BIND_NOT(ld默认采用这种策略)。而对于dlopen来说,设置flag为RTLOAD_LAZY即可。

程序本身是不知道ld到底会使用哪种方法的,所以编译器生成的二进制文件必须同时支持这两种方法。这是如何做到的呢?现在请出第二个辅助手段,PLT。

4.2.4.3 Procedure Linkage Table

Procedure Linkage Table简称PLT,也是一种表结构,不过其表项存储的是一段小小的代码,这段代码能帮助我们触发符号地址的计算以及跳转到正确的符号地址上。类似这种具有辅助功能的代码,在软件界有一个很形象的比喻,叫Trampoline Code。Trampoline一词的含义是杂技表演项目中的“蹦床”。表演者要跳到目标位置去,由于距离太远,所以得先跳到蹦床上,然后再借助蹦床的力量跳到目标位置,这就是蹦床的作用。

图4-23 Procedure Linkage Table的内容

马上来看ELF中的PLT,如图4-23所示。

图4-23中左边是PLT表,每一个表项都指向一段代码,这些代码就是所谓的Trampoline Code,其中:

·PLT[0]存储的是跳转到GOT表Resolver的指令。"pushl got_plus_4"是GOT[1]的元素压栈,"jmp*got_plus_8"则是跳转到GOT[2]所存储的地址上去执行,也就是前面说的Resolver。

·再看PLT[1],*name1_in_GOT表示符号name1在GOT对应项的内容,我们以GOT[name1]表示。如果该符号的地址还没有计算的话,GOT[name1]存储的是其后一条指令(此处是"pushl$offset")的地址。"pushl$offset"的目的是将计算这个符号地址所需的参数压栈(offset的含义我们后面再介绍)。然后,Trampoline Code接着执行到"jmp.PLT0@PC",即跳转到PLT[0]的代码处执行(其结果就是跳到Resolver里了)。

·如果这个符号地址已经计算过,则GOT[name1]存储的就是正确的地址。"jmp*name1_in_GOT"也就直接跳转到目标地址上了。

我们马上通过一个实例来验证上述内容。

4.2.4.4 实例分析

笔者将通过一个示例来验证上述内容。在此,笔者将以x86_64平台为例,介绍32位程序的处理情况。图4-24为示例的源码。

图4-24 示例代码

图4-24所示代码包含以下内容。

·一个动态库文件:libtest.so,输出test和test2两个函数。其中,test内部将调用test2。

·一个可执行文件:main。它依赖于libtest.so,运行时它将调用libtest.so中的test函数。

·Makefile文件:为了在x86_64位平台上编译32位的ELF文件,这里使用了"-m32"参数。"-fPIC"参数中,PIC为Position Indepent Code(位置无关代码)的缩写,详情见下文介绍。

为什么选择x86_64平台

其实没有特殊原因,只是因为x86_64平台(笔者用的是Ubuntu 14.0464位系统)上的工具使用起来相对方便,读者要亲自实践也很容易。另外,从原理上说,其他平台的处理与之类似。这就是所谓的举一反三吧。

另外,为什么要编译32位ELF文件呢?因为x8664位系统有另外一套解决办法,原理差不多。读者阅读完本节后可深入研读参考资料[4]

先来反编译main.out,结果如图4-25所示。

图4-25 main程序反编译结果

图4-25中只显示了main函数和.plt两项的内容。

·在main函数中,调用test的地方对应的指令为"call 0x8048430"。由上文可知,这个地址应该是test符号在PLT表中对应的trampoline code。

·接着查看.plt反编译结果。0x08048430地址处包含三条指令。最后一条的jmp 0x8048400应该是跳转到PLT[0]处。

·果然,0x8048400地址处为PLT[0]。其第一条指令pushl将GOT[1](地址是0x804a004)压栈,第二条指令jmp应该是跳转到GOT[2]对应的地址上。

现在,我们需要确认下面几点:

·GOT[1]的位置是0x804a004,那么GOT[0]的位置就应该是0x804a000。

·PLT[test]第一条jmp指令和随后的push指令的参数分别是什么意思?

马上来看图4-26。

图4-26 一些值的解释

结合图4-25和图4-26可知:

·GOT表位于0x0804a000处,由图4-26上方小图所示的_GLOBAL_OFFSET_TABLE_表示。所以,图4-25中PLT[0]前两条指令的参数确实对应为GOT[1](位置为0x0804a000+4)和GOT[2](未知为0x0804a000+8)。

·在test的trampoline code中,第一条指令"jmp 0x0804a014"的目标地址0x0804a014是test在GOT表对应的项。不过请读者务必小心,此处的GOT的名称是.got.plt。ELF将GOT表分为.got和.got.plt两个表,其内容没什么区别,存储的都是符号的地址,只不过是.got.plt专门存储函数符号的地址罢了。

·"readelf-x.got.plt main"命令查看.got.plt的值。在0x0804a014地址中存储的值是0x08048436。

接着来看图4-27。

由图4-27可知:

·(*0x804a014)最开始存储的内容就是"push 0x10"这条指令的地址(0x08048436)。回顾上文,因为第一次调用test的时候,我们需要触发Resolver计算test的绝对地址。

·push$0x10将0x10压栈。0x10刚好是.rel.plt section中对应的偏移量。因为Resolver需要知道自己该计算哪个符号的地址。

·当Resolver计算出test函数符号的地址后,它会把它回写到地址0x804a014处。如此,下一次再jmp到这个地址时,将直接跳转到目标函数test。

图4-27 几个关键值的解释

以上是我们反编译ELF文件得到的信息,GOT和PLT相关表项的信息都已经准备好。现在我们将实际执行main程序,来看看GOT[test]到底有没有被修改。

提示 符号地址的计算依赖于重定位信息。笔者简单介绍了几个关键值的来历。说实话,这已经算是符号重定位计算中最简单的一种类型了,还有比这复杂得多的重定位类型。不过,除非有特殊需要,笔者建议读者无须死抠这些细节,只要知道它们是用来计算最终的符号地址就可以了。

笔者使用gdb来调试main程序,重点观察内存地址0x804a014处内容的变化,操作过程自上而下,如图4-28所示。

图4-28 gdb调试main

在图4-28中:

·首先在test和main处加上断点,这是通过"b test"和"b main"这两条命令来完成的。

·通过"r"命令启动整个程序,调试器将在main的断点处停止。

·利用"x 0x0804a014"查看该地址内存存储的内容,得到0x08048436。

·输入"c"命令继续执行,将进入test断点,此时test函数已经被调用了,所以我们相信ld的Resolver已经将GOT[test]设置好了。果然,再次查看0x0804a014地址的内容将得到0xf7fd64eb,而这恰好是test函数的地址(通过"p &test"命令得到)。

gdb调试的结果证实了前面所述内容。

心得体会

说实话,这一大段章节能顺利读下来是一个非常有挑战性的工作。笔者自己每次读规范这部分内容,也觉得很困难,总感觉没有全盘掌握它们。那么,它到底难在什么地方呢?笔者个人觉得主要还是难在理解那些参数的含义上。比如push$0x10,这个0x10是什么意思?R386_JUMP_SLOT又是什么含义?还有那些重定位表,以及如何计算符号位置等。

不过,知识的学习和掌握是一个循序渐进的过程,从这个角度考虑,读者完全不用在刚学习的时候就给自己施加一定要一次性读懂的压力。

回到本节内容,笔者认为读者在这一阶段掌握了GOT、PLT和Trampoline Code的原理就可以了。那些参数、类型、重定位表只不过是用于计算符号地址的。而GOT和PLT才是整个流程得以顺利进行的关键。

4.2.4.5 Position Independent Code

回顾图4-24所示的案例,笔者其实设计了两处调用。

·main调用libtest.so的test函数。在这种情况下,PLT[test]对应项的第一条指令是"jmp*0x804a014"。注意,这个值对应main elf文件GOT表的某一项。而main的GOT表加载到内存的位置就是0x804a000。

·libtest.so中的test调用test2函数。在这种情况下,PLT[test2]第一条指令能否和上面的情况一样,为"jmp*0xabcdefg"这样的固定值吗?显然不行,因为libtest.so连自己被加载到什么位置都无法知道。这个问题该如何解决?

在解决这个问题前,我们要先明确下面几点:

·每一个ELF可执行程序或动态库文件都有自己的GOT和PLT。符号处理和计算都是基于各自的GOT和PLT。

·ELF可执行文件加载到内存后,其各Segment的位置基本都是按照ELF文件里定义的值来处理的。所以,对于main而言,它的GOT地址就是编译时决定的,所以可以使用绝对地址。

·对libtest.so而言,它的GOT地址会随着so文件本身加载位置不同而不同。所以它的PLT表项内容应该是"jmp*(目标符号索引@自己的got地址)"。在这条指令中,只有index是固定的,而GOT地址是变化的。这就是所谓的位置无关代码,即Position Independent Code(简写为PIC)。根据ELF规范,x86平台上,GOT地址需要保存到ebx寄存器里。

ELF规范对第一种和第二种情况都有明确说明,来看图4-29。

图4-29 Absolute PLT和PIC PLT在trampline code上的区别

图4-29为Absolute PLT和PIC PLT在trampline code上的区别。Trampoline的功能完全一样,差别就在于GOT地址,Absolute PLT是编译时就决定的绝对地址,而PIC PLT则是运行时计算得到并保存在ebx寄存器中。

下面,我们反编译libtest.so的test函数来确认上面所述是否属实。

图4-30 libtest.so test2函数对应的PLT

在图4-30中,0x18只是相对于GOT的偏移量,而GOT的真正地址则保存在ebx寄存器里。通过这种方式,我们就实现了PIC。即不管libtest.so加载到什么位置,偏移量都是固定的,而GOT的值则是运行过程中计算出来的并保存到ebx寄存器里。

提示 动态库加载到内存空间后,其GOT地址该如何得到呢?请读者阅读参考资料[4]