目录
1 概述
1.1 链接的作用
1.2 链接的分类
2 静态链接
2.1 静态链接示例
2.1.1 源码
2.1.2 编译
2.1.3 链接
2.2 静态链接步骤
2.2.1 第1遍扫描:完成目标文件合并
2.2.2 第2遍扫描:完成重定位
2.3 深入分析符号重定位过程
2.3.1 分析方法
2.3.2 对不同类型符号的处理方式
2.3.3 处理占位符
3 静态链接扩展与验证
3.1 数据段重定位
3.1.1 示例程序修改
3.1.2 数据段重定位分析
3.2 体系结构对重定位计算方式的影响
3.2.1 目标文件分析
3.2.2 重定位分析
4 加载时动态链接
4.1 概述
4.1.1 静态链接的问题
4.1.2 解决问题的基本思路
4.1.3 动态链接要解决的问题
4.2 动态链接示例
4.2.1 动态链接库源码
4.2.2 构建动态链接库
4.2.3 链接动态链接库
4.2.4 运行应用程序
4.3 动态链接库内存布局
4.4 动态链接如何节约内存
4.4.1 code Segment
4.4.2 data Segment
4.5 地址无关代码(Position Independent Code,PIC)
4.5.1 问题引入
4.5.2 解决问题的基本思路
4.5.3 地址无关代码的核心结构
4.5.4 地址无关代码对不同类型符号的处理方式
5 运行时动态链接
5.1 概述
5.1.1 加载时动态链接的问题
5.1.2 解决问题的基本思路
5.1.3 code patch技术引入
5.2 延迟绑定技术
5.2.1 延迟绑定技术原理
5.2.2 延迟绑定技术实现
6 动态链接器工作原理
6.1 概述
6.2 动态链接器工作流程
6.2.1 启动动态链接器
6.2.2 加载依赖的动态库文件
6.2.3 符号重定位与解析
6.2.4 调用init函数
1. 从程序员的视角,是通过函数名来执行程序中的代码,通过变量名来访问变量。其中每个变量和函数都有自己的名称,通常将这些名称叫做符号
2. 从CPU的视角,CPU并不理解符号的概念,他所能理解的只有内存地址的概念。因此无论是读写变量还是函数调用,对CPU而言处理的都是地址
3. 因此需要一个连接程序员和CPU的桥梁,将程序中的符号转换成CPU执行时的内存地址。这个桥梁就是链接器,他负责将符号转换为内存地址
链接器会为每个变量和函数正确地分配内存空间,记录他们的地址,并将这个地址覆写回引用或者调用他们的地方
说明:编译器和链接器的作用
① 编译器的作用主要是将源代码文件翻译成中间结构(e.g. LLVM IR),然后对其进行优化,以提升程序的性能。编译器的输出是汇编文件,汇编文件中仍然保留了符号
② 之后,汇编器将汇编文件翻译成目标文件
③ 最后,链接器将目标文件合并起来,并在此过程中完成符号地址转换,最终生成二进制可执行文件
根据进行符号和地址转换的时机,链接被分为3类,
1. 静态链接
在生成二进制可执行文件的过程中进行符号和地址转换
2. 加载时动态链接
① 在二进制文件被加载进内存时进行符号和地址转换
② 在这种情况下,在二进制文件中会保留符号,在加载时将符号解析成内存地址
3. 运行时动态链接
① 在二进制可执行文件运行时进行符号和地址转换
② 这种情况是将符号解析工作推迟到不得不做时才进行
说明:加载时动态链接和运行时动态链接一般统称为动态链接
1. 在external.c文件中定义外部变量和外部函数
int extern_val = 3;
int extern_func(void)
{
return 30;
}
2. 在example.c文件中操作各类变量和函数
extern int extern_var;
int global_var = 1;
static int static_var = 2;
extern int extern_func(void);
int global_func(void)
{
return 10;
}
static int static_func(void)
{
return 20;
}
int main(void)
{
// 访问外部变量
int var0 = extern_var;
// 访问全局变量
int var1 = global_var;
// 访问静态变量
int var2 = static_var;
// 调用外部函数
int var3 = extern_func();
// 调用全局函数
int var4 = global_func();
// 调用静态函数
int var5 = static_func();
return var0 + var1 + var2 + var3 + var4 + var5;
}
使用如下命令将源文件编译为目标文件,
# -c: 只编译不链接
# -g: 编译时携带调试信息
gcc external.c -c -g -o external.o -fno-PIC
gcc example.c -c -g -o example.o -fno-PIC
说明:关于-fno-PIC编译选项
① -fno-PIC编译选项用于告诉编译器不要生成PIC(Position Independent Code)代码,由于编译时使用的GCC版本为5.4.0,默认会编译为PIC代码,因此添加该编译选项
② PIC编译对动态链接意义较大,由于本节示例的是静态链接因此将其关闭。关于PIC编译的详细内容,可参考后文动态链接部分
使用如下命令将上述目标文件链接为可执行文件,
gcc example.o external.o -o a.out -no-pie
说明1:关于no-pie选项
① PIE(Position Independent Execution)模式意味着系统加载器(loader)在加载可执行文件时可以随机设置起始地址,该模式用于配合Linux操作系统进程地址随机化功能工作
关于进程地址随机化,可参考01. 深入理解虚拟内存 chapter 5.2
② -no-pie编译选项表示关闭PIE模式,此时在64位Linux操作系统中默认的加载起始地址固定为0x400000
说明2:关于创建静态库
示例程序中是直接将2个目标文件链接为可执行程序,还可以使用ar命令将external.o目标文件创建为静态库使用,命令如下,
# ar archive [member...]
# -c: 创建(create)archive
# -r: 将member列出的成员加入archive
ar -cr libexternal.so external.o
关于静态库的完整示例,可参考深入浅出计算机组成原理02:计算机指令 chapter 6.3
2.2.1.1 任务目标
1. 完成目标文件合并
2. 完成虚拟内存布局的分配
3. 完成符号信息收集(供第2遍扫描使用)
2.2.1.2 关于目标文件合并
1. 每个中间文件都有自己的代码段、数据段等多个section,在合并时,一般采用相似段合并的策略,最终生成共享文件(.so)或可执行文件
2. 在合并的过程中,链接器对输入的每个目标文件进行扫描,获取每个段的大小,并且收集所有的符号定义和引用信息,构建一个全局的符号表
3. 当链接器构造好最终的文件布局和虚拟内存布局后,就可以根据符号表确定每个符号的虚拟地址
2.2.2.1 任务目标
完成符号的重定位
说明:什么是符号的重定位
所谓重定位,就是当被调用者的地址发生变化后,需要让调用者知道新的地址是什么
2.2.2.2 关于符号重定位
在这一阶段,会利用第1遍扫描得到的符号表信息,依次对文件中每个引用符号的地方进行地址替换,以确保程序在执行时能正确访问到函数和变量
1. 通过对比目标文件和可执行文件中符号的差异,来分析符号重定位的过程
2. 使用如下命令对目标文件和可执行文件进行反汇编
# -D: disassemble all sections
# -S: display source code intermixed with disassembly
objdump -DS example.o > example.dis
objdump -DS a.out > a.dis
3. 先粗略对比一下目标文件和可执行文件main函数中对符号访问处理的差异,详见后文分析
说明:编译器在生成一个目标文件时,都是从地址0开始生成各Section,因为编译器并不知道当前目标文件的代码段和数据段最终将放置在进程内存地址空间中的什么位置
当完成链接后,程序中的每条指令的全局变量才拥有进程内存地址空间中的确定地址
2.3.2.1 局部变量
1. 局部变量在栈上分配,目标文件和可执行文件的处理方式相同,都是通过rbp寄存器进行分配和释放
2. 由于无需通过虚拟地址进行访问,因此局部变量无需链接器进行重定位
2.3.2.2 静态函数
1. 目标文件和可执行文件对静态函数的处理方式相同,生成的二进制机器码均为e8 bd ff ff ff,其中e8是callq指令的编码,后面的4B对应被调函数的地址
2. callq指令采用相对寻址,在小端字节序下,偏移地址字段为0xffffffbd,也就是-67D(-0x43)。在执行callq
目标文件:0x4e - 0x43 = 0xb
可执行文件:0x400524 - 0x43 = 0x4004e1
3. 由反汇编结果可见,静态函数也无需链接器进行重定位。这是因为在同一个编译单元内部,static_func函数和main函数的相对位置是固定不变的。即便链接的过程中会对不同目标文件中的代码段进行合并,同一个目标文件内部不同函数之间的相对位置也会保持不变,因此在编译阶段就能确定对静态函数调用的偏移量
2.3.2.3 外部变量 / 全局变量 / 静态变量
1. 在目标文件和可执行文件中,都使用基于rip寄存器的相对寻址方式访问外部变量 / 全局变量 / 静态变量
2. 由于在编译阶段不能确定外部变量 / 全局变量 / 静态变量与访问指令之间的偏移量,因此使用0作为占位符,等待链接过程的第2遍扫描将正确的地址回填到这里
说明:为什么静态变量不能在编译阶段确定偏移量?
① 如上文所述,静态函数可以在编译阶段确定与调用指令之间的偏移量
② 在同一编译单元内部,虽然静态变量和访问指令之间的偏移量是确定的。但是静态变量位于数据段,访问指令位于代码段,在链接过程进行目标文件合并时可能会进行重新排布,因此在可执行文件中的偏移量会发生改变
2.3.2.4 外部函数 / 全局函数
1. 在目标文件和可执行文件中,都使用callq指令调用外部函数 / 全局函数,而callq指令采用相对寻址
2. 由于在编译阶段不能确定外部函数 / 全局函数与调用指令之间的偏移量,因此也使用0作为占位符,等待链接过程的第2遍扫描将正确的地址回填到这里
2.3.3.1 重定位信息表
1. 为了帮助链接器完成符号重定位,在目标文件中会包含重定位信息表,可通过如下命令查看相关Section信息
# displays the information contained in the file's section headers
readelf -S example.o
其中的.rela.text段类型为RELA,就是代码段的重定位信息表
说明:重定位信息表的段名一般以.rela开头,例如.rela.text是.text段的重定位信息表,.rela.data是.data段的重定位信息表
2. 通过如下命令,可以查看目标文件中重定位信息表的内容
# displays the contents of the file's relocation section
readelf -r example.o
目标文件example.o的.rela.text段共有5个条目,分别对应代码段中需要重定位的外部变量 / 全局变量 / 静态变量 / 外部函数 / 全局函数
3. 偏移量Offset字段
① 偏移量表示需要符号重定位回填地址的位置在代码段的偏移量
② 以0x20为例,该偏移量指向需要符号重定位的外部变量
4. 类型Type字段
① 重定位条目共有32种类型,该类型会影响重定位的计算方式
② 示例程序中的重定位条目均为R_X86_64_PC32类型,表示重定位的引用使用32位PC相对地址(这也是占位符为4B全零的原因)
5. Sym.Name(符号名称) + Addend(加数)字段
① Sym.Name标识需要重定位的符号名称
② Addend用于重定位计算,是重定位过程中需要的辅助信息,使用方式详见下文分析
说明:在ELF文件格式中,重定位表条目的数据结构如下,
typedef struct {
Elf64_Addr r_offset; /* 重定位表项的偏移地址 */
Elf64_Xword r_info; /* 重定位的类型以及重定位符号的索引 */
Elf64_Sxword r_addend; /* 重定位过程中需要的辅助信息 */
} Elf64_Rela;
2.3.3.2 重定位计算方式
R_X86_64_PC32类型的重定位计算方式为:S - P + A
1. S表示链接后需要重定位的符号在进程内存地址空间中的地址
2. P表示需要进行重定位的位置在进程内存地址空间中的地址
3. A表示重定位表项中的Addend值,他代表了占位符的长度,用于修正计算结果
下面以main函数中对外部变量的访问为例,说明重定位的计算方式,
1. S为extern_var符号的链接地址,即0x601038
2. P为需要进行重定位的位置,即0x4004f6
3. A为extern_var符号重定位表项中的Addend值,即-4
计算结果如下,
S + A - P = 0x601038 - 0x4004f6 + (-4) = 0x200b3e
可见计算结果与实际链接结果一致
2.3.3.3 对重定位计算方式的理解
1. 重定位的目标是通过链接器在指令中占位符的位置填入一个值,使得程序在运行时能够正确访问到符号
2. S - P用于计算目标符号到占位符之间的地址差值(偏移量)
3. 但是在执行访问extern_var的指令时,rip寄存器中存储的是下一条指令的地址,而目标符号到该地址的地址插值就是在(S - P)的基础上加A进行修正
说明:重定位计算方式变换后如下,
S - P + A = S - (P - A)
其中(P - A)就是执行访问extern_var的指令时rip寄存器中存储的下一条指令的地址
2.3.3.4 对静态变量的处理
1. 静态变量没有全局符号
静态变量只在本编译单元内可见,不会对外进行暴露,因此没有全局符号,只能根据本编译单元的.data段的地址进行重定位
因此,在代码段的重定位表中,静态变量对应的表项没有符号名称,只有.data + 0(Addend)。需要特别注意的是,此处的.data是指本编译单元的.data段链接后在进程内存地址空间中的地址
2. 静态变量的重定位计算方式与之前相同(因为也是R_X86_64_PC32类型)
S为目标文件.data段链接后在进程地址空间中的地址,即0x601030
P为需要进行重定位的位置,即0x400508
A为static_var符号重定位表项中的Addend值,即0
计算结果如下,可见计算结果与实际链接结果一致
S + A - P = 0x601030 - 0x400508 + 0 = 0x200b28
说明:由上述分析可见,链接器在符号重定位过程中对静态变量的处理方式与外部变量 / 全局变量是相同的,区别仅仅在于静态变量的符号被隐藏了。而隐藏的方式,就是基于本编译单元的.data段计算偏移量,而不是基于一个全局符号计算偏移量
在上述静态链接示例程序中,只有代码段重定位表而没有数据段重定位表。根据对重定位过程的分析,只要在编译时存在不确定的地址引用,就需要通过重定位回填正确的地址,下面我们修改示例程序进行验证
1. 想要有数据段重定位表,就需要在数据段存在不确定的地址引用,因此我们对example.c文件进行如下修改,
需要注意的是,不能按如下方式进行修改,因为全局变量的初始化式必须是常量
int gloabal_var2 = global_var;
2. 使用readelf -S命令查看目标文件,可见已经有数据段重定位表
1. 首先使用readelf -r命令查看数据段重定位表信息,可见只有一个表项,其中偏移量指向目标文件数据段需要符号重定位回填地址的位置
2. 该重定位条目的类型为R_X86_64_64,表示重定位的引用使用64位绝对地址,也就是直接回填global_var符号的链接地址
上文以X86-64体系结构为例说明了静态链接的过程,下面以ARM64体系结构为例进行分析。虽然体系结构不同,但是链接过程中重定位的目的是一致的
1. 查看目标文件的反汇编结果,可见对于外部变量 / 全局变量 / 静态变量 / 外部函数 / 全局函数也是使用0作为占位符
局部变量仍然是在栈上分配,静态函数也是通过本编译单元内部的偏移量直接访问
2. 使用readelf -S命令查看目标文件,可见也有代码段重定位表
1. 首先使用readelf -r命令查看数据段重定位表信息,此处重定位表项的类型是与体系结构寻址的方式相关的
2. 下面以外部变量为例,说明对外部变量和全局变量的重定位方式
① 首先通过adrp指令获取extern_var符号基于PC的所在页地址
② 之后通过add指令在上一步骤的基础上增加页内偏移量
上述2个步骤与extern_var符号的重定位条目类型是一致的
3. 对于静态变量,目标文件数据段链接后在进程内存地址空间中的地址为0x4108a0,重定位值为.data + 4,即0x4108a4。而重定位的计算步骤,则是与外部变量 / 全局变量相同
4. 对于外部函数和全局函数的重定位方式,二者均被重定位为调用指令到函数的偏移量
1. 静态链接最大的问题就是无法共享,从而会导致内存浪费
2. 假设程序A和程序B都需要调用foo函数,在采用静态链接的情况下,只能将foo函数既链接到程序A的二进制文件中,也链接到程序B的二进制文件中
如果系统中同时运行A程序和B程序,内存中就会装载两份foo函数的代码,这就造成了内存浪费
1. 目前解决共享问题的通用思路,是将公共的函数都放在一个文件中,该文件在整个系统中只会被加载到内存中一次。无论有多少个进程使用他,这个文件在内存中只有一个副本,这种文件就是动态链接库文件
2. 动态链接库文件在不同的操作系统中有不同的格式,
① 在Linux中是共享目标文件(share object,so)
② 在Windows中是动态链接库文件(dynamic linking library,dll)
1. 代码必须地址无关
① 由于动态链接库的代码要在多个不同的进程中进行共享,而每个进程有自己独立的地址空间,同时系统加载器无法保证将动态链接库加载到每个进程的相同地址处,因此共享模块的代码必须是地址无关的
② 假设进程A加载libfoo.so库的起始地址为0x1000,进程B加载libfoo.so库的起始地址为0x3000,如果libfoo.so库中代码使用绝对地址访问函数或数据,就必然会造成冲突
2. 动态重定位
① 要想让程序正确访问动态链接库中的符号,最终还是需要进行符号重定位操作
② 只是对动态链接库的符号重定位操作被推迟到二进制可执行文件加载时或运行时才进行
1. 在foo.h文件中声明foo函数,需要使用foo函数的程序需要包含该头文件
#ifndef __FOO_H__
#define __FOO_H__
void foo(void);
#endif
2. 在foo.c文件中实现foo函数
#include
#include "foo.h"
void foo(void)
{
printf("Hello foo\n");
}
3. 在main_a.c文件中调用foo函数
#include
#include
#include "foo.h"
int main(void)
{
printf("A.exe\n");
foo();
pause();
}
4. 在mian_b.c文件中也调用foo函数,从而构造2个进程共享动态链接库的场景
#include
#include
#include "foo.h"
int main(void)
{
printf("A.exe\n");
foo();
pause();
}
使用如下命令将foo.c构建为动态链接库,
# -fPIC: 产生地址无关代码
# -shared: 告诉链接器生成的目标文件是共享目标文件
gcc foo.c -fPIC -shared -o libfoo.so
说明:使用file命令查看libfoo.so文件类型如下
使用如下命令将libfoo.so链接到可执行文件中,
# -no-pie: 禁止生成地址无关的可执行文件,便于观察进程的内存布局
gcc main_a.c -L. -lfoo -no-pie -o A.exe
gcc main_b.c -L. -lfoo -no-pie -o B.exe
说明1:关于-L编译选项
① -L编译选项用于指定链接库的路径,-L . 就是告诉链接器需要在当前目录下查找库文件
② 除了-L编译选项,也可以通过LIBRARY_PATH环境变量来追加库文件查找路径,命令如下,
③ 由于示例中构建的动态链接库并不在默认查找路径中,如果不指定链接库的路径,链接器(/usr/bin/ld)将会报错,提示找不到动态链接库
说明2:关于-l编译选项
① -l编译选项用于指定链接库的名称
② GCC在处理链接库名称时,会自动加上"lib"前缀和".so"后缀,因此-lfoo就是告诉链接器查找名为libfoo.so的库文件
1. 此时直接运行应用程序会报错,提示加载动态库时无法找到库文件,这是因为动态库在链接时和运行时均需要部署
2. 可以通过ldd命令查看当前应用程序运行时依赖的动态库,可见A.exe和B.exe均依赖libfoo.so库,但是目前该库的状态为not found,因此应用程序运行失败
3. 此处libfoo.so状态为not found的原因是运行时查找动态库的默认路径中没有当前目录,可以设置LD_LIBRARY_PATH环境变量,追加当前目录,命令如下,
设置完成后,可见A.exe和B.exe均可找到libfoo.so库的位置,而且2个应用程序均可正确运行
说明1:以A.exe应用程序中依赖的libc.so.6库为例,说明ldd命令显示的内容,
① 第1列:程序依赖的动态库(libc.so.6)
② 第2列:系统给程序提供的库文件(/lib/x86_64-linux-gnu/libc.so.6)
③ 第3列:在进程地址空间中,该动态库加载的起始地址(0x00007f640cc98000)
其中,由上述实验可见,如果第1列已经可以给出动态库路径,则可以没有第2列的内容
说明2:ldd命令还可以查看动态库依赖的动态库,在示例程序中,foo函数中调用了printf函数,因此libfoo.so库也依赖libc库
此处就涉及了动态库之间的相互引用,具体处理方式详见下文分析
说明3:LIBRARY_PATH和LD_LIBRARY_PATH环境变量辨析
① LIBRARY_PATH环境变量由链接器使用,使用的时机是链接器进行链接时
② LD_LIBRARY_PATH环境变量由动态链接器(也就是ldd命令看到的ld-linux-x86-64.so.2库)使用,使用的时机是在程序运行时
我们同时运行A.exe和B.exe,并观察对应进程的地址空间布局
从实验结果中可见,
1. 动态库并没有和可执行文件合并,他们各自有自己的数据段和代码段,这是与静态链接的不同之处
2. 同一个动态库在两个进程中的虚拟地址并不相同,以libfoo.so库为例,
① 在A.exe进程中的起始地址为7f0697f78000
② 在B.exe进程中的起始地址为7fc3ede96000
说明1:动态库内存映射情况
① 参考编程高手必学的内存知识01:深入理解虚拟内存 chapter 5.1.2,可执行文件或者动态库文件被加载进内存时,文件中的Section会根据不同的属性被加载进内存中的不同Segment。例如,
② 参考编程高手必学的内存知识03:深入理解堆 chapter 2.2,在通过mmap系统调用加载动态库时使用的是私有文件映射,对于不同的Segment特性如下,
因此动态库内存映射情况如下图所示,
③ 在进程间共享动态库得益于虚拟内存技术,只需要在虚拟内存空间中设置到物理地址的映射即可完成共享
说明2:应用程序每次运行时,同一动态库的加载地址并不是固定的。这其实也是动态链接的应有之义,动态链接库只有在加载时才能确定其在进程地址空间中的地址
使用如下命令查看两个进程中libfoo.so库的物理内存占用情况,
cat /proc/[pid]/smaps
1. RSS(Resident Set Size)字段的含义是当前Segment实际加载到物理内存中的大小
libfoo.so库的code Segment虽然不足4KB,但是分配物理页的单位为4KB
2. PSS(Proportional Set Size)字段的含义是进程按比例分配当前Segment所占物理内存的大小
由于多个进程共享了动态库的code Segment,所以PSS的计算方式应该是RSS除以共享进程数,此处就是4KB / 2 = 2KB
3. 如果此时终止B.exe进程,A.exe进程libfoo.so库code Segment的PSS就会变成4KB(因为没有进程与之共享)
1. 与code Segment不同,由于不同进程各自拥有data Segment的副本,所以RSS和PSS均为4KB。这与上文说明的动态库内存映射情况也是一致的
2. 由此可见,动态链接技术确实将共享部分的内存节省下来
假设在应用程序中共享2个动态库,并且这2个动态库之间存在相互引用,则情况会变得复杂
1. 2个进程共享了libc.so和libd.so动态库,而且libc.so中会调用libd.so中的foo函数
2. 进程1将foo函数映射到虚拟地址0x1000处,将调用foo函数的指令映射到虚拟地址0x2000处,此时填入call指令的偏移量应为-0x1000
3. 进程2将foo函数映射到虚拟地址0x2000处,将调用foo函数的指令映射到虚拟地址0x5000处,此时填入call指令的偏移量应为-0x3000
4. 由于引用者(call指令)和被引用者(foo函数)之间的相对位置不能确定,基于rip寄存器的相对寻址就会产生冲突,此时就需要地址无关代码
说明:地址有关代码
① 相对寻址要求目标地址和本条指令之间的相对位置是固定的,这种代码就是地址有关代码。当目标地址和调用地址之间的相对位置不固定时,就需要使用地址无关代码技术
② 本课程更新了我对地址有关代码的认知,之前只是认为使用绝对地址的代码是地址有关代码,使用相对地址的代码是地址无关代码
参考S5PV210体系结构与接口04:代码重定位 & SDRAM初始化 chapter 3,理解不同更多是语境不同造成的
既然导致问题的原因是引用者和被引用者之间的相对位置不固定,那么我们就在动态库中引入一个中间层,该中间层满足2个条件,
1. 动态库中对全局符号的访问指令与中间层之间的相对位置是固定的,这样就可以使用基于rip寄存器的相对寻址
2. 通过中间层,将动态库中对全局符号的访问由直接访问转换成间接访问
满足上述2个条件后,就可以将全局符号的实际地址填写在中间层,之后引用者通过固定相对寻址从中间层中间接获取全局符号的地址
在Linux中引入的中间层就是全局偏移表(Global Offset Table,GOT),GOT的工作原理如下图所示,
1. GOT表是动态库的一部分,动态库中调用foo函数的指令也是动态库的一部分,因此GOT和调用指令之间的偏移量是固定的,可以使用基于rip的相对寻址
即使进程1和进程2将动态库映射到各自进程地址空间中的不同地址,这里的偏移量也是固定的
2. GOT表属于data Segment,是每个进程私有的,每个进程都是访问自己的GOT表。操作系统在加载二进制可执行文件时,会解析目标符号在当前进程中的虚拟地址,并将该地址填写到GOT表中
在示例图中,foo函数在进程1中的虚拟地址为0x1000,在进程2中的虚拟地址为0x2000,他们分别记录在各自的GOT表中,这样就可以确保在不同的进程中都可以查询到正确的目标符号地址
3. GOT表存放了该模块需要访问的所有外部符号的地址,因此对外部符号的访问可以转换成对GOT表的访问,从而解除了对外部符号地址的直接依赖
说明:对动态库中外部符号的解析与重定位由加载器(loader)负责,他为符号分配并记录地址,然后将这些地址回写进GOT表
4.5.4.1 示例代码
1. 动态库源码foo.c如下,
static int static_var;
int global_var;
extern int extern_var;
extern int extern_func(void);
static int static_func(void)
{
return 10;
}
int global_func(void)
{
return 20;
}
int demo(void)
{
int ret_var = 0;
// 访问静态变量
static_var = 1;
// 访问全局变量
global_var = 2;
// 访问外部变量
extern_var = 3;
// 访问静态函数
ret_var += static_func();
// 访问全局函数
ret_var += global_func();
// 访问外部函数
ret_var += extern_func();
return ret_var;
}
2. 使用如下命令构建动态库
gcc foo.c -g -fPIC -shared -fno-plt -o libfoo.so
说明:关于-fno-plt编译选项
① -fno-plt编译选项表示在动态库中只使用GOT,不使用延迟绑定。关于延迟绑定的详细内容,可参考后文延迟绑定技术部分
② -fno-plt编译选项从6.x版本GCC开始支持,实验在Ubuntu 20.04环境下进行,GCC版本如下,
关于GCC版本对-fno-plt编译选项的支持,可参考g++: error: unrecognized comma line option ‘-fno-plt’
3. 使用如下命令反汇编动态库,用于后续分析
objdump -DS libfoo.so > libfoo.dis
说明:查看GOT表在动态库中的Section和Segment信息
① 可见libfoo.so中包含.got Section
② 可见.got Section被加载到data Segment
4.5.4.2 静态变量
1. 使用基于rip寄存器的相对寻址来访问静态变量
执行访问静态变量的指令时,rip寄存器中存储的是下一条指令的地址0x1134,与指令中的偏移量0x2ef0相加,值为0x4024
2. 由于静态变量与访问静态变量的指令在同一个动态库中,因此相对位置是固定的,所以可以通过基于rip寄存器偏移的方式来寻址
说明:这里有一个重要的背景,就是动态库不会与可执行文件合并,所以可以确保动态库中的静态变量与访问静态变量的指令之间相对位置不变
回顾一下chapter 2.3.3.4说明的静态链接对静态变量的处理,由于会与可执行文件合并,因此只能根据合并后.data段的虚拟地址进行重定位
4.5.4.3 静态函数
由于静态函数与访问静态函数的指令也是在同一个动态库中,因此动态库对静态函数的处理方式与静态变量相同,也是使用基于rip寄存器的相对寻址来访问,他们都不能被外部访问
4.5.4.4 外部变量 / 全局变量
1. .got段的起始地址为0x3fc0,结合上文libfoo.so的Section信息,.got段的长度为0x40。因此0x3fc0和0x3ff0都在.got段范围内
2. 访问外部变量和全局变量时,都是先从.got段将目标符号地址读取到rax寄存器,之后再通过间接寻址向变量中写入数据
4.5.4.5 外部函数 / 全局函数
动态库对外部函数 / 全局函数的处理方式与外部变量 / 全局变量相同,都是通过.got段进行间接访问
说明1:地址无关代码除了可以在动态库中使用,同样可以在可执行文件中使用,可以通过-pie编译选项使得GCC生成地址无关的可执行文件。地址无关的可执行文件可以被加载到内存的任意位置执行,这会使得缓冲区溢出的难度增加
但是代价是通过GOT表间接访问会多一次内存访问,性能会下降
说明2:.got段中表项需要重定位
引入GOT表解决了地址无关代码的问题,而全局符号真实地址的解析与写回由动态加载器完成。加载时动态链接的方式与静态链接类似,也是基于重定位信息表进行
可见全局符号的重定位信息被链接在.rela.dyn段,加载时动态链接时会在偏移量处填入符号的真实地址
说明:在生成一个动态库时,一定要加-shared编译选项,但是-fPIC编译选项是必须的吗?
① 首先需要理解的是,添加-fPIC编译选项会引入GOT表,而GOT表的功能是解决符号调用指令和符号之间相对位置不确定的问题
② 所以,如果不存在相对位置不确定的问题,也就不需要GOT表,也就不需要-fPIC编译选项。因此,如果一个动态库没有调用其他动态库中的函数,这个动态库就不必生成地址无关代码
③ 在通常情况下,一个功能完善的动态库往往会依赖各种其他的动态库(e.g. 依赖最常见的glic库)。所以在实际工作中,大多数动态库在编译时需要添加-fPIC编译选项
加载时动态链接的问题是会有性能损失,这种性能损失主要来自于两个方面,
1. 每次对全局符号的访问都要转换为对GOT表的访问,然后进行间接寻址,增加了一次内存访问
2. 加载时动态链接在程序启动时,动态链接器需要对进程中所有GOT表中的符号进行解析和重定位,这就导致程序在启动过程中速度减慢
同时需要注意的是,程序不一定使用动态库提供的所有函数(e.g. 程序不会使用glibc提供的所有函数),因此动态链接器对GOT中不使用符号的解析也是一种浪费
1. 对于GOT表引入的间接寻址,由于仍然需要实现地址无关代码,因此无法省略
2. 对于在加载时进行的符号解析与重定位,可以将其推迟到最后不得不做时才进行,而且可以只解析实际用到的符号
说明:此处介绍code patch技术是作为后续延迟绑定技术的铺垫
1. code patch技术是Java Hotspot虚拟机中使用的一种技术,用于实现运行时加载
2. 在Java中,类是按需加载的,也就是对于一个class文件,只有当Hotspot首次使用他的时候才会将其加载。假设在即时编译A方法时要调用B方法,但是B方法还没有被加载进来,此时会在生成call指令时将目标地址设置为一个虚拟机内部用于解析符号的方法
当CPU执行这条call指令时,就会跳转到解析符号方法,此时虚拟机就会加载B方法所在的类,然后将B方法的地址回写到call指令中。整个过程如下图所示,
说明1:patch code技术将解析符号的过程推迟到符号被访问时才执行,延迟绑定的基本思路与其一致
说明2:patch code技术会直接修改指令,因此存在风险
说明3:关于对代码段的修改
① patch code技术会直接修改指令,也就是会修改代码段,但是在一般的认知中,代码段是只读的
② 这是因为此处的patch code技术是JIT(Just In Time)动态编译出来的代码,在运行时编译时,虚拟机会向操作系统申请一块可写可执行的内存用于存储JIT代码,这就导致JIT代码所在的内存区域可以在运行时被修改
③ 对于静态编译的程序,代码段是不可写的
5.2.1.1 基本思路
1. 延迟绑定(Lazy binding)技术将符号的解析与重定位一直推迟到第一次访问时才进行。这样的话,对于整个程序运行过程中没有访问到的符号可以不进行重定位,从而提高了程序的性能
2. 延迟绑定技术依然使用GOT表进行间接调用,在理想情况下,将GOT表中待解析符号的位置都填写为动态符号解析函数的地址即可
这样当CPU执行到尚未解析的函数时,就会跳转到_dl_runtime_resolve函数进行符号解析,解析后将符号真正的地址回写进GOT表。后续再调用该函数时,则可以直接从GOT表中获取符号地址
说明1:相较于code patch技术,延迟绑定技术只是修改了GOT表(而GOT表是存储在data Segment),不会修改代码,因此安全性更好
同时需要注意的是,在Linux操作系统中,code Segment也是不可写的
说明2:上述方案存在的问题
_dl_runtime_resolve函数在动态解析符号时需要依赖两个参数,
① 当前动态库的ID(因为一个程序可能依赖多个动态库)
② 待解析符号在GOT表中的序号
上述方案中无法传递待解析符号在GOT表中的序号,这就需要用到后文介绍的过程链接表(Procedure Linkage Table,PLT)
说明3:在计算机领域,还有很多用到Lazy思想的实例。比如内存管理中的缺页异常,只有在实际访问到分配的虚拟内存时,才分配物理内存并建立映射
Lazy思想的核心就是将一件事推迟到不得不做时才做
5.2.1.2 引入过程链接表PLT
引入PLT是为了解决传递待解析符号在GOT表中的序号问题,引入之后的结构如下图所示,
该结构要点如下,
1. 对B函数的调用被转换成对B@plt函数的调用,而B@plt函数会通过间接寻址跳转到GOT表中最终记录的B函数真实地址处
2. GOT表中待解析符号位置处的默认值为(相应的@plt函数 + 0x6),也就是@plt函数中push指令的位置,此处压栈的是待解析符号在GOT表中的序号
3. 之后跳转到.plt函数继续执行,在将动态库ID压栈后,通过间接寻址调用_dl_runtime_resolve函数,此时已完成了函数参数传递
4. 当符号解析完成后,会将B函数的真实地址回写到GOT表,后续对B函数的调用就能通过一次跳转就调用到真正的B函数
说明:在引入PLT之后,GOT表布局如下,
① GOT[0]:被加载器保留,其中存放的是.dynamic段的地址
② GOT[1]:存放当前动态库ID,该ID由加载器在加载当前动态库时分配
③ GOT[2]:存放动态链接函数的入口地址,一般是动态链接器中的_dl_runtime_resolve函数
④ 从GOT[3]开始存放待解析符号的地址,序号从0开始
需要特别注意的是,这种布局需要Linker和Loader约定好
5.2.2.1 示例代码
依然使用chapter 4.5.5.1的示例代码,但是使用如下命令构建动态库,也就是去掉-fno-plt编译选项
gcc foo.c -g -fPIC -shared -o libfoo.so
5.2.2.2 对不同类型符号的处理方式
5.2.2.2.1 静态变量 / 静态函数
由于静态变量 / 静态函数及其访问指令在同一个动态库中,因此相对位置固定,可以使用基于rip寄存器的相对寻址来访问
5.2.2.2.2 外部变量 / 全局变量
对外部变量 / 全局变量的访问依然使用基于GOT表的间接寻址,而对GOT表的寻址还是使用基于rip寄存器的相对寻址
说明1:对于外部变量和全局变量,仍然需要使用加载时动态链接的方式确定地址,因为对变量的访问无法转换为对动态符号解析函数的调用
说明2:此处.got段中的表项也需要进行重定位,extern_var和global_var的重定位信息被链接在.rela.dyn段,加载时动态链接时会在偏移量处填入符号的真实地址
5.2.2.2.3 外部函数 / 全局函数
1. 对外部函数和全局函数的调用都被转换成对相应@plt函数的调用
说明:调用@plt函数时,使用的是基于rip寄存器的相对寻址。这是因为.plt段与调用函数的指令在同一个动态库,所以相对位置固定
2. @plt函数被链接在.plt段,而动态库ID和函数真实地址被链接在.got.plt段(而不是.got段)
① 0x201018处的初始值为0x646,也就是extern_func@plt函数中的pushq $0x0指令
② 0x201020处的初始值为0x656,也就是global_func@plt函数中的pushq $0x1指令
说明1:.got.plt段中表项需要重定位
① .plt段和.got.plt段在同一个动态库中,因此相对位置是固定的,所以从.plt段访问.got.plt段中的函数地址可以使用基于rip寄存器的相对寻址
② 但是通过0x201018 / 0x201020处的初始值0x646 / 0x656是无法用jmpq指令跳转到@plt函数中的push指令的,因此.got.plt段中表项也需要重定位
可见相关重定位信息被链接在.rela.plt段,加载时动态链接时会在偏移量处填入符号的真实地址
③ 需要注意的是,此处的重定位类型为R_X86_64_JUMP_SLO,因此重定位时填入的并不是extern_func和global_func的真实地址(现在是运行时动态链接,此时还不知道全局函数符号的真实地址),而只是用于修正.got.plt段中的初始值
说明2:.plt Section与.got.plt Section的加载
① .plt Section被加载在code Segment,可读可执行,不可写
② .got.plt Section被加载在data Segment,可读写,不可执行
1. 在使用ldd命令查看可执行文件依赖的动态库时,有一个特殊的动态库文件ld-linux-x86-64.so(下文简称为ld-linux.so,看名字就与链接器ld有关联),该动态库就是动态链接器。又因为他还负责动态库文件的加载,因此也被称作加载器(Loader)
2. 对于一个完全静态链接的可执行文件,启动时不需要动态链接器的辅助。内核加载完成后,直接跳转到用户代码的入口函数开始执行(由execve系统调用完成)
对于一个需要动态链接的可执行文件,内核会先准备好可执行文件需要的环境,然后依次将可执行文件和ld-linux.so加载到内存中,下一步就是跳转到ld-linux.so的入口函数开始执行
3. 跳转到ld-linux.so的入口函数后,是在用户态执行
1. ld-linux.so本身也是一个动态库,ld-linux.so启动之后首先要完成自己的符号解析与重定位,这个过程叫做动态链接器的自举(bootstrap)
2. 由于ld-linux.so在自举时自身的GOT表 / PLT表解析均未完成,所以在自举过程中的代码不能使用全局符号和外部符号
说明:ld-linux.so的源码在glibc源码的elf目录中,其中自举过程在elf/rtld.c文件中
1. 在动态链接器自举完成后,会根据可执行文件.dynamic段信息依次加载程序依赖的动态库文件
2. 可执行文件的动态库依赖关系往往是一个图的关系,所以加载动态库的过程是一个图遍历的过程,这里往往采用广度优先搜索算法
3. 动态链接器在加载动态库的过程中会维护一个全局符号表,每次加载新的动态库后,会将动态库中的符号信息合并到全局符号表中
说明:动态库重名函数处理
① 在静态链接的过程中,如果不同目标文件中定义了相同的符号,链接器会报出符号重定义错误
② 在动态链接的过程中,如果不同动态库文件中定义了相同的符号,动态链接器只会将解析的第一个符号添加到全局符号表中,后续遇到的重名符号会被自动忽略
③ 这样导致的结果是,不同动态库文件中的同名符号,在运行时能看到的只有加载顺序在前面的符号定义
在完成动态库文件的加载后,全局符号表的信息即收集完成,此时动态链接器就可以根据全局符号表和重定位表的信息对可执行文件和各动态库进行重定位修正
说明:如果是运行时重定位,此处只要做最基本的动态库文件注册
1. 有的动态库会有.init段,进行一些初始化函数的调用;用户自己也可以在.init段定义初始化函数
2. 这些初始化函数会由动态链接器在最后的阶段进行一次调用,之后动态链接器工作完成,会将执行流跳转到可执行文件的入口函数处
说明:动态链接器加载动态链接的可执行文件的流程如下图所示,