《程序员的自我修养》笔记4——动态链接

一、动态链接简介

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装载属性

image.png

普通程序不同的是,动态链接模块 都是从 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地址

可以看到,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是可执行文件的一部分

    1. 由于 程序主模块 的代码并不是 地址无关码,当 程序主模块 引用 全局变量 时的方式跟访问普通数据的方式一致。
    2. 由于 可执行文件 在运行时并不进行 代码重定位,所以共享模块全局变量 的地址必须在 链接时 确定。为了能够使得 链接过程 正常进行,链接器 会在创建 可执行文件 时在 .bss段 创建一个 共享模块全局变量副本
    3. 共享模块全局变量 定义在原本的 共享对象 中,而可执行文件.bss段 又一个副本。此时关于该变量的访问会 发生歧义。为了解决该问题,所有使用该 共享模块全局变量 的指令都指向 .bss段 中的副本
    4. 如果 共享模块全局变量.bss段 中拥有副本,则 动态链接器 会将 GOT 中的对应地址指向该副本以避免发生歧义
    5. 如果 共享模块全局变量共享模块 中被 初始化,则动态链接器 会将 初始化值 赋值到 副本
  • 假设module.c是 共享对象的一部分

    1. 编译器 无法确定 共享模块全局变量跨模块访问 还是 模块内的其他目标访问
    2. 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寄存器,以让程序得以在某处运行,下面尝试计算结果:

  1. 0x10454pc寄存器0x1045c,并将该值存放到 r12寄存器,此时 r12=0x1045c
  2. 0x10458:将 r12寄存器 中的值加上 0x10000,此时 r12=0x2045c
  3. 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

此时 lr0x10bb0,而 pc0x10448+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函数

到了这里,大概可以了解了 延迟绑定 的过程,总结如下:

  1. 跳转到 绑定函数的.plt函数
  2. 跳转到 .plt函数
  3. 调用 _dl_runtime_resolve函数
  4. 完成绑定

在整个过程中,出现了地址 0x2100c0x10444 ,我们使用下面指令查看 section段表

arm-linux-gnueabihf-objdump -h plt

got段

发现地址 0x2100c 就在 got段,再使用下面指令查看 重定位表

arm-linux-gnueabihf-objdump -R plt

可以从上图知道 0x21000got段 的开始,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_tElf32_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 个操作,动态链接器 的实现如下:

  • 动态链接器 本身不依赖于 任何共享对象
  • 动态链接器 通过 自举 完成对自身的 重定位
    1. 动态链接器 的入口地址就是 自举代码 的入口地址,自举代码 找到自身的 GOT
    2. 通过 GOT 找到 .dynamic段
    3. 通过 .dynamic段 获取本身的 重定位表符号表,最终完成 重定位
    4. 完成 重定位 后,动态链接器 可以访问 全局变量静态变量函数

3.2 装载共享对象

  1. 动态链接器可执行文件链接器符号表 合并为 全局符号表
  2. 链接器从中取出所需要的 共享对象名,并找到对应的共享对象
  3. 将找到的 共享对象代码段数据段 映射到进程空间
  4. 共享对象符号表 合并到 全局符号表
  5. 重复以上操作直到将所有的 依赖共享对象 装载到进程空间

搜索 共享对象 的过程与 图的遍历 过程类似,链接器 一般采用 广度优先搜索 来装载 共享对象

在装载 共享对象 时,一个 共享对象全局符号 被另一个 共享对象同名全局符号 覆盖的现象称为 全局符号介入
为了解决该问题,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节探索

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342