概述:
链接是将各种代码和数据片段(例如由编译器生成的.o文件)组合成一个单一文件的过程。可以分为静态链接和动态链接。它们分别在编译时(通过编译器生成.o文件,再由链接器生成可执行文件)和加载/运行(加载器将其到内存中再链接)时被执行。
先来整理一下编译器驱动程序将一个ASCII的C源文件翻译成机器码的整个流程。
main.c(通过c预处理器[cpp])----中间文件main.i(通过C编译器[ccl])-------汇编语言文件main.s(通过汇编器[as])------可重定位目标文件main.o(通过链接器[ld])-------可执行目标文件。
1.1 静态链接
就是将所有的可重定位目标文件由链接器打包生成运行时地址固定的可执行目标文件的过程。他主要完成两个任务:符号解析和重定位(确定运行时地址)。
目标文件有三种类型:可重定位目标文件([.o]由编译器汇编器生成),可执行目标文件(.out),共享目标文件(动态链接时用).。
1.2可重定位目标文件
ELF文件头:包含了目标文件类型,机器类型和节头部表的偏移。
节头部表:描述了各个节的大小以及位置。
因为C语言中,所有的局部变量都是保存在寄存器或者是运行时栈中的,所以这些可重定位文件都只保存全局变量和静态变量以及函数相关信息。
.data:已初始化的全局变量和静态变量
.bss:未初始化的全局变量和静态变量,或者是初始化为0的全局/静态变量。
.symtab:存放程序定义和引用的函数/全局变量信息。
.rel.text:修改重定位后指令的信息。
.rel.data:被模块引用和定义的所有全局变量的重定位信息。例如一个变量是另一个全局变量的地址,或者引用了外部定义的函数,这些定位信息就放在这里面。可执行目标文件已完成重定位,就不需要.rel.text
和.rel.data
数据节了。
.symtab符号表系列:
- 模块自己本身的非静态变量以及函数,这个成为全局符号
- 外部定义,但是自己引用了的非静态变量以及函数,这个成为外部符号
- 带static的变量和函数,称为局部符号(C语言中,static变量和函数仅当前文件可见,外部无法引用)。
注意:局部静态变量会保存在.bss和.data节中,不同模块中,同一个局部静态变量会在这些节中生成唯一的符号。
符号表由汇编器(as)使用编译器给汇编文件中的符号生成。生成的符号表保存在.symtab当中。
name:保存符号名字,是.strtab中的字节偏移。
value:符号的地址。在可重定位模块中,value是定义该符号的节到该符号的偏移量。而在可执行目标文件中,他是一个绝对的运行时地址。
size:当前值的大小(通过value和size就能获得该符号的值)。
section:保存了到节头部表中的索引。
有三个节是不保存在节头部表中的,这些节被称为伪节:
ABS:保存了不应该被重定位的符号。
UNDEF:在本模块引用,却在其他模块定义的符号。
COMMON:未初始化,未被分配位置的数据目标。
对于COMMON和.bss的差别:COMMON主要存放未初始化的全局变量,.bss主要存放未初始化的静态变量,还有初始化为0的全局/静态变量。
前面说到链接器的一个功能就是符号解析,他是通过查询符号表,来将变量的引用与符号表中变量的具体定义连接起来的。
符号解析
- 对于局部链接器符号(如static变量),符号定义和符号引用都在一个可重定位目标文件中。但是对于同名的符号,需要生成唯一的标志。
- 对于全局链接器符号,如果他遇到了一个不是当前模块定义的符号时,会假设就是外面定义的,并且生成一个符号表条目,让链接器去处理。如果链接器找不到这个定义,就会返回错误。
对于全局符号多重定义符号的处理比较棘手,但是链接器有一套自己的规则来处理各种情况。
具体情况看书。
1.3静态库
编译器除了能够将可重定位目标文件链接起来形成可执行目标文件以外,还有一种将目标模块集合在一起形成静态库的概念。静态库以存档形式存储在磁盘中(.a)。它就是一组连接起来的可重定位目标文件的集合。
静态库解决了几个问题:
- 对于其他可重定位文件的组合,每一次编译链接,都会将整个组合复制一遍,极大的浪费了空间。静态库按需加载。
- 相关的函数集合在一起,编译成目标文件并且封装成静态库。这样来实现按需加载
具体过程:
首先先产生可重定位目标文件
gcc -c main2.c
由此可以得到main2.o
,然后运行以下代码:
gcc -static -o prog2c main2.o ./libvector.a
由此就能得到一个可执行目标文件prog2c
。这里的-static
表示链接器需要构建一个完全链接的可执行目标文件,可以加载到内存并运行,无需进一步链接。我们同样可以使用以下方法:
gcc -static -o prog2c main.o -L. -lvector
这里的-lvector
是libvector.a
的缩写,-L.
告诉链接器在当前目录中查找libvector.a
静态库。
静态库来解析引用
静态库解析引用的顺序和规则。(看书)
重定位
完成静态符号解析这一步,就说明文件的所有全局/静态,函数都有了定义,就知道目标代码中的代码和数据节的大小,接下来可以用重定位步骤进行为每个符号分配运行时地址。
第一步,重定位符号定义:将文件中所有的同类型节聚合成一个聚合节,这些聚合节以及里面的符号都会被分配好地址,结束这个步骤后,每条指令和全局变量都会有唯一的运行时地址了。
第二步,重定位符号引用:使对符号的引用重新定位到正确的位置(比如调用外部函数的指令或者函数引用,引用其他模块的全局变量等)。它利用了rel条目(重定位条目)的信息进行重定位。
当汇编器生成目标模块的时候,它并不知道数据以及代码最终会被放在上面地方,所以它就使用占位符先把位置占了,并同时生成一个重定位条目。最后链接器通过这个条目,来对这些未知的位置进行填充。其中,代码的重定位条目放在rel.text中,已初始化数据的重定位条目放在.rel.data中。
offset:利用相对于节的偏移来确定需要修改的符号的内存地址。
type:基本就两种,PC相对定位,绝对定位。
具体定位过程看书。
重定位之后的可执行文件就可以放入内存直接执行。
1.4可执行目标文件
ELF头:描述了整个ELF可执行目标文件的总体格式,还包括了程序运行时要执行的第一条指令的地址。
.init
:定义了一个小函数_init
,程序的初始化代码会调用
.text
、.rodata
和.data
和可重定位目标文件中的类似,只是这里被重定位到了最终的运行时内存地址
因为这个文件已经是被完全链接好的,所以不需要重定位条目了。(rel系列)
段头部表:它记录了包括页大小、虚拟地址内存段(节)、段大小等等。描述了可执行文件连续的片到连续的内存段的映射关系,如下图所示是通过OBJDUMP
显示的prog
的段头部表。
根据不同段的读写需求不同,分为代码段和数据段。上图中,off代表的该段应该从目标文件的什么位置开始读取,vaddr代表在实际的运行过程中,这个段会加载在实际内存中的什么位置。通过off和filesz就能够确定我们需要加载的段的内容。memsz表示这个段在内存中的大小,就是指我们应该将这个段加载到多大的内存中去。flags代表权限。
1.5加载可执行目标文件
系统通过调用加载器来运行可执行目标文件。它根据段头部表提供的信息,将代码以及数据从磁盘复制到内存当中,然后跳转到入口点来运行程序。 linux内核都有内存映像:
代码段/数据断:往往代码段都是从0x400000开始往上增长,其后跟的是数据段(因为有对齐要求,这两个段往往都不要求挨在一起)。
运行时堆:是通过调用malloc往上增长的。
共享库的内存映射:为共享模块保留的。
用户栈:总是从最大的内存地址开始,往小的地方增长。
内核段:操作系统驻留在内存中的部分。
为了安全(防止栈溢出攻击),使用地址空间随机化的方法来抵御这种攻击。它就是每次运行时,共享区域,栈以及堆都会有不同的地址。
1.6动态链接的共享库
静态库的一些缺点:
共享库的出现解决了以上两个问题。他通过将程序的链接阶段由编译时改变到运行时,并且其代码段和数据断均可以被引用了这些代码的程序共享来解决内存空间浪费的问题。同时可以随时提供最新的库,在运行时链接最新版本而不需要重新编译,链接。
动态链接的过程是由动态链接器来执行的。共享库也叫共享目标,在windows中是以.dll为后缀的,而linux中是以.so为后缀。
共享有两层含义:
- 一个库只有一个.so文件,与静态库那样将需要的代码段,数据断复制到每一个引用他们的模块不同,所有的引用.so的,都共享这个库的代码和数据段。
- 共享库的.text节可以被不同的正在运行的内存共享。
1.7加载时动态链接
我们可以通过以下形式产生共享库
gcc -shared -fpic -o libvector.so addvec.c multvec.c
其中,-shared
指示链接器创建一个共享的目标文件,-fpic
指示编译器生成与位置无关的代码。然后我们可以通过以下形式利用该共享库
gcc -o prog2l main2.c ./libvector.so
上个命令的prog2l能够在运行时与libvector.so进行链接。基本过程是:创建可执行文件时,静态执行一部分的链接,在加载时再进行一个动态完成链接。静态链接过程中,并不会将libvector.so的代码段和数据段复制到可执行目标文件中,而是将一些重定位,符号表信息复制进去而已。
在通过加载器加载prog2l时,会遇到.interp节,这个节就包含了动态链接器的路径名,然后就开始加载和运行这个动态链接器(这个链接器本身就是一个共享目标)。
接下来动态链接器通过先将.so的文本和数据放到某个内存段,然后再将可执行目标文件(prog2l)重定位到这些内存段,这个阶段完成之后,共享库的位置固定了,不会再改变了。如果后面还有程序要用它,直接从这个内存取。极大节省空间。
1.8运行时动态链接
Linux提供了一个叫dlopen的接口,来允许程序能够在运行时加载,链接共享库。
1.9库打桩机制
具体的流程是这样:对于一个需要打桩的目标函数,首先对它进行包装,也就是利用包装函数欺骗系统去调用这个包装函数而不是原函数。打桩可以发生在不同的阶段。
编译时打桩
如上图所示,尝试使用编译时库打桩技术。目的是将malloc以及free替换成我们自己的函数。
先定义一个我们自己的malloc.h文件
//本地malloc.h
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)