CSAPP-第七章-链接

330 阅读11分钟

链接之前

Translator

我们已经知道,对于我们书写的每一个.c程序,gcc都会做这样的事情

  • 调用c预处理器(cpp),将main.c翻译成ASCLL中间文件main.i

  • 调用编译器(cc1),将main.i翻译成ASCLL汇编语言文件main.s

  • 编译器生成汇编程序,然后汇编器(as)将其翻译成main.o可重定位目标文件(relocatable object file)

现有两个需要互相调用的程序,main.c和sum.c。gcc分别对他们做完translate生成main.o和sum.o之后,我们便需要链接来使得他们可以拼凑成一个单一的可执行文件然后运行。

你需要先知道

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

作为汇编器的输出二进制文件,但它不可以任何形式直接加载到内存中。

可执行文件a.out(executable file)

由链接器生成,可以直接放入内存执行

共享文件.so(shared object file or .dll dynamic link library in windows)

一种特殊的,可以放入内存的,并且可以在执行前或者执行时动态链接的文件

ELF (executable and linkable format)

它是.o, .out, .so 文件的统一格式

  • elf header

定义生成该文件系统的字节顺序和字的大小,文件的类型,机器的类型, etc.

  • segment header table

指出所有代码不同段在内存的位置(比如栈,共享库,初始化数据,未初始化数据,代码的位置)

只在可执行目标文件中定义

  • .text section(ro)

代码

  • rodata section

只读数据,比如switch中的跳转表

  • data section

所有初始化了的全局变量

  • bss section

包含未初始化的全局变量

不占用实际的空间,仅仅是未初始化的占位符。

  • symtab section

包含程序全局变量的结构数组

使用static定义的所有内容,并且他们都被分配了一个条目

  • .rel text, .rel data

链接器识别对所有符号的引用时留下的记录。

汇编器通知链接器需要去安排的引用,因为汇编器并不知道这个符号会放到哪里

  • debug section

调试的时候用的(-g)

  • section header

记录以上提到的所有节的位置

了解完以上的知识,我们就可以深入研究链接的过程了

链接

链接做了什么

一、符号解析

链接器中的符号

  • 全局符号

    定义在某个模块中的,并且可以被其他模块引用的符号

    比如:非静态全局函数和非静态全局变量

  • 外部符号

被某模块引用的,但是在其他模块定义的全局变量

  • 本地符号

只允许在某个模块中使用的符号

比如:全局静态函数和全局静态变量

注意,这不同于c中的局部变量

要点1:程序会定义和引用符号(符号:也就是全局变量和函数)

如图分别是:

将swap函数定义为符号

swap()是对swap符号的引用

定义一个符号xp,并且初始化为对x的引用

image.png

要点2:汇编器将符号定义作为一张符号表储存在目标文件中

符号表是一个结构体

包含了符号的名字,大小,和位置

要点3:对于重复声明的变量的处理

  • 定义强符号和弱符号

强符号即:函数名称或者已经初始化的全局变量

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

  • 规则

重复定义强符号是不被允许的

当强符号弱符号同时出现时,选择强符号

多个弱符号,随便选一个 使用gcc -fno-common将会给这方面的报错

  • 设想错误定义符号的场景
//p1.c
int x;
int y;
p1(){}

//p2.c
double x;
p2(){}

我们定义了两个弱符号x,链接器将会随机选择他们之间的一个作为引用,假设链接器选择了8字节的double,那么我们在使用x的时候其实会把y的空间覆盖掉(因为int 4字节),这非常可怕!

给书写c语言的一些建议

  • 使用static
  • 初始化全局变量
  • 如果引用外部全局变量请使用extern关键字

现在,链接器已经将每个符号引用与一些简单的定义相关联。接下来它需要获取所有这些模块的可重定位目标文件并且将他们组合到一起,并创建一个大的可执行文件。

二、重定位(relocation)

系统代码

在运行你的程序之前和之后,系统都会先从lib.c运行系统代码,这其中主要是做一些初始化。系统代码做的最后一件事情就是调用main并且传递argc, argv参数。执行完main函数之后,会返回到系统代码中。系统代码同样也包含了.text节和.data节

合并所有模块(重要)

将所有模块合并到一起,也就是将代码节,数据节...合并成可以在系统上加载和执行的一个节。

此过程需要链接器去弄清楚这些符号应该储存在哪里

image.png

图源:CSAPP-LECTURE

Relocation entry(考点)

在编译代码的时候,编译器并不知道链接器将选择哪些地址。因此,编译器会给链接器创建重定位的提醒。这些提醒被储存在可重定位目标文件的可重定位节里面(relocation sections)。这些重定位的条目其实是链接器的指令,比如当我们需要调用其他的函数时,编译器就应该给程序添加上相对寻址或者绝对寻址的代码。

让我们来看一个例子

image.png 图源:CSAPP-LECTURE

在代码中,我们存在一个对array的引用,以及一个对sum函数的引用。因此,编译器会创建两个重定位条目。

第一个用于引用数组array, 我们可以知道,%edi要用于接收数组的地址,但是在给出的代码中,编译器并没有将数组的地址传入%edi,反而是将立即数0传入了%edi, 这是因为编译器本身也不知道数组最终会被放在内存的哪个位置。但是编译器将一条重定位条目放到了main.o的重定位部分中。该条目的意思是:当链接器重定位main.o的时候,需要在.text节偏移9字节的地方使用绝对寻址修改一个对于32位形式数组的引用。

类似的,第二个引用对于函数sum, 编译器同样不知道该函数的具体位置,甚至不知道它所处的模块。因此它依然给需要跳转的地址传入了全0,并且将一条重定位条目放到了main.o的重定位部分中。该条目的意思是,链接器需要在.text节偏移f字节的位置使用相对寻址的方式( PC即相对寻址)修改一个对于sum函数的引用

  • 针对PC偏移的计算

image.png 图源:CSAPP-LECTURE

考虑此图,我们知道main起始位置4004d0,给出sum的位置4004e8以及需要修改reference的位置是main起始偏移f的位置,计算出reference值的方法如下:

首先我们计算需要修改reference的位置为main起始+f=4004df,计算需要修改的reference位置与sum的位置的差值为4004e8-4004df=9,由于我们使用的是pc相对寻址,下一次pc的位置应该是4004e3,也就是reference偏移加上4的位置,因此,我们最终pc需要偏移的距离就应该减去4,得到偏移距离是5。小段模式下,reference位置就应该填05 00 00 00

加载可执行文件

当链接器创建了一个可执行目标文件之后,该文件的的代码和数据便可以保存到内存中而无需做进一步修改。每个程序都加载到内存中的0x400000地址。下图展示了程序加载到内存之后的样子。自下而上分别是只读区,读写区,运行时堆(指针在顶部,malloc创建),共享库空间, 用户栈区(运行时创建,指针在底部)

image.png 图源:CSAPP-LECTURE

创建我们的共享库

静态库(.a archive file)

一些.o文件的集合,每个.o文件包含一个函数。使用archive来获取这些.o文件,将他们放在一个大文件中 。并且提供一个目录,告知每一个.o文件的偏移量

我们将archive传到链接器,链接器将取用有需要的.o文件

如图是创建一个archive的过程

image.png 图源:CSAPP

当然,我们可以随时更新这个库,当其中的一个.c文件改变,我们只需要重新编译该文件,并且重新存档所有的.o文件。

以正确的顺序将文件放在命令行

假设我们在main.o中调用了print.o中定义的print()函数,在书写命令行时将main.o放在了print.o的后面,此时我们将会报错"找不到print()的定义"。这是因为链接在读取.o文件时,总是抽取它所需要的文件,如果link先读取了print.o,它实际上并不会抽取出任何的文件,因为此时它还不知道有main函数调用了它。

动态库

使用静态库存在许多缺陷

  • 使用printf的程序都必须要有printf的副本
  • 如果提供的函数有更新, 那么所有使用它的程序都需要重新链接

共享库提供一种机制,只需要一个共享库成员的实例,系统上每个运行的程序都会共享这样一个实例。

共享库的实例加载到程序的时机可以是运行前,也可以是运行时! anytime!

动态库过程

image.png 图源:CSAPP-LECTURE

首先我们建立一个共享库,而非archive。即创建一个.so文件,而非创建一个存档文件(archive)。 使用gcc的共享参数"-shared"

gcc命令"gcc -shared -o libvector.so \ addvec.c multvec.c将addvec 和 multvec作为输入函数,并放在gcc创建的共享库libvector.so中。

当然,已经书写好的libc.so(包含printf和其他标准库函数)也将动态地加入到程序中。

我们将一个将调用addvec的程序main2.c编译成main2.o, 并且与libc.so 和 libvector.so传输给链接器。 此时链接器并没有复制我们将要使用的addvec或者printf,也不在可执行文件中对他们做任何操作。链接器将在符号表中记录下他们的重定位条目以说明在加载程序时需要解析对这些函数的引用

此时我们得到了一个部分链接的可执行文件prog21, 接下来,系统调用 execve程序,它将可执行文件加载到内存中并运行它们。

我们的Loader在拿到部分链接的可执行文件之后,他还需要共享的.so文件。Loader调用动态链接器,动态链接器将接收.so文件,然后解析所有未解析的引用。addvec和printf的地址也在此时被确定。

在运行时使用动态库

image.png 图源:CSAPP-LECTURE

  • 我们先要声明会动态加载的函数原型

调用dlopen,把.so文件加载到内存中,但时直到程序需要调用它之前,都不要对其的引用做解析。dlopen将会返回一个句柄,用在随后的调用中。如果句柄为空,则报错。

image.png

图源:CSAPP-LECTURE

  • 用dlsym, 传入一个字符串作为我们想要调用的函数的名称,我们将会得到一个指向该函数的指针。同样判断是否为空,为空则报错。

image.png 图源:CSAPP-LECTURE

  • 正常调用即可。

库打桩技术(library interpositioning)

  • 目的

目的是截取来自库的函数调用。

  • 做法

我们需要创建一个包装函数(wrapper),当程序调用函数时,我们要做的就是执行它的包装函数

  • 好处

更好的安全性

帮助调试

对函数进行监视和分析

  • 做法

在#define中把需要调用的函数指向你自己写的包装函数

eg: #define malloc(size) mymalloc(size)

在自己写的包装函数中输出你想要监视的信息

eg:

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

image.png

-I参数告诉gcc首先查看当前目录,也就是库打桩的地方。gcc首先会找到一个malloc.h库,执行我们的#define

此外,链接时,执行时也可以进行库打桩,笔者将在晚些时候补充

总结

  • 链接允许你的程序可以从多个可重定位文件中构造得来。

  • 链接可以发生在程序生命周期的任何时间