WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

编译与链接 - 位置无关代码

分类:编译与链接创建时间:2023-08-22 16:16:09修改时间:2023-09-07 15:48:12

背景

动态链接库在程序启动后由链接器载入,动态链接库每次被载入后,其各个段在内存中的位置是不确定的。而在运行时,程序指令需要具体的虚拟地址方能正常运行,但动态库中指令与数据的地址均是不确定的。为了解决这个问题,一种直观的思路是动态库载入后,对其进行重定位,就像静态链接中的重定位一样。程序启动后,载入动态库,而后修改动态库的 .text 部分,将指令中访问的地址修改为真实的地址,另外对当前进程的 .text 中引用动态库中变量与函数的地址做修改。但这种思路会引发几个问题:

  1. 启动时做大量的重定位工作,这会导致程序启动速度变慢。
  2. 这种重定位策略会导致动态库本身的 .text 被修改,这就无法实现动态库的代码共享。
  3. 当前进程的代码段一般会被操作系统设置为只读,动态修改其中的内容难以实现,且不安全。

为此,需要一种不需要对 .text 部分做修改的策略,而这种方法就是本文要描述的位置无关代码。

位置无关代码(Position Independent Code, PIC) 是一种可以让动态库中的代码不依赖于动态库加载地址的策略,采用这种策略后,无论动态库被加载到内存中的什么位置,动态库的 .text 部分均不需要做任何改动即可正常运行。

在计算机网络中,一个网站的 IP 地址可能常常发生改变,为此人们引入了一个中间层,即域名。用户只需要记住网站的域名,而后通过域名间接地从 DNS 中获取到 IP 地址。看了后文你就会发现,位置无关代码中也采用了类似的思想,通过引入一个中间层解决了动态库加载地址不确定的问题。

思想与原理

我不打算一开始就深入到细枝末节中,这里先从宏观上了解大致位置无关代码的原理。

位置无关代码引入了一个前提,即动态库的 .data.text 这两个节之间的相对位置是固定的,无论动态库被加载到内存中的什么地方,.text 中的指令均可以通过相对寻址来定位到 .data 中的内容。

动态库加载后需要做重定位,而重定位就是要让动态库中的指令能得到符号的实际地址,而这些地址在构建动态库的时候自然是不知道的。但利用 .data.text 两个节之间的相对位置固定这一前提,则可以在 .data 中创建一个表格,表格用于记录各个符号的地址。动态库加载后,运行时链接器可以修改 .data 中的这个表格,将符号的真实地址填入其中。因为 .text.data 之间相对位置固定,.text 中的指令可使用相对寻址得到对应表格单元的地址,并读取其中符号的实际地址,再通过此地址访问符号本身。

下图更具体地说明了上述思路:

在 main 程序内部,维护了一个表格,这个表格中存放的是 infowarnerrorprintf 这几个函数的地址。同样地在 liblog.so 也有一个表格,用于存放 printf 的地址。

程序启动后,首先载入动态库 liblog.so,因为 liblog.so 依赖于 libc.so,于是再载入 libc.so。载入 libc.so 后,就能得到 printf 函数的地址,链接器将其地址填入到 liblog.so 的表格中。而后将 infowarnerrorprintf 这四个函数的地址填入到 main 的表格中。

简单来说,通过引入一个中间层,使用类似 C 语言中指针的指针的思路,运行时在 .data 中簿记函数或变量地址,并利用 .text.data 之间相对位置固定这一前提,在 .text 中使用相对寻址实现 .text 中的指令与自身位置无关,并避免了在链接阶段修改 .text 中的指令内容。

位置无关代码的思想大致如此,但在实现上略有一些差异,请继续看后文介绍。

位置无关代码

在链接生成动态库的时候链接器会构造一个叫做全局偏移量表(Global Offset Table, GOT) 的结构,全局偏移量表用于存储需要被重定位的变量的地址。

GOT 存储在 .got 节中,.got 节和 .text 节之间的偏移量在链接生成动态库的时候就确定了,动态库载入内存后,这个偏移量也不会改变。当动态库被载入到内存中后,代码段和 GOT 的相对偏移量不变,链接器将符号的实际的地址填入到全局偏移量表中。对于未对外暴露的变量和函数,可以通过相对寻址来访问。对于外部变量和函数,可以通过 GOT 表来间接访问。

全局偏移量表(Global Offset Table, GOT)

全局偏移量表中的每一项存储一个符号的地址,动态库加载后,会处理动态库的重定位信息,此时就会给 GOT 中填充上真实的地址。

GOT 存储在 .got 节,运行时这个节对应的 segemnt 是读写的,程序载入后,动态链接器会在 GOT 表中填充入符号的实际地址。下面是一个使用 GOT 访问数据的例子:

mov    0x2e78(%rip),%rax    # 使用指令寄存器加偏移量得到 GOT 表中条目的地址,存入 rax 寄存器
mov    (%rax),%eax    # 通过 GOT 条目中存储的地址,访问实际数据,将其存入 eax 寄存器

数据访问过程

下面结合例子来分析位置无关代码对外部变量的访问过程:

// print.c
#include <stdio.h>

extern int a;

void print() {
    printf("a = %d\n", a);
}

构建动态库:

$ gcc print.c -fPIC -shared -o libprint.so

查看动态库中 print 的指令:

$ objdump -d libprint.so

0000000000001165 <print>:
    1165:       55                      push   %rbp
    1166:       48 89 e5                mov    %rsp,%rbp
    1169:       48 8b 05 78 2e 00 00    mov    0x2e78(%rip),%rax        # 3fe8 <a>
    1170:       8b 00                   mov    (%rax),%eax
    1172:       89 c6                   mov    %eax,%esi
    1174:       48 8d 3d 85 0e 00 00    lea    0xe85(%rip),%rdi        # 2000 <_fini+0xe2c>
    117b:       b8 00 00 00 00          mov    $0x0,%eax
    1180:       e8 bb fe ff ff          callq  1040 <printf@plt>
    1185:       90                      nop
    1186:       5d                      pop    %rbp
    1187:       c3                      retq   
    1188:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
    118f:       00

上面地址为 1169 的这行指令通过指令寄存器 (rip) 加一个偏移量 0x2e78 得到变量 a 在 GOT 表中对应条目的地址,并将其存储在寄存器 rax 中。下一行指令则取 rax 所指地址中的值。

查看 .got 的内容你会发现,a 对应的应该是 .got 中的第 5 个条目,目前 .got 中的内容都是 0,当动态库载入后,其中的内容才会被改写。

$ objdump -s libprint.so
...
Contents of section .got:
 3fd8 00000000 00000000 00000000 00000000  ................
 3fe8 00000000 00000000 00000000 00000000  ................
 3ff8 00000000 00000000

查看重定位表,会看到有对该位置重定位的记录:

$ readelf -r libswap.so 

Relocation section '.rela.dyn' at offset 0x420 contains 6 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000004020  000000000008 R_X86_64_RELATIVE                    4020
000000003fd8  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003fe8  000400000006 R_X86_64_GLOB_DAT 0000000000000000 a + 0
000000003ff0  000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

函数调用过程

使用 GOT 可以实现位置无关代码中数据与函数的访问,但在实际实现中,GOT 仅用于实现数据的访问,对函数的访问采用了更复杂(但原理大致相同)的方法,后文接着介绍。

观察汇编指令中对函数的调用:

$ objdump -d libprint.so

0000000000001165 <print>:
    1165:       55                      push   %rbp
    1166:       48 89 e5                mov    %rsp,%rbp
    1169:       48 8b 05 78 2e 00 00    mov    0x2e78(%rip),%rax        # 3fe8 <a>
    1170:       8b 00                   mov    (%rax),%eax
    1172:       89 c6                   mov    %eax,%esi
    1174:       48 8d 3d 85 0e 00 00    lea    0xe85(%rip),%rdi        # 2000 <_fini+0xe2c>
    117b:       b8 00 00 00 00          mov    $0x0,%eax
    1180:       e8 bb fe ff ff          callq  1040 <printf@plt>
    1185:       90                      nop
    1186:       5d                      pop    %rbp
    1187:       c3                      retq   
    1188:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
    118f:       00

观察地址为 1180 的这行指针,这条指令前并没有从 GOT 表拿 printf 函数地址的操作,call 指令是直接调用了一个叫做 printf@plt 的函数。位置无关代码中,对外部函数的调用实际上是先调用一个包装函数,在这里函数内部会去动态查询实际函数的地址,然后执行调用。在我们深入实现细节之前,先来看看为什么要这样设计。

延迟绑定

动态链接中,动态库中的函数只有在动态库加载完成后才能确定。当一个动态库加载后,需要对其执行重定位,这就涉及到函数地址的查找。查找函数地址的过程就要叫做绑定,通俗来说就是将函数的实际地址与符号绑定起来。函数地址的查找,需要使用函数名称去当前程序以及当前程序加载的各个动态库中查找,这个查找过程需要时间。

默认情况下,在动态库加载后,动态链接器不会立刻对 GOT 中的内容做改写,这是因为一个动态库可能会使用到很多函数,而查找这些函数的位置需要查找当前程序的符号表,如果没有找到还要查找其他已经加载的动态库的符号表,这是比较耗时的操作。而且有很多函数也不一定会被调用,因此对函数的地址的查找会被延迟到函数被调用时。

基于以上考虑,函数的绑定并未在动态库载入后就立刻执行,而是被推迟到函数运行时,这就是延迟绑定。

为了支持延迟绑定,一个新的间接层被引入,它就是过程链接表(Procedure Linkage Table, PLT)。

过程链接表(Procedure Linkage Table, PLT)

对于对外可见的函数(未使用 static 定义的函数)或者外部定义的函数(使用 extern 修饰),动态库的会为其生成包装函数,这些包装函数内部会去调用正在需要被调用的函数。真正需要被调用的函数的地址存放在 GOT 表中,包装函数内部会从 GOT 中获取真正要被调用的函数的地址,并完成调用。这些包装函数被称为 Procedure Linkage Table,PLT。

PLT 存储在 .plt 节中,里面包含一组指令入口,每一个对应一个外部函数,PLT 中每一项都是一段指令,下面是一个例子:

Disassembly of section .plt:

0000000000001030 <.plt>:
    1030:       ff 35 d2 2f 00 00       pushq  0x2fd2(%rip)        # 4008 <_GLOBAL_OFFSET_TABLE_+0x8>
    1036:       ff 25 d4 2f 00 00       jmpq   *0x2fd4(%rip)        # 4010 <_GLOBAL_OFFSET_TABLE_+0x10>
    103c:       0f 1f 40 00             nopl   0x0(%rax)

0000000000001040 <printf@plt>:
    1040:       ff 25 d2 2f 00 00       jmpq   *0x2fd2(%rip)        # 4018 <printf@GLIBC_2.2.5>
    1046:       68 00 00 00 00          pushq  $0x0
    104b:       e9 e0 ff ff ff          jmpq   1030 <.plt>

代码中对 printf 函数的调用,被替换为对函数 printf@plt 调用,printf@plt 会去查找 printf 的地址,并执行对 printf 的调用。

先来看 printf@plt 中的指令:

第一条指令是一个跳转指令,它会跳转到 GOT 表中 printf 对应的条目中指定的地址处。默认情况下,GOT 表中这个位置存放的是 printf@plt 中第二条指令的地址。因此,printf@plt 中第一条跳转指令实际上只是跳到了下一行指令。

第二条指令是一个 push 指令,它实际上是把 printf 在重定位表中的下标压入了栈中。

$ readelf -r libprint.so 

Relocation section '.rela.dyn' at offset 0x420 contains 6 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000004020  000000000008 R_X86_64_RELATIVE                    4020
000000003fd8  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe0  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003fe8  000400000006 R_X86_64_GLOB_DAT 0000000000000000 a + 0
000000003ff0  000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x4b0 contains 1 entry:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000004018  000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0

第三条指令是跳转指令,它会跳转到 .plt 中,.plt 中会基于前面压入的符号表的下标,查找该符号的地址,然后更新 GOT 表。

当 GOT 表更新后,下次再调用时,GOT 表中存储的就是 printf 的地址了,printf@plt 中的第一条指令就跳转到了 printf 处。

动态链接引入 PLT 是为了实习函数的惰性加载,当程序首次调用时会动态查询此函数的地址,并将其填入 GOT 表中。之后就可以使用 GOT 表中之前已经查询到的函数来执行调用了。

与动态链接有关的节

ELF 文件中有的 section 与动态链接有关,这里做个总结。先查看一个动态库中所有的节:

$ readelf -S libprint.so 
There are 34 section headers, starting at offset 0x3b50:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .hash             HASH             0000000000000200  00000200
       000000000000003c  0000000000000004   A       3     0     8
  [ 2] .gnu.hash         GNU_HASH         0000000000000240  00000240
       0000000000000030  0000000000000000   A       3     0     8
  [ 3] .dynsym           DYNSYM           0000000000000270  00000270
       00000000000000f0  0000000000000018   A       4     1     8
  [ 4] .dynstr           STRTAB           0000000000000360  00000360
       0000000000000086  0000000000000000   A       0     0     1
  [ 5] .gnu.version      VERSYM           00000000000003e6  000003e6
       0000000000000014  0000000000000002   A       3     0     2
  [ 6] .gnu.version_r    VERNEED          0000000000000400  00000400
       0000000000000020  0000000000000000   A       4     1     8
  [ 7] .rela.dyn         RELA             0000000000000420  00000420
       0000000000000090  0000000000000018   A       3     0     8
  [ 8] .rela.plt         RELA             00000000000004b0  000004b0
       0000000000000018  0000000000000018  AI       3    21     8
  [ 9] .init             PROGBITS         0000000000001000  00001000
       0000000000000024  0000000000000000  AX       0     0     4
  [10] .plt              PROGBITS         0000000000001030  00001030
       0000000000000020  0000000000000010  AX       0     0     16
  [11] .plt.got          PROGBITS         0000000000001050  00001050
       0000000000000010  0000000000000008  AX       0     0     8
  [12] .text             PROGBITS         0000000000001060  00001060
       0000000000000171  0000000000000000  AX       0     0     16
  [13] .fini             PROGBITS         00000000000011d4  000011d4
       000000000000000e  0000000000000000  AX       0     0     4
  [14] .rodata           PROGBITS         0000000000002000  00002000
       0000000000000008  0000000000000000   A       0     0     1
  [15] .eh_frame_hdr     PROGBITS         0000000000002008  00002008
       0000000000000024  0000000000000000   A       0     0     4
  [16] .eh_frame         PROGBITS         0000000000002030  00002030
       000000000000007c  0000000000000000   A       0     0     8
  [17] .ctors            PROGBITS         0000000000003e28  00002e28
       0000000000000010  0000000000000000  WA       0     0     8
  [18] .dtors            PROGBITS         0000000000003e38  00002e38
       0000000000000010  0000000000000000  WA       0     0     8
  [19] .dynamic          DYNAMIC          0000000000003e48  00002e48
       0000000000000190  0000000000000010  WA       4     0     8
  [20] .got              PROGBITS         0000000000003fd8  00002fd8
       0000000000000028  0000000000000008  WA       0     0     8
  [21] .got.plt          PROGBITS         0000000000004000  00003000
       0000000000000020  0000000000000008  WA       0     0     8
  [22] .data             PROGBITS         0000000000004020  00003020
       0000000000000008  0000000000000000  WA       0     0     8
  [23] .bss              NOBITS           0000000000004028  00003028
       0000000000000010  0000000000000000  WA       0     0     8
  [24] .comment          PROGBITS         0000000000000000  00003028
       0000000000000011  0000000000000001  MS       0     0     1
  [25] .debug_aranges    PROGBITS         0000000000000000  00003040
       0000000000000080  0000000000000000           0     0     16
  [26] .debug_info       PROGBITS         0000000000000000  000030c0
       0000000000000044  0000000000000000           0     0     1
  [27] .debug_abbrev     PROGBITS         0000000000000000  00003104
       0000000000000024  0000000000000000           0     0     1
  [28] .debug_line       PROGBITS         0000000000000000  00003128
       00000000000000c5  0000000000000000           0     0     1
  [29] .debug_str        PROGBITS         0000000000000000  000031ed
       0000000000000066  0000000000000001  MS       0     0     1
  [30] .debug_ranges     PROGBITS         0000000000000000  00003260
       0000000000000080  0000000000000000           0     0     16
  [31] .symtab           SYMTAB           0000000000000000  000032e0
       00000000000005b8  0000000000000018          32    52     8
  [32] .strtab           STRTAB           0000000000000000  00003898
       0000000000000196  0000000000000000           0     0     1
  [33] .shstrtab         STRTAB           0000000000000000  00003a2e
       0000000000000122  0000000000000000           0     0     1

.dynsym

这个是需要动态链接的符号的符号表。.symtab 也是符号表,但是 .symtab 仅在调试的时候有用,将 .symtab 中的内容剔除也没有关系。而 .dynsym 在动态链接时意义重大,不可或缺。比如它在重定位的时候会被重定位条目引用,在符号查找的时候需要使用符号的名称。

.dynstr

与前面的符号表管理的字符串表。

rela.dyn

重定位表,记录需要重定位的变量。

.rela.plt

重定位表,记录需要重定位的函数。

.plt

Procedure Linkage Table

.plt.got

.plt 中包含的是需要惰性加载的函数的包装函数。而 .plt.got 包含的动态库载入后立刻会被重定位的函数,这里面的指令如下:

Disassembly of section .plt.got:

0000000000001050 <__gmon_start__@plt>:
    1050:       ff 25 8a 2f 00 00       jmpq   *0x2f8a(%rip)        # 3fe0 <__gmon_start__>
    1056:       66 90                   xchg   %ax,%ax

0000000000001058 <__cxa_finalize@plt>:
    1058:       ff 25 9a 2f 00 00       jmpq   *0x2f9a(%rip)        # 3ff8 <__cxa_finalize@GLIBC_2.2.5>
    105e:       66 90                   xchg   %ax,%ax

.dynamic

记录的是依赖的动态库信息。

.got

保存变量实际地址的全局偏移量表(GOT)。

.got.plt

保存函数实际地址的全局偏移量表(GOT)。

一些问题

-fPIC 编译选项有什么用?

-fPIC 指示 GCC 在生成目标文件时是否产生位置无关代码,本质就是对引用的其他目标文件中的函数的数据是否产生 GOT 和 PLT。如果采用静态链接,位置重定位会在链接阶段完成,因此这 GOT 和 PLT 是无用的,但若采用动态链接则 -fPIC 选项必不可少。

因此,如果目标文件是用于构造动态链接库的,那么在编译的时候需要加上 -fPIC 选项。

评论 (评论内容仅博主可见,不会公开显示)