链接器可以将不同的编译单元所生成的中间文件组合在一起,并且可以为各个编译单元中的变量和函数分配地址,然后将分配好的地址传给引用者,这个过程就是静态链接。静态链接可以让开发者进行模块化的开发,大大的促进了程序开发的效率,但静态链接缺点在于无法共享。例如程序 A 与程序 B 都需要调用函数 foo,在采用静态链接的情况下,只能分别将 foo 函数链接到 A 的二进制文件和 B 的二进制文件中,这样导致系统同时运行 A 和 B 两个进程的时候,内存中会装载两份 foo 的代码。
动态链接的重定位发生在加载期间或者运行期间,接下来重点分析加载期间的重定位,它的实现依赖于地址无关代码。

什么是动态链接

要想解决静态链接的问题,可以把共享的部分抽离出来,组成新的模块。共享模块用来存放供所有进程公共使用的库函数,私有模块存放本进程独享的函数与数据。
目前解决共享问题,通用的思路是,将常用的公共的函数都放到一个文件中,在整个系统里只会被加载到内存中一次,无论有多少个进程使用它,这个文件在内存中只有一个副本,这种文件就是动态链接库文件。它在 Linux 里是共享目标文件 (share object, so),在 windows 下是动态链接库文件 (dynamic linking library, dll)。
使得公共库函数的代码在多个不同的进程中进行共享是实现动态链接技术的一大问题,也就是说,不同的进程运行的库的代码是同一份,这就要求共享模块的代码必须是地址无关的,因为每个进程都有自己独立的内存空间,系统 loader 无法保证共享模块加载的内存地址,对于每个进程而言都是相同的地址。例如进程 A 加载的 libfoo.so 的起始地址可能是 0x1000,而进程 B 加载的 libfoo.so 的起始地址可能是 0x3000,如果 libfoo.so 里代码访问的函数或者数据是绝对地址的话,那必然会造成进程 A 与 B 的冲突。
此外,虽然在开发的过程中,程序员可以将程序模块化处理,但还是需要使用静态链接来将不同模块链接到一起,这样导致运行时 CPU 才能知道各个函数、变量的真正地址是什么。同样的,要想让程序在运行过程中也进行模块化,那就意味着,不同模块之间符号的链接过程,需要推迟到加载时进行了,这也是动态链接 (Dynamic Linking) 技术名字的由来。

如何生成和使用动态链接库

通过运行一个例子来展示动态链接和加载的完整过程:

  1. // foo.h
  2. #ifndef _FOO_H_
  3. #define _FOO_H_
  4. void foo();
  5. #endif
  6. // foo.c
  7. #include <stdio.h>
  8. #include "foo.h"
  9. void foo() {
  10. printf("Hello foo\n");
  11. }
  12. // main_a.c
  13. #include <stdio.h>
  14. #include "foo.h"
  15. int main() {
  16. printf("A.exe: ");
  17. foo();
  18. while(1) {
  19. }
  20. }
  21. // main_b.c
  22. #include <stdio.h>
  23. #include "foo.h"
  24. int main() {
  25. printf("B.exe: ");
  26. foo();
  27. while(1) {
  28. }
  29. }
  1. $ gcc foo.c -fPIC -shared -o libfoo.so
  2. $ gcc main_a.c -L. -lfoo -no-pie -o A.exe
  3. $ gcc main_b.c -L. -lfoo -no-pie -o B.exe

-no-pie 是禁止生成地址无关的可执行文件,便于查看进程的内存布局

  1. 使用动态链接时,涉及到两个重要的环境变量 LIBRARY_PATH 以及 LD_LIBRARY_PATH。它们区别在于,LIBRARY_PATH 的使用时机是链接器在做链接的时候,LD_LIBRARY_PATH 的使用时机是在程序运行时。

动态链接库内存布局

  1. $ ./A.exe &
  2. $ ./B.exe &
  3. $ cat /proc/`pidof A.exe`/maps
  4. 00400000-00401000 r-xp 00000000 08:10 747270 ./A.exe
  5. 00600000-00601000 r--p 00000000 08:10 747270 ./A.exe
  6. 00601000-00602000 rw-p 00001000 08:10 747270 ./A.exe
  7. 01e58000-01e79000 rw-p 00000000 00:00 0 [heap]
  8. 7fb25b13d000-7fb25b141000 rw-p 00000000 00:00 0
  9. 7fb25b141000-7fb25b142000 r-xp 00000000 08:10 747268 ./libfoo.so
  10. 7fb25b142000-7fb25b341000 ---p 00001000 08:10 747268 ./libfoo.so
  11. 7fb25b341000-7fb25b342000 r--p 00000000 08:10 747268 ./libfoo.so
  12. 7fb25b342000-7fb25b343000 rw-p 00001000 08:10 747268 ./libfoo.so
  13. 7ffed501b000-7ffed503c000 rw-p 00000000 00:00 0 [stack]
  14. 7ffed51bc000-7ffed51c0000 r--p 00000000 00:00 0 [vvar]
  15. 7ffed51c0000-7ffed51c1000 r-xp 00000000 00:00 0 [vdso]
  16. $ cat /proc/`pidof B.exe`/maps
  17. 00400000-00401000 r-xp 00000000 08:10 747269 ./B.exe
  18. 00600000-00601000 r--p 00000000 08:10 747269 ./B.exe
  19. 00601000-00602000 rw-p 00001000 08:10 747269 ./B.exe
  20. 01597000-015b8000 rw-p 00000000 00:00 0 [heap]
  21. 7f2991e85000-7f2991e89000 rw-p 00000000 00:00 0
  22. 7f2991e89000-7f2991e8a000 r-xp 00000000 08:10 747268 ./libfoo.so
  23. 7f2991e8a000-7f2992089000 ---p 00001000 08:10 747268 ./libfoo.so
  24. 7f2992089000-7f299208a000 r--p 00000000 08:10 747268 ./libfoo.so
  25. 7f299208a000-7f299208b000 rw-p 00001000 08:10 747268 ./libfoo.so
  26. 7f29922b6000-7f29922b7000 rw-p 00000000 00:00 0
  27. 7fff73f9e000-7fff73fbf000 rw-p 00000000 00:00 0 [stack]
  28. 7fff73fde000-7fff73fe2000 r--p 00000000 00:00 0 [vvar]
  29. 7fff73fe2000-7fff73fe3000 r-xp 00000000 00:00 0 [vdso]

从上面的命令运行结果中,可以观察到这样两个特点:

  • 第一个特点是,动态库的数据段和代码段是紧靠在一起的,它并没有和可执行程序的数据段,代码段分别合并,这是与静态链接不同的地方;

    紧靠在一起意味着,代码和数据在内存中的相对偏移和在磁盘文件中的相对偏移是一样的

  • 第二个特点是,同一个动态库文件在两个进程中的虚拟地址并不相同,A.exe 跟 B.exe 同时加载了 libfoo.so,但所处的位置分别是 0x7fb25b141000 与 0x7f2991e89000,并不相同。

    物理内存占用情况

    通过 smap 的结果来考察物理内存的实际占用情况: ``bash $ cat /proc/pidof A.exe`/smaps 7fb25b141000-7fb25b142000 r-xp 00000000 08:10 747268 ./libfoo.so Size: 4 kB KernelPageSize: 4 kB MMUPageSize: 4 kB Rss: 4 kB Pss: 2 kB ……

$ cat /proc/pidof B.exe/smaps 7f2991e89000-7f2991e8a000 r-xp 00000000 08:10 747268 ./libfoo.so Size: 4 kB KernelPageSize: 4 kB MMUPageSize: 4 kB Rss: 4 kB Pss: 2 kB …….

  1. Rss 的含义是当前段实际加载到物理内存中的大小,Pss 指的是进程按比例分配当前段所占物理内存的大小。在这个例子中,因为 libfoo.so 本身代码段不足 4K,但是物理页的单位是 4K,所以这里 libfoo.so 代码段本身需要占据一个物理页,也就是 4K 的大小,即 Rss 值为 4K。由于多个进程共享了动态库,所以 Pss 的计算方式应该是 Rss 值除以共享进程数。从上面例子可以看到,A.exe B.exe 共享了 libfoo.so 的代码段,按比例分配的话应该分别占用 2K,即 Pss 的值都是 2K。如果此时把 B.exe 进程终止掉,A.exe 这里的 Pss 值就会变成 4K
  2. <a name="Xt9FQ"></a>
  3. # 为什么会有地址无关的代码
  4. 可执行文件或者动态库文件被加载进内存的时候,文件中不同的 section 会被加载进内存中不同 segment,比如 .data .bss 段被加载进数据段(data segment),而 .code,.rodata 被加载进代码段(code segment)。<br />在多进程共享动态库的时候,因为代码段是不可写的,所以进程间共享不存在问题,而数据段可写,系统必须保证一个进程写了共享库的数据段,另外一个进程看不到。这时的内存映射情况如下图所示:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/21563208/1640445155809-4f871a28-e988-44b1-b752-bbb12753a921.png#clientId=ua52ec4df-c1f2-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u8b48d83f&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1385&originWidth=2284&originalType=url&ratio=1&rotation=0&showTitle=false&size=747503&status=done&style=none&taskId=u4e088678-b711-43b4-a02e-12c4d9bdf13&title=)<br />虽然 libc.so 在物理内存中只有一份,但它可以被多个进程进行映射,而且进程 1 映射 libc.so 代码段的虚拟地址与进程 2 映射 libc.so 代码段的虚拟地址可以不相等。<br />但是如果共享的动态库超过了两个,并且这些动态库之间还有相互引用的时候,情况就变得复杂了。<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/21563208/1640445232493-34f8a1d3-dcba-4ce3-a177-139cfe7fc8e6.png#clientId=ua52ec4df-c1f2-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u8d2a1058&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1261&originWidth=2284&originalType=url&ratio=1&rotation=0&showTitle=false&size=735622&status=done&style=none&taskId=u104a17b0-d67e-41ac-b0b8-5b4dc3e3adb&title=)<br />如上图所示,如果两个进程共享了 libc.so 和 libd.so 两个动态库,而且 libc 中会调用 libd 中定义的 foo 方法。进程 1 将 foo 方法映射到自己的虚拟地址 0x1000 处,而调用 foo 方法的指令被映射到 0x2000 处,那么 call 指令如果采用依赖 rip 寄存器的相对寻址的办法,这个偏移量应该填 -0x1000。进程 2 将 foo 方法映射到自己虚拟地址 0x2000 处,调用 foo 方法的指令被映射到 0x5000 处,那么 call 指令的参数就应该填 -0x3000。这就产生了冲突。<br />静态链接所采用的基于 rip 寄存器进行相对寻址的办法在这里行不通了,因为相对寻址要求目标地址和本条指令的地址之间的相对值是固定的,这种代码就是地址有关的代码。当目标地址和调用者的地址之间的相对值不固定时,就需要地址无关代码技术了。
  5. <a name="yl8hE"></a>
  6. # 地址无关代码的核心结构
  7. 在计算机科学领域,有一句名言:“计算机领域的所有问题都可以使用新加一层抽象来解决”。这句话的应用在计算机领域随处可见。同样地,要实现代码段的地址无关代码,思路也是通过添加一个中间层,使得对全局、外部符号的访问由直接访问变成间接访问。通过引入一个固定地址,让引用者与这个固定地址之间的相对偏移是固定的,然后这个地址处再填入 foo 函数真正的地址。当然,这个地址必然位于数据段中,是每个进程私有的,这样才能做到能在不同的进程里访问不同的虚拟地址。这个新引入的固定地址就是全局偏移表 (Global Offset Table, GOT)。GOT 的工作原理如下图所示:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/21563208/1640445497721-a5ad0353-179c-420d-9284-4bc9b971d29b.png#clientId=ua52ec4df-c1f2-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u64c6ce47&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1307&originWidth=2284&originalType=url&ratio=1&rotation=0&showTitle=false&size=797569&status=done&style=none&taskId=uf8998cc5-7412-424e-8a26-5d9e46096c2&title=)<br />在上图中,call 指令处被填入了 0x3000,这是因为进程 1 的 GOT 与 call 指令之间的偏移是 0x5000-0x2000=0x3000,同时进程 2 的 GOT 与 call 指令之间的偏移是 0x8000-0x5000=0x3000。所以对于这一段共享代码,不管是进程 1 执行还是进程 2 执行,它们都能跳到自己的 GOT 表里。然后,进程 1 通过访问自己的 GOT 表,查到 foo 函数的地址是 0x1000,它就能真正地调用到 foo 函数了。进程 2 访问自己的 GOT 表,查到 foo 函数的地址是 0x2000,它也能顺利地调用 foo 函数。这样我们就通过引入了 GOT 这个间接层,解决了 call 指令和 foo 函数定义之间的偏移不固定的问题。这种技术就是地址无关代码 (Position Independent Code, PIC)。<br />通过具体的例子来看一下 PIC 技术中对几种常见类型的地址访问是如何处理的:
  8. ```c
  9. // foo.c
  10. static int static_var;
  11. int global_var;
  12. extern int extern_var;
  13. extern int extern_func();
  14. static int static_func() {
  15. return 10;
  16. }
  17. int global_func() {
  18. return 20;
  19. }
  20. int demo() {
  21. static_var = 1;
  22. global_var = 2;
  23. extern_var = 3;
  24. int ret_var = static_var + global_var + extern_var;
  25. ret_var += static_func();
  26. ret_var += global_func();
  27. ret_var += extern_func();
  28. return ret_var;
  29. }
  1. $ gcc foo.c -fPIC -shared -fno-plt -o libfoo.so
  2. $ objdump -S libfoo.so
  3. 0000000000000680 <demo>:
  4. 680: 55 push %rbp
  5. 681: 48 89 e5 mov %rsp,%rbp
  6. 684: 48 83 ec 10 sub $0x10,%rsp
  7. 688: c7 05 92 09 20 00 01 movl $0x1,0x200992(%rip) # 201024 <static_var>
  8. 68f: 00 00 00
  9. 692: 48 8b 05 27 09 20 00 mov 0x200927(%rip),%rax # 200fc0 <global_var-0x68>
  10. 699: c7 00 02 00 00 00 movl $0x2,(%rax)
  11. 69f: 48 8b 05 4a 09 20 00 mov 0x20094a(%rip),%rax # 200ff0 <extern_var>
  12. 6a6: c7 00 03 00 00 00 movl $0x3,(%rax)
  13. 6ac: 8b 15 72 09 20 00 mov 0x200972(%rip),%edx # 201024 <static_var>
  14. 6b2: 48 8b 05 07 09 20 00 mov 0x200907(%rip),%rax # 200fc0 <global_var-0x68>
  15. 6b9: 8b 00 mov (%rax),%eax
  16. 6bb: 01 c2 add %eax,%edx
  17. 6bd: 48 8b 05 2c 09 20 00 mov 0x20092c(%rip),%rax # 200ff0 <extern_var>
  18. 6c4: 8b 00 mov (%rax),%eax
  19. 6c6: 01 d0 add %edx,%eax
  20. 6c8: 89 45 fc mov %eax,-0x4(%rbp)
  21. 6cb: b8 00 00 00 00 mov $0x0,%eax
  22. 6d0: e8 95 ff ff ff callq 66a <static_func>
  23. 6d5: 01 45 fc add %eax,-0x4(%rbp)
  24. 6d8: b8 00 00 00 00 mov $0x0,%eax
  25. 6dd: ff 15 ed 08 20 00 callq *0x2008ed(%rip) # 200fd0 <global_func+0x20095b>
  26. 6e3: 01 45 fc add %eax,-0x4(%rbp)
  27. 6e6: b8 00 00 00 00 mov $0x0,%eax
  28. 6eb: ff 15 ef 08 20 00 callq *0x2008ef(%rip) # 200fe0 <extern_func>
  29. 6f1: 01 45 fc add %eax,-0x4(%rbp)
  30. 6f4: 8b 45 fc mov -0x4(%rbp),%eax
  31. 6f7: c9 leaveq
  32. 6f8: c3 retq

-fpie 选项使得 gcc 编译地址无关的可执行文件。地址无关的可执行文件可以被加载到内存的任意位置执行,这会使得缓冲区溢出的难度增加,但代价是通过 GOT 访问地址会多一次访存,性能会下降。

静态变量 static_var 的访问

从 demo 的汇编里来看,在 0x688 的位置(第 7 行),我们可以看到这里对 static_var 变量的访问采用的是基于 %rip 的偏移。其中指令后边的注释标明了当前指令访问的虚拟地址 0x201024,通过 objdump -d libfoo.so 查看 0x201024 位置存放的符号是 static_var,在 .bss 段中。因此可以看出,在同一个共享文件里边,对 static 变量的访问可以通过 %rip 偏移的方式来确定数据的位置。
上面的描述是基于 64 位的系统,但这里值得一提的是,32 位系统下由于没有相对 PC 偏移的寻址方式,编译器在生成 32 位 PC 偏移寻址时,是如下的一段汇编:

  1. 000003c0 <__x86.get_pc_thunk.bx>:
  2. 3c0: 8b 1c 24 mov (%esp),%ebx
  3. 3c3: c3 ret
  4. ...
  5. 000004e5 <demo>:
  6. ...
  7. 4ec: e8 cf fe ff ff call 3c0 <__x86.get_pc_thunk.bx>
  8. 4f1: 81 c3 0f 1b 00 00 add $0x1b0f,%ebx
  9. 4f7: c7 83 14 00 00 00 01 movl $0x1,0x14(%ebx)
  10. ...
  1. 32 位系统是通过一个 call stub 来获取的 pc 的值。因为 call 指令本身会做的一个操作是将 return address 压栈,而在 __x86.get_pc_thunk.bx 这个 stub 里边,则将当前栈顶的值 (%esp) 取出来放到 %ebx 寄存器中,那么此时 %ebx 里存放的就是 ret 之后的 pc 的值了。这个设计利用了 call 指令的会将下一条指令地址压栈的思路,非常巧妙的获取了 pc 的值。

静态函数 static_func 的访问

静态函数和静态变量一样,都是不能被外部访问的,因此访问方式类似于静态变量。

外部变量 extern_var 的访问

demo 中对 extern_var 的访问是 0x69f 和 0x6a6 两条指令(11、12行)。0x69f 先将 extern_var 的地址 mov 到 rax 寄存器中,然后 0x6a6 则将具体的数据 0x3 写到 extern_var 表示的内存地址中。可以得到这条指令中使用的实际地址地址是 0x6a6 + 0x20094a = 0x200ff0,继续通过 objdump 来查看对应位置的内容。

  1. $objdump -D libfoo.so
  2. Disassembly of section .got:
  3. 0000000000200fc0 <.got>:
  4. ...
  1. GOT 表中存放的是该模块需要访问的所有外部符号的地址。这样可以使得对外部符号的访问转换为对 GOT 表的访问。因为 GOT 表的相对偏移在同一个 .so 中肯定是不变的,所以对 GOT 的访问可以使用相对寻址完成。GOT 中指向的是**调用目标在各自进程中的虚拟地址**,这样通过 GOT 表间接访问的方式,将对外部符号地址的直接依赖消除了。每个进程都有自己的私有 GOT 段,GOT 中记录了当前的 .so 文件所引用的所有外部符号。这些外部符号都需要进行解析和重定位,这个工作由 loader 负责,其为符号分配并记录地址,然后将这些地址回写进 GOT 表。这个过程的原理和静态链接的两阶段重定位过程几乎一致,区别仅仅是 linker 操作的是文件中的地址,而 loader 操作的是内存地址。<br />而对于外部函数 extern_func、全局变量 global_func 全局函数 global_func 的访问方式,也是与外部变量 extern_var 保持一致的,都是采用 GOT 的方式。<br />总的来说,如果两个共享库之间有引用关系的话,引用者和被引用者之间的相对位置就不能确定了,这时就需要引入地址无关代码技术。对于内部函数或数据访问,因为其相对偏移是固定的,所以可以通过相对偏移寻址的方式来生成代码;对于外部和全局函数或数据访问,则通过 GOT 表的方式,利用间接跳转将对绝对地址的访问转换为对 GOT 表的相对偏移寻址,由此得到了地址无关的代码。

静态库和动态库区别

静态库是在编译阶段就和可执行文件打包链接在一起,可以看作是中间文件的简单集合,保留了符号,只有在静态链接的过程中,才会做真正的地址分配和重定位;而动态库在编译阶段,它的代码不会被合并进可执行文件中,在运行时才会被加载进内存,并且动态库被装载的位置是不固定的,所以只有在装载后,只能为它的符号分配真正的内存地址,然后再把地址回填到引用它的 GOT 中。
在链接时,程序引用到的每一个符号都需要被处理,对于静态库中的引用符号,需要在链接时进行重定位;而对于动态库中的符号,仅需在 GOT 表中预留好位置,并链接重定位的过程推迟要装载时进行,这样通过 GOT 表加一层间接跳转的方式,解决了代码中 call 指令对绝对地址的依赖,从而实现了 PIC 的能力。
参考资料:深入理解GOT表覆写技术