写在前面的话:此系列文章为笔者学习CSAPP时的个人笔记,分享出来与大家学习交流,目录大体与《深入理解计算机系统》书本一致。因是初次预习时写的笔记,在复习回看时发现部分内容存在一些小问题,因时间紧张来不及再次整理总结,希望读者理解。
《深入理解计算机系统(CSAPP)》第3章 程序的机器级表示 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第5章 优化程序性能 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第6章 存储器层次结构 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第7章 链接- 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第8章 异常控制流 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第9章虚拟内存 - 学习笔记_友人帐_的博客-CSDN博客
第七章 链接
将各种代码和数据片段连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置,组合成为一个可执行文件,这个文件可被加载(复制)到内存并执行。
好处:①模块化:程序可以编写为一个较小的源文件的集合,而不是一个整体巨大的一团;可以构建公共函数库;③效率高:分开编译,使得修改某一个源文件后,仅需重新编译这一个文件,然后重新链接,不需要重新编译其他源文件,节省时间;可以将公共函数聚合为单个文件,而可执行文件和运行内存映像只包含他们实际使用的函数的代码,节省空间。
**编译过程的链接部分:**驱动程序运行链接器程序ld将所有的.o文件以及一些必要的系统目标文件组合起来,创建一个可执行目标文件。
可执行目标文件的执行过程:
./prog
即可执行prog可执行目标文件,shell调用操作系统中一个叫做加载器(loader)的函数,将可执行文件prog中的代码和数据复制到内存,然后将控制转移到整个程序的开头。
**链接器要完成的任务:**符号解析、重定位
1. 三种目标文件(模块)
-
**可重定位目标文件(.o):**包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。每一个.o文件是由一个源(.c)文件生成的。
-
**可执行目标文件(.out):**包含二进制代码和数据,其形式可以被直接复制到内存并执行。
-
**共享目标文件(.so):**一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。在Windows中称为动态链接库(Dynamic Link Libraries, DLL)
一个目标模块(object module)就是一个字节序列;
一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块。
以上均称为ELF二进制文件
2. ELF可执行可链接格式
目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。现代x86-64 Linux和Unix系统使用可执行可链接格式(Execut-able and Linkable Format,ELF)。
2.1 链接视图
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。夹在ELF头和节头部表之间的都是节。
ELF头(ELF header)
开头是16字节的序列
- 描述生成该文件的系统的字的大小和字节顺序(大端、小端)。
剩余部分:ELF头的大小、目标文件的类型(.o, .exec, .so)、机器类型(如x86-64)、节头部表(section header table)的位置,节头部表中条目的大小和数量。
节:
.text:已编译程序的机器代码
.rodata:只读数据。(如printf里的格式串和switch的跳转表等)
.data:数据节,可读可写。已初始化的全局和静态变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss:未初始化或初始化为0的全局和静态变量。仅有节头,节本身不占磁盘空间,仅仅是一个占位符。区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.symtab:符号表,存放在程序中的函数和全局/静态变量的信息、节的名称和位置。符号表是一个结构体的数组,每个条目包括符号的名称、大小和位置,由汇编器生成符号表!(当不同文件中有重名的符号,编译器会自动输出不同的名称,比如将两个x分别表示为x.1和x.2)
.rel.text:可重定位代码,存放.text 节的可重定位信息、在可执行文件中需要修改的指令和指令地址。
.rel.data:可重定位数据,存放.data 节的可重定位信息、在合并后的可执行文件中需要修改的指针数据的地址。
.debug:调试符号表,符号调试的信息(其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件)。
节头表Section header table:每个节的在文件中的偏移量、大小等。
2.2 执行视图
区别:①增加了段头表;②将各节合并为段。
- 段头表/程序头表:页面大小,虚拟地址内存段(节),段大小
加载时:
shell调用操作系统中loader函数,将可执行文件中的代码段(.data, .bss)和数据段(.init, .text, .rodata)复制到内存,然后将控制转移到整个程序的开头。
下图展示了编译、链接时,可重定位目标文件中各节的重组情况,以及OS加载到内存时的情况。
传统:可执行程序载入内存的固定位置为0x400000(64位程序)或0x8048000(32位程序),易受攻击。
改进:现代链接器都是非固定地址的连接,程序可以加载到内存任意位置。readelf看到的信息是text段的vaddr为0,这样在加载时使用动态地址,可以防止攻击。
3. 链接器符号
函数、全局变量或静态变量才有符号。
三种不同的链接器符号:
- 全局符号:由模块m定义、能被其他模块引用的全局符号。非静态(non-static)的C函数和非静态的全局变量。
- 外部符号:由其他模块定义,且被模块m引用的全局符号。
- 本地/局部符号:只被模块m定义和引用的符号。例如带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。(注意:本地符号≠局部变量,局部变量存储在栈中)
4. 链接步骤1-符号解析
符号解析:将每个符号引用与输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。
4.1 处理局部符号
局部静态C变量:存储在.bss(未初始化或初始化为0的全局和静态变量)或.data(已初始化的全局和静态变量)
局部非静态变量:栈
编译器在.data为每个x的定义分配空间。.bss中的符号运行时在内存中分配这些变量,初始值为0。
为重复的局部符号在符号表中创建唯一名称:如两个x->x.1和x.2
4.2 解析多重定义的全局符号
链接器的输入是一组可重定位目标模块。如果多个模块定义同名的全局符号,会采取相应策略。下面是Linux编译系统采用的方法。
在编译时,编译器向汇编器输出每个全局符号,分为强(strong)、弱(weak)两种类型,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。
- 强符号:函数和已初始化的全局变量。
- 弱符号:未初始化的全局变量。
处理多重定义的符号名的规则:
-
规则1:不允许有多个同名的强符号。否则:链接器错误
-
规则2:如果有一个强符号和多个弱符号同名,那么选择强符号。对弱符号的引用将被解析为强符号。
-
规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
解决方法:尽量避免使用全局变量;使用static静态变量;定义时初始化;使用extern声明引用的外部全局符号。
5. 链接步骤2-重定位
将多个输入模块中的代码节和数据节合并为单个节。将符号从它们在.o文件中的相对位置重新定位到可执行文件中的最终绝对内存位置(分配运行时地址)。用它们的新位置,更新所有对这些符号的引用。
5.1 重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。
所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
ELF中两种最基本的重定位类型:
-
R_X86_64_PC32。32位PC相对地址的引用。
-
R_X86_64_32。32位绝对地址的引用。
5.2 重定位算法
算法是给机器的,直接理解透彻即可,这里实际上并不难,仅仅是简单的加减法罢了,不要被纸老虎吓到。
第1行和第2行在每个节s以及与每个节相关联的重定位条目r上迭代执行。
为了使描述具体化,假设每个节s是一个字节数组,每个重定位条目r是一个类型为Elf64_Rela的结构,
另外,还假设当算法运行时,链接器已经为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol)表示)。
第3行计算的是需要被重定位的4字节引用的数组s中的地址。如果这个引用使用的是PC相对寻址,就用5-9行来重定位;使用的是绝对寻址,就用11-13行重定位。
以下图main函数为例:
其反汇编代码:
main函数引用了两个全局符号:array和sum。为每个引用,汇编器产生一个重定位条目,显示在引用的后面一行上。这些重定位条目告诉链接器对sum的引用要使用32位PC相对地址进行重定位,而对array的引用要使用32位绝对地址进行重定位。
5.2.1 重定位PC相对引用
将引用对象地址 - rip(PC、下一条指令地址)差的补码,以小尾顺序写入待修改字段
call指令开始于节偏移0xe的地方,包括1字节的操作码0xe8,后面跟着的是对目标sum的32位PC相对引用的占位符。
重定位条目r由4个字段组成:
r.offset = Oxf (段内偏移量)
r.symbol = sum
r.type = R_X86_64_PC32
r.addend = -4
已经假设当算法运行时,链接器为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol)表示)。
ADDR(s) = ADDR(.text) = 0x4004d0
ADDR(r.symbol) = ADDR(sum) = 0x4004e8
①首先计算出引用的运行时所处地址(找到占位符的地址)
(sum所在段的段地址 + sum在段内的偏移量 = sum运行时的地址)
②更新引用,使其在运行时指向sum程序
通过计算其与下一条指令的地址(pc中存放)之间的相对位置即**refptr*,在运行时通过将%rip(pc)来加上*refptr,跳转到sum函数实际所在的位置进行执行。
在运行时,call指令将存放在地址0x4004de处。当cPU执行call指令时,PC的值为0x4004e3,即紧随在call指令之后的指令的地址。为了执行这条指令,CPU执行以下的步骤:
① 将PC压入栈中
② PC ← PC + 0x5 = Ox4004e3+0x5 = Ox4004e8
因此,要执行的下一条指令就是sum例程的第一条指令。
(以图里数据来理解更容易)
注意:填入的*refptr是小端序的5的补码。
5.2.2 重定位绝对引用
直接将引用对象的地址按小尾顺序写入待修改字段
mov指令开始于节偏移量0x9的位置,包括1字节操作码0xbf,后面跟着对array的32位绝对引用的占位符。
重定位条目r由4个字段组成:
r.offset = Oxa
r.symbol = array
r.type = R_X86_64_32
r.addend = 0
这些字段告诉链接器要修改从偏移量0xa开始的绝对引用,这样在运行时它将会指向array的第一个字节。现在,假设链接器已经确定
ADDR(r.symbol) = ADDR(array) = 0x601018
(直接将array的地址写入占位符即可)
当重定位结束后,在加载的时候,加载器会把这些节中的字节直接复制到内存,不再进行任何修改地执行这些指令。
6. 函数打包
6.1 静态链接静态库
静态库(.a存档文件):将所有相关的目标模块打包成为一个单独的文件。是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
// 创建静态库 ar -rs lib库.a .o .o
ar -rs libc.a atoi.o printf.o ... random.o
静态库缺点:
- 在存储的可执行文件中存在重复(例如每个程序都需libc)
- 在运行的可执行文件中存在重复
- 系统库的小错误修复要求每个应用程序显式地重新链接
6.2 动态链接共享库
共享库,也称动态链接库(DLL),后缀(Linux-.so, windows-.dll),在运行或加载时被动态地加载并链接到程序中。共享库载入内存后,可以由多个进程共享。
链接方式:
-
编译时链接:GCC编译时链接
-
加载时链接:当可执行文件首次加载和运行时进行动态链接,通常由动态链接器(ld-linux.so)自动处理。
-
运行时链接:在程序开始运行后(通过程序语句)进行动态链接。在linux中,通过调用dlopen()接口完成。