一、动态链接简介
1.1 静态链接缺点
在 现代操作系统 中,静态链接 会存在以下 2个 问题:
- 多进程 同时运行,如果一个 函数 同时被 多个进程 使用,此时使用 静态链接 将极大地浪费 内存空间 。
- 静态链接对程序的 更新、部署和发布 也会带来麻烦。在 静态链接 情况下,如果某个模块更新了,那么整个程序都需要 重新链接 然后再进行发布。如果因为一些很小的改动而导致整个模块需要重新链接,这样对于程序来讲是十分不利的。
要解决上面 2 个困难,最简单的方法就是将各个模块相互分割开行程 独立 的文件,而不是将它们 静态链接 在一起。 只有当程序需要运行的时候才进行 链接,即将 链接过程 推迟到 运行时 进行,这就是 动态连接 的基本思想。
当我们有需要更新的模块时,我们只需要 替换 对应的 共享模块,而不需要将整个程序重新进行链接。从而得到更新更加方便的效果。
1.2 动态链接特性
动态链接 需要 操作系统 的支持。因为 动态链接的情况下,进程 虚拟地址空间 比 静态链接 复杂得多。同时还需要 存储管理、内存共享 和 进程 等机制在 动态链接 下都会有相应的变化。Linux 支持动态链接,其 ELF动态链接文件 被称为 动态共享对象(Dynamic Shared Objects),即 共享对象象(.so文件),一般也称为 动态链接库。
装载程序时,动态链接器 会将需要的 所有动态链接库 装载到进程的地址空间,并且将程序所有 未决议符号 绑定的相应的 动态库 中,并进行 重定位工作。
1.2.1 动态链接优点:
- 节省内存
- 减少物理页的换出换出
- 增加cache命中率(因为进程间的 指令访问 都是在同一个 共享模块 上)。
- 提高程序的兼容性和扩展性,同一个函数在不同的操作系统上可能有不同的实现,通过动态链接可以提高程序在不同操作系统间的兼容性。
1.2.2 动态链接缺点
- 新模块 与 旧模块 的 接口不兼容问题 会导致程序 无法运行。
二、动态链接过程
在 编译阶段,链接器 需要确定符号的性质是 静态链接 还是 动态链接。并根据对应的规则进行:
- 静态链接:对符号的地址进行 重定位
- 动态链接:将符号标记为 动态链接符号,不进行 重定位,而是装载程序 时再进行。
由于 链接器 需要知道这些信息,那么需要在 链接 时使用 动态库 来说明符号的性质。
2.1 动态链接的地址分布
我们先看下面的代码例程:
/* Program1.c */
#include "Lib.h"
int main()
{
foobar(1);
return 0;
}
/* Program2.c */
#include "Lib.h"
int main()
{
foobar(2);
return 0;
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
/* Lib.c */
#include <stdio.h>
void foobar(int i)
{
printf("Printing from Lib.so %d\n", i);
}
使用以下命令进行编译:
arm-linux-gnueabihf-gcc -fPIC -shared -o Lib.so Lib.c
arm-linux-gnueabihf-gcc -o Program1 Program1.c ./Lib.so
arm-linux-gnueabihf-gcc -o Program2 Program2.c ./Lib.so
使用以下命令查看 Lib.so 的 装载属性
与 普通程序不同的是,动态链接模块 都是从 0x00000000 地址开始,也就是说 共享对象的最终装载地址在编译时并不确定。
2.2 地址无关码
在 静态链接 时,链接器 直接将各个 目标文件 进行重定位,因而没有地址冲突问题。
在 动态链接 中,由于 链接 是在 装载 时进行,所以对于 共享对象文件 的 地址分配 成了首要问题。由于 编译 时,链接器 并没有对 共享对象 进行重定位,所以 共享对象 并不清楚自己在 虚拟地址空间 中的 位置。与 共享对象文件 不同的是,可执行文件 则是第一个被装载的文件,并且可以确定自己在 虚拟地址空间 中的位置。
要解决上面提到的问题,首先想到的就是 如何让共享独享文件可以装载在任意地址
2.2.1 装载时重定位
装载时重定位 又被称为 基址重置,其含义如下:
在没有 虚拟内存 的多进程状态下,程序直接被操作系统当成一个整体装载进物理内存,此时 程序 的 指令 和 数据 的 相对位置 不会改变。比如 程序 在 编译 时预设的 装载地址 为 0x1000,如果装载时发现该地址已经被占用,且 0x4000 这个地址有空间可以容纳程序。程序就被装载到 0x4000,其 代码 和 数据 的 绝对引用(因为是在物理内存) 加上 0x3000 偏移即可。
装载时重定位可以分下面 2 点来讨论:
- 不适用 指令部分,因为 共享模块 的 指令部分 是在多个进程之间共享的,如果进行 装载时重定位,则指令对于每一个进程来讲都是不同的。
- 对于 可修改数据部分,由于每个进程都拥有副本,因而可以使用 装载时重定位 来解决 地址分配 问题。
装载时重定位 是解决 动态模块 中 绝对地址引用 的办法之一,使用 -shared 选项就是指定使用 装载时重定位。
2.2.2 代码地址无关
一般来说,为了实现程序 动态模块 中的 指令部分 在装载时不需要因为装载地址的改变而改变,所以得将 指令 中需要 修改 的部分分离出来,跟 数据部分 放在一起。这样 指令部分 可以保持不变,而 数据部分 则在每个 进程 中都有一个副本,此方法称为 地址无关码。
共享模块 中的 地址引用 可以按照 是否跨模块 和 引用方式 分为以下几类:
- 模块 内部 的 函数调用、跳转
- 模块 内部 的 数据访问
- 模块 外部 的 函数调用、跳转
- 模块 外部 的 数据访问
下面的代码例程解释了 4 种情况的体现:
/* pic.c */
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1;//类型2:模块内部的数据访问
b = 2;//类型4:模块外部的数据访问
}
void foo()
{
bar();//类型1:模块内部的函数调用
ext();//类型3:模块外部的函数调用
}
使用下面命令进行编译和查看反汇编:
arm-linux-gnueabihf-gcc pic.c -shared -fPIC -o pic.so
arm-linux-gnueabihf-objdump -D pic.so > pic.S
2.2.2.1 模块内部的指令引用
由于 被调用函数 和 调用函数 在同一个模块内,所以他们的相对位置是固定,可以通过 相对地址调用 实现访问,从而不需要进行 重定位。
我们查看代码的 反汇编,可以得到下面的片段:
00000488 <bar@plt>:
add ip, pc, #0, 12
add ip, ip, #16, 20 ; 0x10000
ldr pc, [ip, #2944]! ; 0xb80
000005a8 <foo>:
push {r7, lr}
add r7, sp, #0
blx 488 <bar@plt>//跳转到 bar@plt
...
可以看到 foo函数 使用 相对地址 的访问方式跳转到了另一个函数,一般来说 相对地址访问 使用的是 分支指令 来实现。按照笔者理解,ARM 下的 分支指令 使用的是相对于 PC寄存器 的 偏移量,偏移量是不能大于 32M 。
2.2.2.2 模块内部的数据访问
一个模块前面是若干页的 代码,后面是若干页的 数据,这些 页 之间的 相对位置 是固定的。所以 模块内任一条指令 与 模块内任一数据 之间的 相对位置 也是 固定 的。
与 模块内部的指令引用 一样,我们只要通过在 代码 中加入 偏移量 即可访问到 模块内的任一数据
我们看看 ARM汇编 的数据访问:
00000574 <bar>:
......
57c: 4b08 ldr r3, [pc, #32] ; (5a0 <bar+0x2c>)//将 0x10ab6 装入r3寄存器
/* pc指针指向下一条指令的地址,即0x580 */
57e: 447b add r3, pc//将r3中的0x10ab6加上0x580即得到0x11038,即变量a的地址
580: 4619 mov r1, r3//将变量a的地址赋给r1寄存器
582: 2301 movs r3, #1//将立即数1赋给r3
584: 600b str r3, [r1, #0]//将r3中的值即 1 赋给变量a
......
59c: 00010a82 andeq r0, r1, r2, lsl #21
5a0: 00010ab6 ; <UNDEFINED> instruction: 0x00010ab6
5a4: 00000024 andeq r0, r0, r4, lsr #32
Disassembly of section .bss:
00011034 <__bss_start>:
11034: 00000000 andeq r0, r0, r0
00011038 <a>://0x11038为变量a的地址
11038: 00000000 andeq r0, r0, r0
从上面可以看出,是通过 pc寄存器 加上一个事先在 编译阶段 计算好的 偏移 来得到变量的地址
2.2.2.3 模块外部的数据访问
模块外部的数据访问比较麻烦,因为模块间的 数据地址 需要等到 装载时 才能确定,所以我们要将这种数据变成 代码地址无关 。前面提到过,做到 代码地址无关,基本思想就是把地址相关的部分放到 数据段。
ELF 在 数据段 中建立了 全局偏移表(Global Offset Table,GOT) ,它是一个 指向模块变量的指针数组,每一项为 4个字节,在程序中表现为一个 section 。当代码需要访问 全局变量 时,可以通过 GOT 中对应的项间接引用。
链接器 在装载模式的时候会查找每个 变量 所在的地址,然后填充到 GOT 中,确保每个 指针 所指向的地址正确。GOT 是存放在 数据段 中,每个进程拥有 独立的副本,所以在装载时修改互不影响。
与 模块内部的数据访问 类似,在 编译 时可以确定 GOT 与 当前指令 的偏移。通过 pc寄存器 访问到 GOT,再通过 变量地址 在 GOT 中的 偏移 即可得到 变量地址。GOT 中的地址与变量之间的对应关系由 编译器 决定。
通过下面指令查看 GOT 的地址:
arm-linux-gnueabihf-objdump -h pic.so
可以看到,GOT 在文件内的偏移为 0x11000。
再通过下面命令查看 变量重定位信息
arm-linux-gnueabihf-objdump -R pic.so
可以看到 变量b 的偏移为 0x11024,我们可以计算到它在 GOT 中的 偏移 为:
0x11024 - 0x11000 = 0x24
0x24 / 0x4 = 0x9
故相当于在 GOT 中的 第9项。
2.2.2.4 模块外部的指令引用
模块外部的指令引用 与 模块外部的数据访问 类似,GOT 中也保存了 目标函数地址。当需要调用 模块外函数 时,可以通过查找 GOT 获取到对应的 函数地址 进行跳转。
2.3 共享模块的全局变量问题
先看下面这段代码:
/* module.c */
extern int global
int foo()
{
global = 1;
}
这种情况是 可执行文件中的一个模块引用了定义在共享对象中的全局变量。
这里有将导出一个问题,即 编译器编译目标文件时,无法根据上下文判断变量是定义在同一个模块的其他目标文件还是定义在另一个共享对象中,所以无法判断是否为跨模块间的调用
为了解决这种情况,下面需要假设 2 种情况:
-
假设module.c是可执行文件的一部分:
- 由于 程序主模块 的代码并不是 地址无关码,当 程序主模块 引用 全局变量 时的方式跟访问普通数据的方式一致。
- 由于 可执行文件 在运行时并不进行 代码重定位,所以共享模块全局变量 的地址必须在 链接时 确定。为了能够使得 链接过程 正常进行,链接器 会在创建 可执行文件 时在 .bss段 创建一个 共享模块全局变量副本。
- 共享模块全局变量 定义在原本的 共享对象 中,而可执行文件 的 .bss段 又一个副本。此时关于该变量的访问会 发生歧义。为了解决该问题,所有使用该 共享模块全局变量 的指令都指向 .bss段 中的副本
- 如果 共享模块全局变量 在 .bss段 中拥有副本,则 动态链接器 会将 GOT 中的对应地址指向该副本以避免发生歧义
- 如果 共享模块全局变量 在 共享模块 中被 初始化,则动态链接器 会将 初始化值 赋值到 副本。
-
假设module.c是 共享对象的一部分:
- 编译器 无法确定 共享模块全局变量 是 跨模块访问 还是 模块内的其他目标访问。
- GCC编译器 会对 共享模块全局变量 按照 跨模块访问模式 来产生代码。
2.4 数据段地址无关性
由于 共享对象 中 代码部分 可以做到 地址无关,即 使用相对地址来访问代码。而 数据部分 有可能出现 绝对地址,那么此时 数据部分 如何做到 地址无关 呢?
先看下面这段代码:
static int a;
static int* p = &a;
在 共享对象 的代码中,指针p 中存放的是一个 绝对地址,该地址指向 变量a。而 变量a 的地址会随着 共享对象 的 装载地址 改变而改变,那么如何解决该问题?
解决该问题可以从 2 个角度入手:
- 数据段:从上面可以知道,共享模块 的 数据部分 在 程序 中都有一个 副本,且在程序之间,该数据段 独立存在,所以 变量地址 不担心被修改。由此可以选择 装载时重定位 的方法来解决 数据段的绝对引用 问题。按照笔者的理解,就是在装载时选择一块空闲内存存放 数据部分,然后修改 指针p 指向的地址。
- 共享对象:在 共享对象 中存在 数据的绝对引用 时,编译器 和 链接器 产生 重定位表,并使用 RELATIVE 类型的重定位入口来描述该 地址引用。 动态链接器 装载 共享对象 时,如果发现 重定位表 存在该类型的 重定位入口,则对其进行 重定位。
2.5 延迟绑定
动态链接 比 静态链接 要灵活得多,但与此同时也牺牲了一部分性能。其缺点体现在以下 2 个方面:
- 动态链接 对于 全局 和 静态 的数据访问要进行复杂的 GOT定位,再进行 间接寻址
- 动态链接 是在 装载 时完成 链接工作,这将减慢程序的 启动速度。
在程序运行时,有很多函数在程序执行时不会被用到,比如错误处理或者 用户比较少用的功能模块等,所以不需要所有函数在一开始就链接好。
延迟绑定(Lazy Binding) 的基本思想是 函数第一次被调用时才进行绑定(符号查找、重定位等),如果没有则不进行绑定。要实现 延迟绑定 需要使用到名为 PLT(Procedure Linkage Table) 的方法。
我们可以先从 动态链接器 的角度来看,如果 liba.so 需要调用 libc.so 中的 bar函数,当 liba.so 第一次调用 bar 时,需要调用 动态链接器 来完成 地址绑定。
下面使用一个例子来进行说明:
/* pic.h */
#ifndef PIC_H
#define PIC_H
void bar(void);
#endif
/* pic.c */
static int a;
void bar(void)
{
a = 1;//类型2:模块内部的数据访问
}
/* ptl.c */
#include "pic.h"
int main()
{
bar();
bar();
}
使用下面指令编译,并将程序和动态库拷贝到开发板上运行:
arm-linux-gnueabihf-gcc pic.c -shared -fPIC -o pic.so
arm-linux-gnueabihf-gcc plt.c pic.so -o plt
接下来我们使用 gdb 来查看 延迟绑定 的过程:
#gdb ./plt #使用gdb运行程序
#(gdb) disassemble main #查看main函数的反汇编
Dump of assembler code for function main:
0x00010544 <+0>: push {r7, lr}
0x00010546 <+2>: add r7, sp, #0
0x00010548 <+4>: blx 0x10454 <bar@plt> #可以调用 bar 时是调转到 bar@plt
0x0001054c <+8>: blx 0x10454 <bar@plt>
0x00010550 <+12>: movs r3, #0
0x00010552 <+14>: mov r0, r3
0x00010554 <+16>: pop {r7, pc}
End of assembler dump.
#(gdb) b *0x10454 #在 <bar@plt> 处设置断点
Breakpoint 1 at 0x10454
#(gdb) r//执行程序
Starting program: /mnt/plt
Breakpoint 1, 0x00010454 in bar@plt ()
#(gdb) x/4i $pc #查看pc寄存器附近的汇编
=> 0x10454 <bar@plt>: add r12, pc, #0, 12
0x10458 <bar@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x1045c <bar@plt+8>: ldr pc, [r12, #2992]! ; 0xbb0
0x10460 <__libc_start_main@plt>: add r12, pc, #0, 12
在 bar@plt 中进行了几次计算,并将计算结果赋值给 pc寄存器,以让程序得以在某处运行,下面尝试计算结果:
- 0x10454:pc寄存器 为 0x1045c,并将该值存放到 r12寄存器,此时 r12=0x1045c
- 0x10458:将 r12寄存器 中的值加上 0x10000,此时 r12=0x2045c
- 0x1045c:将 r12寄存器的值 加上 2992(十进制),再将 该值指向的地址 中的 内容 赋值给 pc,此时 r12=0x2100c
使用 gdb 查看 0x2100c 中的值:
#(gdb) x/xr 0x2100c
0x2100c: 0x00010440//此时 pc寄存器的值为0x00010440
我们可以查看 plt 的反汇编来看看这个地址的内容:
00010440 <.plt>:
10440: e52de004 push {lr} ; (str lr, [sp, #-4]!)
10444: e59fe004 ldr lr, [pc, #4] ; 10450 <.plt+0x10>
10448: e08fe00e add lr, pc, lr
1044c: e5bef008 ldr pc, [lr, #8]!
10450: 00010bb0 ; <UNDEFINED> instruction: 0x00010bb0
将程序运行几个指令,进入该函数:
#(gdb) si
0x00010458 in bar@plt ()
......
#(gdb) si
0x00010440 in ?? ()
#(gdb) x/4i $pc #可以看到函数已经进入 .plt
=> 0x10440: push {lr} ; (str lr, [sp, #-4]!)
0x10444: ldr lr, [pc, #4] ; 0x10450
0x10448: add lr, pc, lr
0x1044c: ldr pc, [lr, #8]!
#(gdb) si #执行下一步的指令
0x00010444 in ?? ()
......
#(gdb) x/8i $pc
=> 0x10448: add lr, pc, lr
0x1044c: ldr pc, [lr, #8]!
0x10450: ; <UNDEFINED> instruction: 0x00010bb0
0x10454 <bar@plt>: add r12, pc, #0, 12
0x10458 <bar@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x1045c <bar@plt+8>: ldr pc, [r12, #2992]! ; 0xbb0
0x10460 <__libc_start_main@plt>: add r12, pc, #0, 12
0x10464 <__libc_start_main@plt+4>: add r12, r12, #16, 20 ; 0x10000
#(gdb) info registers
r0 0x1 1
r1 0xbe9feda4 3198152100
r2 0xbe9fedac 3198152108
r3 0x10545 66885
r4 0xbe9fec68 3198151784
r5 0x0 0
r6 0x0 0
r7 0xbe9fec50 3198151760
r8 0x0 0
r9 0x0 0
r10 0xb6f27000 3069341696
r11 0x0 0
r12 0x2100c 135180
sp 0xbe9fec4c 0xbe9fec4c
lr 0x10bb0 68528
pc 0x10448 0x10448
cpsr 0x40070010 1074200592
此时 lr 为 0x10bb0,而 pc 为 0x10448+8,那么下一步 lr=0x21000
注意:这里不细讲pc寄存器的特性,感兴趣的读者请查看参考链接
#(gdb) si
0x0001044c in ?? ()
#(gdb) x/8i $pc
=> 0x1044c: ldr pc, [lr, #8]!
0x10450: ; <UNDEFINED> instruction: 0x00010bb0
0x10454 <bar@plt>: add r12, pc, #0, 12
0x10458 <bar@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x1045c <bar@plt+8>: ldr pc, [r12, #2992]! ; 0xbb0
0x10460 <__libc_start_main@plt>: add r12, pc, #0, 12
0x10464 <__libc_start_main@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x10468 <__libc_start_main@plt+8>: ldr pc, [r12, #2984]! ; 0xba8
#(gdb) info registers
r0 0x1 1
r1 0xbe9feda4 3198152100
r2 0xbe9fedac 3198152108
r3 0x10545 66885
r4 0xbe9fec68 3198151784
r5 0x0 0
r6 0x0 0
r7 0xbe9fec50 3198151760
r8 0x0 0
r9 0x0 0
r10 0xb6f27000 3069341696
r11 0x0 0
r12 0x2100c 135180
sp 0xbe9fec4c 0xbe9fec4c
lr 0x21000 135168
pc 0x1044c 0x1044c
cpsr 0x40070010 1074200592
该指令会将 lr加上8 所指向的地址中的内容复制给 pc寄存器。lr=0x21000,则访问的就是地址 0x21008,使用 gdb 查看该地址中的内容
#(gdb) x/xr 0x21008
0x21008: 0xb6f0e518
(gdb) x/8i 0xb6f0e518
=> 0xb6f0e518 <_dl_runtime_resolve>: push {r0, r1, r2, r3, r4}
0xb6f0e51c <_dl_runtime_resolve+4>: ldr r0, [lr, #-4]
0xb6f0e520 <_dl_runtime_resolve+8>: sub r1, r12, lr
0xb6f0e524 <_dl_runtime_resolve+12>: sub r1, r1, #4
0xb6f0e528 <_dl_runtime_resolve+16>: add r1, r1, r1
0xb6f0e52c <_dl_runtime_resolve+20>: blx 0xb6f09de8 <_dl_fixup>
0xb6f0e530 <_dl_runtime_resolve+24>: mov r12, r0
0xb6f0e534 <_dl_runtime_resolve+28>: pop {r0, r1, r2, r3, r4, lr}
发现 0x21008 中的地址指向了 _dl_runtime_resolve 函数,而 0x21008 就在程序的 got段 中。
#(gdb) si #进入 _dl_runtime_resolve 函数,其地址为 0x21008
0xb6f15518 in _dl_runtime_resolve () from /lib/ld-linux-armhf.so.3
我们前面说到调用 动态链接器 进行 绑定,而_dl_runtime_resolve函数 就是进行 动态链接器进行 绑定。
#(gdb) s #直接跳过 _dl_runtime_resolve 函数
Single stepping until exit from function _dl_runtime_resolve,
#(gdb) x/xr 0x2100c #查看 0x2100c的内容
0x2100c: 0xb6f3a4c1
#(gdb) disassemble 0xb6f3a4c1 #查看 0xb6f3a4c1 所在的汇编,对于反汇编文件,会发现这就是bar函数
Dump of assembler code for function bar:
=> 0xb6f3a4c0 <+0>: push {r7}
0xb6f3a4c2 <+2>: add r7, sp, #0
0xb6f3a4c4 <+4>: ldr r3, [pc, #16] ; (0xb6f3a4d8 <bar+24>)
0xb6f3a4c6 <+6>: add r3, pc
0xb6f3a4c8 <+8>: mov r2, r3
0xb6f3a4ca <+10>: movs r3, #1
0xb6f3a4cc <+12>: str r3, [r2, #0]
0xb6f3a4ce <+14>: nop
0xb6f3a4d0 <+16>: mov sp, r7
0xb6f3a4d2 <+18>: ldr.w r7, [sp], #4
0xb6f3a4d6 <+22>: bx lr
0xb6f3a4d8 <+24>: andeq r0, r1, r2, ror #22
经过上面的步骤,就完成了延迟绑定,下一次调用 bar函数 时将不再进行 .plt函数,而是直接跳转到 bar函数。
到了这里,大概可以了解了 延迟绑定 的过程,总结如下:
- 跳转到 绑定函数的.plt函数
- 跳转到 .plt函数
- 调用 _dl_runtime_resolve函数
- 完成绑定
在整个过程中,出现了地址 0x2100c 和 0x10444 ,我们使用下面指令查看 section段表
arm-linux-gnueabihf-objdump -h plt
发现地址 0x2100c 就在 got段,再使用下面指令查看 重定位表:
arm-linux-gnueabihf-objdump -R plt
可以从上图知道 0x21000 是 got段 的开始,got段 的前 3 项比较特殊,如下:
- 第一项:保存了 .dynamic段 的地址,在例子中的地址为 0x21000。
- 第二项:保存 本模块ID 的所在地址,在例子中的地址为 0x21004。由 动态链接器 在装载时初始化
- 第三项:保存 _dl_runtime_resolve函数 的地址,在例子中的地址为 0x21008,也符合例子中的过程论证。由 动态链接器 在装载时初始化。
发现 符号bar 的便宜就是在 0x2100c。按照笔者理解就是修改地址 0x2100c 中的地址指向,在没有绑定的时候是指向 .plt函数,当绑定完了以后就指向 函数所在地址。由此可以优化 动态链接 在装载时需要 绑定所有符号 的缺点。
2.6 动态链接的相关结构
2.6.1 .interp段
动态链接器 存放在 文件系统 中,而程序需要知道其 存放路径 才能调用,所以就需要在程序中添加 动态链接器 的路径,而 .interp段 就是存放 动态链接器 的路径。
查看程序的反汇编可以看到如下代码:
Disassembly of section .interp:
00010154 <.interp>:
10154: 62696c2f rsbvs r6, r9, #12032 ; 0x2f00
10158: 2d646c2f stclcs 12, cr6, [r4, #-188]! ; 0xffffff44
1015c: 756e696c strbvc r6, [lr, #-2412]! ; 0xfffff694
10160: 72612d78 rsbvc r2, r1, #120, 26 ; 0x1e00
10164: 2e66686d cdpcs 8, 6, cr6, cr6, cr13, {3}
10168: 332e6f73 ; <UNDEFINED> instruction: 0x332e6f73
在 gdb 中查看其内容:
#(gdb) x/as 0x10154
0x10154: "/lib/ld-linux-armhf.so.3"
可以发现 动态链接器 的路径为 /lib/ld-linux-armhf.so.3
同理,也可以使用下面指令查看 动态链接器:
arm-linux-gnueabihf-readelf -l plt
2.6.2 .dynamic段
.dynamic段 保存了 动态链接器 所需要的基本信息,比如 依赖的共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象的初始化代码地址 等。
其代码表现为 Elf32_Dyc结构体数组 ,代码如下:
typedef struct
{
Elf32_Sword d_tag; /* Dynamic entry type */
union
{
Elf32_Word d_val; /* Integer value */
Elf32_Addr d_ptr; /* Address value */
} d_un;
} Elf32_Dyn;
下表列出了常用的 类型 和 值。
d_tag | d_un |
---|---|
DT_SYMTAB | 动态链接符号表地址,d_ptr表示 .dynsym 地址 |
DT_STRTAB | 动态链接字符串表地址,d_ptr表示 .dynstr 地址 |
DT_STRSZ | 动态链接字符串表大小,d_val表示大小 |
DT_HASH | 动态链接哈希表地址,d+ptr表示 .hash 地址 |
DT_SONAME | 共享对象的 SO-NAME |
DT_RPATH | 动态链接共享对象的搜索路径 |
DT_INIT | 初始化代码段地址 |
DT_FINIT | 结束代码段地址 |
DT_NEED | 依赖的共享对象文件,d_ptr表示所依赖的共享对象文件名 |
DT_REL DT_RELA |
动态链接重定位表地址 |
DT_RELENT DT_RELAENT |
动态链接重定位入口数量 |
2.6.3 动态符号表
- 导入函数:主程序 引用 动态共享对象 中的函数,该函数称为 导入函数
- 导出函数:动态共享对象 中的函数提供给 主程序 使用,该函数称为 导出函数
ELF 使用了 动态符号表(Dynamic Symbol Table) 来保存 动态链接 符号的 导入导出关系,段名为 .dynsym(Dynamic Symbol)。该段仅保存 动态链接符号,对于模块内部的符号则不保存。
与 .symtal(静态符号表) 类似,动态符号表 使用了一些辅助的表来进行表示。包括:
- .dynstr:保存 动态符号名 的 动态符号字符串表(Dynamic String Table)。
- .hash:加速 符号查找 过程 的 字符哈希表。
动态链接符号表 几乎与 静态链接符号表 一样,对于其结构本章节不做赘述。
2.6.4 动态链接重定位表
由于 导入符号 的存在,共享对象 也需要进行重定位。因为在 编译时 无法知道 导入符号 的 地址,这些地址需要在 装载时 才能确定和修正,也就是 重定位。
对于使用 PIC技术 的 可执行文件或共享对象 来说,代码段 是不需要重定位的。但 数据段 是需要重定位的,原因有下面 2个:
- 数据段 有可能会引用 绝对地址。
- 代码段 中引用 绝对地址 的部分被分离出来成为 GOT表,但 GOT 存放于 数据段。
在 动态链接 中,重定位表 所在的段分别如下:
- .rel.dyn:该段用于修正 数据引用,即 .got段 和 数据段。对应 静态链接 的重定位表 .rel.data。
- .rel.plt:该段用户修正 函数引用,即 .got.plt段 。对应 静态链接 的重定位表 .rel.text
使用下面命令查看 动态重定位表:
arm-linux-gnueabihf-readelf -r pic.so
动态重定位表 的数据结构与 静态重定位表 一样,这里不做赘述。
待补充 ARM架构 下的指令修正
2.6.5 动态链接辅助数据
当 动态链接器 开始进行链接时,需要一些信息,比如 可执行文件segment数量、segment属性、程序入口 等。此类信息都需要由 操作系统 存放在 进程堆栈,由此传递给 动态链接器。这些信息成为 辅助信息数据(Auxiliary Vector)。
代码结构体如下:
typedef struct
{
uint32_t a_type; /* Entry type */
union
{
uint32_t a_val; /* Integer value */
/* We use to have pointer elements added here. We cannot do that,
though, since it does not work when using 32-bit definitions
on 64-bit platforms and vice versa. */
} a_un;
} Elf32_auxv_t;
Elf32_auxv_t 和 Elf32_Dyn 非常相似,下面简单讲解常用个的值
a_type定义 | a_type值 | a_val意义 |
---|---|---|
AT_NULL | 0 | 表示 辅助信息数组 的 结束 |
AT_EXEFD | 2 | 表示 可执行文件 的 文件句柄。操作系统打开 可执行文件 后获得的 文件句柄,再将该 句柄 传递给 动态链接器 |
AT_PHDR | 3 | 程序头表(Program Header)的 地址 |
AT_PHENT | 4 | 程序头表(Program Header) 中每一个 入口(Entry) 的大小 |
AT_PHNUM | 5 | 程序头表(Program Header) 中 入口(Entry) 的数量 |
AT_BASE | 7 | 表示 动态链接器 本身的装载地址 |
AT_ENTRY | 9 | 可执行文件 的 启动地址 |
在 程序 中,我们是可以获取到这些 辅助信息,这些信息处于 环境变量指针 后面。
三、 动态链接步骤
动态链接 步骤概括如下:
1. 动态链接器自举
2. 装载共享对象
3. 重定位和初始化
3.1 动态链接器自举
动态链接器 本身是一个 共享对象。那么在启动时就需要进行 共享对象 装载的操作:
- 重定位
- 加载依赖库
针对上面 2 个操作,动态链接器 的实现如下:
- 动态链接器 本身不依赖于 任何共享对象
-
动态链接器 通过 自举 完成对自身的 重定位。
- 动态链接器 的入口地址就是 自举代码 的入口地址,自举代码 找到自身的 GOT
- 通过 GOT 找到 .dynamic段
- 通过 .dynamic段 获取本身的 重定位表 和 符号表,最终完成 重定位
- 完成 重定位 后,动态链接器 可以访问 全局变量 、 静态变量 和 函数。
3.2 装载共享对象
- 动态链接器 将 可执行文件 和 链接器 的 符号表 合并为 全局符号表。
- 链接器从中取出所需要的 共享对象名,并找到对应的共享对象。
- 将找到的 共享对象 的 代码段 和 数据段 映射到进程空间
- 将 共享对象 的 符号表 合并到 全局符号表
- 重复以上操作直到将所有的 依赖共享对象 装载到进程空间
搜索 共享对象 的过程与 图的遍历 过程类似,链接器 一般采用 广度优先搜索 来装载 共享对象。
在装载 共享对象 时,一个 共享对象 的 全局符号 被另一个 共享对象 的 同名全局符号 覆盖的现象称为 全局符号介入。
为了解决该问题,Linux动态链接器 定义了一个规则:当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则最后的符号被忽略。
所以 共享对象 的重名符号存在 忽略现象,当程序大量使用 共享对象 时应该非常小心 符号重名问题。
3.2.1 全局符号介入与地址无关码
前面说过 模块内部的函数调用 问题,如果将函数都使用这种方法进行装载,则有可能出现 全局符号接入问题。而为了解决 全局符号介入,又需要对 代码 进行 重定位,这又与 地址无关 的特性想违背。因此 编译器 一般采用 模块外部的函数调用 进行处理,将函数使用 .got.plt 进行 重定位,从而解决了 需要重定位代码段 的问题。
书中给出了一个技巧:为了提高模块内部函数的调用效率,可以使用static定义函数。编译器就可以确定函数不被其他模块覆盖,从而使用模块内部的函数调用方式进行加载,可以加快函数的调用速度
3.3 重定位和初始化
链接器 开始重新遍历 可执行文件 和 共享对象 的 重定位表,将它们的 GOT/PLT表 中每个 重定位入口 进行修正,接着尝试执行每个 共享对象 的 .init段,用以实现 共享对象 特有的初始化
四、 动态链接操作
4.1 环境变量
LD_PRELOAD:该变量指定的 文件 会在 动态链接器 按照固定的规则搜索 共享库 之前装载。
由于 全局符号介入 的存在,该宏的存在可以使得程序员可以改写 标准C库 中的而不影响其余函数,对于程序的 调试 比较有用。由于带有 __attribute__((constructor)) 属性的函数在装载时会被执行,也有使用 LD_PRELOAD 进行 代码注入 的破坏行为,但这种行为比较低端和容易识别。LD_DEUBG:该变量可以打开 动态链接器 的调试功能。动态链接器 会在运行时打印出该环境变量指定的文件的信息,便于调试 共享库。
4.2 构造/析构函数
- __attribute__((constructor)) 属性让 函数 被编译进 .init_array段 ,这些函数在 动态库 装载时会被运行以进行 初始化操作。这些函数称为 构造函数。
- __attribute__((destructor)) 属性让 函数 被编译进 .finit_array段 ,这些函数在 动态库 卸载时会被运行以进行 去初始化操作。这些函数称为 析构函数。
使用 析构/构造函数 时,不可以使用编译选项 -nostartfiles 或 -nostdlib。因为 构造/析构函数 需要在系统默认的 标准运行库 上运行,如果没有这些库,则 析构/构造函数 无法运行。
如果有多个 析构/构造函,则可以使用下面的宏进行定义:
- __attribute__((constructor(n))):构造函数 的 n越小,函数越先被执行
- __attribute__((destructor(n))):构造函数 的 n越小,函数越慢被执行
五、 参考链接
ARM汇编中PC寄存器详解
延迟绑定(PLT)
Android C语言_init函数和constructor属性及.init/.init_array节探索