想要将动态链接库拷贝到共享存储里运行。
相关编译过程概述
概述 - GCC工作的前三个阶段
预编译
- 处理宏定义和文件包含。
- 宏定义:可以理解为递归地替换
- 文件包含:将包含在头文件中的声明拷贝到同一个文件内(
extern
)。但是这一步的本质是为了让编译器在生成.S文件时的符号表管理和语义检查不会出错。
编译
编译器完成的工作有词法分析、语法分析、语义分析和代码生成(编译优化)。其中词法分析、语法分析和语义分析中涉及到错误处理。语法分析和语义分析还有代码生成涉及到符号表管理。其中,词法分析通过有限状态自动机分析、语法分析通过语法树的生成构建、语义分析则是对一些语义的检查(声明与定义、表达式和语句的正确性等)、代码生成就是翻译为汇编代码的过程。我们重点关注符号表管理。
- 符号表管理:一个比较典型的符号表保存三个数据结构:变量表、函数表和串表。一种设计是,变量表和函数表使用哈希散列。串表保存的是程序中用到的字符串常量,用链表保存。
汇编
汇编器的构造与编译器大同小异,因为它本质上是汇编的编译器,汇编器的符号来源有四种:数据、标签、宏、外部符号。
其中
- 标签用于表示一段内存区域的地址,可能来自于函数名
- 外部符号用于表示引用的其他文件的数据或标签。
此时,已经开始生成ELF格式文件,所以我们比较关心汇编器的符号表和重定位表的操作。
汇编允许符号的后置定义,所以汇编器需要对程序进行两次扫描。第一次扫描的时候,汇编器将引用的不在符号表中的符号作为外部符号处理,添加到符号表。当扫描到符号的定义时,使用该符号的定义信息替换原有的符号信息。
表信息生成
由前面的“ELF文件格式”一节可以知道可重定位目标文件的结构。其中段表、符号表和重定位表是最重要的三个表。
汇编器在第一次扫描时,将汇编信息的段信息导出为段表项,填充到段表。在第二遍扫描时,将符号信息导出为符号表项,填充到符号表,并在产生重定位的位置生成重定位项,填充重定位表。这里重点关注符号表和重定位表。
- 符号表
- 符号表中记录的符号信息包括数据定义符号、宏符号、代码标签、函数名等。是为链接器、调试器、反汇编器等服务等。
- 符号表中记录符号名、所在段、段内偏移、类型(global, local)
- 对于外部符号,统一设定为全局符号(因为链接器会对全局符号进行符号解析,忽略局部符号的内容)(这是否是普遍的做法?),所在段设为未知。
- 重定位表
- 重定位表中记录重定位符号名,重定位位置所在段,重定位位置段内偏移,重定位类型(相对/绝对)。【这里需要注意的是,和符号表的所在段和段内偏移不一样,符号表的这两个属性是符号被定义时的位置,而重定义表中的所在段和段内偏移的属性是在被使用时的位置】
链接重定位和位置无关代码
在预编译、编译中所操作的符号表,并不是可执行文件所生成的符号表或重定位表。他们所用的符号表只是在编译过程中,为了执行语法和语义的错误检查而维护的中间变量。我们所真正关心的是最终生成的可执行文件的符号表和重定位表。链接主要负责的工作有地址空间分配、符号解析和重定位。链接器会将同名的段合并。
链接过程中的重定位操作
相对重定位和绝对重定位
- 相对重定位:使用一个相对地址的引用
- 绝对重定位,使用一个绝对地址的引用
- 相对重定位地址=重定位符号地址-重定位位置+重定位位置数据内容=重定位符号地址-下一条指令地址
- 【凡是对数据符号的引用都需要重定位,而对标签符号的引用,只有外部引用时才需要重定位。】调用本地声明的函数时,无论链接器如何调整
.text
字段,调用者和被调用者的相对位置都不会改变,所以不需要进行重定位。【如call fun
,call
指令内保存的相对地址不会变化。】 - 具体操作
- 对于一般的直接引用符号地址的指令会使用绝对地址重定位,对于函数调用或者跳转指令会使用相对地址重定位。(这是否是普遍的做法?)
位置无关代码
位置无关代码,可以加载而无需重定位代码段部分。
PIC数据引用
无论在内存中的何处加载一个目标模块,数据段与代码段的距离总是保持不变。所以PIC数据引用利用了这个特点。在数据段开始的地方设置了全局偏移量表(Global Offset Table,GOT)。每个被当前目标模块引用的全局数据目标都有一个8字节条目,编译器为GOT中的每个条目生成一个重定位记录。加载的时候,动态链接器会重定位【GOT中的每个条目】。这意味着动态链接器并不会重定位代码段的信息。
每个引用全局目标的目标模块都有自己的GOT。
PIC函数调用(延迟绑定)
一个典型的应用程序常常使用共享库的很少一部分,而延迟绑定被提出解决这一问题。一个目标模块调用在共享库中的任何函数,就会有自己的GOT和PLT。延迟绑定依赖于两个部分
- 过程连接表(PLT,Procedure Linkage Table)
- PLT是以16字节代码为单位(一个条目)构成的数组。PLT[0]跳转到动态链接器中,PLT[1]初始化执行环境,PLT[2]开始的条目调用用户代码调用的函数。
- 全局偏移量表(GOT)
- GOT是以8字节的地址为单位构成的数组。GOT[0]和[1]包含动态链接器在解析函数地址时会使用的信息(addr
of .dynamic / addr of reloc
entries)。GOT[2]是动态链接器在
ld-linux.so
中的入口点。其余每一条对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一条对应的PLT条目。初始时,每个GOT条目都指向对应PLT条目的第二条指令。
- GOT是以8字节的地址为单位构成的数组。GOT[0]和[1]包含动态链接器在解析函数地址时会使用的信息(addr
of .dynamic / addr of reloc
entries)。GOT[2]是动态链接器在
整个过程如下(如被调用函数为func):
第一次执行call func
- 程序经过三次寻址(跳转到PLT,查询GOT条目,跳转到GOT条目对应地址),程序跳转到PLT对应表项的第二条指令。
- PLT表项的第二条指令,将函数的ID压入栈中,然后跳转到PLT[0]。
- PLT[0]压入GOT[1]存放的参数(是什么?),然后通过GOT[2]跳转到动态链接器中。动态链接器通过函数ID和GOT[1]参数确定函数
func
地址。用得到的地址重写对应GOT表中的地址。
第二次执行call func
- 程序仍然经过上面三次寻址(跳转到PLT,查询GOT条目,跳转到GOT条目对应地址),此时GOT条目对应地址为
func
地址。
一个问题:延迟绑定修改后的GOT地址是相对地址还是绝对地址?经过思考,应为绝对地址
位置无关代码的函数内部调用
使用一些简单的程序进行实验,对函数内部调用有基本的认识。使用-R
查看dynamic
relocation
entries,使用-d
查看反汇编结果,使用-t
查看符号表内容,使用-T
查看动态符号表内容。RIP寄存器存放着当前指令的地址。
64位RIP寻址
在没有RIP寻址之前,ELF文件的PIC实现大费周章。必须先调用一个函数拿到PC,再加上偏移地址来获得变量的地址或者GOT位置。RIP寻址则解决了这一问题,这让代码可以通过offset(%rip)
的形式偏移寻址。
调用全局数据符号(RIP寻址) √
和前面的讲述一致。完成编译的位置无关代码动态链接库调用static数据符号时,采用offset(%rip)
的形式找到GOT对应表项。加载的时候GOT对应表项被链接器重定位。而符号本身(也许)是存放在全局变量区,属于数据段的一部分。同时,动态符号表中将产生对应的表项。尚不清楚全局数据符号的实际地址在什么得放,猜测存放在全局变量区。
调用static数据符号(RIP寻址) √
和前面的讲述一致。但是在动态符号表中没有相应表项。猜测符号本身是存放在全局变量区,属于数据段的一部分。
调用函数内部数据符号 √
栈内创建,《编译原理》书上说应通过程序运行时指针sp
访问。实际是用栈底+偏移的方式。(offset(%rbp)
)
补充:call汇编指令(intel user manual. P694)
call
汇编指令有许多种形式,其中e8 cd
代表字节
0xE8 后面跟着一个2字节操作数表示要跳转到的地址与当前地址的偏移量。
下文中的call
在64位机器上启用-fPIC
等编译选项后的执行方式正好为e8 cd
格式。可以归纳在下面的情景下,都为相对寻址。
调用外部函数符号
调用外部函数符号时,从汇编文件产生时,就已经有的call xxx@PLT
的调用形式了。反编译得到的结果可以验证这一点,同时可以肯定,含有@PLT
的应为绝对地址,因为最终是通过GOT获得了外部函数地址,而GOT本身是绝对地址。前提是使用位置无关方法。
调用static函数符号
调用static函数符号时,汇编文件产生的调用的形式为call xxx
,同时汇编文件中没有.globl
字段。程序运行时应为相对地址调用。
- 假如开了
-O2
选项,编译器有可能(未验证过是否都会)将static声明的函数优化为硬代码插入调用其的函数中。凡是全局声明的函数,都不会被这样优化。
全局函数符号直接拷贝运行失败的原因
对于调用了全局函数符号的代码,直接拷贝最根本的原因应该在于PLT表和GOT表可能产生的错误:
- 函数首先通过相对偏移跳转到对应的PLT表。此时,【能否找到PLT表】成为第一个问题。问题的关键在于【PLT表是否已经被复制过来】。
- 接下来需要通过查找GOT表中对应的条目,跳转到对应的GOT表位置。【能否找到GOT表】成为第二个问题。(确定GOT表的位置时采用的寻址方式需要确定)
- 接下来GOT表记录的绝对地址应该是已经被修改过的。但是因为是绝对地址,所以【这个地址一定是错误的】,这是第三个问题。
- 如果调用任何数据符号,通过偏移寻址能否找到的关键则在于【有没有将数据段也一并拷贝过来】。
全局函数符号的拷贝解决
首先需要保证【PLT表和数据段被拷贝到代码段的正确相对位置】,其次要保证【PLT表能够正确访问GOT表】,最后要保证【GOT表存储的值被全部修改】。
- 预编译阶段:全部修改为static函数,这样所有东西都是通过相对寻址访问的了。
- 编译或汇编阶段:全部修改为static函数解析。
- 链接或加载阶段:编写llvm pass,将动态链接库中函数按照static方式解析。
- 程序运行阶段:将动态链接库加载到我们想要拷贝到的地址。