赞
踩
静态链接通过将整个库都编译到可执行文件的方式来生成可执行文件,而动态链接则利用共享库来实现可执行文件对共享库中函数的调用,在执行时将共享库加载并绑定到该进程的地址空间中。
由于要探究的是共享库,所以我们需要实现一个共享库文件:
首先是头文件:
add.h:
#ifndef ADD_H
#define ADD_H
int add(int a,int b);
#endif
然后是实现文件:
add.c:
#include "add.h"
int add(int a,int b)
{
return a+b;
}
最后编译一波生成.so文件:
gcc -shared -fPIC add.c -o libadd.so
这里shared参数说明要生成一个共享库。
PIC意为position independent code,意思是说生成的代码中没有绝对地址,全部是相对地址,这也是为了共享库的通用性而加的。
这样就生成了一个只有函数的共享库。
可以用readelf -h libadd.so查看一下ELF头:
ELF 头:
.......
类型: DYN (共享目标文件)
可以看到类型是共享目标文件,使用readelf -l 查看它的段的话也会发现没有INTERP段,因为它不需要程序解释器。
接下来编写一个简单的a+b程序调用一下这个共享库,为了防止其他共享库造成影响,这里并没有IO过程:
test.c:
#include "add.h"
int main(){
int a=1,b=2;
int c=add(a,b);
return 0;
}
然后需要进行编译,这里需要干两件事,第一件是强制程序到当前的目录去找库文件,第二件事就是编译,命令如下:
export LD_LIBRARY_PATH=.
gcc test.c -L. -l add
然后当前目录就可以生成一个a.out。
当一个共享库被加载到一个进程的地址空间中时,动态链接器会修改可执行文件中的全局偏移表GOT,从而达到让可执行文件访问库函数的目的,而由于GOT会被修改,所以它位于数据段中,也就是.got.plt节,如下所示:
0000000000400420 <.plt.got>:
400420: ff 25 d2 0b 20 00 jmpq *0x200bd2(%rip) # 600ff8 <_DYNAMIC+0x1d0>
400426: 66 90 xchg %ax,%ax
这个0x600ff8也处于数据段中。
这一节读完之后也不是很懂这个辅助向量是干嘛的,需要读完PLT/GOT这一节,就能够理解这个辅助向量的用处了。
通过系统调用sys_execve()将程序加载到内存中时,对应的可执行文件会被映射到内存的地址空间,并为该进程的地址空间分配一个栈。这个栈会用特定的方式向动态链接器传递信息。这种特定的对信息的设置和安排即为辅助向量。栈底存放了如下信息:
Auxilary |
---|
environ |
argv |
Stack |
辅助向量的项目满足如下结构:
typedef struct{
uint64_t a_type;
union{
uint64_t a_val;
} a_un;
}Elf64_auxv_t;
a_type指定了辅助向量的条目类型,a_val为辅助向量的值。
辅助向量是由内核函数create_elf_tables()设定的,该内核函数在Linux的源码/usr/src/linux/fs/binfmt_elf.c中
- sys_execve()
- 调用do_execve_common()
- 调用search_binary_handler()
- 调用load_elf_binary()
- 调用create_elf_tables()
程序被加载进内存,辅助向量被填充好以后,控制权就交给了动态链接器,它会解析要链接到进程地址空间的用于共享库的符号和重定位。
使用ldd命令可以查看一个可执行文件所依赖的共享库列表。
$ ldd a.out
linux-vdso.so.1 => (0x00007ffdb7354000)
libadd.so => ./libadd.so (0x00007f78f0f4a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f78f0b80000)
/lib64/ld-linux-x86-64.so.2 (0x00007f78f114c000)
当一个程序调用共享库中的函数时,需要到程序运行时才能解析这些函数调用。这里我实验的时候和书上的例子不太一样。。大概是因为书上的是32位而我的是64位,不过表达的意思都差不多。
来看我们之前准备好的例子:
使用objdump -d a.out,看main函数中的内容:
4006a2: 89 d6 mov %edx,%esi
4006a4: 89 c7 mov %eax,%edi
4006a6: e8 b5 fe ff ff callq 400560 <add@plt>
可以看到这里调用了地址为0x400560的一个函数add,也就是我们库中实现的函数,然后来看0x400560对应的内容:
0000000000400560 <add@plt>:
400560: ff 25 b2 0a 20 00 jmpq *0x200ab2(%rip) # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
400566: 68 00 00 00 00 pushq $0x0
40056b: e9 e0 ff ff ff jmpq 400550 <_init+0x28>
可以看到这里有一个跳转到0x601018中的地址的指令,这个地址就是GOT条目,存储着共享库中函数add的实际地址。
`动态链接器使用默认的延迟链接方式时,不会在函数第一次调用时就对地址进行解析。延迟链接意味着动态链接器不会在程序加载时解析每一个函数,而是在调用时通过.plt和.got.plt节来对函数进行解析。可以通过修改LD_BIND_NOW环境变量将链接方式修改为严格加载,以便在程序加载的同时进行动态链接。但是延迟链接能够提高性能。
这里先看一下add函数的重定位条目:
$ readelf -r a.out
......
000000601018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 add + 0
......
可以看到,重定位的偏移地址为0x601018,跟add函数PLT的跳转地址相同。动态链接器需要对add的地址进行解析,并把值存入add的GOT条目中,看一下测试程序的GOT==(这个0x18应该对应的就是0x601018,也就是说这个GOT应该为0x601000)==:
_GLOBAL_OFFSET_TABLE_+0x18>
400566: 68 00 00 00 00 pushq $0x0
40056b: e9 e0 ff ff ff jmpq 400550 <_init+0x28>
这个0x0实际上是第4个GOT条目,即GOT[3],共享库中的地址并不是从GOT[0]开始的,而是从GOT[3]开始的,前三个条目有其他的作用:
这里如果把_GLOBAL_OFFSET_TABLE+0x18当做这个0x0(GOT[3])会好理解很多
它的最后一条指令是jmpq 0x400550,那么我们来看一下这个地址的指令:
0000000000400550 <add@plt-0x10>:
400550: ff 35 b2 0a 20 00 pushq 0x200ab2(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
400556: ff 25 b4 0a 20 00 jmpq *0x200ab4(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
40055c: 0f 1f 40 00 nopl 0x0(%rax)
由于64位系统一个单位为8个字节,则0x10/8=2,第一条指令将GOT[1]的地址压入栈中,jmpq 0x601010则跳转到第3个GOT条目,即GOT[2],在GOT[2]中存放了动态链接器函数的地址,对函数add进行解析后,后续所有对PLT条目add的调用都会跳转到add的代码本身,而不是重新指向PLT。
这个GOT[1]的地址相当于_GLOBAL_OFFSET_TABLE+0x08,也就是GOT[3-2+1]=GOT[1],GOT[2]则为_GLOBAL_OFFSET_TABLE+0x10,也就是GOT[3-1+1]=GOT[2],其为程序解释器的地址。这里压入的栈我觉得可以联系到前面提到的辅助向量。
动态链接器需要在程序运行时引用段,动态段需要相关的程序头。
动态段保存了一个如下结构体组成的数组:
这里有必要对下列的结构体成员类型进行一下解释,以下信息来自ELF手册:
ElfN_Addr Unsigned program address, uintN_t
ElfN_Off Unsigned file offset, uintN_t
ElfN_Section Unsigned section index, uint16_t
ElfN_Versym Unsigned version symbol information, uint16_t
Elf_Byte unsigned char
ElfN_Half uint16_t
ElfN_Sword int32_t
ElfN_Word uint32_t
ElfN_Sxword int64_t
ElfN_Xword uint64_t
数组如下:
typedef struct {
Elf64_Sxword d_tag;
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} Elf64_Dyn;
d_tag字段保存了类型的定义参数
也就是说,可以通过对这一段的解读,找到.dynsym等节,这样不用节头只用程序头也可以找出这些节。于是在缺少节头表的情况下也可以通过这一段重建部分节头表。
d_val成员保存了一个整型值,可以存放各种不同的数据,如条目大小。
p_ptr保存了一个内存虚址,可以指向链接器需要的各种类型的地址,如d_tag DT_SYMTAB符号表的地址。
p_val与p_ptr位于一个联合体,也就是说这个结构体的第二个成员有可能是一个地址也可能是一个数值。
动态链接器利用d_tag来定位动态段的不同部分,每一部分都通过d_tag保存了指向某部分可执行文件的引用,对应的d_prt给出了指向该符号表的虚址。
动态链接器映射到内存中时,首先会处理自身的重定位,因为链接器本身就是一个共享库。接着会查看可执行程序的动态段并查找DT_NEEDED参数,该参数保存了指向所需要的共享库的字符串或者路径名。当一个共享库被映射到内存之后,链接器会获取到共享库的动态段,并将共享库的符号表添加到符号链中,符号链存储了所有映射到内存中的共享库的符号表。
链接器为每个共享库生成一个link_map结构的条目,并将其存到一个链表中:
struct link_map{
ElfW(Addr) l_addr; //共享对象的基址
char *l_name; //对象的绝对文件名
ElfW(Dyn) *l_ld; //共享对象的动态节
struct link_map *l_next,*l_prev;
}
这个link_map应该就是GOT[1]中存储的内容
链接器构建完依赖列表之后,会挨个处理每个库的重定位,同时会补充每个共享库的GOT。延迟链接对共享库的PLT/GOT仍然适用,因此,只有当一个函数真正被调用时,才会进行GOT重定位。
为了理解这一段,我进行了一些小实验。
这里假设链接器已经完成了自身的重定位,首先我们关注可执行文件:
我们可以用readelf -d命令查看动态段的项:
$readelf -d a.out
Dynamic section at offset 0xe18 contains 25 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[libadd.so]
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
...
0x0000000000000003 (PLTGOT) 0x601000
...
0x0000000000000000 (NULL) 0x0
这里我们看到了PLTGOT偏移表的地址为0x601000,也与我们之前的猜想相同,我们同样可以看到第一条就写出了共享库libadd.so,那么这个值是如何得来的呢?
我们先查看一下a.out程序头的情况:
DYNAMIC 0x0000000000000e18 0x0000000000600e18 0x0000000000600e18
0x00000000000001e0 0x00000000000001e0 RW 8
首先我们可计算出Elf64_Dyn结构体的大小为8*2=16字节,DYNAMIC中共有0x1e0个字节,也就是0x1e0/16=30项,这与之前的25项似乎有点出入,不过可以看之前打印出来的最后一条是NULL,说明之后的项目不再有意义,也就是说一共只有24项有实际含义,第25项表示结束,往后的都再无意义了。
接下来我们需要搞清楚这个libadd.so是如何得到的,为了搞清楚这个,我们来查看一波从0xe18开始的前两条的情况:
01 00 00 00 00 00 00 00 ................
00000E20 01 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 ................
00000E30 74 00 00 00 00 00 00 00
那么第一个结构体的d_tag值为1,d_val的值为1,第二个结构体d_tag值也为1,d_val的值为0x74,接下来我们查看一下源码中的.dynstr节所在的地址:
$ readelf -S a.out
[ 6] .dynstr STRTAB 00000000004003f0 000003f0
00000000000000b4 0000000000000000 A 0 0 1
知道了是0x3f0这个地址之后,使用hexedit a.out去找这个地址对应的内容:
000003F0 00 6C 69 62 61 64 64 2E 73 6F 00 5F 49 54 4D 5F .libadd.so._ITM_
...
00000460 69 6E 69 00 6C 69 62 63 2E 73 6F 2E 36 00 5F 5F ini.libc.so.6.__
...
到这里我们就能够明白libadd.so和libc.so.6的来历了,因为这个d_val代表的是偏移量,第一个结构体的偏移量是1,也就指向了这里的libadd.so,第二个结构体的偏移量是0x74,0x3F0+0x74=0x464,也就指向了这个libc.so.6。
那么现在链接器成功读取了共享库的名称,并成功将其映射到内存中了,下一步就是获取共享库的动态段了。
首先我们观察共享库的动态段:
$readelf -l libadd.so
Dynamic section at offset 0xe48 contains 21 entries:
标记 类型 名称/值
0x0000000000000005 (STRTAB) 0x368
0x0000000000000006 (SYMTAB) 0x230
可以知道符号表的起始地址为0x230,(通过对共享库的节头表各项地址的查看,可以知道这里的符号表指的是动态符号表)
接下来我们用readelf直接看一下符号表中的各项内容:
$ readelf -s libadd.so
Symbol table '.dynsym' contains 13 entries:
......
8: 0000000000201028 0 NOTYPE GLOBAL DEFAULT 22 _end
9: 0000000000000650 20 FUNC GLOBAL DEFAULT 11 add
10: 0000000000201020 0 NOTYPE GLOBAL DEFAULT 22 __bss_start
......
这里第8项就是我们一直想要找的add函数的实际地址,这里表达的意思是在偏移量650的位置,于是我们再用objdump查看一下650是不是add函数的位置:
$ objdump -d libadd.so
.......
0000000000000650 <add>:
650: 55 push %rbp
651: 48 89 e5 mov %rsp,%rbp
.......
可以看出,这里的确是真正的add函数的位置,这也是最终调用函数的地址,到这里,可执行文件能够获得真正的函数地址,也可以进一步调用这个函数了。
所以总结一下整个过程,就是可执行文件加载时,首先把GOT之类的东西压入辅助向量,然后将控制权交给动态链接器,它把真实地址从共享库中找出来,然后替换GOT中的值,这样可执行文件调用共享库函数的时候,就可以访问真实的函数入口了。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。