静态链接
a.c
int foo(int a,int b){
return a+b;
}
b.c
int x = 100,y = 200;
main.c
#include <stdio.h>
extern int x,y;
int foo(int a,int b);
int main(){
printf("%d + %d = %d\n",x, y, foo(x, y));
}
readelf -d main.o(图1)
readelf -a main.o | less(图2)
可以看到y-4
所在的地址是0a
,对应于图1中0a的地址是保留着4字节的0的,这4个字节是为y的地址留的,之后会借助rela.text中的信息在链接的时候会填入
。
为什么是y-4而不是y呢?
解答:
因为
8: 8b 35 00 00 00 00 mov 0x0(%rip),%esi # e <main+0xe>
是一个位置无关代码,而rip总是指向下一条指令的地址,即0xe,而0xe-0xa=0x4,所以需要在0xa的位置填入y的地址-4
才可以。归纳就可知道,-4的4就表示了需要填入的地址到下一个指令的距离。
这里,我们想知道执行a.out时的第一条指令在哪里
看main时可以发现所有待填的内容都已经填好了。
可以看到入口点地址为0x401c00
,操作系统把这个可执行文件加载后,从这个入口地址开始执行。
回去查a.txt
,得到:这个地址处是_start
的代码
怎么从第一条指令开始调试这个程序?
——gdb其实提供了这个功能
gdb ./a.out
starti
->
layout asm
->
_start
之后又会进入__libc_start_main
之后经过很长的初始化以后才会进入到main。
ld链接时,说没有定义printf,这是因为我们没有链接libc.so。
我们把main.c的printf行注释掉。
这样就只剩下一个警告了。
这时候执行a.out会有一个段错误,什么原因呢,让我们用gdb调试吧
gdb ./a.out
starti
layout asm
可以看到一开始就执行main
经过两次si,准备执行ret的时候,查找rsp中的值为1。
再si执行ret指令,就触发了段错误,这是因为无法访问0x1的地址
所以直接用ld链接起来是不行的,并且我们给了一个小例子(如下),直接用三行汇编代码做一个系统调用,这样用ld是可以的。所以后面也会探寻gcc除了ld以外还做了哪些事(显然的有链接库函数)。
最小的可执行文件?
./a.out发现可以正常执行 echo $?看到返回值确实为99
a.S
可以用gdb去调试刚才这段代码
gdb ./a.out | starti | layout asm| si si si
->
程序就以0143,即99退出了。
所以整个计算机系统没有任何神秘的东西,所有的Linux世界,都是用这样的方式构造起来的。
man gcc中可以找到下图
-Wl,option
可以给gcc在调用链接器时传入额外的选项
-Wl,-verbose
可以看到gcc的很多细节
等等等等。
#include <stdio.h>
#include <stdlib.h>
int x=100,y=200;
int foo(int a,int b){
return a+b;
}
__attribute__((constructor)) void a(){//C标准库以外的,在main函数执行前就执行
puts("Hello");
}
__attribute__((destructor)) void b(){//C标准库以外的,在main函数执行后执行
puts("World");
}
int main(){
atexit(b);//C标准库支持的,在程序退出前执行函数b
printf("%d + %d = %d\n",x,y,foo(x,y));
}
动态链接与加载
静态链接 a.out文件的大小有87w+
而动态链接的a.out只有16k+
最早的时候,由于没有很多的程序,完全可以用静态链接。但随着bash中程序的飞速增加,共享的东西很多,静态链接会占用非常大的空间,动态链接是最好的选择。
libc代码,不仅在文件里,还是在物理内存里,都只有唯一的一份副本,而我们的一个a.out的进程启动以后,a.out有它自己的代码和数据,a.out还需要liibc,甚至还需要一些别的库,这个时候,我们的物理内存里只有一份libc代码,通过虚拟内存映射到这个同一个物理内存,这个libc加载到地址空间的哪里其实是完全没有关系的,加载到这里也可以,加载到那里也可以,只要按页面对齐就行。
默认会采用PIC,即所有的位置都是先用0(rip)占位,链接时再确定位置。如果想要不采用PIC,则用-no-pie编译。
ldd
可以帮忙看共享库的映射
可以看到,将libc.so.6映射到0x7f58…ld-linux-x86-64.so.2映射到0x7fb5,且每次都是不一样的,说明加载时虚拟地址是不确定的。
/lib64/ld-linux-x86-64.so.2 ./a.out
与 ./a.out
所做的事完全一样,通过strace看他们两者就是完全一样的。说明运行./a.out不是简单的执行一下,而是需要请操作系统运行/lib64/ld-linux-x86-64.so.2
这个解释器,a.out就是这个解释器的参数,这个解释器加载好a.out
,a.out依赖的共享库
,然后才正式运行。
可以通过readelf -a a.out
看到里面写明了以/lib64/ld-linux-x86-64.so.2
作为解释器。
直接vim a.out
也可以看到这个
所以计算机科学里面没有任何的魔法。
动态链接的实现
如果让你来实现动态链接,你会怎么实现?
首先,比较容易想到的是,在每一次程序加载的时候重新做一次静态链接就好了。
以printf为例,call的时候一开始也是填着call $0x0(rip),然后将共享库加载到内存后做一次静态链接,修改那些0的地方,这样做确实是可以的。
但是有一些缺陷,如:
①如果在多个进程都运行同一个a.out,在运行时每个a.out都需要重新做一次静态链接,把0的地方修改了。
②如果一个程序很长很长,用到了几万个的库的函数调用,但是实际上只执行一点点代码就结束了,这样静态链接庞大的量会很没有效率
那么比较好的办法是什么呢?
很简单,查表即可。我们的需求很明确,就是call printf,但是我们现在不知道printf在哪,直接查表就好了,把printf这个函数所在的地址存放在一个给定的地址中,然后只需要修改printf的地址即可,不需要对中间的每个printf去修改,只需要在遇到printf时直接查这个表就可以了。
objdump -d main.o
->
objdump -d a.out
看10e4行中,是callq *…,10ea+2efe=3fe8,就是后面注释所写的地址,printf所在的地址存放在这个地址中的。
这边讲一下豪华版的查表——PLT(默认选项)
objdump -d a.out | less
->
对printf的调用直接变成跳转到一个确定的地址(这里是0x1080),这个地址是一个新的函数
这里汇编看不太懂,老师说什么老版本的gcc不是这样的…
反正就是该函数实际上做了两件事:
1)解析出printf的地址并将值填入这个表项中.
2)跳转执行真正的printf函数.
在第一次调用printf的时候,在3fc8的表项中还没有填入printf的地址,只有执行到这个__printf_chk@plt
函数时,才会填入,称为延迟绑定。