开始学 CSAPP的第七章

逐步更新:

  • 2023-1-20:开始写
  • 2023-1-23:写完

梗概

这一节讲的就是链接,链接作为编译可执行文件的最后一步,也是比较重要的,书上也罗列了一些链接带来的问题:

  • 理解链接器将帮助你构造大型程序
  • 理解链接器将帮助你避免一些危险的编程错误
  • 理解链接将帮助你理解语言的作用域规则是如何实现的。例如,全局和局部变量之间的区别是什么?当你定义一个具有static属性的变量或者函数时,实际到底意味着什么
  • 理解链接将帮助你理解其他重要的系统概念

编译器驱动程序

它给了一个多文件编译的一个示例,也不写了,大概意思就是一个地方调用函数但是不写定义,另一个文件写该函数的定义,然后把两个文件一起编译得到可执行文件。

其实我们所说的广义上的编译器(如 gcc)指的是编译系统给我们提供的编译器驱动程序,它是语言预处理器(cpp),编译器(cc1),汇编器(as),链接器(ld)的集合。

main.csum.c 一起编译的一个命令是:

1
gcc -o prog main.c sum.c

把它的步骤拆开就是这样的:

预编译展开头文件和替换宏定义

1
cpp main.c /tmp/main.i

编译:将高级语言翻译成低级语言

1
cc1 /tmp/main.i /tmp/main.s

汇编:将汇编语言翻译成机器语言

1
as /tmp/main.s /tmp/main.o

同样的步骤处理 sum.c 文件。

链接

1
ld -o prog /tmp/main.o /tmp/sum.o 

我们要运行一个可执行文件(ELF)就直接在 shell 中输入该文件的具体路径。

静态链接

完成了汇编这一步我们得到的文件还不可以直接运行,我们需要让链接器完成两个主要的任务:

  • 符号解析,这一步是为了让每一个符号引用和一个符号定义关联起来。
  • 重定位,编译器和汇编器生成从地址0开始的代码和数据节,这一步是为了给每个符号一个确定的虚拟地址。

目标文件

目标文件有三种形式:

  • 可重定位目标文件:其实就理解为.o文件
  • 可执行目标文件:理解为 ELF 文件
  • 共享目标文件:理解为libc

可重定位目标文件

它有很多节组成,常见的也就是那些

  • text:代码段
  • rodata:只读数据段
  • bss:未初始化全局变量和静态变量
  • data:已初始化的全局变量和静态变量
  • symtab:符号表,保存全局变量的信息,默认生成,可以使用STRIP命令去除。
  • rel.text:被链接的时候会被修改位置。
  • rel.data:被链接的时候会被修改位置。
  • .debug:调试符号表,只有以 -g 选项时会得到这张表,其中包含了局部变量的定义和类型,以及原始的 C 文件,只有 -g 命令会生成这张表。

为什么区分 bss 段和 data 段?因为初始化的变量需要使用一段空间去保存初始化得到的值,而未初始化则不需要,可以理解为减少文件落地的大小,实际上初始化与否不影响内存的占用大小。

符号和符号表

符号表包含了一个模块的引用符号信息,一共有三种不同的符号:

  • 由模块 m 定义并能被其它模块引用的全局符号,对应于非静态的 C 函数和全局变量。
  • 由其它模块定义并被模块 m 引用的全局符号
  • 定义在模块 m 并只被 m 引用的局部符号,他们对应了静态的 C 函数和全局变量。

所以说,静态变量作用域只能是本文件,其它文件不能引用。

symtab是不包含局部非静态变量的任何符号的,它们在编译阶段就能被处理完了,因为它们通过栈来管理,可以通过编译加上 -g 参数选择保留局部变量符号在 .debug 中。

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

这张符号表包含这样一个结构体条目的数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;

这里有一个很有趣的定义,它把一个 char 类型分给了两个条目,两个条目分别占四位。

可重定位的目标文件还包含了三个伪节。

  • ABS:该节的符号不该被重定位
  • UNDEF:未定义的符号,没有在本模块定义的符号
  • COMMON:还未被分配位置的初始化的数据目标

ELF 不会包含这些节,因为ELF已经把符号确定到了一个地址上,符号已经没有存在的意义了。

比如用书上的C文件去编译得到 .o 文件,使用 readelf -s main.o 去读出它的符号表,我们着重看最后四行:

array 是我们定义的数组,这里的下标是 3,表示 data 段。

main 是我们的主函数,这里下标是1,表示 text 段。

sum 是我们在 main.c 申明的函数,它在 UND 段,因为在本模块没有定义,所以是在 UND 段。

未初始化的数据目标会在 COMMON 节。

符号解析

当编译器遇到一个不在当前模块中定义的符号时,会假设该符号在其它模块定义并生成链接符号表条目,并交给链接器处理。如果找不到这个符号,那么就输出一条错误信息并终止。

比如下面的程序:

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

用以下命令编译 gcc -g s.c -o s 就会在链接阶段报出错误 undefined reference to 'foo'

对这种全局符号解析会遇到一个多定义的问题,如果多个模块定义了相同的全局符号,在引用的时候连接器要么报错,要么以一种方式选择一个而丢弃其它所有的定义。

这里作者经过试验也是得出了一个结论:

对于变量而言,如果是相同的定义类型(对于具体类型这个比较弱,只要占用空间一样就会被认定为是相同),那么会使用被初始化的那个定义,其余定义被丢弃,如果出现多个初始化,那么报错退出。

如果是不一样大小的类型,那么会使用较大的那个定义,这时候,如果只有较小的定义被初始化那么会报错退出。

C++和JAVA的函数重载。在这两个语言当中,函数名允许重复,此时会有一个叫名称粉碎机制,会把函数名和类型一起生成一个新的名称,在底层看上去还是唯一的,这个过程叫重整,相反的过程叫恢复。

链接器如何解析多重定义

没想到下一节就讲了,然后作者还傻乎乎的去试了以下hh。

他这里规定了强弱符号,函数和已初始化的变量都是强符号,未初始化的全局变量则是弱符号,它有这么一个规定:

  • 不允许有多个同名的强符号
  • 如果有一个强符号和多个弱符号,那么选择强符号的定义
  • 如果有多个弱符号,那么随机选择一个。会不会挑最大那个就不知道了

后面举了一个例子表示,链接器不会检测到定义了多个相同的全局符号,它只会判断有没有满足第一个规则,如果满足了它不会说任何话,这就会导致我们定义了相同的两个符号,编译到一起之后,两边的函数操作的是同一个变量,而如果我们单一地去查一个文件很难发现问题。

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

与静态库链接

将所有相关的目标模块打包成为一个单独的文件,称为静态库,它可以用作链接器的输入,当需要生成一个可执行的文件时,它只会复制静态库里被应用程序引用的目标模块。

书上介绍了如果没有静态库链接的情况该怎么办,一种是编译器去寻找合适的函数,缺点是编译器开发人员压力很大;第二种是把函数编译成可重定位的文件形式,缺点是程序员需要自己找到对应的可重定位文件进行链接。

后面讲了静态库的编译方法和链接方法:

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

这里实验一遍吧,本来不想实验的,可是看着内容有点多,为了理解还是实验吧。

创建 add.c 和 sub.c 文件,内容如下

1
2
3
4
5
6
7
8
//add.c
int add(int a,int b){
return a+b;
}
//sub.c
int sub(int a,int b){
return a-b;
}

先用命令 gcc -c add.c sub.c 创建 .o 文件

然后用命令 ar rcs libmath.a add.o sub.o 去生成 libcmath.a 静态库。

然后创建一个 main.c 文件

1
2
3
4
//main.c
int main(){
int a=add(1,2);
}

使用命令 gcc --static -o a.out libcmath.a 生成 可执行文件。

并且链接器很聪明,因为 main.c 只调用了 add 函数,所以在 a.out 文件中,不会看到任何 sub 相关的代码。

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

这里介绍了静态链接的过程,告诉了我们在静态链接的时候需要注意参数顺序。

链接过程中维护了三个集合 E U D 分别是可重定位文件集合,未解析符号的集合,已经定义的符号集合。

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

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

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

重定位

重定位的目的就是为了确定所有符号的地址以及修改代码让符号可以被正确地引用。

重定位条目

每当汇编器遇到了对最终位置不确定的目标引用,就会生成一个重定位条目,告诉链接器如何修改这个引用,这个重定位条目可以时代码段的,也可以是数据段的。

1
2
3
4
5
6
typedef struct {
long offset;
long type:32,
symbol:32;
long addend;
}Elf64_Rela;

ELF 定义了32种不同类型的引用,这里我们只需要知道两类:

一类是 PC 相对寻址(R_X86_64_PC32),一类是绝对地址引用(R_X86_64_32)。

比如 call 这一类的,如果符号不在自己这我需要重定位之后,算出这一条指令到对应符号的一个偏移值作为 call 指令的操作数。比如 mov 这一类的,需要知道确定的一个地址放到上面,那就是绝对地址引用。

书上给的例子我确实没编译出来,因为现在基本也是 ip 相对寻址了。

图中框出的部分是要被重定位的代码,可以看到这里在传值的时候使用了 lea (%rip),它也是 PC 相对寻址。

而书上的例子这里是 mov 指令,那就是绝对寻址了。

可执行目标文件

这是一个 ELF 文件的信息结构

LOAD(段表头部) 段会告诉我们哪些段在哪个位置,权限是什么,如下图所示:

这里也解释了一个现象,就是段的起始地址不一定是从一页的起始位置开始,它可能会有偏移,据书上这么讲,它的起始地址应该满足下面的式子:

\(off\ mod\ align=vaddr\ mod\ align\)

据说是因为加载速度会变快,这个原因会在第九章讲到,那就尽请期待吧。

加载可执行目标文件

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

1
linux> ./prog

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

加载器加载的一个操作顺序是:在表头的引导下将文件读取到内存中,接下来跳转到程序的入口点 _start 函数,这个函数会调用 libc.so__libc_start_main 函数去初始化执行环境,里面还会调用用户层的 main 函数。这里就到了我们自己认为的入口函数了,但是其实 main 函数并不是最先被执行的。

下面是 x86_64 运行环境的一个内存空间,64位系统我们只用了 48 位。

动态链接共享库

前面讨论了静态链接,它的一个劣势就是会导致可执行文件占用磁盘空间过大,并且多个程序一起运行时,有很大一部分的内存资源存放的是同样的代码,也造成了内存的浪费。静态链接唯一的优势就是不依赖环境,只要架构支持,就一定能运行,而动态链接需要比较严格的环境要求。

共享库是在运行时加载,可以加载到任意的内存,因此编译的时候,必须编译位置无关代码。

编译动态链接库的命令:gcc -shared -fpic -o out.so file1.c file2.c

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

此时可以把这个库作为输入文件进行编译了,这个时候并不会把库的代码复制到可执行文件中,而是会留下一些重定位项用于运行时重定位,那么在 Linux 的 ELF 当中我们知道这是有 PLT 和 GOT 表实现的。

动态链接的优点就是文件体积小,多个文件同时运行占用内存显著地小了很多,缺点则是编译完成之后要转移机器运行必须连着库文件一起打包,这一点会比较麻烦。


下面是自己的试验:

准备两个 c 文件编译库,跟之前一样用 add 和 sub。

1
2
3
4
5
6
7
8
//add.c
int add(int a,int b){
return a+b;
}
//sub.c
int sub(int a,int b){
return a-b;
}

再准备一个调用输出的 main.c

1
2
3
4
5
#include<stdio.h>

int main(){
printf("val:%d\n",add(1,2));
}

编译库:

1
gcc -fpic -shared -o libmath.so add.c sub.c

编译程序:

1
gcc -o ./a.out main.c ./libmath.so

这里需要注意库名需要指定具体的路径,可以用相对路径,如果不指定会默认从 /lib/x86_64-linux-gnu/ 目录下加载对应名字的库,如果没有就会报 no such file。

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

动态链接库有一个很秒的应用就是 WEB 服务器:一般来说网站是需要处理并发请求的,而常规的思路就是主进程死循环阻塞,遇到有请求连接的时候 fork 一个子进程去 execve 对应的一个函数去处理请求。然而现在可以基于动态链接的思路,当一个请求到达时,直接去调用动态链接库的函数去处,这样我们处理一个请求的内存开销仅仅只有函数调用所产生的内存开销,并且带来的一个好处就是更新网站时可以直接替换动态链接库而不需要停止服务。

我们可以使用 dlfcn.h 文件中的一些函数来实时获取动态链接库的函数,使用函数指针接收。

也写一个demo吧:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>
#include<dlfcn.h>
int main(){
int (*func)(int,int);
void *handle=dlopen("./libmath.so",RTLD_LAZY);
if(!handle){
return 0;
}
func=dlsym(handle,"add");
printf("%d\n",func(1,2));
dlclose(handle);
}

位置无关代码

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

PLT 和 GOT 怎么实现的之前搞pwn的也是知道的,用了一个延迟绑定机制,在第一次运行的时候会调用函数 dl_runtime_resolve 函数去寻找指定函数的地址并调用。PLT表和GOT表的工作原理可以看这篇。感觉写的不是很好,等空了回来补一下吧,mark了。

库打桩机制

怎么说呢,类似 hook 吧,只是这个 hook 发生在编译时。

比如,main -> malloc 是正常的一个调用栈,而我们可以在中间插入我们自己写的代码变成了 main -> mymalloc -> malloc

编译时打桩

建立下面三个文件:

malloc.h:

1
2
3
4
5
6
#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
# 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

我们搞一个动作就是让它在 malloc 分配或者是 free 的时候打印它的具体调用参数。

main.c

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

先编译库:

1
gcc -c malloc.c -o malloc.o

再打桩编译

1
gcc -g main.c -o a.out main.c malloc.o -I.

这个在调试的时候挺有用的,而且不会破坏它的源码。

链接时打桩

就还是这个源码,库还是一样的编译成可重定位的模块,同时也把 main.c 也编译成可重定位的模块。

链接时库打桩有点麻烦,我们不能自定义调用名。在我们自己写的模块中,我们定义的函数必须是 __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 函数,而 __wrap_xxx 函数在我们自己写的一个模块中定义了。

运行时打桩

运行时打桩出现了点问题,因为书上可能所用的版本比较老,因此没有出现这样的情况,实际上 printf 会调用 malloc,所以我们在里面用 printf 的时候会调用自身然后又打一次桩,造成死循环,但是 free 不会这样循环,因此我们打桩打在 free 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//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 文件的链接库

这里我私人再推一款 patchelf,可以很方便地为我们切换libc的库。

小结

通过学习链接这个过程,学会了多文件编译的方法,以及变量,函数作用域。目标二进制文件的三种形式:可重定位的,可执行的和共享的。也知道了怎么去编译得到一个静态库和共享库,以及对应的链接方式,还学习了重定位的一个方法,对这个理解也加深了很多。

最后知道了一个打桩机制,对于我们调试来说又是一等利器。CSAPP,永远的神!