静态链接是把不同的文件片段拼在一起,这些片段有不同类型:代码,初始化的数据,未初始化的数据.所以在link的过程中主要是两件事:symbol resolution,relocation
object files可以分为三类
一个Executable and Linkable Format(ELF)结构如下
symbol可以分为三类
从上面可以看出对可见性而言,C中的static相当于java中的private.另一个对于static的说明参见下面代码(两个x不会相互混淆,他们各自在data或bss中有自己的位置)
int f(){
static int x=0;
return x;
}
int g(){
static int x=1;
return x;
}
Symbol tables 由assembler根据compiler给出的.s文件而产生.具体结构参见下面
typedef struct{
int name; /*String table offset*/
int value; /*Section offset(relocatable module),or VM address(executable object file)*/
int size; /*Object size in bytes*/
char type:4 /*Data,func,section,or src file name(4 bits)*/
binding:4/*Local or global(4 bits)*/
char reserved;/*unused*/
char section;/*section header index,ABS,UNDEF, or COMMON */
} Elf_Symbol
compiler碰到没有定义的函数时会假定这个函数在其他模块中定义,不会报错.到了link的阶段,如果linker还是找不到这个函数,就会报错.相反,如果linker找到多个时候,会采用不同的策略,也许会报错,也许随便选一个而不报错.
在将到unix处理多个symbol冲突的问题前先对symbol分类
具体在解决symbol冲突时遵循以下原则:
所以在冲突的时候会发生各种意向不到的事情,甚至没有冲突的变量也会受到影响(例如下面的y)
#include
void f(void);
int x=1234;
int y=4321;
int main(){
f();
printf("x=0x%x y=0x%x \n",x,y);
return 0;
double x;
void f(){
x=-0.0;
}
按照书中说法上面打印的结果如下
x=0x0 y=0x80000000
由于会发生上面诡异的bug,可以通过fno-common这个参数来强制报错
文中先否定了Pascal采用的将标准库和编译器捆绑的方法,因为这样增加了编译器的复杂度
然后又探讨了将整个库文件加入执行文件的缺点,这样会占用太多的硬盘和内存空间,并且标准库的改变需要代码重新编译.于是出现了archive文件,它包含了很多库函数,然后在link的时候按需拷贝.并且编译器一般默认会把libc.a作为linker的参数.至于archive文件使用ar命令产生,语法如下
unix>gcc -c addvec.c multvec.c
unix>ar rcs libvector.a addvec.o multvec.o
然后在C代码中如下应用,注意和标准库有所不同.
#include "vector.h"
然后在链接时指定链接库(但是gcc会只把需要的部分从链接库里拷贝出来)
unix>gcc -O2 -c main.c
unix>gcc -static -o p main.o ./libvector.a
上面的static参数表明是现在拷贝而不是load时候
另外需要注意上面顺序,需要库在后面(如果有多个库,并且有调用关系,那么被调用的在后面),如果有两个库存在循环调用那么(例如libx.a和liby.a),则需要下面的语法(libx.a出现两遍),或者将这两个库合并为同一个.
unix>gcc foo.c libx.a liby.a libx.a
relocation可以分为两步
这依赖于assembler产生的下面relocation entry来告诉linker来如何处理引用问题
typedef struct {
int offset; /*offset of the reference to relocate*/
int symbol:24, /*symbol the reference should point to*/
type:8; /*relocation type*/
}Elf32_Rel;
而这些数据存放在.rel.text和.rel.data里
其中type有多种预定义类型,文中提到了R_386_PC32和R_386_32分别是指相对于PC的偏移和绝对地址,他们的差别从下面linker处理的代码可以更清楚的看出来
foreach section s{
foreach relocation entry r{
refptr=s+r.offset;/*ptr to reference to be relocated*/
/*relocate a pc-relative reference*/
if(r.type==R_386_PC32){
refaddr=ADDR(s)+r.offset;/*ref's run-time address*/
*refptr=(unsigned)(ADDR(r.symbol)+ *refptr-refaddr);
}
/*Relocate an absolute reference*/
if(t.type==R_386_32)
*refptr=(unsigned)(ADDR(r.symbol)+ *refptr);
}
}
80483ba:e8 09 00 00 00 call 80483c3
上面是一条调用swap函数(其入口地址是80483c3)的指令。其中call(也就是e8)的执行逻辑如下
也就是在保存了PC寄存器的值后再加上9,注意到call这条指令时其实PC已经指向了下一条指令的地址,也就是0x80483bf
结合上面的图说下整个过程(整个过程由编译器和linker配合完成):
对于全局变量一般采用这种方式
extern int buf[];
int *bufp0=&buf[0]
上面一条指令会被compiler放入.data
00000000
0:00 00 00 00
0:R_386_32 buf
当linker确定了buf的地址后,加上原值(此处为0)直接把这个地址给bufp0
∗ r e f p t r = a d d r ( r . s y m b o l ) + ( ∗ r e f p t r ) = a d d r ( b u f ) + ( ∗ r e f p t r ) *refptr=addr(r.symbol)+(*refptr)=addr(buf)+(*refptr) ∗refptr=addr(r.symbol)+(∗refptr)=addr(buf)+(∗refptr)
假设linker确定buf的地址为0x8049454,那么在linker进行relocated后得到下面
0804945c
804945c: 54 95 04 08
上面同样使用了little ending,另外对于bufp0的地址804945c无需关注
Executable Object File的格式与Relocatable Object File格式类似,有以下几个差别
read-only code segment
LOAD off 0x00000000 vaddr 0x08048000 paddr 0x08048000 align 2**12
filesz 0x00000448 memsz 0x00000448 flags r-x
read/write data segment
LOAD off 0x00000448 vaddr 0x08049448 paddr 0x08049448 align 2**12
filesz 0x000000e8 memsz 0x00000104 flags rw-
其中off表示偏移地址,vaddr和paddr表示地址,align表示对齐,filesz和memsz表示该段在文件/内存中大小,flags表示权限
当在shell执行一条指令,如果shell发现这个不是内置指令时,shell会认为执行的是executable object file,于是使用execve函数调用loader来执行该指令。loader会加载executable object file到内存后(当然,后面的Virtual Memory里会指出其实并没有加载到内存,而是通过mm来完成懒加载)转跳到entry point,这个过程也称为loading
loader会在segmant table的帮助下把文件加载到内存成以下状态
然后loader会进入entry point,也就是_start(这个地址一般在ctr1.o)执行startup code,startup进行的操作大致如下
为了解决static library的浪费以及及时更新的问题,出现了shared library,共享有两种方式,一种是共享整个so文件,包括里面的代码和数据,另一种是只共享里面text,也就是代码部分
在gcc中通过shared参数来创建动态链接对象,通过fPIC来创建Position-Indepanedent code。在同时带上这两个参数后就可以得到一个so结尾的动态库。
在执行时,loader会发现interp这个section,其中动态包含了动态链接地址,然后loader会发现这个动态链接又使用了其他的动态链接,例如libc,于是loader会先加载libc.so,然后逆向加载,直到最后需要加载main所在的可执行文件,然后开始执行。
由于程序可以在执行的过程中(而不仅仅是在启动加载的时候)动态加载动态库(下面的dlopen),所以动态库可以用来更新,也可以用来动态生成代码来执行从而获得更高的性能。
#include
//returns ptr to handle if OK,NULL on error
//flag 取RTLD_NOW来立刻加载,取RTLD_LAZY来延迟到执行时加载,也可以使用RTLD_GLOBAL
void *dlopen(const char *filename,int flag);
//return ptr to symbol if OK,NULL on error
//handle是上个函数的返回值,而symbol是想调用的方法名
void *dlsym(void *handle,char *symbol);
//return 0 if OK,-1 on error 关闭
int dlclose(void *handle);
//返回上面三个方法执行时的最后一个错误
const char*dlerror(void);
在dlsym执行后就可以像静态链接一样调用那些方法了
一种天真的想法就是为每个动态库固定在一个地址,这样loader在加载的时候会比较方便。但是,显然不可行,因为随着动态库的调整之前的空间可能不够用,另外即使这个动态库未被用到,也占用了内存空间,另外很多的动态库之间怎么安排以保证不重叠也很困难,于是出现了position-independent code,也就是可以让动态库可以加载到内存的任意地方
compiler基于以下技巧,当一个模块被加载到内存后,其code和data这两个segment的位置就确定了,所以他们俩的距离也确定了(也就是下面的VAROFF),这里使用了一个类似于指向指针的指针来解决访问变量,代码获取Global Offset Table的地址,而GOT里存放着在load的时候放入实际地址。下面代码说明了对全局变量的访问(显然采用了PIC之后各增加了4条和3条指令)
call L1 //将下一条指令地址压栈
L1:popl %ebx //将压栈的PC弹出到%ebx
addl $VAROFF,%ebx //计算得到GOT地址
movl (%ebx),%eax //从GOT中得到变量地址到%eax
movl (%eax),%eax //获取变量的值到%eax
对于方法的调用采用类似的方案
call L1
L1: popl %ebx
addl $PROCOFF,%ebx
call *(%ebx)
相对于上面获取变量而言,方法调用少了一步。另外在方法调用时还可以采用lazy binding的方案,也就是采用Procedure Linkage Table来存放,另外GOT的前三个也用来存放一些额外信息,具体如下
在初始状态下
GOT:
address | entry | contents | description |
---|---|---|---|
08049674 | GOT[0] | 0804969c | .dynamic地址 |
08049678 | GOT[1] | 4000a9f8 | linker的一些信息 |
0804967c | GOT[2] | 4000596f | dynamic linker的入口地址 |
08049680 | GOT[3] | 0804845a | PLT[1]中pushl指令的地址 |
08049684 | GOT[4] | 0804846a | PLT[2]中pushl指令的地址 |
PLT:
PLT[0]
08048444: pushl 0x8049678 –添加GOT[1]为参数
0804844a: jmp *0x804967c –跳转执行linker
08048450: 00 00 –padding
08048452: 00 00 –padding
PLT[1]
08048454: jmp *0x8049680 –跳转到GOT[3]
0804845a: pushl $0x0 –printf的id
0804845f: jmp 8048444 *跳到PLT[0]
当代码中要调用相应函数的时候,把地址填为PLT的地址(注意,此处不再采用上面介绍的偏移量的方法),然后从PLT跳到GOT的指定位置,按理说GOT里应该指向实际代码的地址,但是这里由于懒加载的原因,GOT这个时候是指向懒加载的代码,回到PLT填入参数后执行GOT[2]指向的linker方法,linker完成懒加载后将GOT指向真正的地址,后面再调用PLT到GOT就不会再跳回PLT了,而是跳向真正的地址
懒加载的顺序如下图所示,最终调用linker完成懒加载并把GOT[n+2]的指向改为刚加载的函数
完成懒加载后再调用相应函数时的流程,这次从GOT[N+2]就直接过去了
需要注意的GOT里存放的是数据,而PLT里存放的是代码。所以每次call的地址必然是PLT[n]这一代理方法,然后PLT再根据GOT里的数据将调用委托到后面的方法。所以call的地址不能跟GOT的地址(call是方法调用,如果直接使用jmp到GOT指向的地址,那么存放PC的这一步就没有了,并且这破坏了原本的方法调用的语义)。
如此看来PLT[n]其实由两部分组成,一个是只做jmp的代理,另一个是做懒加载的部分。
这些工具在binutil里: