10 动态链接:程序内部的“共享单车”

前言:
由于博主目前的对技术的理解水平相当有限,目前处于模仿和向大佬学习的阶段,所以此文是对徐文浩大大的《深入浅出计算机组成原理》这门课的引用(说难听点就是照抄),一来想通过这样的方式来建立对计算机的基本认识框架,此后方能写出带有更多关于自己对技术的原创性理解 。二来,也是对学习过的知识做一个记录,顺便也能够分享给读者 。
蓝色字体为本博主的一些思考总结,黑色字体为原文 。请享用 。
我们之前讲过,程序的链接 , 是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件 。这个链接的方式,让我们在写代码的时候做到了“复用” 。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了 。
这么说来 , “链接”其实有点儿像我们日常生活中的标准化、模块化生产 。我们有一个可以生产标准螺帽的生产线,就可以生产很多个不同的螺帽 。只要需要螺帽 , 我们都可以通过链接的方式,去复制一个出来,放到需要的地方去,大到汽车,小到信箱 。
但是,如果我们又很多个程序都要通过装载器装载到内存里面,那里面链接好的功能代码,也都需要再装在一遍,再占一遍内存空间 。这就好比,假设每个人都有骑自行车的需要,那我们给每个人都生产一辆自行车带在身边,固然大家都有自行车用了,但是马路上肯定会特别拥挤 。
链接可以分动、静,共享运行省内存
我们上一节解决程序装载到内存的时候,讲了很多方法 。说起来,最根本的问题其实就是内存空间不够用 。如果我们能够让同样的功能代码,在不同的程序里面,不需要各占一份内存空间,那该多好啊 。就好比,现在马路上的共享单车 , 我们并不需要给每个人都造一辆车,只要马路上有这些单车,谁需要的时候 , 直接通过手机扫码,都可以解锁骑行 。
这个思路就引入了一种新的链接方法,叫作动态链接 。相应的,我们之前说的合并代码段的方法,就是静态链接 。
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库 。顾名思义,这里的共享库重在“共享”这两个字 。
这个加载到内存中的共享库会被很多个程序的指令调用到 。在下 , 这些共享库文件就是.dll文件,也就是-Link (DLL,动态链接库) 。在Linux下,这些共享库文件就是.so文件,也就是 (一般也称之为动态链接库) 。这两大操作系统下的文件名后缀 , 一个用了“动态链接”的意思,另一个用了“共享”的意思,正好覆盖了两方面的含义 。
所以你会发现,动态链接库其实就像是函数 , 它只需要你发明一次,然后用的时候直接调用它,不用再重新再写一遍 。区别在于,动态链接库是在装载的时候实现的 。
地址无关很重要,相对地址解烦恼
不过,要想要在程序运行的时候共享代码 , 也有一定的要求,就是这些机器码必须是“地址无关”的 。也就是说 , 我们编译出来的共享库文件的指令代码 , 是地址无关码 。换句话说,这段代码 , 无论加载在哪个内存地址,都能够正常执行 。如果不是这样的代码,就是地址相关的代码 。
这里不是很理解 。无论加载在哪个内存地址,都能够正常执行 。这里无论加载在哪个内存地址是啥意思?
如果还不明白 , 我给你举一个生活中的例子 。如果我们有一个骑自行车的程序,要“前进500米,左转进入天安门广?。?再前进500米” 。它在500米之后要到天安门广场了,这就是地址相关的 。如果程序是“前进500米 , 左转,再前进500米”,无论你在哪里都可以骑车走这1000米,没有具体地点的限制,这就是地址无关的 。
你可以想想 , 大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了 。无论是实现一个向量加法,还是实现一个打印的函数 , 这些代码逻辑和输入的数据在内存里面的位置并不重要 。
而常见的地址相关的代码 , 比如绝对地址代码、利用重定位表的代码等等,都是地址相关的代码 。你回想一下我们之前讲过的重定位表 。在程序链接的时候,我们就把函数调用后要跳转访问的地址确定下来了,这意味着,如果这个函数加载到一个不同的内存地址,跳转就会失败 。
这里的意思是说,函数调用时要跳转访问的地址是虚拟地址还是物理地址?
所以这个意思是说,重定位表是目标文件已经通过重定位表确定了要调用的函数的地址,那么如果调用的函数的地址被加载到了另一个内存地址中 , 这时候指令跳转的时候就会失败 。
这就像我在手机端浏览器不能访问到电脑端能够访问的文件一样,因为这些文件的地址在电脑里,在手机中找不到这段地址,而且就算找到,也找不到相关的文件 。当然这种类比 , 多少还是有些粗糙的 。
对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的 。我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致 。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配 。
那么问题来了,我们要怎么样才能做到 , 动态共享库编译出来的代码指令 , 都是地址无关码呢?
动态代码库内部的变量和函数调用都很容易解决,我们只需要使用相对地址就好了 。各种指令中使用到的内存地址 , 给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址 。因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的 。
PLT 和 GOT,动态链接的解决方案
要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似,还是和前面一样,我们还是拿出一小段代码来看一看 。
首先 , lib.h定义了动态链接库的一个函数 。
// lib.h
# LIB_H
# LIB_H
void (int money);
#endif
lib.c包含了lib.h的实际实现 。
//lib.c
#
void (int money)
("Show me USD %d from lib.c \n",money)
然后 , .c调用了lib里面的函数 。
// .c
#"lib.h"
int main()
int money = 5;
(money);
最后,我们把 lib.c 编译成了一个动态链接库,也就是 .so 文件 。
你可以看到,在编译的过程中,我们指定了一个-fPIC的参数 。这个参数其实就是Code的意思 , 也就是我们要把这个编译成一个地址无关代码 。
然后 , 我们再通过gcc编译动态链接了lib.so的可执行文件 。在这些操作都完成了之后,我们把这个文件通过出来看一下 。
……
00540 :
:ff 35 12 05 20 00pushQWORD PTR [rip+]#
:ff 25 14 05 20 00jmpQWORD PTR [rip+]#
:0f 1f 40 00nopDWORD PTR [rax+0x0]
00550 :
:ff 25 12 05 20 00jmpQWORD PTR [rip+]#
:68 00 00 00 00push0x0
:e9 e0 ff ff ffjmp
……
00676 :
:55pushrbp
:48 89 e5movrbp,rsp
:48 83 ec 10subrsp,0x10
:c7 45 fc 05 00 00 00movDWORD PTR [rbp-0x4],0x5
:8b 45 fcmoveax,DWORD PTR [rbp-0x4]
:89 c7movedi,eax
:e8 c1 fe ff ffcall
:c9leave
:c3ret
:66 2e 0f 1f 84 00 00nopWORD PTR cs:[rax+rax*1+0x0]
:00 00 00
:0f 1f 44 00 00nopDWORD PTR [rax+rax*1+0x0]
......
我们还是只关心整个可执行文件中的一小部分内容 。你应该可以看到,在main函数调用的函数的时候,对应的代码是这样的:
这里面有一个@plt的关键字,代表我们需要从PLT,也就是程序链接表里面找要调用的函数 。对应的地址呢 , 则是这个地址 。
那当我们把目光挪到上面的这个地址 , 你又会看到里面进行了一次跳转 , 这个跳转指定的跳转地址,你可以在后面的注释里面可以看到,+0x18 。这里的,就是我接下来要说的全局偏移量 。
:ff 25 12 05 20 00 jmp QWORD PTR [rip+]#
在动态链接对应的共享库,我们在共享库的data 里面,保存了一张全局偏移表(GOT,Table) 。虽然共享库的代码部分的物理内存是共享的 , 但是数据部分是各个动态链接它的应用程序里面各加载一份的 。所有需要引用当前共享库外部的地址的指令,都会查询GOT , 来找到当前运行程序的虚拟内存里的对应位置 。而GOT表里的数据 , 则是在我们加载一个个共享库的时候写进去的 。
不同的进程 , 调用同样的lib.so,各自GOT里面指向最终加载的动态链接库里面的虚拟内存地址是不同的 。
这样 , 虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库 , 但是不需要去修改动态库里面的代码所使用的地址 , 而是各个程序各自维护好自己的GOT,能够找到对应的动态库就好了 。
所以说,GOT就像是一个程序与共享库之间的联络员,就像是共享单车的二维码 。程序通过扫描二维码来链接共享库中的代码文件 。
我们的GOT表位于共享库自己的数据段里 。GOT表在内存里和对应的代码位置之间的偏移量,始终是确定的 。这样,我们的共享库就是地址无关的代码,对应的各个程序只需要在物理内存里面加载同一份代码 。而我们又要通过各个可执行程序在加载时,生成的各个不相同的GOT表,来找到它需要调用到的外部变量和函数的地址 。
这是一个典型的、不修改代码,而是通过修改“地址数据”来进行关联的办法 。它有点像我们在C语言里面用函数指针来调用对应的函数,并不是通过预先已经确定好的函数名称来调用,而是利用当时它在内存里面的动态地址来调用 。
总结延伸
这一讲,我们终于在静态链接和程序装载之后 , 利用动态链接把我们的内存利用到了极致 。同样功能的代码生成的共享库,我们只要在内存里面保留一份就好了 。这样,我们不仅能够做到代码在开发阶段的复用,也能做到代码在运行阶段的复用 。
实际上,在进行 Linux 下的程序开发的时候 , 我们一直会用到各种各样的动态链接库 。C 语言的标准库就在 1MB 以上 。我们撰写任何一个程序可能都需要用到这个库,常见的 Linux 服务器里 , /usr/bin 下面就有上千个可执行文件 。如果每一个都把标准库静态链接进来的,几 GB 乃至几十 GB 的磁盘空间一下子就用出去了 。如果我们服务端的多进程应用要开上千个进程,几 GB 的内存空间也会一下子就用出去了 。这个问题在过去计算机的内存较少的时候更加显著 。
通过动态链接这个方式,可以说彻底解决了这个问题 。就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆 , 只会在系统内制造大量无用的垃圾 。
过去的 05~09 这五讲里,我们已经把程序怎么从源代码变成指令、数据,并装载到内存里面,由 CPU 一条条执行下去的过程讲完了 。希望你能有所收获 , 对于一个程序是怎么跑起来的 , 有了一个初步的认识 。
总结一下,这节课,理解起来确实有些费劲 。问题在于,计算机里面个各种机制实在是复杂 , 我们很难用微观体感去感受到其中发生了什么 , 只能借助抽象概念和抽象描述来说明计算机里面的各种机制 。
也许我们会有一个疑问就是 , 只要我们会用动态链接就行了,不需要知道它的机制和原理是什么 。确实是如此,但是也不妨反过头来想一想,这里面更重要的不是你是否能够用到这些原理,而是这些机制在设计时候的背后的思维逻辑,动态链接之所以发明,其实就是一个我们现在能够看到的“共享经济”的形式,它能够通过“共享”这种方式增加资源的利用效率 。而这背后的实现机制也很有意思,就是它是借助了两个表,一个是PLt程序连接表 , 另一个是GOt全局偏移表 。
不妨类比到现实生活中 , 假设你现在开了一家共享书店,你会怎么去实现这种共享机制,那么借助动态链接里面的思想,我们就可以制作一张书店里的书籍表,然后书籍表的每一本书都对应了一个地址,通过这个地址用户就能够找到书籍所在的位置 。
然后用户(程序)就可以根据这张表去借书 。
【10 动态链接:程序内部的“共享单车”】而且你还可以将其做成电子书的形式,这样一来一本书就可以供多个用户同时借阅,只要把数据复制到用户端就行 。