0%

CSAPP-第七章—链接

虽然现在还没怎么使用过链接,因为小项目往往单个文件即可完美解决,但是当工程量大的时候,多文件操作是不可避免的。链接就是将各种代码和数据片段收集并组合成一个单一文件的过程。

为什么学习链接:

  • 理解链接有利于我们构造大型程序。
  • 理解链接器将帮助我们避免一些危险的编程错误。
  • 理解链接帮助我们理解语言的作用域规则是如何实现的。
  • 理解链接将帮助我们理解其他重要的系统概念。
  • 理解链接使我们能够利用共享库。

静态链接

静态链接(Static linking)是指在编译阶段将目标文件中所需的库文件代码全部复制到可执行文件中,形成一个独立的、完整的可执行文件。在运行程序时,操作系统不需要加载额外的库文件,因为所有必要的代码都已经包含在可执行文件中。

优点:执行速度快、稳定性好、容易分发(移植性高?)、安全性高

缺点:可执行文件大、更新困难、内存浪费

动态链接

动态链接(Dynamic linking)是指在程序运行时,需要某个库文件时才去查找和载入所需的库文件。与静态链接不同,动态链接并不会将所有的代码和数据都打包到可执行文件中,而是在程序启动时只加载所需的代码和数据,避免了一些资源浪费的问题。

优点:文件小、容易更新、节约内存、更容易定位问题

缺点:执行速度较慢、安全性问题、容易出错

编译器驱动程序

跟着书上操作一下

了解一下gcc的使用:gcc -Og -o prog main.c sum.c

  • gcc: 使用 GNU C 编译器
  • -Og: 优化级别为“优化等级 g”,即启用大多数优化选项,但不会影响调试信息。
  • -o prog: 将生成的可执行文件命名为 prog。(如果没有该选项,则默认生成名为 a.out 的可执行文件)
  • main.c: 主程序文件,包含程序的入口函数。
  • sum.c: 函数库文件,包含用于计算求和的函数代码。

综上所述,该命令会将 main.csum.c 两个源文件编译成一个名为 prog 的可执行文件,并且在编译过程中启用了大多数优化选项,同时保留了调试信息以方便调试。

目标文件

目标文件有三种形式

  • 可重定位目标文件。包含二进制代码和数据,可执行目标文件的中间产物。

可重定位目标文件包含了代码、数据和符号表等信息,但并不包含所有的地址信息。因此,在链接时需要通过重新定位(relocation)来分配地址,并建立各个目标文件之间的联系,生成最终的可执行文件或动态链接库。

  • 可执行目标文件。包含二进制代码和数据,可以直接复制到内存并执行。
  • 共享目标文件。一种特殊类型的可重定位文件。

可重定位目标文件

我们拿一个具体的文件对照着理解

mian1.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<stdio.h>
int count=10;
int value;
void func(int sum)
{
printf("sum is:%d\n",sum);
}

int main()
{
static int a=1;
static int b=0;
int x=1;
func(a+b+x);
return 0;
}

gcc -c main1.c 命令将源文件编译成可重定位目标文件。-c只编译源代码不进行链接操作,生成main1.o文件

readelf -h main1.o -h选项表示只显示header信息。

这就是7-3所示的ELF头的内容。

我们可以看到节头部表的起始位置是992.共有14个表,每个长度为64字节。992+14*64=1888和我们用wc(word count)命令查看的文件的字节数目一致。

readelf -S main1.o -S选项,打印整个表的信息

.text:存放已经编译好的机器代码。通过objdump -s -d main1.o命令可以查看

.data:存放已经初始化的全局和静态变量

我们初始化了一个count和a,值分别为10和1.其实还初始化了b,不过赋值为0,当作未初始化处理。我们看到的是小端存储,0a000000实际上是0000000a。

.bss:未初始化的全局变量和静态变量,以及所以被初始化为0的全局或静态变量。在目标文件中这个节不占实际的空间,仅仅是一个占位符。

可以看到他和.rodata起始位置是相同的。

.bss 段不包含实际的数据内容,是因为该段中存储的是未初始化的全局变量和静态变量。在程序启动时,这些变量会被自动初始化为 0 或者 NULL,而不是在编译时就确定了其具体的值。因此,在编译时并没有将这些变量的实际值保存到 .bss 段中。

对于已经初始化的全局变量和静态变量,它们将被保存在 .data 段或 .rodata 段中,并在编译时就确定了其具体的值。相比之下,.bss 段只是记录了需要分配多少空间给未初始化的变量,并在程序启动时进行自动初始化。因此,.bss 段中不包含实际的数据内容。

.rodata:只读文件,比如printf语句中的格式串和开关语句的跳转表

其余的

符号和符号表

每个可重定位目标模块m都有一个符号表,它包含m定义和应用的符号的信息。

readelf -s main1.o 查看符号表内容

从这个表中我们可以看到所有定义的符号以及他们的位置和大小。比如说main函数,在0x2b处大小是52字节。对于count 和 value 他俩的type为object表明符号是个数据对象,ndx值是其段位置,3代表count在section3即.data中。

局部变量x并未出现在符号表中,因为局部变量在运行时栈中被管理,因此不会出现。

符号解析与静态库

链接器解析符号引用的方法是将每个引用与他输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对于局部变量,解析是非常明了的。对于全局变量,编译器会假设他在其他的某个模块被定义,如果链接器在任何一个模块中都未被定义,就会输出一个错误信息并终止。

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

编译和汇编过程没有问题。连接成可执行文件时报错

编译器如何解析多重定义的全局符号

全局符号分强弱两种类型。

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

弱符号:未初始化的全局变量

静态库

静态库就是各种可重定位文件的集合,如lib.a静态库就包含了printf.o等

静态链接就是链接静态库的过程。

静态库的解析过程

连接器从左到右扫描文件并维护三个集合,顺序是比较重要的

E:可重定位目标文件的集合,这个集合会被合并起来并形成可执行文件。

U:未解析的符号(引用了但是尚未定义的符号)集合

D:在前面输入文件中已经定义的符号集合。

最开始是空的

接收目标文件放入E

main中有未解析的符号

继续往下查看静态库,发现了addvec的定义,于是从U中删除,将addvec.o放入E中

D的目的是动心定位,分配地址。

重定位

重定位合并输入模块,并未每个模块分配运行时地址。重定位由两步组成:

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

重定位条目

当汇编器生成一个目标模块时,并不知道数据和代码最终将存放在什么位置。当汇编器遇到对最终位置未知的目标引用,他就会生成一个可重定位条条目,告诉连接器在将文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。

在重定位之前,操作码之后的内容被汇编器填充为0

根据重定位条目来确定具体内容。

0x5,是两个地址之间的距离

现在学的内容和当时学习汇编语言接触到的东西接轨了,比如说为什么call指令会进行这样那样的操作,jmp指令有的是相对跳转有的是绝对跳转。这里解释的是重定位相对引用。

重定位绝对引用比较简单

最终

可执行目标文件

可以复制到内存并直接执行的文件

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

./prog 运行可执行文件prog。

动态链接共享库

库打桩机制

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

先说明一下库打桩的应用场景,比如说我们使用库函数malloc(),他返回一个地址,而我们呢想知道malloc函数被调用了多少次,这时我们就可以通过库打桩将我们自己编写的可以打印信息的mymolloc进行替换,这好像和hook有点关系。库打桩技术的主要思想是:在不修改原程序源代码的情况下,替换掉不稳定或难以获取的第三方库。替换的库称为桩库或模拟库,它实现目标库的接口,但是里面的实现是自定义的代码。通过使用桩库,可以隔离原程序与第三方库,便于测试或解决依赖问题。常用于软件测试中,以提高测试用例的准确性和可重复性。

按照打桩时机,库打桩分为以下三类:

  1. 编译时打桩。此种情况需要能够访问源代码。
  2. 链接时打桩。需要能够访问可重定位文件文件。
  3. 运行时打桩。没有上述需求,比较nb。

编译时打桩

使用宏定义将malloc()定义为mymalloc()即可,自定义malloc.h

1
2
#define malloc(size) mymalloc(size)
void *mymalloc(size_t size);

接下来我们自定义malloc的内容

1
2
3
4
5
6
7
8
9
10
11
#ifdef XXX      //相当于一个if语句,就是如果定义了宏XXX,下面的代码就会被加载,在编译中我们使用DXXX来定义XXX
#include<stdio.h>
#include<stdlib.h>
//打桩函数
void *mymalloc(size_t size)
{
void* ptr=malloc(size);
printf("mzy success\n");
return ptr;
}
#endif

main函数

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

然后

1
2
3
$ gcc -DMYMOCK -c mymalloc.c    #编译源代码文件而不进行链接
$ gcc -I . -o main main.c mymalloc.o
$ ./main

很离谱的是没有任何反应

经过一番求助之后,原来是tmd头文件的名字写错了,

跟着这个就来了,但是作者是怎么成功的呢,按理来说他应该include”mymalooc.h”才对呀。

链接时打桩

l连接过程中对应关系是

1
2
fun       ---->     _ _wrap_fun
__real_f ----> fun
1
2
3
4
5
6
7
8
9
10
11
12
#ifdef XXX
#include<stdio.h>
#include<stdlib.h>
void *__real_malloc(size_t size);

void* __wrap_malloc(size_t size)
{
void* ptr =__real_malloc(size);//调用malooc
printf("mzy success*2");
return ptr;
}
#endif

main.c和上面有一点不同,将头文件去掉

1
2
3
4
5
6
7
8
#include<stdio.h>
//#include"malloc.h"
int main()
{
int *p =malloc(32);
free(p);
return 0;
}

然后进行

1
2
3
4
$ gcc -DXXX -c mymalloc.c    #得到mymalloc.o
$ gcc -c main.c #得到main.o
$ gcc -Wl,--wrap,malloc -o main main.o mymalloc.o
##-Wl,--wrap,malloc把--wrap malloc传递给链接器

运行时打桩

不需要源代码,不需要重定位文件,太酷啦。在学习这个炫酷的技能之前,我们先了解一下如何制造动态库。

动态库的制作

test.c代码

1
2
3
4
5
#include"test.h"
void test()
{
printf("hello,world");
}

test.h

1
2
#include<stdio.h>
void test();

制作生成.so文件

1
$ gcc test.c -fPIC -shared -o libtest.so
1
$ gcc -DMYMOCK -shared -fPIC -o libmymalloc.so mymalloc.c -ldl
  • -DMYMOCK:定义预处理器宏 MYMOCK,使得在编译时预处理阶段可以根据这个宏名称进行条件编译。
  • -shared:指定生成一个共享库(即动态链接库)。
  • -fPIC:指定编译为位置独立代码(Position Independent Code),以便被动态链接库调用。
  • -o libmymalloc.so:指定输出文件名为 libmymalloc.so,其中 .so 表示共享对象文件(Shared Object)的后缀名。
  • mymalloc.c:指定输入文件名为 mymalloc.c,该文件包含了需要编译成动态链接库的代码。
  • -ldl:告诉编译器需要链接 dl 库(即动态链接库库),该库包含了 dlsym() 函数等相关函数的实现。

当执行这个命令后,将会在当前目录下生成一个名为 libmymalloc.so 的动态链接库文件,该库包含了我们在 mymalloc.c 文件中定义的 malloc() 封装函数。用户可以使用 LD_PRELOAD 环境变量来加载这个动态链接库,从而覆盖掉系统默认的 malloc() 函数。

LD_PRELOAD :可以通过设置该环境变量,达到在加载动态库或者解析一个符号时,从LD_PRELOAD指定的库中寻找符号的目的。

跟着操作了一番出现了Segmentation fault,查了一下我不是个例。mymalloc.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifdef XXX
#define _GNU_SOURCE
#include<stdio.h>
#include<stdlib.h>
#include <dlfcn.h> //提供动态链接库的相关函数和类型的声明

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

mallocp=dlsym(RTLD_NEXT,"malloc"); //得到libc malloc的地址
if((error=dlerror())!=NULL) //dlerro()检查最后一次动态链接库操作是否出现错误,如果失败则返回一个非空字符串指针赋值给error
{
fputs(error,stderr);
exit(1);
}
char* ptr =mallocp(size);//在封装函数中调用原函数
printf("malloc(%zu)=%p\n",size,ptr);
return ptr;
}
#endif

RTLD_NEXT是一个伪句柄,表示在当前库之后的库中查找malloc,为什么是下一个库?因为当前库被LD_PRELOAD设置成了mymalloc.so

错误产生的原因:malloc的反复调用,应该是类似没有终止的递归导致,这里留一个坑位,学明白了再解释。

为什么出现段错误: 我们自定义的malloc函数调用了printf函数,printf函数又调用了malloc函数,malloc函数有调用printf函数,这是一个死循环,会导致栈溢出。

解决方法:避免反复调用/使用不调用打桩函数的函数。我们设置了静态变量来检测调用malloc的情况,因为每次进入的状态是0,然后进行自增,运行到后面如果次数为1,那么一切正常,如果为2那么说明此时对malloc进行了第二次调用,第一次调用还没有结束,那么直接跳过printf避免它再次触发我们的malloc。

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
#ifdef XXX
#define _GNU_SOURCE
#include<stdio.h>
#include<stdlib.h>
#include <dlfcn.h> //提供动态链接库的相关函数和类型的声明

void* malloc(size_t size)
{
static int calltimes;
calltimes++;
void *(*mallocp)(size_t size)=NULL;
char *error;

mallocp=dlsym(RTLD_NEXT,"malloc"); //得到libc malloc的地址
if((error=dlerror())!=NULL) //dlerro()检查最后一次动态链接库操作是否出现错误,如果失败则返回一个非空字符串指针赋值给error
{
fputs(error,stderr);
exit(1);
}
char* ptr =mallocp(size);//在封装函数中调用原函数
if(calltimes==1)
{
printf("malloc(%zu)=%p\n",size,ptr);
}
calltimes=0;
return ptr;
}
#endif

只要设置 LD_PRELOAD=”./mymalloc.so”,所有调用malloc函数的程序都会被改变。

1
2
export LD_PRELOAD=./libxxx.so     #应用到全局
unset LD_PREALOD #取消环境变量设置

参考链接:

库打桩机制-偷梁换柱 | 守望的个人博客 (yanbinghu.com)

(17条消息) CSAPP第三版运行时打桩Segmentation fault_imred的博客-CSDN博客