静态链接库将代码和数据在编译时整合到可执行文件,使程序独立运行。动态链接库允许在程序运行时加载,而不是在编译时将库的代码和数据静态地合并到可执行文件中。这允许多个程序共享同一份库,减小程序体积。由于动态链接库在编译时并未确定其在内存中的具体位置,而是在运行时加载,因此必须进行加载时重定位。
另外一方面,这个特性也用于实现U-Boot的代码重定位的功能,这些都与位置无关码有关,所以本篇文章就来详细地介绍一下位置无关代码的实现。
这四个编译选项与位置无关代码(Position Independent Code
,PIC
)和位置无关可执行文件(Position Independent Executable
,PIE
)有关。它们的作用主要是为了提高代码的可重定位性,使得代码更适用于共享库和在内存中的不同位置加载的情况。
-fPIC
,以确保库中的代码可以在内存中的不同位置加载。-fPIE
会生成一个可以在内存中的不同位置加载的可执行文件。-fPIE
类似。-pie
可以生成位置无关的可执行文件,这也是为了提高安全性。与 -fPIE
不同的是,-pie
在链接时指定,而不是在编译时。-fno-pic
禁用,生成与地址相关的代码。编译和链接指定?
# 编译时指定 -fPIC
gcc -c -fPIC source.c -o object_file.o
# 链接时指定 -pie
gcc object_file.o -o executable -pie
与可执行文件不同,构建动态链接库时,链接器无法假定已知的代码加载地址。因为每个程序可以使用多个共享库,事先无法知道任何给定共享库将在进程的虚拟内存中的哪个位置加载。对于不同操作系统来说,有不同的解决方法。本文就介绍一下Linux是如何解决这个问题的。
现在我们看一个简单的代码:
int test = 10;
int func(int a)
{
test += a;
return test;
}
将上面的代码编译成动态链接库后,就涉及到使用mov
指令将全局变量test
的值从内存位置取到寄存器中。但mov
指令需要绝对地址,但由于动态链接库没有预定义的加载地址,所以这个地址将在运行时确定。
在Linux中,动态链接器是一段代码,位于/lib/ld-linux.so.2
(2为版本)。当可执行文件运行时,它负责将共享库从磁盘加载到内存中。动态链接器就是用来解决前面的绝对地址的问题。
在Linux ELF共享库中,主要有两种解决这个问题的方法:加载时重定位和位置无关代码。
存在的问题:
(1)性能问题
当一个应用程序加载与加载时重定位条目关联的共享库时,尽管只需加载重定位的条目,但如果一个复杂的软件在启动时加载多个大型共享库,并且每个库都需要进行加载时重定位,会导致应用程序启动时间明显延迟。
(2)代码段无法共享
共享库的初衷之一是为了节省RAM,使得一些常见的共享库能够被多个应用程序共享。这意味着对于每个应用程序,共享库都必须完全加载到内存中,导致相当大量的RAM浪费。
(3)要求代码段可写
为了允许在加载时动态地修改其中的绝对地址,将其调整为实际的加载地址,加载时重定位要求代码段保持可写状态,这带来了潜在的代码安全风险。
对于加载时重定位这种方法,实际上已经过时了,甚至最新的编译器已经不支持这种方法。PIC
是目前常见的解决方案,接下来我们就深入讨论一下位置无关代码。
PIC的原理很简单:在代码中对所有全局数据和函数引用添加一个额外的中间层。通过巧妙地利用链接和加载过程中的结果,使共享库的代码部分实现位置无关。
PIC的一个关键点是利用链接时已知的代码段和数据段之间的偏移。当链接器合并多个目标文件时,它会整合它们的各个部分,形成一个大的代码段。因此,链接器了解各个部分的大小和它们的相对位置。
举例来说,代码段可能直接跟在数据部分后面,这意味着从代码部分中的任意指令到数据段开头的偏移量等于代码部分的大小减去指令距离代码部分开头的偏移量。这两个量都是链接器已知的。
如上图所示,代码段被加载到某个地址(在链接时未知),假设是0xXXXX0000,紧随其后的是偏移为0xXXXXF000的数据段。如果代码段中偏移为0x80的某个指令需要引用数据部分的内容,链接器知道相对偏移量(在上图中为0xEF80),所以可以将其编码到指令中。
全局偏移表GOT
可以帮我们实现位置无关数据寻址。实际上GOT
就是一个地址表,存储在数据段中。假设代码段中的某个指令想要引用一个变量。它会引用GOT
中的一个条目,而不是直接使用绝对地址引用(这将需要进行重定位)。由于GOT
位于数据段的一个已知位置,这个引用是相对的,并且在链接器中是已知的,而GOT
条目本身将包含变量的绝对地址:
通过将变量引用重定向到GOT
,我们避免了在代码段中直接使用绝对地址,而是通过GOT中的条目进行引用,从而减少了需要在加载时进行的具体地址修正。但是,我们在数据段中引入了一个新的重定位,因为全局偏移表仍然需要包含变量的绝对地址。那么,这样做的优点有哪些呢?
实际上这就是解决前面提到的加载时重定位的三个缺点。
我们编写一个简单的函数,其中有一个全局变量myglob
,在函数中取全局变量的值然后加上a
和b
返回。然后使用-fPIC
和-shared
将这个代码编译成动态链接库:
然后我们输入下面的命令来看一下这个动态链接库的汇编:
objdump -d -Mintel libreloc.so
主要来关注一下ml_func
函数:
mov rbp, rsp
: 将栈底指针rbp
设置为当前栈指针rsp
mov DWORD PTR [rbp-0x4], edi
: 将函数的第一个参数(edi
寄存器)保存到栈中mov DWORD PTR [rbp-0x8], esi
: 将函数的第二个参数(esi
寄存器)保存到栈中mov rax, QWORD PTR [rip+0x2ed2]
: 加载当前指令地址(rip
寄存器)偏移0x2ed2处的值到rax
寄存器中。后面的汇编很好理解就不继续往下分析了。这里重点关注一下mov rax, QWORD PTR [rip+0x2ed2]
,我们可以猜测,这个可能就是GOT
的地址。
接下来我们使用readelf -S libreloc.so
来查看一下库的section表:
再回到前面的rip+0x2ED2
,rip
存储当前正在执行的指令的地址,由于指令流水线和对齐的原因,rip
的值在这里是下一条指令的地址0x110E
,0x110E+0x2ED2=0x3FE0
。而前面我们用readelf
看到.got
表格的首地址为0x3FD8
,那0x3FE0
肯定就是.got
表格的内容了,里面保存着myglob
全局变量的地址。
现在我们需要思考一下,全局变量myglob
是如何保存到GOT表格中的?
我们输入readelf -r libreloc.so
查看库的重定位表:
我们可以看到myglob
在0x3FE0
处。该重定位的类型是R_X86_64_GLOB_DAT
,它告诉动态加载器,要将符号的实际地址放入该偏移量。
我们先来写一个driver.c
,在这里面来调用库中的ml_func()
函数:
然后我们链接libreloc.so
库并编译一下:
gcc -o driver driver.c -L. -lreloc
在执行之前,我们需要把库搜索路径添加到系统配置中,否则运行./driver
的时候会提示找不到库。如下图所示:
echo "/home/vino/Desktop/test" | sudo tee -a /etc/ld.so.conf
sudo ldconfig
运行结果如下图所示:
现在我们就用GDB来调试一下程序
gdb driver #调试程序
break ml_func #查看断点
run #运行程序
set disassembly-flavor intel #设置汇编显示语法
disas ml_func #查看ml_func的反汇编
如下图所示:
rip = 0x7FFFF7FB210E
加上0x2ED2
,就等于0x7FFFF7FB4FE0
,和后面的注释一样。根据前面的分析,我们知道这个地址里保存的应该是全局变量myglob
的地址。
x/gx 0x7FFFF7FB4FE0 #查看从该内存开始64字节的值
p &myglob #查看myglob的地址
结果如下图所示:
可以看到两个地址是匹配的。
前面介绍的是全局变量的重定位,对于函数也需要重定位,它有着另一种机制:懒绑定。
当共享库引用某个函数时,函数的真实地址在加载时未知。为了加速这个过程,引入了过程链接表(PLT
)。PLT
包含对函数进行间接调用的代码,而不是直接包含函数地址。在程序执行时,当函数首次调用时,PLT
代码负责将函数的真实地址填充到全局偏移表(GOT
)中的相应条目。此后的调用直接通过GOT
访问函数地址,避免了每个函数调用时的绑定延迟。这种机制减少了不必要的解析工作,提高了程序执行效率。
本篇文章就不分析函数的重定位了,实际上原理类似。
本文解释了什么是位置无关代码,以及它如何帮助创建具有可共享只读文本段的共享库。位置无关代码(PIC
)通过引入全局偏移表(GOT
)和过程链接表(PLT
)实现,解决了共享库加载时的重定位问题。GOT提供了数据和函数的间接引用,PLT
实现了懒绑定,推迟函数地址的解析。当然这也伴随额外的内存加载和寄存器使用成本,但在权衡之下,现代的编译器都更倾向于使用PIC
。