基础

记得gcc编译器编译的步骤吗?预处理,编译,汇编,链接。首先前两步是为了生成.s的汇编文件,然后第三步就是生成机器码。但是如果第三步就已经完成了所有事为什么还要第四步呢?

首先我们要知道,编译时是各个文件独立编译的,也就是说这个时候如果分配了地址很可能发生这个函数的地址和另外一个文件中函数的地址相同的尴尬事情发生,为了避免这种事情,在编译到.o文件的时候一些其他文件要用的函数和变量使用一个符号来表示的,然后在链接阶段再来连连看把地址安上。

目标文件

目标文件有三种:

  • 可重定位目标文件,也就是汇编后形成的.o文件。可以和其他文件链接新城可执行文件
  • 可执行目标文件,也就是可执行文件
  • 共享目标文件,一种特殊的可重定向目标文件,可以在运行时重定向(动态链接)

可重定位目标文件

上面是一个elf文件结构图,这是现在linux中目标文件格式。各种目标文件中都有类似的格式。

  1. ELF头,存放了一些基础信息,例如大端序还是小端序,文件类型等等。
  2. .text节,已编译的机器代码。
  3. .rodata。只读数据,例如printf中的格式串和跳转表
  4. .data;以初始化全局和静态变量
  5. .bss; 未初始化全局和静态变量
  6. .symtab。符号表
  7. rel.text; .text节位置列表。可执行文件中不存在,一般会省略
  8. .rel.data; 同上
  9. .line; 源程序中行号和.text节机器指令间的映射,可以使用-g选项启用它
  10. 节头部表; 存放每一节相对于elf开始位置的偏移量。

符号和符号表

每个.o文件中都有一个符号表。总共有三种符号

  • 全局符号:由该文件定义并且能被其他模块引用的全局符号。全局符号表示非静态函数和全局变量
  • 外部符号:有其他模块定义并且被该模块引用的符号。这些符号叫外部符号。对应其他模块(文件)的非静态函数和全局变量
  • 局部符号。这是带static的函数和全局变量。这些符号在函数内部都可见,但是不能呗其他函数引用。
符号表的构成:
struct Elf64_Symbol
{
int name;//在符号表中的字节偏移
char type:4,//符号类型,是数据还是函数还是文件还是未定义
binding:4;//符号是本地(static)还是全局的
char reserved;
short section;
long value;//符号地址
long size;//符号大小,对于函数是函数指令字节总个数
}

例:

其中ndx中的UND是undefine。

静态链接完成的任务

静态链接就是编译时完成的链接,与之对应的运行时的链接。静态链接主要完成了两个任务,符号解析和重定位。

符号解析

符号解析时,各个文件都加载到虚拟内存空间之中,并且此时各个段进行合并并且位置确定了。因此此时我们可以确定各个符号的地址。

例如合并完成之后.text段起始地址为0x8048094, .data段起始地址为0x8049108。然后开始计算各个符号的虚拟地址。然后我们可以从符号表中得知符号在文件中的偏移量,然后可以推断出在段中的偏移量,段的首地址加上偏移量就是符号的地址。

符号表地址填写完成之后,就可以进行符号解析了。

符号解析就是将上面的符号表一一对应起来,例如上面第二种符号就匹配其他文件的第一种符号。

但是这里还有一些问题,因为开始编译是各做各的,所以难免出现名字相同的情况,甚至一个文件中也有函数重载导致重名的情况。这是符号解析中的难题。

对于一个文件中的重载,gcc链接器会对这些符号进行重整。例如同样是A函数,可能通过链接器的重整一个符号名就成了A1,另外一个成了A2。

如果是全局(其他模块可见)的变量之间导致重名。有以下方法。

在编译时,编译器想汇编器输出每个全局符号,分成强弱两类。函数和以初始化的全局变量时强符号,未初始化全局变量时弱符号。

链接规则:

  1. 不能出现同名的强符号
  2. 如果一个强符号,其他都是弱符号,那么其他弱符号都使用强符号地址
  3. 如果都是弱符号,那么随机选取一个弱符号,其他弱符号使用这个弱符号地址

注意第二点和第三点,系统都只分配了一片内存空间,其他符号都是共用这片内存空间,这就可能导致一些奇奇怪怪的错误,例如数据莫名被修改。

sum.c

int x;
int y;

a.c

int x;

因为x都是弱符号,所以随便选一个,然后让另一个共享内存空间,也就是在sum.c中进行更改会导致a.cx的更改。

sum.c
int x;
int y;

a.c
double x;

这种问题更为严重,如果选到了a.c中的x,那么空间是8字节,而这时在sum.cx和y是连到一起的也是8字节,这就意味着这次对a.cx改变可能会导致y改变。

上面这个问题在编译阶段很难发现,所以只有养成良好习惯,才可以减少这类问题。下面是几点建议。

  1. 能不使用全局变量就不使用全局变量
  2. 使用全局变量记得初始化
  3. 确定要引用外部变量时用extern

重定位

前面的符号解析我们已经可以获得每一个符号的地址,但是这些地址并没有填入代码中(因为开始不知道这个文件会被放在哪,所以没办法了填入虚拟地址)。而重定位就是将正确的地址填入代码中。

重定位表中的值其实就是他们在对应段中的偏移,可以通过这个找到重定位的位置。

重定位有两步

  1. 重定位节和符号定义:这一部分是把上面所说的每一个文件中的节。例如所有文件的.data节合成为一个节。之后就把内存地址赋给新的节,并且赋给每个符号。这时就有运行时的内存地址了。
  2. 重定位节的符号引用。

重定位条目

在重定位之前,符号并没有赋值,这个时候就有重定位条目确定我们要怎么把地址赋给每一个符号。

代码重定位条目存放在.rel.text中,数据重定位条目在.rel.data中。

重定位条目内容:

struct Elf64_Rela
{
long offset;//相对于函数首地址的偏移量
long type:32,//类型
symbol:32;//对应的符号
long addend; //附加信息
}

这里只讲两种基本类型:R_X86_64_PC32(相对引用,32位),R_X86_64_32(绝对引用)。

这种重定位类型只支持小型代码模型,只能引用-2G到2G的范围。

下面讲怎么重定位。

如果是相对地址:

refaddr = ADDR + R.offset;//ADDR是函数首地址,例如main函数首地址,offset是偏移量
//refaddr是需要修改内存的首地址,例如e8 00 00 00 00,后面四个零就是我们要填充的地址,那么refaddr是第一个00的地址
*refptr(refaddr地址的内容) = ADDR(r.symbol) + r.addend - refaddr);

/*这一段就是计算相对跳转地址的,addr是对应符号的绝对地址,本来addr-refaddr就是相对跳转地址,但是注意这里的相对跳转地址是当前指令的,而我们要从下一条指令进行跳转,所以要加上一个addend,用下一个指令起始地址来减,所以这里的addend是-4,因为refaddr指向e8后面,加上4就是下一条指令,而因为是末地址减首地址所以addend是负数。*/

//绝对地址

refaddr 同上;
*refptr = ADDR(r.symbol) + r.addend;
//这个相对简单,内容就是对应符号绝对地址嘛,但是这里也要加addend因为有可能是数组等因此还要加上数组的偏移量。

动态链接

动态链接库指的是在程序加载或运行阶段进行链接。在程序开始时(程序不是从main开始,前面还有一段代码负责初始化)会启用动态链接器。动态链接比静态链接会慢2%-5%

但是动态链接无法提前进行重定位。只有在程序真正开始运行时才会进行重定位(装载时重定位)。但这时便有一个问题,程序的虚拟地址大致相同,比如linux中程序入口大多在0x8048000,因此难免会产生碰撞。因此便需要重定位将新来的程序移动到另一个位置。但是这时必须每一个进程都要有一份副本,动态链接节省空间的优势也就没有了。

地址无关代码

全局变量或函数的访问大致可以分为四种:

  1. 模块内部的函数调用、跳转
  2. 模块内部的数据访问
  3. 模块外部的函数调用、跳转
  4. 模块外部的数据访问,如其他模块定义的 全局变量等

模块内部可以直接使用静态链接的方法。但是实际上有一种特殊情况是使用动态链接的方法。

extern int global;
int foo()
{
global = 1;
}
这种情况下Global可能是模块内部其他文件的变量,也可能是其他模块的变量,如果按照静态链接,最终结果可能是
movl $0x1, XXXXX //XXXXX是global的地址
由于可执行文件在运行时并不对代码进行重定位(没有修改权限改不了),因此如果是跨模块调用问题就明显了。因为此时global变量没有对应符号,链接失败,只有一个无效地址,而动态链接在运行时才会确定符号的地址。

解决方法是将它看做第四种情况,使用GOT

模块外部需要利用到GOT。

GOT 全局偏移量表,他其实就是一个数组,这个表用来存全局变量的地址,在data段开始的地方,而静态链接的时候那些符号的跳转地址都是跳转到这个表中,然后在运行时这个跳转表才会真正赋予地址。

例:

mov 0x100010(%rip), %rax
addl $0x1, (%rax)

第一行就是跳转到跳转表然后取出里面地址,然后第二段取出之歌地址中的值并加一

pic数据引用

上面是程序运行前将所有文件全部绑定了,但是绑定的时候有可能有很多函数并不需要用,这样就拖延了程序启动的时间。为了加快程序启动,可以在使用函数时再进行链接,这时就需要PLT表。

PLT 过程链接表。举个例子

这个例子是从右上角开始的,先调用函数到了PLT表,之后跳转到GOT[4]所指位置,第一次GOT[4]指向4005c6也就是下一条指令,第一次后GOT[4]就会变成函数地址。

4005c6是把addvec的ID拖入栈中,然后跳转到GOT[2]的位置同时把GOT[1]放到栈中,GOT[1]是解析函数用到的信息,GOT[2]是动态链接器地址。跳到GOT[2]后就根据栈中的内容链接函数并把GOT[4]的值变成函数首地址。

在真正的文件中GOT拆分成了两个表.got和.got.plt。.got是保存全局变量地址,.got.plt保存外部函数地址。此外.got.plt的前三项有特殊含义

  • 第一项是.dynamic段的地址,包含了动态链接相关信息
  • 第二项是本模块的ID
  • 第三项是动态链接器的地址,它传入两个参数函数ID和模块ID

动态链接相关节和段

.interp

.interp保存的是动态链接器的地址,例如在linux中.interp中的内容可能是"/lib/ld-linux.so.2".

.dynamic

.dynamic保存了动态链接器所需要的基本信息。它是Elf32_Dyn类型的数组,Elf32_Dyn的定义为:

typedef struct
{
Elf32_Sword d_tag;//类型
union//类型的值
{
Elf32_Word d_val;
Elf32_Addr d_ptr;
}
}Elf32_Dyn;

d_tag 含义
DT_SYMTAB 动态链接符号表的地址, d_ptr表示.dynsym的地址
DT_STRTAB 动态链接字符串表的地址, d_ptr表示.dynstr的地址
DT_STRSZ 字符串表的大小, d_val表示大小
DT_HASH 动态链接哈希表地址
DT_SONAME 本共享对象的SO-NAME
DT_RPATH 动态链接共享对象搜索路径
DT_INIT 初始化代码地址
DT_FINIT 结束代码地址
DT_NEED 依赖的共享对象文件,d_ptr表示共享对象文件名
DT_REL/DT_RELA 动态链接重定位表地址
DT_RELENT/DT_RELAENT 重定位表入口数量

.dynsym

动态链接符号表,他只保存和动态链接相关的符号,其他的符号如模块私有变量不保存,他们保存在.symtab中,.symtab保存了所有的符号。

.rel.dyn和.rel.plt

这两种都是重定位表,和前面的.rel.text和.rel.data一样,.rel.dyn是保存代码段的重定位表,.rel.plt是数据段的重定位表。

前面已经说了两种重定位类型R_X86_64_PC32和R_X86_64_32,这里还要接受R_X86_64_RELATIVE、R_X86_64_GLOB_DAT和R_X86_64_JUMP_SLOT

其中R_X86_64_GLOB_DAT和R_X86_64_JUMP_SLOT会将对应位置修改为GOT表中的对应项,然后将对应项修改为符号的地址。

R_X86_64_RELATIVE是针对共享对象的数据段的,它实际上没办法做到地址无关。因为他可能会包含绝对地址引用。例如:

static int a;
static int* p = &a;

由于a的地址在开始时不确定的,但是它的值可以使相对文件的偏移B,假设在装载后文件的基地址为A,那么实际上p的值是A+B,也就是需要将p的值加上B。因此这种重定位条目会有p的地址和B。

动态链接过程

  1. 动态链接器自举: 由于动态链接器本身也是一个程序,因此他本身也需要链接,这时只能不依赖其他系统库、运行库并且设计一段代码完成自身初始化。
  2. 装载共享对象: 将所有符号表合并成一个,称为全局符号表。然后链接器开始寻找现有文件中依赖的库或对象,然后将这些对象的符号表也添加进全局符号表。要注意的是如果某个符号已经在全局符号表中存在,那么这个符号将被忽略。
  3. 重定位和初始化,根据每个文件的重定位表对表项进行重定位,然后执行初始化函数。

相关函数

dlopen()

void* dlopen(const char* filename, int flag);

打开一个动态库,加载到进程空间并完成初始化。第二个参数时符号解析方式,RTLD_LAZY表示延迟绑定,RTLD_NOW表示加载时就绑定。

dlsym

void* dlsym(void* handle, char* symbol);

作用: 通过符号名找到符号

第一个参数时dlopen返回的句柄,第二个参数是符号的名字。失败将返回NULL,否则如果是常量将返回值,如果是变量将返回地址。

dlerror

通过dlerror判断上述函数是否执行成功,然后是NULL表示执行成功。

dlclose

卸载已经加载的模块。

静态链接库

过去我们有两种选择进行连接,第一种是把一堆常用函数放到一个可重定向文件中,另一种是把所有常有函数都分开,然后一个个链接。第一种占用空间太大,可能为了一两个函数加了一两千个函数,第二中编译时太难写,所以使用了静态链接库。

静态链接库集合了上面两种方法,首先它是一个可重定向目标文件。但是不同在于他有一个符号表需要哪个函数就把那个函数链接进去,这样就兼顾上面二者的优点。

可以使用gcc -static -o prog2c main2.c -L. -lvector其中L是链接库的路径,l是库名,一般库开头都是lib所以lib可以省略,后缀名可以省略。

可以使用ar rcs libvector.a addvec.o multvec.o来创建静态库,libvector.a是库名

为了效率考虑,链接时从左向右每个文件只会扫描一次,这样就可能导致问题。例如,最右边的模块有外部符号(引用其他文件的),这时因为扫描已完毕,所以就会报错。

此外,对于一些常用函数如cout等,如果每个程序都复制一次,那么还是太浪费空间。

而且如果库函数有错误或者考虑不周需要修改,那么对于绝大多数程序来说都是一个灾难,因为大型程序重新编译一下可能会导致冲突等不可预料的后果。

为了解决这些问题,提出了动态链接

动态链接库

库打桩技术

总体来说库打桩是指自己写一个库然后先于系统库加载这时程序运行的就是你自己写的库了。

链接时库打桩

例:

#define malloc(size) mymalloc(size)
#define malloc(ptr) myfree(ptr)

void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("malloc(%d)=%p\n", (int)size, ptr);
return ptr;
}
void myfree(void *ptr)
{
free(ptr);
printf("free(%p)\n", ptr);
}
...

通过上面就可以实现不使用库函数而使用自己的函数了。这个函数便于跟踪内存请求和释放情况。但是每个文件都要写太过麻烦。

也可以把这个单独写成一个文件,然后通过编译器gcc -Wl ,--wrap,malloc -Wl,--wrap,free -o int1 int.0 mymalloc.o进行链接

运行时库打桩

运行时库打桩基于LD_PRELOAD环境变量。在这个变量路径下的库会先于系统库进行加载。

更多内容请看