抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

这篇文章是阅读《深入理解计算机系统》第7章:链接后写下的,很多地方写得并不详细,也无法保证完全正确。主要是为了记录我学习链接相关知识的过程,仅供参考。

什么是链接?

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

链接可以执行于编译时、加载时、运行时。

编译器驱动程序

大多数编译系统提供编译器驱动程序(compile driver),它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。

用GNU编译系统构造程序时,我们调用GCC(GNU Compiler Collection)驱动程序。

GCC有几个常用的参数:

参数 功能
-c 将源文件编译成目标文件但不链接
-d 在执行过程中打印出所有的调试信息
-S 将源文件编译成汇编代码,不进行汇编和链接
-E 只对源文件进行预处理,不进行编译、汇编、链接
-o 指明输出文件名为file
-g 打开调试开关,让编译的目标文件有调试信息
-O0, -O1, -O2, -O3 控制代码优化强度,-O3最强

举个例子

要通过上图源程序一步步构造成一个完全链接的可执行目标文件,需要以下几步:

每一步对应如下代码:

1
2
3
4
gcc -E -o main.i main.c # 预处理
gcc -S -o main.s main.i # 编译
as -o main.o main.s # 汇编
ld -static -o prog main.o sum.o # 链接

可重定位目标文件

如图所示是典型的ELF可重定位目标文件的格式。

ELF:Executable and Linkable Format(可执行可链接格式)

ELF头以一个16字节的序列开始,前四个字节被称为魔数,用来确认文件类型;第5个字节表示ELF文件类型:0x1代表32位,0x2代表64位;第6个字节表示字节序:0x1代表小端法,0x2代表大端法;第7个字表示ELF的版本号,通常都是0x1;剩余的字节填充为0。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。

夹在ELF头和节头部表之间的都是节。较为重要的节如下:

名称 内容 备注
.text 已编译程序的机器代码
.rodata read-only data。比如printf语句中的格式串
.data 已初始化的全局和静态变量 局部变量不在.data也不在.bss,保存在栈中
.bss 未初始化以及初始化为0的的全局和静态变量 不占据空间,仅是占位符
.symtab 符号表,包含在程序中定义和引用的函数和全局变量的信息
.rel.text 机器代码的重定位信息
.rel.data 被模块引用或定义的所有全局变量的重定位信息

符号和符号表

链接过程的本质就是将不同的目标文件粘合在一起,而符号则可以看作是链接过程的粘合剂

下图为ELF符号表的条目:

在使用readelf -s main.o命令查看main.o程序的符号表时,要注意section字段有3个特殊的伪节:

名称 含义
ABS 代表不被重定位的符号
UNDEF 代表未定义的符号,即在本目标模块中引用、在别处定义的符号
COMMON 表示还未被分配位置的未初始化的数据目标

注意:只有可重定位目标文件中才有这些伪节,可执行目标文件中没有。

COMMON.bss的区别:

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

符号解析与静态库

符号解析

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

换言之,一个符号至少要对应一个定义,无论两者是在同一模块还是不同模块。(每个模块中每个局部符号只有一个定义,全局符号引用解析的情况较为复杂,因为多个目标文件可能会定义相同名字的全局符号)

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

编译器解析多重定义的全局符号有以下三条规则:

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

规则2和规则3常常引发一些不易察觉的错误。为了避免此类错误,可以在编译时添加-fno-common选项,该选项会使得链接器在 遇到多重定义的全局符号时触发一个错误;或者添加-Werror选项,该选项会把所有的警告变成错误。

静态库

所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库

静态库文件,又称存档(archive)文件。

将上图addvec.cmultvec.c构造成一个静态库:

1
2
gcc -c addvec.c multvec.c
ar rcs libvector.a addvec.o multvec.o

第一行代码得到两个目标文件addvec.omultvec.o,再输入第二行代码就构造了一个名为libvector.a的静态库。下图程序可以调用这个静态库,其中vector.h定义了这个静态库libvector.a中的函数原型。

1
2
gcc -c main2.c
gcc -static -o prog2c main2.o ./libvector.a

上述代码将源文件main2.c编译为目标文件main2.o,然后链接目标文件main2.o和静态库文件libvector.a,创建了可执行文件prog2c。过程如下图所示:

由于链接器判定main2.o只引用了libvector.aaddvec.oaddvec符号,因此只复制addvec.o到可执行文件,而不复制multvec.o

静态库的解析过程

1
gcc -static -o prog2c main2.o ./libvector.a

上一节中使用上式链接源文件和静态库,其中涉及到利用静态库解析符号引用。具体过程如下:
链接器维护3个集合,分别为:

  • E,输入的可重定位目标文件集合
  • U,引用了但是尚未定义的符号的集合
  • D,已定义的符号的集合

链接器从左到右扫描可重定位目标文件和存档(静态库)文件,因此首先输入可重定位目标文件main.omain.o进入集合E;addvecprintf符号没有在main.o中被定义,因此进入集合U;剩余的进入集合D。

接着,继续扫描到存档文件libvector.a,存档文件不进入集合E。链接器尝试匹配U中未解析的符号和由存档文件成员定义的符号,链接器发现libvector.a的成员addvec.o中存在符号addvec的定义,因此将addvec.o加入集合E,并将addvec从集合U中删除,另外addvec.o中定义的其他符号,即addcnt会被加入到集合D中。对静态库中每个成员目标文件都会进行上述过程,此例中另一个成员multvec.o没有定义U中的符号,因此集合U和D不发生变化,链接器继续处理下一个输入的文件。

上面的代码实际上隐式执行了对静态库libc.a的链接,实际上会输入libc.a,执行上述过程后集合情况如下:

此时集合U为空,说明没有未定义的符号,链接器将合并和重定位E中的目标文件,构造可执行目标文件。若集合U非空,则说明有未定义的符号,链接器将输出一个错误并终止。

这个过程也解释了命令行输入文件的顺序的重要性,例如将main2.o./libvector.a互换位置,由于输入libvector时集合U为空,因此addvec.o不会被加入集合E,因此最后集合U中会存在未定义符号addvec,从而导致错误。

重定位

在上一节中链接器确定了一个集合E,即要合并的目标文件,接下来就需要进行重定位操作。重定位分为两步:

  • 重定位节和符号定义
  • 重定位节中的符号引用

重定位节和符号引用

示意图如下:

这一步完成后,程序中每条指令和全局变量都有唯一的运行时内存地址了。

重定位节中的符号引用

这一步依赖重定位条目,对于代码的重定位条目位于.rel.text,对于已初始化数据的重定位条目位于.rel.data

上图展示了ELF重定位条目的结构体定义。其中type(重定位类型)有32种,需要知道的有两种:

  • R_X86_64_PC32(PC相对地址,PC值通常是下一条指令在内存中的地址)
  • R_X86_64_32(绝对地址)

关于重定位的计算,可以参考【CSAPP-深入理解计算机系统】7-6. 重定位_哔哩哔哩_bilibili

可执行目标文件

如图所示为典型的ELF可执行目标文件
ELF可执行文件的连续的片被映射到连续的内存段,程序头部表描述了这种映射关系。

将程序从磁盘复制到内存的过程叫做加载。加载器运行时,创建内存映像,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,然后加载器跳转到程序的入口点。

如图所示为Linux x86-64运行时的内存映像。

动态链接共享库

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

构造共享库的方式与构造静态库的方式很相似,只需将构造静态库的代码修改为:

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

动态链接共享库的过程如图所示:

此外,还可以从应用程序中加载和链接共享库。

dlopen函数加载和链接共享库filename

dlsym函数返回输入符号的地址

如果没有其他共享库还在使用该共享库,dlclose函数就卸载该共享库

dlerror函数返回dlopendlsymdlclose函数的错误信息。

*库打桩机制

作用:截获对共享库函数的调用,用其他代码取代

  • 编译时打桩
  • 链接时打桩
  • 运行时打桩

评论