【深入浅出计算机组成】 PLT 和 GOT,动态链接的解决方案

x33g5p2x  于2022-07-13 转载在 其他  
字(5.8k)|赞(0)|评价(0)|浏览(274)

前言:本篇为笔者学习极客时间《深入浅出计算机组成》过程中的部分疑问和解疑记录,因为这一块问题笔者学习过程中搜索所得答案较少,故留此博客。

1. 编译、链接和加载

我们知道,“C 语言代码 - 汇编代码 - 机器码” 这个过程,在我们的计算机上进行的时候是由两部分组成的。

第一个部分由编译(Compile)、汇编(Assemble)以及链接(Link)三个阶段组成。在这三个阶段完成之后,我们就生成了一个可执行文件。

第二部分,我们通过装载器(Loader)把可执行文件加载(Load)到内存中。CPU 从内存中读取指令和数据,来开始真正执行程序。

其中程序的链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件。这个链接的方式,让我们在写代码的时候做到了“复用”。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
在静态链接中:

链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。

但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间

2. 链接可以分动、静,共享运行省内存

如果我们能够让同样功能的代码,在不同的程序里面,不需要各占一份内存空间,那该有多好啊

这个思路就引入一种新的链接方法,叫作动态链接(Dynamic Link)。相应的,我们之前说的合并代码段的方法,就是静态链接(Static Link)。

动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。顾名思义,这里的共享库重在“共享“这两个字。

这个加载到内存中的共享库会被很多个程序的指令调用到。在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。

3. 动态链接如何做到地址无关?

不过,要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“地址无关”的。也就是说,我们编译出来的共享库文件的指令代码,是地址无关码。换句话说就是,这段代码,无论加载在哪个内存地址,都能够正常执行。如果不是这样的代码,就是地址相关的代码。

如果还不明白,我给你举一个生活中的例子。如果我们有一个骑自行车的程序,要“前进 500 米,左转进入天安门广场,再前进 500 米”。它在 500 米之后要到天安门广场了,这就是地址相关的。如果程序是“前进 500 米,左转,再前进 500 米”,无论你在哪里都可以骑车走这 1000 米,没有具体地点的限制,这就是地址无关的。

你可以想想,大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了。无论是实现一个向量加法,还是实现一个打印的函数,这些代码逻辑和输入的数据在内存里面的位置并不重要。

常见的地址相关的代码,比如绝对地址代码(Absolute Code)、利用重定位表的代码等等,都是地址相关的代码。你回想一下我们之前讲过的重定位表。在程序链接的时候,我们就把函数调用后要跳转访问的地址确定下来了,这意味着,如果这个函数加载到一个不同的内存地址,跳转就会失败。

对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的

那么怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢?

动态代码库内部的变量和函数调用都很容易解决,只需要使用相对地址就好了。各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。

4. PLT 和 GOT,动态链接的解决方案

要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似,还是和前面一样,我们还是拿出一小段代码来看一看。

首先,lib.h 定义了动态链接库的一个函数 show_me_the_money。

// lib.h
#ifndef LIB_H
#define LIB_H
 
void show_me_the_money(int money);
 
#endif

lib.c 包含了 lib.h 的实际实现。

// lib.c
#include <stdio.h>
 
 
void show_me_the_money(int money)
{
    printf("Show me USD %d from lib.c \n", money);
}

然后,show_me_poor.c 调用了 lib 里面的函数。

// show_me_poor.c
#include "lib.h"
int main()
{
    int money = 5;
    show_me_the_money(money);
}

最后,我们把 lib.c 编译成了一个动态链接库,也就是 .so 文件。

$ gcc lib.c -fPIC -shared -o lib.so
$ gcc -o show_me_poor show_me_poor.c ./lib.so

你可以看到,在编译的过程中,我们指定了一个 -fPIC 的参数。这个参数其实就是 Position Independent Code 的意思,也就是我们要把这个编译成一个地址无关代码。

然后,我们再通过 gcc 编译 show_me_poor 动态链接了 lib.so 的可执行文件。在这些操作都完成了之后,我们把 show_me_poor 这个文件通过 objdump 出来看一下。

(重点来了,仔细看分析!!!)

$ objdump -d -M intel -S show_me_poor
……
0000000000400540 <show_me_the_money@plt-0x10>:
  400540:       ff 35 12 05 20 00       push   QWORD PTR [rip+0x200512]        # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
  400546:       ff 25 14 05 20 00       jmp    QWORD PTR [rip+0x200514]        # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
  40054c:       0f 1f 40 00             nop    DWORD PTR [rax+0x0]
 
0000000000400550 <show_me_the_money@plt>:
  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
  400556:       68 00 00 00 00          push   0x0
  40055b:       e9 e0 ff ff ff          jmp    400540 <_init+0x28>
……
0000000000400676 <main>:
  400676:       55                      push   rbp
  400677:       48 89 e5                mov    rbp,rsp
  40067a:       48 83 ec 10             sub    rsp,0x10
  40067e:       c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
  400685:       8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  400688:       89 c7                   mov    edi,eax
  40068a:       e8 c1 fe ff ff          call   400550 <show_me_the_money@plt>
  40068f:       c9                      leave  
  400690:       c3                      ret    
  400691:       66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
  400698:       00 00 00 
  40069b:       0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
……

我们还是只关心整个可执行文件中的一小部分内容。你应该可以看到,在 main 函数调用 show_me_the_money 的函数的时候,对应的代码是这样的:

call   400550 <show_me_the_money@plt>

这里后面有一个 @plt 的关键字,代表了我们需要从 PLT,也就是程序链接表(Procedure Link Table)里面找要调用的函数。对应的地址呢,则是 400550 这个地址。

当我们把目光挪到上面的 400550 这个地址,你又会看到里面进行了一次跳转,这个跳转指定的跳转地址,你可以在后面的注释里面可以看到,GLOBAL_OFFSET_TABLE+0x18。这里的 GLOBAL_OFFSET_TABLE,就是我接下来要说的全局偏移表。

400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>

在动态链接对应的共享库,我们在共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。而 GOT 表里的数据,则是在我们加载一个个共享库的时候写进去的

不同的进程,调用同样的 lib.so,各自 GOT 里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。这样,虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库,但是不需要去修改动态库里面的代码所使用的地址,而是各个程序各自维护好自己的 GOT (注意GOT是每个程序都维护一张),能够找到对应的动态库就好了

  1. GOT 表位于共享库自己的数据段里。GOT 表在内存里和对应的代码段位置之间的偏移量,始终是确定的。这样我们的共享库就是地址无关的代码;
  2. 对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时,生成的各不相同的 GOT 表,来找到它需要调用到的外部变量和函数的地址。

这是一个典型的、不修改代码,而是通过修改“地址数据”来进行关联的办法。它有点像我们在 C 语言里面用函数指针来调用对应的函数,并不是通过预先已经确定好的函数名称来调用,而是利用当时它在内存里面的动态地址来调用。

**前面由一个简单的例子说明动态链接需要考虑的各种因素,但实际总结起来说两点:**参考blog:PLT和GOT补充博客

  1. 需要存放外部函数的数据段
  2. 获取数据段存放函数地址的一小段额外代码

如果可执行文件中调用多个动态库函数,那每个函数都需要这两样东西,这样每样东西就形成一个表,每个函数使用中的一项。

总不能每次都叫这个表那个表,于是得正名。存放函数地址的数据表,称为全局偏移表(GOT, Global Offset Table),而那个额外代码段表,称为程序链接表(PLT,Procedure Link Table)。它们两姐妹各司其职,联合出手上演这一出运行时重定位好戏。

5. GOT 到底保存在共享库里面还是各个程序里?

文中提到各个程序各自维护好自己的 GOT ,但又提到GOT 表位于共享库自己的数据段里,所以到底在哪?
在程序里,GOT,PLT是每个可执行文件都会有的, 调用某个共享库的函数时,got用来存储经计算后的函数地址,plt存的是另外的指令,意思就是:got表里没有该函数地址就发起地址计算,并存好,如果有了,就直接操纵指令跳转。

我整理一下:

  1. GOT 表位于共享库自己的数据段里。且GOT 表在内存里和对应的代码段位置之间的偏移量,始终是确定的,这样共享库就是地址无关的代码;
  2. 而每个程序运行的时候如果调用共享库,虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的
  3. 所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。
  4. GOT 表里的数据,则是在我们加载一个个共享库的时候写进去的

重点就是:

GOT表是位于共享库自己的数据段里,每个程序运行的时候如果调用共享库,共享库的数据部分是各个动态链接它的应用程序里面各加载一份的,GOT 表里的数据,则是在这个加载的时候写进去的。

追问:1.动态库有没有自己的GOT表?
有啊,动态库他自己也是程序,他有可能再去调用其他动态库,当然也有自己的GOT表。

追问:2.一般没有使用共享库的代码或者函数,他的地址是什么地址?

绝对地址 或者说虚拟地址

追问:3.linux下逻辑地址和虚拟地址是不是一回事,物理地址和他们有什么关系

逻辑地址跟虚拟地址是一回事,虚拟地址分段之后是线性地址,线性地址经过分页转为物理地址(这里虚拟地址和物理地址还有一个页表来映射)

追问:4.加载器在将程序搬入内存,起了一个什么作用

加载器在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。

6. 总结

所以描述一个函数的位置,无非两种方式,一种是采用绝对的描述方式,一种是采用相对的。相对地址的寻址方式,就是位置无关,因为他取得是偏移量。整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。

当加载器加载,如果是绝对地址就直接加载,如果是这种相对地址,加载完成之后,加载器要修改你的就是把你的那个相对地址啊,把相对地址改成线性地址,这样CPU再以读取线性地址的方式直接去访问虚拟地址,然后再经过页表直接给你一个物理地址完了。

在静态链接和程序装载之后,利用动态链接把我们的内存利用到了极致。同样功能的代码生成的共享库,我们只要在内存里面保留一份就好了。这样,不仅能够做到代码在开发阶段的复用(静态链接),也能做到代码在运行阶段的复用(动态链接)

相关文章