CSAPP 第七章 链接

动态链接和静态链接

链接(linking):将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。就是我们之前有学过的编译文件的最后一步,分静态和动态链接两种可能。

在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。

编译器驱动程序

我们这一章都是在用下面的小函数示例讲解学习的:

这是两个源文件分别是 main.c 和 sum.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//main.c
int sum(int *a, int n);
int arry[2]={1,2}

int main()
{
int val=sum(array,2);
return val;
}

//sum.c
int sum(int *a,int n)
{
int i,s=0;
for(i=0;i<n;i++)
{
s+=a[i]
}
return s;
}

大多编译系统提供编译器驱动程序(如 gcc ),它实际上是广义的”编译器“,是语言预处理器、编译器、汇编器和连接器的集合。

我们可以使用下面的命令把两个文件链接在一起编译:

1
gcc -o prog main.c sum.c

当然也可以将这个命令拆来来编译:

1
2
3
4
5
6
7
8
9
10
11
12
#预编译:展开头文件和替换宏定义
cpp main.c /tmp/main.i

#编译:将高级语言翻译成低级语言
cc1 /tmp/main.i /tmp/main.s

#汇编:将汇编语言翻译成机器语言
as /tmp/main.s /tmp/main.o

#sum.c文件相同。
#链接:将文件连接在一起生成可执行文件
ld -o prog /tmp/main.o /tmp/sum.o

静态链接

我们的命令 ld 就是调用了 Linux LD 程序这样的静态链接器,它以一组可重定位目标文件和命令行参数作为输入,生成一个完全连接的、可以加载和运行的可执行目标文件作为输入。

连接器要完成两个主要任务才能实现链接目的:

  • 符号解析:将每个符号引用和一个符号定义关联起来
  • 重定位:编译器和汇编器生成从地址 0 开始的代码和数据节,将每一个符号引用和一个内存位置关联起来。

目标文件

目标文件有三种形式:

  • 可重定位目标文件(.o):包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
  • 可执行目标文件(.elf):包含二进制代码和数据,其形式可以被直接复制到内存并执行。
  • 共享目标文件(.libc):一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。

可重定目标文件

可重定目标文件分成几个部分:

  • .text:已编译程序的机器代码段
  • .rodata:只读数据段
  • .data:已初始化的全局变量和静态变量
  • .bss:未初始化全局变量和静态变量
  • .symtab:符号表,保存全局变量的信息。默认生成,可以使用STRIP命令去除。
  • .rel.text:一个.text 借中的位置表,被链接的时候会被修改位置。
  • .rel.data:被模块引用或定义的所有全局变量的重定位信息,被链接的时候会被修改位置。
  • .debug:调试符号表,只有以 -g 选项时会得到这张表,其中包含了局部变量的定义和类型,以及原始的 C 文件,只有 -g 命令会生成这张表。
  • .line:原始 C源程序中的行号和text 节中机器指令之间的映射。只有以-g 选项调用编译器驱动程序时,才会得到这张表。
  • .strtab:一个字符串表,其内容包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。

同是程序中的变量,区分 .bss 段 .data 段的原因是初始化的变量需要使用一段空间去保存初始化得到的值,而未初始化则不需要, .bss 段存储为初始化数据可以更好的节省空间

符号和符号表

每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:

  • 模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。

  • 由其他模块定义并被模块 m 引用的全局符号。

  • 只被模块 m 定义和引用的局部符号。它们对应了带 static 属性的 C 函数和全局变量。

.symtab 中的符号表并不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,连接器对此类符号不感兴趣。

静态的局部变量不会在栈中管理,会放到 bss 或者是 data 段上。

.symtab 节中会包含一个 ELF 符号表,这张符号表包含这样一个结构体条目的数组:

1
2
3
4
5
6
7
8
9
typedef struct {
int name; /* String table offset */
char type:4, /* Function or data (4 bits) */
binding:4; /* Local or global (4 bits) */
char reserved; /* Unused */
short section; /* Section header index */
long value; /* Section offset or absolute address */
long size; /* Object size in bytes */
} Elf64_Symbol;

每个符号都被分配到目标文件的某个节,由 section 字段表示,该字段也是一个到节头部表的索引。

对于 section 字段有三个特殊的伪节,它们在节头部表中是没有条目的:

  • ABS 代表不该被重定位的符号。
  • UNDEF 代表未定义的符号,也就是在本目标模块中用,但是却在其他地方定义的符号。
  • COMMON 表示还未被分配位置的未初始化的数据目标。

现代的GCC 版本根据以下规则来将可重定位目标文件中的符号分配到 COMMON 和 bss 中:

COMMON:未初始化的全局变量

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

符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。

静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理

如果链接器在它的任何输人模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。

例如下面的文件:

1
2
3
4
5
6
void foo(void);
int main()
{
foo();
return 0;
}

我们编译和链接这个源文件,编译器会没有障碍地运行,但是当链接器无法解析对 foo 的引用时,就会终止。

连接器如何解析多重定义的全局符号

连接器会将模块与对应的符号一一对应,如果碰到两个模块同名的情况,就不面具发生混淆。

首先我们要理解一个强弱符号的概念:

在编译时,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。

而根据强弱符号的定义,Linux 链接器使用下面的规则来处理多重定义的符号名。规则:

  • 不允许有多个同名的强符号
  • 如果有一个强符号和多个弱符号同名,那么选择强符号
  • 如果有多个弱符号同名,那么从这些弱符号中任意选择一个

有一个问题是:连接器只会对全局符号双重定义问题发出警告而不是报错。

例如在两个模块链接在一起的模块中分别定义了 double x;int x; ,它只会触发一个警告而不是报错。这就会导致我们定义了相同的两个符号,编译到一起之后,两边的函数操作的是同一个变量,而我们很难发现问题所在。

为了避免这类错误,我们尽量加上 -fno-common 标志来高速链接器遇到多重符号定义的时候触发一个错误。

与静态库链接

我们可以把所有标准函数都编译在一个可重定目标文件里,再通过下面的命令链接到自己的可执行文件中:

1
gcc main.c /usr/lib/libc.o

但是这个方法对内存和磁盘空间来说都是极大的浪费,并且如果某一个模块发生了改变就要重新编译整个文件,维护和开发也很复杂。

所以静态库概念被提出来,以解决这些缺点。

在 Linux 系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。

静态库允许将所有相关的目标模块打包成一个单独的文件,用作连接器的输入,并以一种称为存档(archive)的特殊文件格式存放在磁盘中。

存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀 .a 标识。

当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。例如我们调用C标准库和数学库中的函数:

1
gcc main.c /usr.lib/libm.a usr/lib/libc.a

这就减少了可执行文件在磁盘和内存中的大小,同时应用程序员只需要包含较少的库文件的名字(C 编译器驱动程序总是默认传送 libc.a 给链接器,所以我们在命令行中可以省略对 lbc.a 的引用)。

用命令 ar rcs out.a xx1.o xx2.o .... 命令把可重定位文件整合成 out.a 静态库。链接的时候,我们需要将我们编译出的可重定向文件和静态库文件一起作为链接器的数。

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

在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。

链接过程维护了三个集合:

  • E:可重定位目标文件,这个集合中的文件会被合并起来形成可执行文件
  • U:未解析的符号,即引用了但是尚未定义的符号
  • D:在前面输入文件中已定义的符号

初始时,E、U 和 D 均为空。

对于命令行上的每一个输入文件,会判断是目标文件还是静态库文件。如果是目标文件,就会修改 U 和 D 来反映文件中的符号引用,并继续下一个文件;如果是存档文件(.a),会对应匹配 U 中的一个引用,如果匹配成功会把对应的符号从 U 删除并加入到 D 中,并且会把 .a 中的对应的模块(.o)加入到 E 集合中,而不包含在 E 中的可重定位文件会被丢弃。

如果扫描完所有的输入文件之后, U 非空,那么报错推出,否则合并 E 集合的可重定位文件输出可执行文件。

需要注意的是,如果我们在静态链接的时候,把静态库放在了前面,那么会因为匹配不到任何一个 U 中的符号(因为一开始 U 为空)而直接被链接器丢弃。所以在上面的情况,我们如果使用命令 gcc --static -o a.out libcmath.a main.c 则会报错。

重定位

链接器完成符号解析后,就可以开始重定位步骤了。重定位有两个步骤:

  • 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
  • 重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。

重定位条目

无论何时,汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时该如何修改这个引用。其中,代码的重定位条目放在 .rel.text 中,已初始化数据的重定位条目放在 .rel.data 中。

下面就是重定义位条目的格式:

1
2
3
4
5
6
7
typedef struct 
{
long offset; /* Offset of the reference to relocate */
long type:32/* Relocation type */
symbol:32; /* Symbol table index */
long addend; /* Constant part of relocation expression */
}Elf64_Rela;

ELF 定义了32种不同类型的引用,这里我们只关心两种最基本的类型:

  • PC 相对寻址 R_X86_64_PC32 :一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量
  • 绝对地址引用 R_X86_64_32 :通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址,不需要进一步修改。

重定位符号引用

重定位符号引用也分为 PC 相对引用和绝对引用两种。

判断 PC 相对引用和绝对引用的标准主要取决于指令或数据的寻址方式和地址计算方法。我们需要实现分支跳转和存储数据的指令就会采取 PC 相对引用,例如 call 指令;我们在程序运行时需要确定确切的内存地址来表示目标地址的指令,就需要采用绝对引用,例如 mov 指令。

可执行目标文件

可执行目标文件的格式类似于可重定位目标文件的格式,分为以下几个部分:

ELF 头描述文件的总体格式,它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。

.text 、.rodata 和 .data 节与可重定位目标文件中的节是相似的,不同的是这些节已经被重定位到它们最终的运行时内存地址。

.init 节定义了一个小函数,叫做 init,是程序的初始化代码。

因为可执行文件是完全链接的(已被重定位),所以它不再需要 .rel 节。

程序头部表中的其中 LOAD 段会告诉我们哪些段在哪个位置,权限是什么,如下:

加载可执行文件

在Linux中运行可执行目标文件prog,可以在Linux shell的命令行运行下面的命令:

1
./prog

prog 是文件的名字,shell 通过调用某个驻留在存储器中叫做加载器的操作系统代码来运行它。

加载器将可执行目标文件中的代码与数据从磁盘复制到内存,然后通过跳转到程序的第一条指令或入口点来运行该程序,这个将程序复制到内存并运行的过程叫做加载

加载器运行时,它创建如下图所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来跳转到程序的入口点 _start 函数,这个函数会调用 libc.so 中的系统启动函数 __libc_start_main 函数去初始化执行环境,最后载调用用户层的 main 函数。

动态链接共享库

之前一直在讨论静态链接的内容,动态链接弥补了静态链接的缺点,致力于解决静态库缺陷的一个现代创新产物。

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。

共享库也称为共享目标(shared object),在 Linux 系统中通常用 .so 后缀来表示。windows操作系统的共享库称为 DLL(动态链接库)。

静态链接唯一的优势就是不依赖环境,只要架构支持,就一定能运行,而动态链接需要比较严格的环境要求。共享库是在运行时加载,可以加载到任意的内存,因此编译的时候,必须编译位置无关代码。

我们可以使用下面的命令编译动态链接库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
gcc -shared -fpic -o out.so file1.c file2.c
````

`-shared` 表明生成动态链接库文件,`-fpic` 参数要求生成位置无关代码

![](/img/CSAPP-第七章-链接/7-动态链接库.png)

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

应用程序允许它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。

动态链接是一项强大有用的技术,有广泛的应用,例如:

- 分发软件:

微软 Windows 应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。

- 构建高性能 Web 服务器:

早期的 Web 服务器通过使用 fork 和 execve 创建一个子进程,并在该子进程的上下文中运行 CGI 程序来生成动态内容。现代高性能的 Web 服务器可以使用基于动态接的更有效和完善的方法来生成动态内容。

其思路是将每个生成动态内容的函数打包在共享库中。当一个来自 Web 浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它,而不是使用 fork 和 execve 在子进程的上下文中运行函数。更进一步地说,在运行时无需停止服务器,就可以更新已存在的函数,以及添加新的函数。

Linux 为我们提供了一个接口—— `dlfcn.h` ,我们就可以利用文件中的一些函数来实时获取动态链接库的函数,使用函数指针接收。



# 位置无关代码

可以加载而无需重定位的代码称为**位置无关代码**(Position-Independent Code,PIC)。用户对 GCC 使用 `-fpic` 选项指示 GNU 编译系统生成 PIC 代码。需要注意的是共享库的编译必须总是使用该选项。

无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变,我们也是运用这个事实来声称对全局变量的 PIC 引用的。

我们使用 PLT(过程链接表)和 GOT (全局偏移量表)来实现延迟绑定机制。~~这里不多说了~~



# 库打桩机制

库打桩(library interpositioning)允许使用者截获对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,可以追踪对某个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。

打桩机制的基本思想是:

给定一个需要打桩的目标函数,创建一个包装函数,它的原型与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是目标函数了。包装函数通常会执行它自已的逻辑,然后调用月标函数,再将日标函数的返回值传递给调用者。

### 编译时打桩

先准备下面三个文件:

malloc.h:

```C
#define malloc mymalloc
#define free myfree

void *mymalloc(int);

void *myfree(void *);

malloc.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ifdef COMPILETIME
#include <stdio.h>
#include <malloc.h>
/* malloc wrapper function */
void * mymalloc(size_t size)
{
void * ptr = malloc(size);
printf("malloc(%d)=%p\n",(int)size, ptr);
return ptr;
}

/* free wrapper function */
void myfree(void * ptr)
{
free (ptr);
printf("free(%p)\n", ptr);
}
# endif

main.c

1
2
3
4
5
6
7
#include<stdio.h>
#include<malloc.h>
int main()
{
void *p=malloc(0x20);
free(p);
}

我们想要 mymalloc.c 中的包装函数调用目标函数,打印追踪记录,并返回。只需要像下面这两条命令一样编译程序:

1
gcc -I.-o intc int.c mymalloc.o

由于有 -I参数,所以会进行打桩,它告诉 C 预处理器在搜索通常的系统目录之前,先在当前目录中查找 malloc.h

链接时打桩

我们调用 -wrap f 标志进行链接时打桩。

链接时库打桩不能自定义调用名,在我们自己写的模块中,我们定义的函数必须是 __wrap_xxx ,比如 __wrap_malloc ,我们在 __wrap_malloc 中调用真实的 malloc 要写成 __real_malloc 去调用。

我们在 malloc 和 free 两个函数打桩,可以使用下面的命令:

1
gcc -g -Wl,--wrap,malloc -Wl,--wrap,free -o a.out main.o malloc.o

每个 -Wl,--wrap,xxx 就表示使用 __wrap_xxx 函数去替换 xxx 函数。

运行时打桩

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//malloc.c
# define _GNU_SOURCE
# include <stdio.h>
# include <stdlib.h>
# include <dlfcn.h>
/* malloc wrapper function */

void * malloc(size_t size)
{
void *(* mallocp) (size_t size);
mallocp = dlsym(RTLD_NEXT, "malloc"); /* Get address of libc malloc */
char * ptr = mallocp(size); /* Call libc malloc */
return ptr;
}

void free(void *ptr)
{
void (*freep) (void *) = NULL;
if (!ptr)
return;
freep = dlsym(RTLD_NEXT, "free"); /* Get address of libc free:*/
freep(ptr); /* Call libc free */
printf("free(%p)\n", ptr);
}

使用命令 gcc malloc.c -o malloc.so -ldl -shared -fPIC 编译得到 .so 库。

然后使用命令 LD_PRELOAD="./malloc.so" ./xxxx 来运行时加载 malloc.so 进行打桩。

处理目标文件的工具

  • ar:创建静态库,插入、删除、列出和提取成员。
  • strings:列出一个目标文件中所有可打印的字符串。
  • strip:从目标文件中删除符号表信息。
  • nm:列出一个目标文件的符号表中定义的符号。
  • size:列出目标文件中节的名字和大小。
  • readelf:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含 SIZE 和 NM 的功能。
  • objdump:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大勺作用是反汇编.text节中的二进制指令。
  • ldd:列出一个动态链接 ELF 文件的链接库

那些没有符号表的题目是不是就是用了 strip 命令!


这里的知识都是学过的,所以进行起来比较快

打桩是新知识!感觉很神奇!🤗


CSAPP 第七章 链接
https://shmodifier.github.io/2023/11/28/CSAPP-第七章-链接/
作者
Modifier
发布于
2023年11月28日
许可协议