LOADING

加载过慢请开启缓存 浏览器默认开启

csapp:ch7_Linking(完结)

2024/9/21

链接

首先是这张图

静态链接就是输入可重定位目标文件和一些命令行参数,输出可执行文件的过程。

为什么需要链接?修改某一个源码之后可以编译这一个文件,并链接。而不是重新编译所有文件。

为了完成链接,链接器要完成两个任务:符号解析,重定位

目标文件

有三种形式:

可重定位目标文件(.o文件)

每个.o文件由对应的.c文件生成

它包含了二进制的代码和数据,可以和其他可重定位目标文件链接,生成可执行目标文件。

可执行目标文件(.out)文件

包含二进制的代码和数据,可以直接复制到内存中然后执行。

共享目标文件(.so)文件

特殊类型的可重定位目标文件,可以在加载时或运行时加载到内存并动态链接

windows上称为动态链接库(.dll)

可执行可连接的格式

Executable and Linkable Format(ELF)

二进制格式

为上面三种目标文件提供一种统一的格式封装

通用名称:ELF二进制文件

ELF目标文件格式

其中包含了如图所示的几个部分:

1.ELF头

​ 包含了字大小、字节顺序、ELF头的大小、目标文件类型、机器类型、节头表的偏移量等等。

2.程序头部表(段头部表)

​ 包含:页大小、虚拟地址内存段(节)、段大小的信息

​ 可执行目标文件中必须存在此部分

3..text节

​ 已编译程序的机器代码

4..rodata节

​ 只读数据:跳转表等

5.data节

​ 已初始化的全局和静态C变量,局部变量不会保存在这里

6.bss节

​ 未初始化的全局和静态C变量,以及初始化为0的。

​ 原名:“块存储开始”

​ 可以把它看做是“更好的节省空间”的缩写

​ 有节头信息,但不占空间,只有在加载时,才会在内存分配空间。

7.symtab节

​ 符号表

​ 存放定义和引用全局变量和函数的信息

注:不是必须要使用-g选项在编译的时候才能显示出符号表,每一个.o文件都有一个符号表,只不过这两个符号表有些区别,就是.symtab没有局部变量。

8.rel.text节

​ .text节中的重定位信息

​ 存放那些需要在可执行目标文件中被修改的指令地址信息

​ 链接器在将这个文件和其他文件链接时,需要修改,尤其是当这个文件调用了其他文件中的函数或者引用了全局变量的时候,而调用本地函数,不需要修改。

​ 在链接之后的可执行文件中,它可能不存在了,因为他的使命在重定位完成之后就完了。

9.rel.data节

​ .data节中的重定位信息

​ 在合并后的可执行文件中需要修改的数据所在的地址

​ 如果全局变量的初值是一个全局变量地址或者外部定义的函数地址,都需要修改。

10.debug节

​ 一个调试符号表,包含了局部变量,类型定义,全局变量等

​ 调试使用的符号信息 (在gcc中使用-g选项生成)

(补充).line节

​ .c程序的行号和.text节中的指令的映射,需要-g选项才能得到。

(补充).strtab节

​ 字符串表,每个symtab和debug表项的名字。

11.节头部表

​ 每个节的偏移量和大小

符号和符号表

每个可重定位目标模块都有符号表,而这个符号表中的表项有三种

1.全局符号

​ 这个模块定义并能被其他模块引用

​ 非static函数和非static全局变量

2.外部符号

​ 其他模块定义,并被当前模块引用的全局符号

3.局部符号

​ 当前模块定义的static的函数和static的全局变量,只能被当前模块引用的符号

​ 补充:局部变量不会在.symtab中体现,它们在运行时的栈中被管理,因此,链接器也对它们不感兴趣。如果是static的局部变量,那么也可以显示。

如图所示,链接器看不到局部变量。

符号表结构

.symtab节中包含了符号表,他是由汇编器构造的。

如图所示的定义类型,他包含了符号表每个条目的信息。这样的一个数组,就构成了符号表,如下图所示。

name表示这个符号的名字,在.strtab的偏移量,他指向一个字符串。

value指这个符号,在定义他自己的节里面距离节的起始位置的偏移量。如果这是一个可执行文件,那么value就代表绝对的地址值。

size是大小,以字节为单位

type表示类型,通常要么是函数,要么是数据。而且还有其他的表项,对应其他的类型。

binding表示该符号是本地的还是全局的。

不要忘了,每个符号都在elf文件中的某个节,而这个节就是用的section表示,他是一个节头部表的表项索引,告诉你这是哪个节。

有三个特殊的伪节,它们在节头表中没有条目:ABS表示不被重定位,UNDEF表示未定义(在本模块引用,但在其他模块定义),COMMON表示未初始化,它的size是最小的大小。

你可能会奇怪,按理说未初始化的应该在.bss里面,但是它们还是有一点区别的。

COMMON 未初始化全局变量

.bss 未初始化静态变量,初始化为0的全局或静态

这种分类是因为符号解析的方式。

下图是通过readelf指令读出的符号表。

全局符号main,它是一个位于.text节(Ndx = 0)中偏移量 为0(即value值)处的24 字节函数。其后是全局符号array的定义,它是一个位 于.data 节中偏移量为 0处的 8字节目标。最后一个条目来自对外部符号sum的引用。这里Ndx=1表示.text节,而Ndx=3表示.data节。

符号解析

链接器如何解析每个符号的引用情况?

将每一个引用,和他自己的symtab的定义对比,找到那一个表项。对于在引用和定义在一个模块的局部符号,这非常简单。

但是全局符号就非常麻烦了,当编译器遇到不是在当前模块定义的引用,就会生成一个symtab的条目,然后交给链接器。如果链接器没有在所有的模块中找到他,就会生成错误信息并终止。

旁注:为什么c++和java中允许函数重载?

名字相同,参数列表不同的函数,如何识别?因为编译器将每个函数名和它们的参数列表组合,编码成为一个对链接器来说唯一的名字,这种方法叫重整,相反的过程叫恢复。而且这个新名字就是原来的名字和数字,下划线组成的。

多重定义的全局符号

全局符号包括强符号和弱符号

强符号 函数和已初始化全局变量

弱符号 未初始化全局变量

在链接时,如果出现符号同名

  • 规则1:不允许多个同名强符号

  • 规则2:如果有一个强符号和多个弱符号同名 那么选择强符号

  • 规则3:如果多个弱符号同名,那么随机选一个

以下是可能出现的情况:

这里第二行和第三行可能会有点难以理解,在x86-64/linux系统上,double类型是8字节,int类型是4字节,在这个系统中,假设x的地址是0x601020,y的地址为0x601024,因此在p2中,将x的值写入,会将这个数的双精度浮点表示覆盖在x和y的位置。第二行中写可能是因为x和y都是弱符号,它们的定义可能在一块,而第三行,它们都是强符号,那么定义是在一块的。这个错误非常讨厌,而且一般是在很远的地方才表现出来。

那么我们可以使用选项

GCC -fno -common表示遇到多重定义符号时,变成一个错误。

或者直接-Werror将所有警告变成错误。

在上一节 我们知道了编译器将符号分配为COMMON和.bss,这到底是为什么了,这里不多说了。详细请看p474页中间。

不过最最根本的防止此类错误的方法,就是防止使用全局变量,尽量用static,就算用,那就要初始化它。如果在其他文件中使用此文件的全局变量,要用extern声明。

与静态库连接

编译器还提供了一种机制,将所有的相关的目标模块打包成为一个单独的文件,成为静态库(static library),它可以用作链接器的输入,当链接器构造可执行文件的时候,链接器可以仅仅复制目标程序引用的目标模块。

为什么要采用这种机制?当然是因为其他的机制都不行了,具体为什么不行,请看475页。

相关函数可以被编译为独立的一个个模块,封装成为静态库。然后,在命令行中指定文件的名字,以使用这些库中的函数。(这些静态库的后缀为.a,被称为存档文件)

在链接时,链接器只复制被引用的模块。

476,477页举了一个具体的例子。

链接器如何使用静态库来解析引用

gcc -static a.c b.c c.c d.a e.a  

-static表示生成可执行文件

在符号解析阶段,链接器从左到右地按照在控制台中的顺序扫描.o和.a文件(编译器自动将.c转化为.o文件),并维护三个集合:

  • E:可重定位目标文件或模块
  • U:引用但未定义的符号
  • D:在前面已经定义的符号集合

对于每一个输入文件f,首先判断是.o还是.a文件,如果是.o那就添加到E,并修改U和D。如果是.a,那就尝试和U中的元素匹配,匹配成功,就将目标模块加入E,修改U和D,直到U和D不再变化,然后丢弃没有匹配的部分,继续下一个。

如果完成全部扫描之后,U非空,就输出错误信息并终止。U空,就输出可执行文件。

不过,这种方式会导致一些错误,因为命令行里面的文件顺序就很重要了,如果先输入.a,再输入.c,那先输入的.a就会全部被抛弃,而.c也不能正常链接。

一般准则是把.a放在结尾,如果.a中也有互相调用的情况,也要调整顺序,同一个文件重复出现也是可以的。

gcc foo.c la.a lb.a lc.a la.a

重定位

如图所示,符号解析完成之后,每个符号引用就和一个定义关联起来了,然后开始重定位,即合并这些模块,并为每个符号分配运行时的地址。

合并这些模块就是.text都归到可执行文件的.text,.data都归到可执行文件的.data等等,这个很好理解。

下面我们具体说明第二个步骤,分配地址。

重定位条目

当汇编器在生成.o文件时,如果遇到了最终位置未知的引用,比如外部定义的符号引用和全局变量引用,就会生成重定位条目,告诉链接器如何修改这个引用。

代码的重定位条目在.rel.text

已初始化数据的重定位条目在.rel.data

下图展示了ELF重定位条目的格式

offset表示引用的偏移量

symbol表示被修改的引用的符号

type表示重定位类型,elf有32种,其中最主要的两种在图片中。

addend是一个有符号常数,一些类型的重定位要用到。

相对引用表示距离程序计数器eip当前运行时值的偏移量,即当前eip的值加上这个偏移量,而eip通常是下一条指令在内存中的地址。

绝对引用 cpu直接使用这个值作为地址值

在这里,地址都是32位的,因此我们也假设内存大小小于2GB

重定位符号引用

如图所示的main.c,它引用了全局变量array和外部定义函数sum(在sum.o中),于是在反汇编的main.o中,它们在汇编器中生成了一个重定位条目,在引用的下一行上(注意这里实际上重定位条目和代码存在elf不同节中,为了方便,objdump将它们显示在一起)。

上图是链接器的重定位算法,其中s是对应的节,r.symbol表示被引用的符号。

假设算法运行的时候,链接器已经为每一个节s(这里表示引用发生的节)和每个符号r.symbol(这里表示引用的符号)分配了运行时地址。

下面我们用例子分析这两种引用。

重定位PC相对引用

在汇编语言的第六行中,main调用了sum,sum是在sum.o中定义的,call指令的opcode为e8,后面是4个占位符。重定位条目有4个字段组成

r.offset = 0xf;
r.symbol = sum;
r.type = R_X86_64_PC32;
r.addend = -4;

首先offset为0xf,是指在引用sum的call指令opcode后面的操作数,地址为0xf,在这个地方,有一次引用。addend是用来计算的

前文说,链接器已经确定了s,在这里是.text的地址,以及sum的地址,然后由于是相对引用,对应算法7-8行,首先算出引用的运行时地址,然后将那个引用位置上的数改成正确的指向sum的立即数

ADDR(s) = ADDR(.text) = 0x4004d0

ADDR(r.symbol) = ADDR(sum) = 0x4004e8

refaddr = ADDR(s) + r.offset

​ = 0x4004d0 + 0xf

​ = 0x4004df

*refptr = (unsigned) (ADDR(r.symbol) + r.addend- refaddr)

​ = (unsigned) (0x4004e8 + (-4) - 0x4004df)

​ = (unsigned) (0x5)

这里addend应该是不用管,就是一个用来计算的数,汇编器生成的。

于是call指令的立即数就算完了,并写入文件中。

4004de: e8 05 00 00 00		callq 4004e8 <sum>

重定位绝对引用

这个比较简单,下图中mov指令将array的地址(一个32位立即数)复制到寄存器%edi

对应的重定位条目:

r.offset = Oxa
r.symbol = array
r.type = R_X86_64_32
r.addend = 0

不做解释了,和前文一样

ADDR(r. symbol) = ADDR(array) = 0x601018

使用算法中的13行

*refptr = (unsigned) (ADDR(r. symbol) + r. addend)

​ = (unsigned) (0x601018 + 0)

​ = (unsigned) (0x601018)

然后写入文件中

4004d9: bf 18 10 60 00		mov 	$0x601018,%edi

之后我们得到了最终版本

00000000004004d0 <main>:
  4004d0:       48 83 ec 08             sub    $0x8,%rsp
  4004d4:       be 02 00 00 00          mov    $0x2,%esi
  4004d9:       bf 18 10 60 00          mov    0x601018,%edi
  4004de:       e8 05 00 00 00          callq  4004e8 <sum>
  4004e3:       48 83 c4 08             add    $0x8,%rsp
  4004e7:       c3                      retq

00000000004004e8 <sum>:
  4004e8:       b8 00 00 00 00          mov    $0x0,%eax
  4004ed:       ba 00 00 00 00          mov    $0x0,%edx
  4004f2:       eb 09                   jmp    4004fd <sum+0x15>
  4004f4:       48 63 ca                movslq %edx,%rcx
  4004f7:       03 04 8f                add    (%rdi,%rcx,4),%eax
  4004fa:       83 c2 01                add    $0x1,%edx
  4004fd:       39 f2                   cmp    %esi,%edx
  4004ff:       7c f3                   jl     4004f4 <sum+0xc>
  400501:       f3 c3                   repz retq

可执行目标文件

现在,多个目标文件已经合并成了一个可执行文件,下图是这个可执行文件的各种信息

它类似于可重定位格式。ELF头还包括了程序入口点(entry point),即程序执行第一条指令的地址。.text,.rodata,.data除了已经重定位了,其他部分和.o相似。.init节定义了一个函数,叫做_init,程序初始化代码的时候调用。可执行文件已经不需要.rel节了。

段头表描述了可执行文件的连续的片(chunk)是如何映射到连续的内存片段的。下图是objdump显示的段头表

表中告诉我们这个可执行文件的内容分为两个片,加载到两个内存段中。

对于任何段s,链接器选择的起始地址vaddr必须要满足

vaddr mod align = off mod align

这是一个对齐的要求,使得当程序运行时,目标文件的段可以很有效率的传送到内存。原因在于虚拟内存的组织方式,它是大小为2的次方的字节片。第九章会学到。

加载可执行目标文件

要运行一个可执行文件,假设它的名字是prog,我们在linux shell命令行中输入它的名字

因为prog不是一个内置的shell命令,所以shell认为他是一个可执行文件的文件名,调用加载器(loader)(这是操作系统的代码,存在存储器中)来运行他。对于linux程序,通过调用execve函数来调用loader,在第八章某一节中会详细介绍。loader会将可执行文件的代码和数据复制到内存中,然后跳转到程序的第一条指令或入口点运行该程序。

这个将代码和数据复制到内存的过程,叫加载。

在运行时的内存分配,如图所示

其中,代码段的地址总是从0x400000开始,用户栈从最大的合法地址向下增长。

虽然这里堆,变量段,代码段,以及用户栈栈底紧贴kernel,但是由于对齐要求,实际上变量段和代码段有间隙。

同时,由于第三章第十节提到的ASLR,即地址空间布局随机化,栈,共享库,堆的地址每次运行的时候都是改变的,但是相对地址不变。

接下来,loader完事之后,加载器跳转到入口点,即_start函数的地址,这个函数是在系统目录文件ctrl.o中定义,然后start调用系统启动函数__libc_start_main,该函数定义在libc.o中,它初始化程序的执行环境,然后调用main,之后处理main的返回值,并在需要的时候将控制返回给kernel。

旁注:上文描述的加载器概念上是正确的,但是实际上不完全准确,要完全理解加载器,我们还要学习进程,虚拟内存,内存映射,之后我们再介绍它。如果你现在就想知道,看书485页旁注。

动态链接共享库

在前文提到了静态库,也是有一些缺点:

1.如果在多个可执行文件中都引用了相同的函数,比如说标准I/O函数等,那就要把这个函数的模块的代码复制多份,这对内存是极大的浪费。

2.如果静态库有了更新,比如说修复bug等,那就要为每一个程序显式重新链接。

于是发明了共享库

共享库(shared library)是一个目标模块,在运行或加载时,可以加载到任意内存地址,然后和一个内存中的程序链接,这个过程,叫动态链接,由动态链接器执行。共享库也称作共享目标,动态链接库,DLL,在linux中,用.so后缀来表示。

动态链接可以在两个时期发生:在可执行文件第一次加载运行时(加载期链接),在程序运行后(运行期链接)。

我们先讨论第一种。

首先,为了构造共享库,我们可以使用指令

gcc -shared -fpic -o libvector.so addvec.c multvec.c

其中,-fpic指示编译器生成位置无关代码,一会讨论。-shared表示创建一个.so文件。

然后链接到程序

gcc -o prog21 main2.c ./libvector.so

如图所示

创建的可执行文件,可以在运行时和.so链接。

注意此时的可执行文件只是完成了一部分静态链接,关于动态链接的内容,没有任何代码被复制到可执行文件中,而是复制了一些重定位和符号表信息,以便运行时的解析。

当加载器运行可执行文件时,首先按照上一节的内容,加载到内存,然后由于可执行文件有一个.interp节,这个节包含了动态链接器的路径名,而动态链接器本身是一个共享目标(.so),加载器加载并运行这个动态链接器,然后动态链接器完成后续任务。

1.重定位所有.so文件的代码和数据到一些内存段

2.重定位可执行文件对应的引用。

最后,动态链接器将控制流传递到应用程序。此时,共享库的位置就固定了,在程序执行过程中都不会改变。

从应用程序中加载和链接共享库

下面我们讨论运行期链接,即程序运行后。

运行期链接的应用有很多,包括分发软件,构建高性能web服务器,运行时打桩等(详细请看书487页)
在linux中,通过调用dlopen()来实现。

#include <dlfcn.h>//动态链接库

void *dlopen(const char *filename, int flag);
//返回:若成功,返回指向句柄的指针,若出错,则为NULL

其中filename是.so文件的路径,flag是一个选项,决定加载模式,有RTLD_NOW,指立即解析对外部符号的引用,RTLD_LAZY,指推迟对外部符号引用的解析,到真正调用时,才解析。RTLD_GLOBAL表示这个共享库定义的符号可以被后面打开的其他共享库里引用。
举个例子,假设libA.so中定义了一个函数a(),而libB.so中定义了另一个函数b(),而如果b()调用了a(),那就要在打开libA.so的时候加上RTLD_GLOBAL选项,以便b()可以调用成功。
而且RTLD_NOW和RTLD_LAZY都可以和RTLD_GLOBAL取或,这样就可以同时应用这两个选项。

接下来获取要引用的函数的指针

#include <dlfcn.h>

void *dlsym(void *handle, char *symbol);
//返回:若成功,返回指向符号的指针,若出错,则为NULL

这里handle是刚才得到的句柄,symbol是引用的符号,函数不带括号。
dlclose函数可以卸载共享库

#include <dlfcn.h>

int dlclose (void *handle);
//返回:若成功,则为0,若出错,则为-1

dlerror函数可以检查有没有发生错误

#include <dlfcn.h>

const char *dlerror(void);
//返回:如果前面对dlopen、dlsym或dlclose的调用失败,则为错误消息,如果前面的调用成功,则为 NULL。

接下来举个例子
程序的源文件是

#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

 int main(){
    void* handle;
     void (*addvec)(int *, int *, int *, int);
     char *error;
     /* Dynamically load the shared library containing addvec() */
    handle = dlopen("./libvector.so", RTLD_LAZY);
     if(!handle){
         fprintf(stderr, "%s\n", dlerror());
         exit(1);
    }
    /* Get a pointer to the addvec() function we just loaded */
     addvec = dlsym(handle, "addvec");
    if ((error = dlerror()) != NULL){
         fprintf(stderr, "%s\n", error);
         exit(1);
    }
    /* Now we can call addvec() just like any other function */
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n",z[0], z[1]);
    /* Unload the shared library */
    if (dlclose(handle) < 0){
         fprintf(stderr, "%s\n", dlerrorO);
         exit(l);
    }
    return 0;
 }                

然后编译这个文件。

gcc -rdynamic -o prog2r dll.c -ldl

-rdynamic选项告诉编译器将这个源文件的所有全局符号加入动态符号表,目的是可以允许动态加载的符号可以反向引用这个源文件的全局符号。比如说最后调用的addvec(x,y,z,2),就引用了x,y,z,如果不加这个选项 就不行。
-ldl选项是因为要用dlopen这些函数,而添加的选项,表示链接到libdl库。

位置无关代码

共享库的主要目的就是让多个正在运行的程序共享相同的库代码,如何来实现呢?

让特定的共享库使用特定的内存片,这肯定不行,如果不用这个库了,那就又浪费了。

于是采用特别的方法编译共享模块的代码段,使得他们可以被加载到内存任何位置,而无需链接器修改(重定位),这样,可以让很多个进程使用同一段代码了。

而这种可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code, PIC),使用-fpic选项来生成这种代码。注意如果是共享库,就必须要加这个选项。

在x86-64中,如果是内部的引用,生成PIC不需要其他处理,直接用PC相对寻址即可。如果是外部引用,就要用到一些办法。

PIC数据引用

一个特性:不论我们在内存中的任何位置加载一个目标模块(包括共享),数据段和代码段的距离总是不变的。

利用这个特性,在引用全局变量的目标模块中,数据段开始的地方创建一个表,叫全局偏移量表(Global Offset Table, GOT),在GOT数组中,每个被此模块引用的全局符号(函数和变量),都有对应条目,这个条目是重定位记录(8字节),然后动态链接器会重定位这些记录,使他们变成真正的地址值。然后在由于代码段和数据段的距离为定值,因此,要访问对应变量,只需加上那个定值,以访问GOT即可。

如图所示,addvec()函数通过GOT[3]来引用全局变量addcnt,然后使它的值加一。

这里addvec()和addcnt都是在libvector.so中定义的,要达到这个引用,可以不用GOT,只有外部变量用GOT,但这里的方案很直接,那就是不论内外,全都用GOT。

PIC函数调用

如果程序调用了一个共享库定义的函数,那么GNU编译系统会使用延迟绑定(lazy binding),将函数地址绑定推迟到第一次引用它。
这个机制可以确保,如果某程序只调用了共享库中的一部分函数,那么另一部分不需要进行重定位。
下面具体介绍。
延迟绑定是通过两个数组,GOT和PLT(过程链接表,procedure linkage table)来实现的,它们都是在编译时期生成的,其中GOT在数据段,PLT在代码段。

  1. PLT:它的每个条目是16字节二进制代码,PLT[0]跳转到动态链接器,PLT[1]调用系统启动函数(__libc_start_main),从PLT[2]开始,以后的条目调用用户代码调用的函数。
  2. GOT:每个条目是8字节地址,和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时用到的信息。GOT[2]为动态链接器在ld-linux.so模块的入口。剩下的每个条目对应一个被调用的函数,它们的地址在运行时才解析。

每个GOT条目都匹配一个PLT条目,例如,在下图例子中,GOT[4]和PLT[2]对应addvec()。在一开始(第一次调用前),GOT条目指向对应PLT条目的第二条指令。

如图所示,有a,b两张图,分别表示第一次调用和第二次调用。

当第一次调用时:首先进入PLT[2],然后跳转到GOT[4],由于初始GOT[4]指向对应PLT第二条指令,因此执行下一条指令,将0x1,即addvec()的ID压栈,然后跳转PLT[0],将GOT[1]的地址作为参数压栈,然后跳转GOT[2],即动态链接器中。动态链接器确定addvec()的地址,然后将其值重写GOT[4],然后调用addvec()。
第二次调用时,就直接通过GOT[4],将控制转移到addvec()。

总结

首先,我们要使用共享库,那就要先把.c源文件编译为.so文件,而且在编译时,因为这些共享库都是位置无关代码,所以必须要加上-fpic选项。然后得到.so,假设是a.so。

接下来,我们有两种时期都可以使用它,分别是加载期和运行期。

先讨论加载期,那么我们要用指令将源文件(b.c)编译为b.o,此时,假设b.c中引用了a.so中定义的全局变量x和函数add(),由于此时编译器遇到了外部定义符号,因此生成.rel节的重定位信息。

然后和a.so链接。链接器会进行符号解析,将全局变量x和add()的引用和a.so中的定义相关联,然后由于a.so是PIC代码,于是在b文件数据段生成GOT(此时GOT的条目还只是重定位记录),在代码段生成PLT,然后生成可执行文件b

之后加载并运行b,由于b包含一个.interp节,于是加载动态链接器,将a.so的代码和数据加载到某个内存,然后填补GOT,其中全局变量重定位为正确的地址,而函数则是指向对应PLT条目的第二条指令。然后控制传到用户程序。

然后是运行期。要在运行期使用共享库,就要将b.c源文件中引用的a.so的符号全部改成dlopen那一套函数,同时include对应的库。

然后编译b.c,由于用到了dlopen等函数,因此要加上-ldl选项,意思是链接动态链接库。由于此时不需要和共享库链接,所以我们直接编译并链接,得到可执行文件b

注意时编译器不会生成重定位条目,因为编译器不会把x和add当成外部符号。

#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

 int main(){
    void* handle;
     void (*add)(int, int);
    handle = dlopen("./a.so", RTLD_LAZY);
     add = dlsym(handle, "add");
    int *x = dlsym(handle, "x");
    *x += add(2,3);
    dlclose(handle) 
    return 0;
 }                

如图所示,add和x都是局部变量。

于是在运行时期,程序走到dlopen这里,才会加载和链接共享库,使用不同的选项,控制什么时候来解析符号。

使用RTLD_LAZY选项可以推迟到实际需要时再解析。而dlsym函数只是获取那个函数的地址,整个过程不涉及外部符号引用,因此这个过程不会像刚才那样生成GOT和PLT

库打桩技术

linux链接器有一种很强大的技术,叫库打桩(library interpositioning),允许你截获对共享库函数的调用,转而实行你自己的代码。即拦截。

通过这一点,你可以追踪某个函数的调用情况,如多少次调用,输入输出都是多少等,甚至是把它替换为你自己的代码。

基本方法:

假设要劫持的目标函数是a();创建一个包装函数,他的原型和目标函数完全一样,使用一些机制,就可以欺骗操作系统调用你的包装函数了。

打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。

下面我们分别讨论,利用打桩来分析下面程序中,对malloc和free的调用。

#include <stdio.h>
#include <malloc.h>

int main(){
    int *p = (int *)malloc(32);
    free(p);
    printf("hello, world\n");
    exit(0);
}

然后我们使用本地的malloc.h和mymalloc.c

#ifdef COMPILETIME
/* Compile-time interposition of malloc and free using C
 * preprocessor. A local malloc.h file defines malloc (free)
 * as wrappers mymalloc (myfree) respectively.
 */

#include <stdio.h>
#include <malloc.h>

/*
 * mymalloc - malloc wrapper function
 */
void *mymalloc(size_t size, char *file, int line)
{
    void *ptr = malloc(size);
    printf("%s:%d: malloc(%d)=%p\n", file, line, (int)size, ptr);
    return ptr;
}
/* free wrapper function */
void myfree(void *ptr, char *file, int line){
    free(ptr);
    printf("%s:%d:free(%p)\n", ptr)
}
#endif             //mymalloc.c
#define malloc(size) mymalloc(size, __FILE__, __LINE__ )
#define free(ptr) myfree(ptr, __FILE__, __LINE__ )

void *mymalloc(size_t size, char *file, int line);
void myfree(void *ptr, char *file, int line);
//malloc.h

mymalloc.c中的是包装函数。

编译时打桩

使用指令编译

gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o hello hello.c mymalloc.o

第一行-DCOMPILETIME表示在编译时创建一个宏COMPILETIME(没有D),用于条件编译。

mymalloc.c中有#ifdef COMPILETIME意为如果定义了宏,就编译这段代码。

第二行-I.选项指示在搜索系统标准库之前,先在当前目录查找malloc.h,所以会运行打桩。(.表示当前目录,I后面的内容表示的就是路径,可以绝对,也可以相对)

注:mymalloc.c中的函数编译是用标准malloc.h编译的。

运行

./helloc
hello.c:7: malloc(10)=0x501010
hello.c:7: free(0x501010)
hello, world

链接时打桩

Linux 静态链接器支持用–wrap f标志进行链接时打桩。这个标志告诉链接器,把对符号f的引用解析成__warp_f(前缀是两个下划线),还要把对符号 _ _real _ f(前缀是两个下划线)的引用解析为f。

我们修改mymalloc.c

#ifdef LINKTIME
/* 
   Link-time interposition of malloc and free using the static
   linker's (ld) "--wrap symbol" flag.
*/

#include <stdio.h>

void *__real_malloc(size_t size);
void __real_free(void *ptr);

/*
 * __wrap_malloc - malloc wrapper function
 */
void *__wrap_malloc(size_t size)
{
    void *ptr = __real_malloc(size);
    printf("malloc(%d) = %p\n", (int)size, ptr);
    return ptr;
}
/* free wrapper function */
 void __wrap_free(void *ptr){
    __real_free(ptr);         /* Call libc free */
    printf("free(%p)\n", ptr)
 }
#endif

然后分别编译两个文件

linux> gcc -DLINKTIME -c mymalloc.c
linux> gcc -c hello.c

之后链接

linux> gcc -W1,--wrap,malloc -W1,--wrap,free -o hello hello.o mymalloc.o

-wl,option表示把option传递给链接器。其中option中的每个逗号都替换为空格。

所以-wl,–wrap,malloc 就把–wrap malloc 传递给链接器

运行

linux> ./hello
malloc(10) = 0x501010
free(0x501010)
hello, world

加载/运行期打桩

编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访问程序的可重定位文件。不过,运行时打桩,只需要访问可执行目标文件。这个机制基于动态链接器的LD_PRELOAD环境变量。

如果LD_PRELOAD 环境变量被设置为一个共享库路径名(绝对或相对)的列表(以空格或分号分隔),那么当你加载和执行一个程序,需要解析未定义的引用时,动态链接器(LD-LINUX.SO)会先搜索LD_PRELOAD库,然后才搜索任何其他的库。有了这个机制,就可以实现打桩。

下面是包装函数mymalloc.c

#ifdef RUNTIME
 /* Run-time interposition of malloc and free based on
  * dynamic linker's (ld-linux.so) LD_PRELOAD mechanism */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

void *malloc(size_t size){
    static void *(*mallocp)(size_t size);
    char *error;
    void *ptr;

    /* get address of libc malloc */
    if (!mallocp) {
        mallocp = dlsym(RTLD_NEXT, "malloc");
        if ((error = dlerror()) != NULL) {
            fputs(error, stderr);
            exit(1);
        }
    }
    ptr = mallocp(size);
    printf("malloc(%d) = %p\n", (int)size, ptr);
    return ptr;
}
/* free wrapper function */
void free(void *ptr){
    void (*freep)(void *) = NULL;
    char *error;
    if (!ptr)
        return;
     freep = dlsym(ETLD_NEXT, "free"); /* Get address of libc free */
    if ((error = dlerror()) != NULL){
        fputs(error, stderr);
        exit(1);
    }
    freep(ptr); /* Call libc free */
    printf("free(°/ÿp)\nM, ptr);
}
#endif

构建含有包装函数的共享库

linux> gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl

编译主程序

linux> gcc -o hello hello.c

在shell中运行

1inux> LD_PRELOAD="/usr/lib64/libdl.so ./mymalloc.so" ./hello  
malloc(10) = 0x501010
free(0x501010)
hello, world

处理目标文件的工具

在处理目标文件这类二进制文件的时候,linux有一些工具可以使用。

比如说GNU binutlis包

  • AR: 创建静态库,插人、删除、列出和提取成员。
  • STRINGS: 列出一个目标文件中所有可打印的字符串。
  • STRIP: 从目标文件中删除符号表信息。
  • NM:列出一个目标文件的符号表中定义的符号。
  • SIZE: 列出目标文件中节的名字和大小。
  • READELF: 显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含 SIZE 和 NM的功能。
  • OBJDUMP: 所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大 的作用是反汇编.text节中的二进制指令。 Linux 系统为操作共享库还提供了LDD程序:
  • LDD: 列出一个可执行文件在运行时所需要的共享库。

本章完