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

合并代码的方式叫做静态链接(Static Link)这里介绍一种新的链接方式:动态链接(Dynamic Link)。

在动态链接的过程中,要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。这个加载到内存中的共享库会被很多个程序的指令调用到。

在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。2980d241d3c7cbfa3724cb79b801d160.webp

地址无关很重要,相对地址解烦恼

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

多数函数库都可以做到地址无关,接受输入进行操作并返回结果,而常见的地址相关的代码,比如绝对地址代码(Absolute Code)、利用重定位表的代码等等,都是地址相关的代码。8cab516a92fd3d7e951887808597094a.webp
对于所有动态链接共享库的程序来讲,虽然他们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。

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

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

  1. // lib.h
  2. #ifndef LIB_H
  3. #define LIB_H
  4. void show_me_the_money(int money);
  5. #endif
  6. // lib.c
  7. #include <stdio.h>
  8. void show_me_the_money(int money)
  9. {
  10. printf("Show me USD %d from lib.c \n", money);
  11. }
  12. // show_me_poor.c
  13. #include "lib.h"
  14. int main()
  15. {
  16. int money = 5;
  17. show_me_the_money(money);
  18. }
  19. $ gcc lib.c -fPIC -shared -o lib.so
  20. $ gcc -o show_me_poor show_me_poor.c ./lib.so
  21. $ objdump -d -M intel -S show_me_poor
  22. ……
  23. 0000000000400540 <show_me_the_money@plt-0x10>:
  24. 400540: ff 35 12 05 20 00 push QWORD PTR [rip+0x200512] # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
  25. 400546: ff 25 14 05 20 00 jmp QWORD PTR [rip+0x200514] # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
  26. 40054c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
  27. 0000000000400550 <show_me_the_money@plt>:
  28. 400550: ff 25 12 05 20 00 jmp QWORD PTR [rip+0x200512] # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
  29. 400556: 68 00 00 00 00 push 0x0
  30. 40055b: e9 e0 ff ff ff jmp 400540 <_init+0x28>
  31. ……
  32. 0000000000400676 <main>:
  33. 400676: 55 push rbp
  34. 400677: 48 89 e5 mov rbp,rsp
  35. 40067a: 48 83 ec 10 sub rsp,0x10
  36. 40067e: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
  37. 400685: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
  38. 400688: 89 c7 mov edi,eax
  39. 40068a: e8 c1 fe ff ff call 400550 <show_me_the_money@plt>
  40. 40068f: c9 leave
  41. 400690: c3 ret
  42. 400691: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
  43. 400698: 00 00 00
  44. 40069b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
  45. ……

在 main 函数中调用 show_me_the_money 的函数的时候,对应的代码是这样的:

  1. call 400550 <show_me_the_money@plt>
  1. @plt 的关键字,代表了需要从 **PLT**,也就是**程序链接表**(Procedure Link Table)里面找要调用的函数。对应的地址则是 400550 这个地址。
  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
在400550 这个地址可以看到里面进行了一次跳转,这个跳转指定的跳转地址为 GLOBAL_OFFSET_TABLE+0x18 。这里的 **GLOBAL_OFFSET_TABLE**,就是**全局偏移表。**

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

不同的进程,调用同样的 lib.so,各自 GOT 里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。

这样,虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库,但是不需要去修改动态库里面的代码所使用的地址,而是各个程序各自维护好自己的 GOT,能够找到对应的动态库就好了。1144d3a2d4f3f4f87c349a93429805c8.webp GOT 表位于共享库自己的数据段里。GOT 表在内存里和对应的代码段位置之间的偏移量,始终是确定的。这样,我们的共享库就是地址无关的代码,对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时,生成的各不相同的 GOT 表,来找到它需要调用到的外部变量和函数的地址。

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

总结延伸

实际上,在进行 Linux 下的程序开发的时候,我们一直会用到各种各样的动态链接库。C 语言的标准库就在 1MB 以上。我们撰写任何一个程序可能都需要用到这个库,常见的 Linux 服务器里,/usr/bin 下面就有上千个可执行文件。如果每一个都把标准库静态链接进来的,几 GB 乃至几十 GB 的磁盘空间一下子就用出去了。如果我们服务端的多进程应用要开上千个进程,几 GB 的内存空间也会一下子就用出去了。这个问题在过去计算机的内存较少的时候更加显著。

通过动态链接这个方式,可以说彻底解决了这个问题。就像共享单车一样,如果仔细经营,是一个很有社会价值的事情,但是如果粗暴地把它变成无限制地复制生产,给每个人造一辆,只会在系统内制造大量无用的垃圾。