CSAPP(第三版)第七章链接学习笔记
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
学习链接的理由:
- 理解链接器将帮助你构造大型程序。
- 理解链接器将帮助你避免一些危险的编程错误。
- 理解链接将帮助你理解语言的作用域规则是如何实现的。
- 理解链接将帮助你理解其他重要的系统概念。
- 理解链接将使你能够利用共享库。
编译器驱动程序
静态链接
为了构造可执行文件,链接器必须完成两个主要任务:
- 符号解析(symbol resolution):目标文件定义和引用符号,每个符号对应于一个函数,一个全局变量或者一个静态变量。
- 重定位(relocation):编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与内存为止关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。
目标文件
目标文件有三种形式:
- 可重定位目标文件:包含二进制和数据,其形式可以在编译时与其他可冲定位目标文件合并起来,创建一个可执行目标文件。
- 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制并执行。
- 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
目标文件是按照特定的目标文件格式进行组织的,Windews中使用可移植可执行(Portable Executable,PE)格式,Max OS-X使用Mach-O格式,x86-64 Linux和Unix使用可执行可链接格式(Executable and Linkable Format,ELF)。
可重定位目标文件
以可重定位目标文件的ELF格式为例,如下图所示
一个典型的ELF可重定位目标文件包含下面几个节(或者段,可以直接叫sections):
- .text:已编译程序的机器代码。
- .rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
- .data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss中。
- .bss: 保存未初始化的静态变量(全局和局部),以及被初始化为0的全局变量和静态变量(全局和局部)。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化的变量是为了空间效率,在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
- .symtab: 符号表,存放在程序中定义和引用的函数和变量的符号信息。
- .rel.text:一个.text节中位置的列表,当链接器这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
- .rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
- .debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。
- .line:原始C源程序中的行号和.text节中机器指令之间的映射。
- .strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
- 由其他模块定并被模块m引用的全局符号。这些符号称为外部链接器符号,对应于在其他模块中定义的非静态C函数和全局变量。
- 只被模块m定义和引用的局部链接器符号。他们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
符号表是由汇编器构造的,使用编译器输出到会变语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组,请看下图:
- name:字符串表中的字节偏移,保存符号名字。
- type:说明该符号的类型,是函数、变量还是数据节等等。
- binding:说明该符号是本地还是全局的。
- section:说明该符号保存在哪个节中,是节头部表中的偏移量。
- value:对于可重定位目标文件而言,是定义该符号的节到该符号的偏移量(比如函数就是在.text中,初始化的变量在.data,未初始化的变量在.bss中);对于可执行目标文件而言,是绝对运行形式地址。
- size:目标的大小(以字节为单位)。
三个特殊的伪节(pseudosection),它们在节头部表中是没有条目的: - ABS:代表不该被重定位的符号。
- UNDEF:代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号。
- COMMON:表示还未被分配位置的未初始化的数据目标。
符号解析
定义:链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保他们拥有唯一的名字。
对于全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或者函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在他的任何输入模块中都找不到这个被引用的符号的定义,就输出一条错误信息并终止。
链接器如何解析多重定义的全局符号
在编译时,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含的编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
- 不允许有多个同名的强符号。
- 如果有一个强符号和多个弱符号同名,那么选择强符号。
- 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
与静态库链接
所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(statis library),它可以用作链接器的输入。在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的课重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
优点:
- 相关的函数可以被编译为独立的目标模块,然后封装成一个独立的静态库文件。
- 链接时,链接器只会复制静态库中被应用程序引用的目标模块,减少了可执行文件在磁盘和内存中的大小。
- 应用程序员只需要包含较少的库文件名就能包含很多的目标模块。
链接器如何使用静态库来解析引用
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命行上出现的顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E,U和D均为空。
- 对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反应f中的符号定义和引用,并继续下一个输入文件。
- 如果f是一个存档文件,name链接器就尝试匹配U中未解析的符号和由档案文件成员定义的符号。如果某个存档成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反应m中的符号定义和引用。对存档文件中的所有成员目标文件都一次进行这个过程,直到U和D都不在发生变化。此时任何不包含在E中的成目标文件都简单的被丢弃,而链接器将继续处理下一个输入文件。
- 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构造输出的可执行文件。
重定位
当链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义关联起来,此时链接器就知道他输入的目标模块中的代码节和数据节的确切大小,就可以开始重定位步骤了,重定位分为两步:
- 重定位节和符号定义。这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。
- 重定位节中的符号引用。这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。
重定位条目
当汇编器生成一个目标模块,它并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以无论何时汇编器遇到对最终位置未知的目标引用,它会生成一个重定位条目,告诉链接器在将目标文件合并可执行文件是如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
ELF定义了32中不同的重定位类型,我们只了解其中两种基本的重定位类型:
- R_X86_64_PC32。(相对地址)
- R_X86_64_32。(绝对地址)
重定位符号引用
- 重定位PC相对引用
- 重定位绝对引用
可执行目标文件
以上内容是链接器如何将多个文件合并成一个可执行目标文件。以下是一个典型的ELF可执行文件中的各类信息:
- ELF头:描述文的总体格式。还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。
- .init:定义一个小函数,叫做_init,程序的初始化代码会调用它。
- 因为可执行文件是完全连接的,所以它不在需要rel节。
- 其他的格式类似于可重定位目标文件的格式。
程序头部表描述了这种映射关系。包括页大小,虚拟地址内存段(节),段大小等等。
从程序头部表,我们看到根据可执行目标文件的内容初始化分为两个段: - 代码段:包含ELF头、段头部表、.init、.text和.rodata。
- 数据段:包括.data和.bss。
(r表示可读,x表示可执行,-表示没有写入权限,w表示可写)
下面的数据段多出8个字节用来存放.bss section的数据,虽然不占用可执行空间,但是运行时需要初始化为0。
对于任何一个段,链接器必须选择一个起始地址vaddr,使得
off表示端在可执行文件中的相对于起始位置的偏移量 ,align是程序头部表中指定的对齐量。
比如:
这种对齐要求是优化的一种,使得程序执行时,可执行文件中的段能够高效的传送到内存中。
加载可执行目标文件
定义:加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,这个将程序从磁盘复制到内存并运行的过程叫做加载。
在Liunx x86-64的系统,代码段总是从地址0x400000处开始,然后是数据段,运行时堆在数据段之后,堆的增长方向是从低地址到高地址。堆后面的区域是为共享模块保留的,这个区域把堆和栈隔开了,用户栈的起始地址是2的48次方减1,这里是最大的合法用户地址。关于栈的增长方向是从高地址到低地址的,这里需要特别注意一下。再往上,从地址2的48次方开始,是为操作系统的代码和数据保留的,这部分内存空间对用户空间是不可见的。从这个图可以看出,代码段,数据段以及堆之间都是相邻的,同时还把栈的起始位置放在了最大的合法用户地址处,实际上由于数据段有地址对齐的要求,所以代码段和数据段之间是有间隙的。同时,为了防止程序收到攻击,在分配栈,共享库以及堆的运行时地址时,链接器还会使用到地址空间随机化的策略。所以每次程序运行这些区域的地址都会改变,不过他们的相对位置是不变的。
动态链接共享库
静态库缺点:需要定期维护和更新。几乎每个C程序都使用标准I/O函数,运行时,这些函数的代码会被复制到每个运行进程的文本段中,这将是是对稀缺内存系统资源的极大浪费。
共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序连接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也成为了共享目标,在Linux系统中常用.so后缀来表示。
共享库的“共享”具有两层含义:
- 在任意文件系统中,一个库只有一个.so文件,所有引用该共享库的可执行目标文件都共享该.so文件中的代码和数据,不像静态库的内容会被复制到可执行目标文件中。
- 在内存中,一个共享库的.text节可以被不同正在运行的进程共享。
动态链接器通过执行下面的重定位完成链接任务: - 重定位libc.so的文本和数据到某个内存段。
- 重定位libvector.so的文本和数据到另一个内存段。
- 重定位prog21中所有对由libc.so和libvector.so定义的符号的引用。
从应用程序中加载和链接共享库
动态链接是一项强大有用的技术,下面是一些常用例子:
- 分发软件。微软Window应用的开发者常常利用共享来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动连接和加载新的共享库
- 构建高性能Web服务器。许多Web服务器生成攻台内容,比如个性化的Web页面,账户余额和广告标语。主要思路是将每个生成动态内容的函数打在共享库中。当一个来自Web浏览器的请求到达时,服务器动态的加载和链接器适当的函数,然后直接调用它,而不是使用fork和execve在子程序的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用开销就可以处理随后的请求了。这对于一个繁忙的网站来说是有很大影响的。
Linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库
dlopen函数加载和链接共享库filename。
dlsym函数的输入是一个指向前面已经打开了的共享库的句柄和一个symbil名字,如果该符号存在,就返回符号的地址,否则返回NULL。
如果没有其他共享库还在使用这个共享库,dlclose函数就写在该共享库。
dlerror函数返回一个字符串,它描述的是调用dlopen,dlsym或者dlclose函数时发生的最新的错误,如果没有错误,就返回NULL
位置无关代码
共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的代码库,因而节约宝贵的内存资源。多个进程如何共享一个副本:一种方法是给每个共享库分配一个实现预备的专用的地址空间片,然后要求加载器总是在这个地址加载共享库,但是这个方法对地址空间的使用率不高,因为即使一个进程不适用这个库,空间还是会被分配出来。而且难以管理。要避免这些问题,现代系统以这样一种方式编译共享模块的代码段,使得可以把他们加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。
可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)。
- PIC数据引用:无论我们再内存的呵斥加载一个目标模块,数据段与代码段的举例总是保持不变的。因此代码段中任何指令和数据段中的任何变量志建的举例都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。想要生产对全局变量PIC引用的编译器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(Global Offset Table, GOT)。在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节条目。编译器还为GOT中的每个条生产一个重定位记录,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的GOT。
- PIC函数调用
假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生产一条重定位记录,然后动态链接器在程序加载的时候再解析他,不过这种方并不是PIC,因为他需要链接器修改调用的代码段,GNU编译系统用了一种很有趣的技术来解决,就是延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
以上是展示PLT和GOT如何协作在运行时解析函数的地址。
- 过程链接表
- 全局偏移量表
库打桩机制
Linux链接器支持库打桩(Library Interpositioning)技术,允许你截获对共享库函数的调用,替换成自己的代码。
基本思想:给定一个需要打桩的目标函数,创建一个包装函数(比如wrapper函数),它的原型与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是目标函数了。
编译时打桩
编译时打桩说起来就是将对目标函数的调用替换为对应wrapper的调用。
链接时打桩
Linux静态链接器支持用--wrap f标志进行链接时打桩。
运行时打桩
编译时打桩需要访问程序的源代码,连接时打桩需要能够访问程序的可冲定位的对象文件。不过运行时打桩仅需要访问可执行目标文件即可,它的基本原理是基于动态链接器的LD_PRELOAD环境变量的。
如果LD_PRELOAD环境变量被设置为一个共享库路径的列表,那么当你加载和执行一个程序,需要解析未定义的引用时,动态链接器会先搜索LD_PRELOAD给定的库,然后才搜索任何其他的库。有了这个机制,当你加载和执行任意可执行文件时,可以对任何共享库中任意函数打桩。
处理目标文件的工具
- AR:常见静态库,插入,删除,列出和提取成员。
- STRINGS:列出一个目标文件中所有可打印的字符串。
- STRIP:从目标文件中删除符号表信息。
- NM:列出一个目标文件中的符号表定义的符号。
- SIZE:列出目文件中节的名字和大小。
- READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含SIZE和NM的功能。
- OBJDUMP:所有二进制工具之母,能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令。
- LDD:列出一个可执行文件在运行时所需要的的共享库。