文章目录
- Compiler Drivers
- Static Linking
- Object Files
- Relocatable Object Files
- Symbols
- Symbol Tables
- Symbol Resolution
- Relocation
- Executable Object Files
- Loading Executable Object Files
- Dynamic Linking with Shared Libraries
- Loading and Linking Shared Libraries from Applications
- Position-Independent Code
- Tools for Manipulating Object Files
Compiler Drivers
Static Linking
静态链接是把不同的文件片段拼在一起,这些片段有不同类型:代码,初始化的数据,未初始化的数据.所以在link的过程中主要是两件事:symbol resolution,relocation
Object Files
object files可以分为三类
- Relocatable object file
在编译阶段可以和其他relocatable object file合并到一起 - Executable object file
在执行阶段可以被直接加载到内存执行 - Shared object file
在加载阶段或执行阶段被加载到内存并动态链接
一般由compiler和assembler生成relocatable object file和shared object file,由linker生成executable object file
Relocatable Object Files
一个Executable and Linkable Format(ELF)结构如下
- sections
- ELF header
- word size & byte ordering
- information that allow linker to parse
- the size fo the ELF header
- the object file type(relocatable,executable,stared)
- the machine type
- the file offset of the section header table
- the size and number of entries in the section header table
- text
machine code - rodata
read only data - data
initialized global c variables - bss
uninitialized global c variables - symtab
functions and global variables that are defined and referenced in the program - rel.text
包含text里linker需要处理的地方,这些地方一般是外部函数或全局变量 - rel.data
- debug
- line
- strtab
给.symtab和.debug使用的字符串表
- ELF header
- describes object file sections
- Section header table
Symbols
symbol可以分为三类
- Global symbols
在模块m中定义并且可以被其他模块使用,对应C中的非静态函数和非静态全局变量 - externals
在模块m外定义但是可以被m使用,对应C中其他模块定义的的函数和变量 - Local symbols
在模块m中定义并且只有m使用,对应C中的静态函数和静态变量
从上面可以看出对可见性而言,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
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
Symbol Resolution
compiler碰到没有定义的函数时会假定这个函数在其他模块中定义,不会报错.到了link的阶段,如果linker还是找不到这个函数,就会报错.相反,如果linker找到多个时候,会采用不同的策略,也许会报错,也许随便选一个而不报错.
unix
在将到unix处理多个symbol冲突的问题前先对symbol分类
- strong
函数&初始化了变量 - weak
未初始化的变量
具体在解决symbol冲突时遵循以下原则:
- 多个strong symbols冲突时报错
- 一个strong symbol和多个weak symbol时使用strong symbol
- 多个weak symbol时随便选
所以在冲突的时候会发生各种意向不到的事情,甚至没有冲突的变量也会受到影响(例如下面的y)
#include <stdio.h>
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这个参数来强制报错
static libraries
文中先否定了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
relocation可以分为两步
- relocating sections and symbol definitions
这一步是将各个输入文件的相同section合并为同一个 - relocating symbol references within sections
这一步是修改引用地址
这依赖于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);
}
}
关于call的调用
80483ba:e8 09 00 00 00 call 80483c3 <swap>
上面是一条调用swap函数(其入口地址是80483c3)的指令。其中call(也就是e8)的执行逻辑如下
- push PC onto stack
- PC <- PC+0x9=0x80483bf+ox9=ox80483c8
也就是在保存了PC寄存器的值后再加上9,注意到call这条指令时其实PC已经指向了下一条指令的地址,也就是0x80483bf
relocating symbol references
结合上面的图说下整个过程(整个过程由编译器和linker配合完成):
- e8:编译器在编译main的时候发现有一个对swap的调用,函数调用本质上是把PC改成被调用函数的入口地址,但是为了调用返回后继续执行,需要记录当前函数执行到哪儿了,所以需要保持当前PC地址。于是编译器使用了e8这条指令(也就是call)来完成PC的保存和修改。把它放入.text的的6这个地址。
- fc ff ff ff 上面这一条指令的下一条指令的地址是10,这两个地址的差距就是-4,使用little ending的编码就是fc ff ff ff这个值,至于为什么把这个值放入这个位置后面再说
- 0x7:按理说call后面应该是被调用函数地址,但是编译器并不知道,这个需要等到linker来把程序绑定到一起的时候才知道。于是它创建了一个symbol来告诉linker这个地方需要处理,也就是说0x7这个地方的地址需要linker替换成swap的真正地址。
- addr(.text):linker开始把几个.o文件整合到一起,它出于其他的考虑把代码地址(ADDR(.text))确定为0x80483b4,然后把main.o里面的.text拷贝过来,所以main.o里面的位置6的e8指令也到了这边的0x80483b4+0x6=0x80483ba这个地址。
- 平行线:于此同时之前地址是10的指令的新地址是0x80483bf。图中左侧的三条表示了main.o和linker处理后的文件的地址对应关系。其实我觉得画成平行线可能更清楚。
- addr(swap):linker把swap的地址(ADDR(swap))确定为0x80483c8.那么下一步就是要把call指令后面的地址换成正确的地址。
- 09 00 00 00假设e8后面的参数是x,注意x相对于addr(.text)的偏移在main.o的symbol里已经指出 ,那么我们现在解一个方程。当CPU执行到call指令时,PC是指向下一条指令的地址的,也就是参数的地址加4(似乎和上面的-4有某种神秘联系哦),所以
a d d r ( s w a p ) = x + p c / / e 8 的 定 义 = x + a d d r ( n e x t i n s t r u c t i o n ) / / p c 的 定 义 = x + ( x . l e n g t h ) + a d d r ( x ) / / 文 件 结 构 = x + ( x . l e n g t h ) + a d d r ( . t e x t ) + ( a d d r ( x ) − a d d r ( . t e x t ) ) / / 恒 等 变 换 = x + ( x . l e n g t h ) + a d d r ( . t e x t ) + ( o . a d d r ( x ) − o . a d d r ( . t e x t ) ) / / 平 行 线 = x + ( x . l e n g t h ) + a d d r ( . t e x t ) + o . s y m b o l . o f f s e t / / o f f s e t 定 义 \begin{aligned} addr(swap) &=x+pc &//e8的定义 \\ &=x+addr(next \quad instruction) &//pc的定义 \\&=x+(x.length)+addr(x) &//文件结构 \\&=x+(x.length)+addr(.text)+(addr(x)-addr(.text)) &//恒等变换 \\&=x+(x.length)+addr(.text)+(o.addr(x)-o.addr(.text))&//平行线 \\&=x+(x.length)+addr(.text)+o.symbol.offset&//offset定义 \end{aligned} addr(swap)=x+pc=x+addr(nextinstruction)=x+(x.length)+addr(x)=x+(x.length)+addr(.text)+(addr(x)−addr(.text))=x+(x.length)+addr(.text)+(o.addr(x)−o.addr(.text))=x+(x.length)+addr(.text)+o.symbol.offset//e8的定义//pc的定义//文件结构//恒等变换//平行线//offset定义
两个addr由linker决定,o.addr表示在main.o里地址,而o.symbol.offset由compiler在symbol里指定(结合上面的平行线说法,在link前后offset是不变的),而x.length则由compiler做出了一个大胆的设定(按理说x.length要等到x确定了之后才知道),认为linker一定会给4位长度,所以compiler给出了4(这里需要compiler和linker有点默契)
计算得到x的值是9,使用little ending编码后放入
relocating absolute references
对于全局变量一般采用这种方式
extern int buf[];
int *bufp0=&buf[0]
上面一条指令会被compiler放入.data
00000000<bufp0>
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<bufp0>
804945c: 54 95 04 08
上面同样使用了little ending,另外对于bufp0的地址804945c无需关注
Executable Object Files
Executable Object File的格式与Relocatable Object File格式类似,有以下几个差别
- segment header table
这个给出了一些读/写的信息,内容参见上图,格式参见下面
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表示权限
- init
这里有些初始化指令,会在main前执行 - rel
由于已经完成了link,所以没有这些数据
Loading Executable Object Files
当在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进行的操作大致如下
- call __libc_init_first
- call _init
- call atexit
这里会注册一些routine,在代码中执行exit时会执行这些注册的代码,然后才使用_exit来返回OS - call main
- call _exit
结束自己,返回OS
Dynamic Linking with Shared Libraries
为了解决static library的浪费以及及时更新的问题,出现了shared library,共享有两种方式,一种是共享整个so文件,包括里面的代码和数据,另一种是只共享里面text,也就是代码部分
在gcc中通过shared参数来创建动态链接对象,通过fPIC来创建Position-Indepanedent code。在同时带上这两个参数后就可以得到一个so结尾的动态库。
在执行时,loader会发现interp这个section,其中动态包含了动态链接地址,然后loader会发现这个动态链接又使用了其他的动态链接,例如libc,于是loader会先加载libc.so,然后逆向加载,直到最后需要加载main所在的可执行文件,然后开始执行。
Loading and Linking Shared Libraries from Applications
由于程序可以在执行的过程中(而不仅仅是在启动加载的时候)动态加载动态库(下面的dlopen),所以动态库可以用来更新,也可以用来动态生成代码来执行从而获得更高的性能。
#include <dlfcn.h>
//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执行后就可以像静态链接一样调用那些方法了
Position-Independent Code
一种天真的想法就是为每个动态库固定在一个地址,这样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[0]
address of .dynamic section - GOT[1]
identifying info for the linker - GOT[2]
entry point in dynamic linker
在初始状态下
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]<printf>
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的代理,另一个是做懒加载的部分。
Tools for Manipulating Object Files
这些工具在binutil里:
- ar
按照官方说明是create,modify and extract from archives - strings
print the strings of printable characters in files - strip
discard symbols from object files - nm
list symbols from object files - size
list section sizes and total size - readelf
displays information about ELF files - objdump
display information from object files
按照书上说法,这是个全能方法,主要用途是查看.text - ldd
print shared object dependancies