小知识:
readelf -s 既能显示静态符号.symtab, 也能显示动态符号.dynsym
动态符号.dynsym 中包含导出函数表,导入函数表.
strip 只会删除静态符号.symtab 及对应的.strtab, 并不会删除动态符号.dynsym及对应的.dynstr
注意啊,so或exe文件可以strip, 但链接式文件不能strip, 因为它们没有的静态符号就成废的文件了,将不能参与静态连接了.
问题: 当运行文件调用动态连接库的函数时, 它是怎样找到函数的地址的呢? 下面就来研究一下:
1.创建测试文件
创建文件common.c
int val = 1;
int func(void)
{
return (val+10);
}
创建文件test.c
extern int val;
extern int func(void);
int main()
{
val = 10;
func();
return 0;
}
2.编译
Makefile 如下:
all: test
test: test.o common.so
gcc -g -o $@ test.o ./common.so
common.so: common.c
gcc -shared -fPIC -o $@ $<
%.o:%.c
gcc -g -c -o $@ $<
clean:
rm *.o common.so test
3. test 可执行文件分析
反汇编
可执行程序如何访问动态连接库中的变量和函数的呢?
我们看它的反汇编代码!
objdump -S test > test.S
00000000004006cd <main>:
extern int val;
extern int func(void);
int main()
{
4006cd: 55 push %rbp
4006ce: 48 89 e5 mov %rsp,%rbp
val = 10;
4006d1: c7 05 65 09 20 00 0a movl $0xa,0x200965(%rip) # 601040 <__TMC_END__>
4006d8: 00 00 00
func();
4006db: e8 f0 fe ff ff callq 4005d0 <func@plt>
return 0;
4006e0: b8 00 00 00 00 mov $0x0,%eax
}
4006e5: 5d pop %rbp
4006e6: c3 retq
4006e7: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
4006ee: 00 00
val 地址是601040
func 地址是4005d0
查test各段地址, 601040落入bss节, 看来动态链接库的全局变量会在可执行文件的.bssx节留有副本.
4005d0 属于.plt节, .plt节已经被反汇编成代码,容易查看
Disassembly of section .plt:
00000000004005a0 <__libc_start_main@plt-0x10>:
4005a0: ff 35 62 0a 20 00 pushq 0x200a62(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
4005a6: ff 25 64 0a 20 00 jmpq *0x200a64(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
4005ac: 0f 1f 40 00 nopl 0x0(%rax)
00000000004005b0 <__libc_start_main@plt>:
4005b0: ff 25 62 0a 20 00 jmpq *0x200a62(%rip) # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
4005b6: 68 00 00 00 00 pushq $0x0
4005bb: e9 e0 ff ff ff jmpq 4005a0 <_init+0x20>
00000000004005c0 <__gmon_start__@plt>:
4005c0: ff 25 5a 0a 20 00 jmpq *0x200a5a(%rip) # 601020 <_GLOBAL_OFFSET_TABLE_+0x20>
4005c6: 68 01 00 00 00 pushq $0x1
4005cb: e9 d0 ff ff ff jmpq 4005a0 <_init+0x20>
00000000004005d0 <func@plt>:
4005d0: ff 25 52 0a 20 00 jmpq *0x200a52(%rip) # 601028 <_GLOBAL_OFFSET_TABLE_+0x28>
4005d6: 68 02 00 00 00 pushq $0x2
4005db: e9 c0 ff ff ff jmpq 4005a0 <_init+0x20>
第一条指令,向601028所存储的地址跳跃.
601028属于.got.plt节,
$ readelf -x .got.plt test
Hex dump of section '.got.plt':
0x00601000 180e6000 00000000 00000000 00000000 ..`.............
0x00601010 00000000 00000000 b6054000 00000000 ..........@.....
0x00601020 c6054000 00000000 d6054000 00000000 ..@.......@.....
601028存储数据为4005d6, 周转半天原来执行的还是func@plt 的下一条指令.
继续向下,把2 push到堆栈,跳转到4005a0.
4005a0 是plt的第一个入口.
它向堆栈中推入601008,跳转到601010存储的地址
这两个数据都在.got.plt 中, 目前显示全为0, 这两个地址显然应该由动态连接程序
要填上正确的地址才能执行.
现在再看看.got.plt, 每8byte为一个entry, 4,5,6 3个entry 分别指向.plt 的2,3,4入口
经查资料知,第一个入口600e18是.dynamic 地址, 第2个入口是module id, 第3个入口是
_dl_runtime_resolve()函数的入口,这两个现在全是0, 动态加载器会初始化它们.
调用动态库函数为什么这么复杂呢? 我忽然领悟原来这就是传说中的”延时bind”.
调用_dl_runtime_resolve()被bind 之后, 就会修改.got.plt的对应入口项, 下一次调用就
直接拿到了调用函数地址了, 而不是.plt的第一个入口进行地址解析.
4. 调试跟踪内存的变化
用gdb 或 ida 可以调试跟踪,下面就以gdb为例子吧. gdb 很强大!
gdb test
b main
r
程序断下来了, 我们关心什么呢?
1. val 变量的地址.
(gdb) p &val
$3 = (int *) 0x601040
func 地址,
p func, gdb 一直打印0x7fff f7bd86a5,所以这个地址是被gdb掩盖住了.
当执行到func()时, 你可以用si 指令单步跟入,就可以继续查看所关心的地址了!.got.plt 内容是如何变化的.
执行func()之前, 可见module_id, resolv()都已填好.
__libc_start_main@plt 也已填好地址, 因为__libc_start_main()函数已经被调用过了.
gmon_start@plt 没有填好
func@plt 没有填好
(gdb) x/12x 0x601000
0x601000: 0x00600e18 0x00000000 0xf7ffe1c8 0x00007fff
0x601010: 0xf7df04a0 0x00007fff 0xf7831e50 0x00007fff
0x601020: 0x004005c6 0x00000000 0x004005d6 0x00000000
(gdb) x/4x 0x601028
0x601028 <func@got.plt>: 0x004005d6 0x00000000 0x00000000 0x00000000
执行func()之后
(gdb) x/4x 0x601028
0x601028 <func@got.plt>: 0xf7bd86a5 0x00007fff 0x00000000 0x00000000
从readelf -h test 中看程序入口点是0x4005e0, 从反汇编中看这是.text开始位置, _start符号地址,
可见程序从start 开始执行而不是main符号, _start 会调用 _libc函数__libc_start_main@plt, 而后者是库函数,
它会调用到main
好了,上面讲清楚了.got.plt 和 .plt, 就知道了动态binding, 延迟加载的含义和为什么动态调用是这样的.
5.下面来点理论总结
如果elf文件需要动态加载, 那么elf文件中要指明动态加载器, 这由.interp 节指明
$ readelf -p .interp test String dump of section '.interp': [ 0] /lib64/ld-linux-x86-64.so.2
- 动态加载器首先要完成自举,就是自己加载自己,这个不是我们关心的.
然后加载器加载我们需要的共享对象.
可执行文件的.dynamic段中的DT_NEEDED入口下,记录了该可执行文件所依赖的共享对象。$readelf -d test |grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [./common.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]动态链接器将所有依赖的共享对象装载到内存,并将符号表合并到全局符号表中,
所以当所有的共享对象都被装载进来的时候,全局符号表中包含了进程中所有动态链接所需要的符号。各对象重定位和初始化
链接器已经拥有了进程的全局符号表, 然后对可执行文件和共享对象的 .got/.got.plt等的每个需要重定位的位置进行修正。
重定位完成之后,如果共享对象有.init段,那么动态链接器会执行.init段中的代码,用以实现共享对象特有的初始化过程。
当初始化都完成之后,链接器将进程的控制权转交给程序入口并开始执行。
程序的入口是_start, 它调用libc的__libc_start_main, 后者调用可执行文件从main
6.总结2
符号的本质是什么?
符号的本质是符号化数值.
编译程序在编译阶段, 对于某些数据或函数的引用还不能确定其地址,只能留给下一步去完成,
这时候就需要引入符号, 符号是有名称的,此时符号的地址全部给0, 待符号值确定之后,再修改为正确的地址值.重定位的本质是什么?
重定位的本质,就是要修改引用,就是要修改代码段和数据段的内容,当然,它的修改是有依据的,这就是重定位表.
修改意味着,代码和数据是不能共享的,因为共享的条件是:
一块区域, 其他的程序都可以引用它,而不能修改它.否则就会影响别的程序对它的使用.
可执行文件对数据和函数的引用可以通过.rel.text和.rel.data表,修正.text, .data中的调用部分
但是,共享库中不能包含.rela.text, .rela.data, 因为它要共享,区块不能被修改,
那如果它调用外部函数和外部数据,又是如何做到的呢?
3.共享库对全局数据的访问.
不管是内部数据还是外部数据,只要是全局的,都按外部数据处理.
在共享对象中引入一个段叫.got, 全局偏移表. 它储存的是每一个符号的地址,由于这个符号地址目前还不知道,所以先全部填充为0, 具体的符号地址由加载器来填充. 这个是不是可以叫做动态重定位?!
这样,共享库对数据的访问就可以固定了下来. 它是通过先取到地址,再取到数据两个步骤来访问数据的.
加载器要根据动态重定位数据节.rela.dyn 来修改为符号地址
如果动态加载器遇到两个同名的全局符号怎么办?
如果遇到同名符号,一种简单的办法是忽略后解析到的同名符号,这种现象叫做全局符号介入。
这意味着,后加载的模块定义的变量可能会被前面加载的模块的变量所覆盖.
4.共享库对全局函数的访问.
和数据一样,共享对象不能使用相对地址访问全局函数,也是要分两步,先拿到全局函数地址,再调用函数.
这个表叫.got.plt 表, 用来储存全局函数的地址.
共享库还采用了一种延迟绑定技术,引入了.plt 节,
.plt 节中储存有简短的跳转到函数地址的代码,其中的函数地址,就是.got.plt表的内容, 但.got.plt表的地址加载器并没有解析, 而是保留着它的初始值. 这样执行.plt 中的代码将会调用的.plt后部分,解析这个函数地址的代码,并把结果返回到.got.plt表项中, 相当于重定位了函数地址. 以后再调用就不会解析函数地址了,而是直接调用函数.这种在运行时再确定函数地址的方法叫延迟绑定.
好处是节省了初始化时对导入函数的binding时间
对函数的调用,也有全局符号介入问题,即后出现的同名函数会被忽略,不能进入函数地址表中.