Linux支持动态连接库,不仅节省了磁盘、内存空间,而且可以提高程序运行效率[1]。不过引入动态连接库也可能会带来很多问题,例如动态连接库的调试[4]、升级更新[5]和潜在的安全威胁[6][7]。这里主要讨论符号的动态链接过程,即程序在执行过程中,对其中包含的一些未确定地址的符号进行重定位的过程[3][8]。 本篇主要参考资料[3]和[8],前者侧重实践,后者侧重原理,把两者结合起来就方便理解程序的动态链接过程了。另外,动态连接库的创建、使用以及调用动态连接库的部分参考了资料[1][2]。 下面先来看看几个基本概念,接着就介绍动态连接库的创建、隐式和显示调用,最后介绍符号的动态链接细节。 1、基本概念 1.1 ELF ELF是Linux支持的一种程序文件格式,本身包含重定位、执行、共享(动态连接库)三种类型。(man elf) 代码: Code: [Ctrl+A Select All] 演示:
$ gcc -c test.c #通过-c生成可重定位文件test.o,这里不会进行链接 $ file test.o test.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped $ gcc -o test test.o #链接后才可以执行 $ file test test: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), not stripped //也可链接成动态连接库,不过一般不会把main函数链接成动态连接库,后面再介绍 $ gcc -fpic -shared -W1,-soname,libtest.so.0 -o libtest.so.0.0 test.o $ file libtest.so.0.0 libtest.so.0.0: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), not stripped
虽然ELF文件本身就支持三种不同的类型,不过它有一个统一的结构。这个结构是: 文件头部(ELF Header) 程序头部表(Program Header Table) 节区1(Section1) 节区2(Section2) 节区3(Section3) ... 节区头部表(Section Header Table) 无论是文件头部、程序头部表、节区头部表,还是节区,它们都对应着C语言里头的一些结构体(elf.h中定义)。文件头部主要描述ELF文件的类型,大小,运行平台,以及和程序头部表和节区头部表相关的信息。节区头部表则用于可重定位文件,以便描述各个节区的信息,这些信息包括节区的名字、类型、大小等。程序头部表则用于描述可执行文件或者动态连接库,以便系统加载和执行它们。而节区主要存放各种特定类型的信息,比如程序的正文区(代码)、数据区(初始化和未初始化的数据)、调试信息、以及用于动态链接的一些节区,比如解释器(.interp)节区将指定程序动态装载/连接器ld-linux.so的位置,而过程链接表(plt)、全局偏移表(got)、重定位表则用于辅助动态链接过程。 1.2 符号 对于可执行文件除了编译器引入的一些符号外,主要就是用户自定义的全局变量,函数等,而对于可重定位文件仅仅包含用户自定义的一些符号。
$ gcc -c test.c #生成可重定位文件test.o //包含全局变量、自定义函数以及动态连接库中的函数,但不包含局部变量;发现这个三个符号的地址都没有确定 $ nm test.o #nm可以用来查看ELF文件的符号表信息 00000000 B global 00000000 T main U printf $ gcc -o test test.o #生成可执行文件 //经过链接后,global和main的地址都已经确定了,但是printf却还没有,因为它是动态连接库glibc中定义函数,需要动态链接,而不是这里的“静态”链接 $ nm test | egrep "main$| printf|global$" 080495a0 B global 08048354 T main U printf@@GLIBC_2.0
1.3 重定位:"是将符号引用与符号定义进行连接的过程"[8] 从上面的演示可以看出,重定位文件test.o中的符号地址都是没有确定的,而经过“静态"链接(gcc默认调用ld进行链接)以后有两个符号地址已经确定了,这样一个确定符号地址的过程实际上就是链接的实质。链接过后,对符号的引用变成了对地址(定义符号时确定该地址)的引用,这样程序运行时就可通过访问内存地址而访问特定的数据。 我们也注意到符号printf在可重定位文件和可执行文件中的地址都没有确定,这意味着该符号是一个外部符号,可能定义在动态连接库中,在程序运行时需要通过动态链接器(ld-linux.so)进行重定位,即动态链接。 通过这个演示可以看出printf确实在glibc中有定义。
$ nm /lib/libc.so.6 | grep "/ printf$" 000457b0 T printf
1.4 动态链接 动态链接就是在程序运行时对符号进行重定位,确定符号对应的内存地址的过程。 Linux下符号的动态链接默认采用Lazy Mode方式[3],也就是说在程序运行过程中用到该符号时才去解析它的地址。这样一种符号解析方式有一个好处:只解析那些用到的符号,而对那些不用的符号则永远不用解析,从而提高程序的执行效率。 不过这种默认是可以通过设置LD_BIND_NOW为非空来打破的(下面会通过实例来分析这个变量的作用),也就是说如果设置了这个变量,动态链接器将在程序加载后和符号被使用之前就对这些符号的地址进行解析。 1.5 动态连接库 上面提到重定位的过程就是对符号引用和符号地址进行链接的过程,而动态链接过程涉及到的符号引用和符号定义分别对应可执行文件和动态连接库,在可执行文件中可能引用了某些动态连接库中定义的符号,这类符号通常是函数。 为了让动态链接器能够进行符号的重定位,必须把动态连接库的相关信息写入到可执行文件当中,这些信息是什么呢?
$ readelf -d test | grep NEEDED 0x00000001 (NEEDED) Shared library: [libc.so.6]
ELF文件有一个特别的节区,.dynamic,它存放了和动态链接相关的很多信息,例如动态链接器通过它找到该文件使用的动态连接库。不过,该信息并未包含动态连接库libc.so.6的绝对路径,那动态链接器去哪里查找相应的库呢? 通过LD_LIBRARY_PATH参数,它类似shell解释器中用于查找可执行文件的PATH环境变量,也是通过冒号分开指定了各个存放库函数的路径。该变量实际上也可以通过/etc/ld.so.conf文件来指定,一行对应一个路径名。为了提高查找和加载动态连接库的效率,系统启动后会通过ldconfig工具创建一个库的缓存/etc/ld.so.cache。如果用户通过/etc/ld.so.conf加入了新的库搜索路径或者是把新库加到某个原有的库目录下,最好是执行一下ldconf以便刷新缓存。 需要补充的是,因为动态连接库本身还可能引用其他的库,那么一个可执行文件的动态符号链接过程可能涉及到多个库,通过read -d可以打印出该文件直接依赖的库,而通过ldd命令则可以打印出所有依赖或者间接依赖的库。
$ ldd test linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/libc.so.6 (0xb7da2000) /lib/ld-linux.so.2 (0xb7efc000)
lib.so.6通过read -d就可以看到的,是直接依赖的库;而linux-gate.so.1在文件系统中并没有对应的库文件,它是一个虚拟的动态连接库,对应进程内存映像的内核部分,更多细节请参考资料[11];而/lib/ld-linux.so.2正好是动态链接器,系统需要用它来进行符号重定位。那ldd是怎么知道/lib/ld-linux.so就是该文件的动态链接器呢? 那是因为ELF文件通过专门的节区指定了动态链接器,这个节区就是.interp。
$ readelf -x .interp test Hex dump of section '.interp': 0x08048114 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so 0x08048124 2e3200 .2.
可以看到这个节区刚好有字符串/lib/ld-linux.so.2,即ld-linux.so的绝对路径。 我们发现,与libc.so不同的是,ld-linux.so的路径是绝对路径,而libc.so仅仅包含了文件名。原因是:程序被执行时,ld-linux.so将最先被装载到内存中,没有其他程序知道去哪里查找ld-linux.so,所以它的路径必须是绝对的;当ld-linux.so被装载以后,由它来去装载可执行文件和相关的共享库,它将根据PATH变量和LD_LIBRARY_PATH变量去磁盘上查找它们,因此可执行文件和共享库都可以不指定绝对路径。 下面着重介绍动态连接器本身。 1.6 动态连接器(dynamic linker/loader) Linux下elf文件的动态链接器是ld-linux.so,即/lib/ld-linux.so.2。从名字来看和静态连接器ld(gcc默认使用的连接器,见参考资料[10])类似。通过man ld-linux可以获取与动态链接器相关的资料,包括各种相关的环境变量和文件都有详细的说明。 对于环境变量,除了上面提到过的LD_LIBRARY_PATH和LD_BIND_NOW变量外,还有其他几个重要参数,比如LD_PRELOAD用于指定预装载一些库,以便替换其他库中的函数,从而做一些安全方面的处理[6][9][12],而环境变量LD_DEBUG可以用来进行动态链接的相关调试。 对于文件,除了上面提到的ld.so.conf和ld.so.cache外,还有一个文件/etc/ld.so.preload用于指定需要预装载的库。 从上一小节中发现有一个专门的节区.interp存放有动态链接器,但是这个节区为什么叫做.interp(interpeter)呢?因为当shell解释器或者其他父进程通过exec启动我们的程序时,系统会先为ld-linux创建内存映像,然后把控制权交给ld-linux,之后ld-linux负责为可执行程序提供运行环境,负责解释程序的运行,因此ld-linux也叫做dynamic loader(或intepreter)(关于程序的加载过程请参考资料[13]) 那么在exec()之后和程序指令运行之前的过程是怎样的呢?ld-linux.so主要为程序本身创建了内存映像(以下内容摘自资料[8]),大体过程如下: 1) 将可执行文件的内存段添加到进程映像中; 2) 把共享目标内存段添加到进程映像中; 3) 为可执行文件和它的共享目标(动态连接库)执行重定位操作; 4) 关闭用来读入可执行文件的文件描述符,如果动态链接程序收到过这样的文件描述符的话; 5) 将控制转交给程序,使得程序好像从exec()直接得到控制 关于第1)步,在ELF文件的文件头中就指定了该文件的入口地址,程序的代码和数据部分会相继map到对应的内存中。而关于可执行文件本身的路径,如果指定了PATH环境变量,ld-linux会到PATH指定的相关目录下查找。
$ readelf -h test | grep Entry Entry point address: 0x80482b0
对于第2)步,上一节提到的.dynamic节区指定了可执行文件依赖的库名,ld-linux(在这里叫做动态装载器或程序解释器比较合适)再从LD_LIBRARY_PATH指定的路径中找到相关的库文件或者直接从/etc/ld.so.cache库缓冲中加载相关库到内存中。(关于进程的内存映像,推荐参考资料[14]) 对于第3)步,在前面已提到,如果设置了LD_BIND_NOW环境变量,这个动作就会在此时发生,否则将会采用lazy mode方式,即当某个符号被使用时才会进行符号的重定位。不过无论在什么时候发生这个动作,重定位的过程大体是一样的(在后面将主要介绍该过程)。 对于第4)步,这个主要是释放文件描述符。 对于第5)步,动态链接器把程序控制权交还给程序。 现在关心的主要是第3步,即如何进行符号的重定位?下面来探求这个过程。期间会逐步讨论到和动态链接密切相关的三个数据结构,它们分别是ELF文件的过程链接表、全局偏移表和重定位表,这三个表都是ELF文件的节区。 1.7 过程链接表(plt) 从上面的演示发现,还有一个printf符号的地址没有确定,它应该在动态连接库libc.so中定义,需要进行动态链接。这里假设采用lazy mode方式,即执行到printf所在位置时才去解析该符号的地址。 假设当前已经执行到了printf所在位置,即call printf,我们通过objdump反编译test程序的正文段看看。
$ objdump -d -s -j .text test | grep printf 804837c: e8 1f ff ff ff call 80482a0 </PRINTF@PLT>
发现,该地址指向了plt(即过程链接表)即地址80482a0处。下面查看该地址处的内容。
$ objdump -D test | grep "80482a0" | grep -v call 080482a0 : 80482a0: ff 25 8c 95 04 08 jmp *0x804958c </PRINTF@PLT>
发现80482a0地址对应的是一条跳转指令,跳转到0x804958c地址指向的地址。到底0x804958c地址本身在什么地方呢?我们能否从.dynamic节区(该节区存放了和动态链接相关的数据)获取相关的信息呢?
$ readelf -d test Dynamic section at offset 0x4ac contains 20 entries: Tag Type Name/Value 0x00000001 (NEEDED) Shared library: [libc.so.6] 0x0000000c (INIT) 0x8048258 0x0000000d (FINI) 0x8048454 0x00000004 (HASH) 0x8048148 0x00000005 (STRTAB) 0x80481c0 0x00000006 (SYMTAB) 0x8048170 0x0000000a (STRSZ) 76 (bytes) 0x0000000b (SYMENT) 16 (bytes) 0x00000015 (DEBUG) 0x0 0x00000003 (PLTGOT) 0x8049578 0x00000002 (PLTRELSZ) 24 (bytes) 0x00000014 (PLTREL) REL 0x00000017 (JMPREL) 0x8048240 0x00000011 (REL) 0x8048238 0x00000012 (RELSZ) 8 (bytes) 0x00000013 (RELENT) 8 (bytes) 0x6ffffffe (VERNEED) 0x8048218 0x6fffffff (VERNEEDNUM) 1 0x6ffffff0 (VERSYM) 0x804820c 0x00000000 (NULL) 0x0
发现0x8049578地址和0x804958c地址比较近,通过资料[8]查到前者正好是.got.plt(即过程链接表)对应的全局偏移表的入口地址。难道0x804958c正好位于.got.plt节区中? 1.8 全局偏移表(got) 现在进入全局偏移表看看,
$ readelf -x .got.plt test Hex dump of section '.got.plt': 0x08049578 ac940408 00000000 00000000 86820408 ................ 0x08049588 96820408 a6820408 ........
从上述结果可以看出0x804958c地址(即0x08049588+4)处存放的是a6820408,考虑到我的实验平台是i386,字节顺序是little-endian的,所以实际数值应该是080482a6,也就是说*(0x804958c)的值是080482a6,这个地址刚好是过程链接表的最后一项call 80482a0中80482a0地址往后偏移6个字节,容易猜到该地址应该就是jmp指令的后一条地址。
$ objdump -d -d -s -j .plt test | grep "080482a0 :" -A 3 080482a0 : 80482a0: ff 25 8c 95 04 08 jmp *0x804958c 80482a6: 68 10 00 00 00 push $0x10 80482ab: e9 c0 ff ff ff jmp 8048270 <_init+0x18> </PRINTF@PLT></PRINTF@PLT>
80482a6地址恰巧是一条push指令,随后是一条jmp指令(暂且不管push指令入栈的内容有什么意义),执行完push指令之后,就会跳转到8048270地址处,下面看看8048270地址处到底有哪些指令。
$ objdump -d -d -s -j .plt test | grep -v "jmp 8048270 <_init+0x18>" | grep "08048270" -A 2 08048270 <__gmon_start__@plt-0x10>: 8048270: ff 35 7c 95 04 08 pushl 0x804957c 8048276: ff 25 80 95 04 08 jmp *0x8049580
同样是一条入栈指令跟着一条跳转指令。不过这两个地址0x804957c和0x8049580是连续的,而且都很熟悉,刚好都在.got.plt表里头(从上面我们已经知道.got.plt的入口是0x08049578)。这样的话,我们得确认这两个地址到底有什么内容。
$ readelf -x .got.plt test Hex dump of section '.got.plt': 0x08049578 ac940408 00000000 00000000 86820408 ................ 0x08049588 96820408 a6820408 ........
不过,遗憾的是通过readelf查看到的这两个地址信息都是0,它们到底是什么呢? 现在只能求助参考资料[8],该资料的“3.8.5 过程链接表”部分在介绍过程链接表和全局偏移表相互合作解析符号的过程中的三步涉及到了这两个地址和前面没有说明的push $0x10指令。 1) 在程序第一次创建内存映像时,动态链接器为全局偏移表的第二(0x804957c)和第三项(0x8049580)设置特殊值。 2) 原步骤5。在跳转到08048270 <__gmon_start__@plt-0x10>,即过程链接表的第一项之前,有一条压入栈指令,即push $0x10,0x10是相对于重定位表起始地址的一个偏移地址,这个偏移地址到底有什么用呢?它应该是提供给动态链接器的什么信息吧?后面再说明。 3) 原步骤6。跳转到过程链接表的第一项之后,压入了全局偏移表中的第二项(即0x804957c处),“为动态链接器提供了识别信息的机会”(具体是什么呢?后面会简单提到,但这个并不是很重要),然后跳转到全局偏移表的第三项(0x8049580,这一项比较重要),把控制权交给动态连接器。 从这三步发现程序运行时地址0x8049580处存放的应该是动态连接器的入口地址,而重定位表0x10位置处和0x804957c处应该为动态连接器提供了解析符号需要的某些信息。 在继续之前先总结一下过程链接表和全局偏移表。上面的操作过程仅仅从“局部”看过了这两个表,但是并没有宏观地看里头的内容。下面将宏观的分析一下, 对于过程链接表:
$ objdump -d -d -s -j .plt test 08048270 <__gmon_start__@plt-0x10>: 8048270: ff 35 7c 95 04 08 pushl 0x804957c 8048276: ff 25 80 95 04 08 jmp *0x8049580 804827c: 00 00 add %al,(
