CSAPP阅读笔记-链接-第七章-P464-P500
链接概述
经预处理器->编译器->汇编器处理后,源文件可被转化为一组可重定位目标文件,链接器将它们组合起来形成可执行文件。
每个可重定位目标文件由不同的“代码节”和“数据节”组成,每一个节都是一个连续的字节序列。由于每个可重定位目标文件的生成是独立的,因此组合时会出现两个问题:
一是若某个可重定位目标文件中使用了外部的全局变量,而此变量定义于另一个可重定位目标文件中,怎么办?
二是生成目标文件时如何确定地址。链接器生成的可执行文件在实际运行时,需要由加载器将其代码及数据复制到内存,再将控制转移到此程序的开头,若每个链接前的目标文件都以0为起始地址,势必会造成重合,怎么办?
这就是链接器要做的两件事:符号解析与重定位
符号解析:目标文件中可能会定义或引用一定的函数,全局变量或静态变量,这些都是所谓的“符号”,符号解析的目的就是把符号的定义和引用关联起来。
重定位:如上所述,第二个问题的解决方案就是重定位,链接器会把符号的定义与内存位置相关联,重定位时将符号的引用指向此内存位置。
可重定位目标文件
下图为典型的可重定位目标文件结构:
可重定位目标文件的内容以“节”的形式存在。
ELF头描述一些系统的和文件本身的信息。
节头部表描述不同节的位置和大小。
简略讲述几个重要的节:
.text节存放代码,.rodata节存放只读数据,.data节存放已初始化的全局和静态变量,.bss节存放未初始化或被初始化为0的全局和静态变量,此节不占用实际空间,只有在运行时才会被分配实际内存空间,另外局部变量在运行时被存放于栈中,不会出现在某个节中。.symtab节存放了符号表(符号表由汇编器构造)。
注意:静态局部变量不存放在栈中,而是会出现在符号表中。若同一目标文件中存在两个同名的静态局部变量,编译器会向汇编器输出两个不同名字的符号。
符号解析
当编译器遇到一个不是当前模块定义的符号时,会生成一个符号表条目,并交给链接器处理。链接器会在所有输入模块中寻找其定义,找不到时报错。
若出现多个目标文件定义相同名字的全局变量怎么办?
Linux系统的方法:编译器向汇编器输出全局变量对应的符号时,会标记是“强”符号(函数和已初始化的全局变量)还是“弱”符号(未初始化的全局变量),注意,静态变量独属于本模块,没有多重定义的问题,无需标记。汇编器将此信息编码在符号表内,链接器在链接时根据以下规则进行处理:
1.不允许有多个同名的强符号
2.如果有一个强符号和多个弱符号同名,选强符号。
3.若多个弱符号同名,随机选一个。
给个例子:
此时,假设x的地址是0x601020,y的地址是0x601024,运行程序后,赋值x时会覆盖到y,导致发生错误。
静态链接
所有相关的函数被编译为独立的目标模块,并被打包成一个单独的文件后,此文件被称为静态库,可以用做链接器的输入,当链接器构造可执行文件时,只会复制静态库里被应用程序引用的目标模块,这么做比起将每个独立的目标模块单独输入到链接器,显然可以简化链接器输入时的步骤,当然,对运行时内存的改善无优化,对于具有引用了相同标准目标模块的程序,运行时每次都需从静态库中拷贝这些目标模块。在Linux中静态库以“存档”的形式(一组连接起来的可重定位目标文件的集合)存放于磁盘,文件名后缀是.a。
举个例子:
左边是两个独立的目标模块,右边的main函数要调用它们,此时先为左边两个函数创建一个静态库:
随后编译和链接输入文件main.o和libvector.a:
注意顺序:链接器从左到右来扫描可重定位文件和存档文件,我的理解是,定义必须出现在引用后面。另外,库一般放在命令行的结尾。
比如上面的main2.o中调用了addvec和multvec,所以放前面,随后出现libvector.a。
重定位
链接器在符号解析完成后开始进行重定位:将相同类型的节合并,为新的节和其中定义的符号赋予新的运行时内存地址,而对于各个节中的符号引用,则会通过“重定位条目”来重定位。
重定位条目:当汇编器遇到最终位置未知的目标引用,会生成重定位条目,告诉链接器在合并成可执行文件时如何修改引用,它存放于.rel.text节中,已初始化数据的重定位条目存放于.rel.data节中。
上图为一个典型的重定位条目格式,offset是节偏移,symbol标识引用应该指向的符号,type标识如何修改引用,addend是有符号常数,用于对引用作偏移调整。引用的重定位有两种修改方式:R_X86_64_PC32和R_X86_64_32,前者代表以相对引用重定位,后者代表以绝对引用方式重定位。
举个详细的例子帮助理解:
上图是一个main函数的汇编代码,里面有定义一个全局变量array,也有引用一个外部函数,sum。
先来看全局符号sum,其重定位条目如下:
假设链接器已经确定运行时.text节的地址为0x4004d0,此时:
节偏移为r.offset=0xf,因此,sum引用的内存地址位于0x4004d0+0xf=0x4004df处,即汇编代码中的第6行,位于e8后面,注意0xe8是指令的操作码。
r.symbol代表sum定义处的运行时地址,假设为0x4004e8。
r.type = R_X86_64_PC32,代表此时为相对引用。
r.addend用作偏移调整,这里是-4,我个人的理解是这代表了sum引用的内存地址到下一条指令地址的偏差,比如这里sum引用的内存地址是0x4004df,PC要执行的下一条指令的地址为0x4004e3(汇编代码中第8行)
综上,可以这么理解:由重定位条目sum我们可以定位到需要修改的引用地址为0x4004df,由r.addend可以定位到下一条指令的地址0x4004df-(-4)=0x4004e3,这个地址与sum定义处的运行时地址相差0x4004e8-0x4004e3=0x5。因此重定位时,汇编代码第6行的指令会被改为:
注意,这里0x5被写成05 00 00 00,这说明这里的表示方法是“小端法”(高字节位放高地址,低字节位放低地址)
实际运行时,执行到这条call指令时,PC值为0x4004e3(即下一条指令的地址),call指令执行时,这里用PC的值和05 00 00 00决定跳转后的地址,步骤如下:
1.将PC压栈
2.PC+0x5 = 0x4004e8->PC
此时会跳转到sum定义处的地址。
再来看全局符号array,其重定位条目如下:
r.type=R_X86_64_32,说明这里用的是绝对引用。
对array的引用位于汇编代码第4行,这是一条mov指令,起始于0x4004d0+0x9=0x4004d9处,它有1字节的操作码0xbf。
r.offset=0xa,告诉链接器要修改位于0x4004d0+0xa=0x4004da处的array引用地址
假设已经确定array定义地址为0x601018,结合第三章知识点,个人理解如下:由于这里是一条mov指令,不像call指令一样,call指令执行时是根据下一条指令地址(存于PC)来决定当前call指令中的跳转值的,是相对跳转,而mov指令是数据传送指令,直接将数据存入寄存器,无需作相对跳转,因此相应的r.addend=0,也因此重定位时直接修改此指令中的数据值即可,以下是重定位后对应的汇编代码:
可执行目标文件
典型的可执行目标文件的结构图:
它与之前的可重定位目标文件类似,具体不再赘述。
上图是可执行目标文件的程序头部表,前两行对应代码段,flags r-x代表有读/执行权限,此段开始于0x400000处,总共占内存大小为0x69c个字节
数据段有读/写权限,开始于0x600df8处,总共占内存大小为0x230个字节,可以推出.data节始于0x600df8-0xdf8=0x600000处,目标文件中大小为0x228字节,为什么会比总共占内存小0x230-0x228=8个字节?因为.bss节的数据只是占位符,位于可重定位目标文件时不占空间,链接成可执行文件后会为其初始化,分配内存空间。
加载可执行目标文件
运行可执行目标文件,是通过调用加载器来实现的,加载器会将程序复制到内存并运行。
上图是程序运行时的内存映像,具体不作解释,看书即可。