CSAPP(7)Linking

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使用的字符串表
  • 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冲突时遵循以下原则:

  1. 多个strong symbols冲突时报错
  2. 一个strong symbol和多个weak symbol时使用strong symbol
  3. 多个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_PC32R_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)的执行逻辑如下

  1. push PC onto stack
  2. PC <- PC+0x9=0x80483bf+ox9=ox80483c8

也就是在保存了PC寄存器的值后再加上9,注意到call这条指令时其实PC已经指向了下一条指令的地址,也就是0x80483bf

relocating symbol references

在这里插入图片描述
结合上面的图说下整个过程(整个过程由编译器和linker配合完成):

  1. e8:编译器在编译main的时候发现有一个对swap的调用,函数调用本质上是把PC改成被调用函数的入口地址,但是为了调用返回后继续执行,需要记录当前函数执行到哪儿了,所以需要保持当前PC地址。于是编译器使用了e8这条指令(也就是call)来完成PC的保存和修改。把它放入.text的的6这个地址。
  2. fc ff ff ff 上面这一条指令的下一条指令的地址是10,这两个地址的差距就是-4,使用little ending的编码就是fc ff ff ff这个值,至于为什么把这个值放入这个位置后面再说
  3. 0x7:按理说call后面应该是被调用函数地址,但是编译器并不知道,这个需要等到linker来把程序绑定到一起的时候才知道。于是它创建了一个symbol来告诉linker这个地方需要处理,也就是说0x7这个地方的地址需要linker替换成swap的真正地址。
  4. addr(.text):linker开始把几个.o文件整合到一起,它出于其他的考虑把代码地址(ADDR(.text))确定为0x80483b4,然后把main.o里面的.text拷贝过来,所以main.o里面的位置6e8指令也到了这边的0x80483b4+0x6=0x80483ba这个地址。
  5. 平行线:于此同时之前地址是10的指令的新地址是0x80483bf。图中左侧的三条表示了main.o和linker处理后的文件的地址对应关系。其实我觉得画成平行线可能更清楚。
  6. addr(swap):linker把swap的地址(ADDR(swap))确定为0x80483c8.那么下一步就是要把call指令后面的地址换成正确的地址。
  7. 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表示偏移地址,vaddrpaddr表示地址,align表示对齐,fileszmemsz表示该段在文件/内存中大小,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进行的操作大致如下

  1. call __libc_init_first
  2. call _init
  3. call atexit
    这里会注册一些routine,在代码中执行exit时会执行这些注册的代码,然后才使用_exit来返回OS
  4. call main
  5. 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:

addressentrycontentsdescription
08049674GOT[0]0804969c.dynamic地址
08049678GOT[1]4000a9f8linker的一些信息
0804967cGOT[2]4000596fdynamic linker的入口地址
08049680GOT[3]0804845aPLT[1]中pushl指令的地址
08049684GOT[4]0804846aPLT[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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值