引言通过上节课的学习我们了解到链接器可以将不同的编译单元所生成的中间文件组合在一起并且可以为各个编译单元中的变量和函数分配地址然后将分配好的地址传给引用者。这个过程就是静态链接。静态链接可以让开发者进行模块化的开发大大的促进了程序开发的效率。但同时静态链接仍然存在一个比较大的问题就是无法共享。例如程序 A 与程序 B 都需要调用函数 foo在采用静态链接的情况下只能分别将 foo 函数链接到 A 的二进制文件和 B 的二进制文件中这样导致系统同时运行 A 和 B 两个进程的时候内存中会装载两份 foo 的代码。那么如何消除这种浪费呢这就是我们接下来两节课的主题动态链接。动态链接的重定位发生在加载期间或者运行期间这节课我们将重点分析加载期间的重定位它的实现依赖于地址无关代码。我们知道深入地掌握动态链接库是开发底层基础设施必备的技能之一如果你想要透彻地理解动态链接机制就必须掌握地址无关代码技术。在你掌握了地址无关代码技术后你还将对程序员眼中的“风骚”操作比如如何通过重载动态库对系统进行热更新如何对动态库里的函数进行 hook 操作以便于调试和追踪问题等等都会有更深入的理解。我们先来一起看一下动态链接是怎样解决静态链接不能充分共享代码这个问题的。什么是动态链接要想解决静态链接的问题可以把共享的部分抽离出来组成新的模块。为了让一些公共的库函数能够被多个程序在运行的过程中进行共享我们可以让程序在链接和运行过程中也拆分成不同的模块即共享模块和私有模块。共享模块用来存放供所有进程公共使用的库函数私有模块存放本进程独享的函数与数据。分析到这里动态链接的基本思路就呼之欲出了。目前解决共享问题采用的通用的思路是将常用的公共的函数都放到一个文件中在整个系统里只会被加载到内存中一次无论有多少个进程使用它这个文件在内存中只有一个副本这种文件就是动态链接库文件。它在 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) 技术名字的由来。在讲解动态链接的具体实现之前我们还是先来看下动态链接的小例子来对动态链接有一个初步的印象。如何生成和使用动态链接库我们通过运行一个例子来展示动态链接和加载的完整过程// foo.h #ifndef _FOO_H_ #define _FOO_H_ void foo(); #endif // foo.c #include stdio.h #include foo.h void foo() { printf(Hello foo\n); } // main_a.c #include stdio.h #include foo.h int main() { printf(A.exe: ); foo(); while(1) { } } // main_b.c #include stdio.h #include foo.h int main() { printf(B.exe: ); foo(); while(1) { } }以上例子分了三个模块分别是共享模块的 foo.c两个主程序 main_a.c 和 main_b.c主程序都调用了 foo.c 中的 foo 方法最后放一个死循环用来保证程序不退出以便于查看进程的相关信息。我们先把 foo.c 编译成 libfoo.so$ gcc foo.c -fPIC -shared -o libfoo.so其中 -fPIC 目的是开启地址无关代码一会儿我会给你详细解释-shared 意思是告诉链接器生成的目标文件是共享目标文件。然后我们分别编译 main_a.c 和 main_b.c来生成可执行文件 A.exe 和 B.exe$ gcc main_a.c -L. -lfoo -no-pie -o A.exe $ gcc main_b.c -L. -lfoo -no-pie -o B.exe我先来解释下这块代码中几个选项的意思。-L 指定了查找链接库的路径 (或者可以通过设置环境变量 LIBRARY_PATH 来追加路径)。-L. 就是告诉链接器需要到当前目录下查找共享文件。-l 则指定了具体链接库的名称需要注意的是gcc 在处理链接库名称时会自动加上 lib 的前缀和.so 的后缀所以gcc 命令选项写的 -lfoo就是告诉链接器查找 libfoo.so 这个共享目标文件。-no-pie 是禁止生成地址无关的可执行文件方便我们查看进程的内存布局。此时我们执行“ldd A.exe”或者“ldd B.exe”的时候就可以看到两个可执行文件依赖的 so 中多了一个 libfoo.so:$ ldd A.exe linux-vdso.so.1 (0x00007ffebc5ed000) libfoo.so not found libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 (0x00007f07e5ffe000) /lib64/ld-linux-x86-64.so.2 (0x00007f07e65f1000)我要提醒你的是上面的命令在输出过程中libfoo.so 的指向是 not found。这是因为 libfoo.so 所在的路径是当前路径运行时查找共享库的时候默认并不会来找寻当前路径因此 libfoo.so 的指向目前是无法确认的。如果此时执行./A.exe 同样也会报错解决方法就是将当前路径设置到 LD_LIBRARY_PATH 的环境变量中。$ export LD_LIBRARY_PATH.:$LD_LIBRARY_PATH此时再执行ldd A.exe就能找到 libfoo.so 的位置了。运行结果如下所示$ ldd A.exe linux-vdso.so.1 (0x00007ffef1cba000) libfoo.so ./libfoo.so (0x00007fe9d6998000) libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe9d65a7000) /lib64/ld-linux-x86-64.so.2 (0x00007fe9d6d9c000)在上面的例子中我们提到了两个环境变量 LIBRARY_PATH 和 LD_LIBRARY_PATH你可能对这两个环境变量的作用不是很清楚或者容易混淆在这里我们再对这两个变量的作用进行一下对比区分。这两个环境变量都是用来设置库文件的查找路径的只不过使用的时机不一样。其中 LIBRARY_PATH 是由链接器来使用的一般系统默认是 gnu ld。对于大部分开发者来讲如果 LIBRARY_PATH 没有设置好在使用 gcc 或者 clang 这些编译器其实它们都调用了 ld 这个链接器真正做事情的是 ld的时候会碰到类似/usr/bin/ld: cannot find -lfoo的错误。LIBRARY_PATH 的一个等价的选项就是上文讲的 -L 指定路径的选项。而另一个 LD_LIBRARY_PATH 的环境变量是由动态链接器来使用的即我们通过 ldd 看到的 ld-linux-x86-64.so.2 这个库。动态链接器的知识我们会在下一节课中详细展开。目前这里我们只需要知道这个动态链接器是在程序加载运行时执行的就可以了。因此如果 LD_LIBRARY_PATH 没有设置好的话会碰到类似./A.exe: error while loading shared libraries: libfoo.so: cannot open shared object file: No such file or directory的问题。总结下来LIBRARY_PATH 的使用时机是链接器在做链接的时候LD_LIBRARY_PATH 的使用时机是在程序运行时。接下来我们再看一下可执行程序运行起来以后它的内存布局是什么样子的这样我们就能清楚动态链接技术是怎么节省内存的。动态链接库内存布局我们通过执行两个进程一起看下它们的内存布局$ ./A.exe $ ./B.exe $ cat /proc/pidof A.exe/maps 00400000-00401000 r-xp 00000000 08:10 747270 ./A.exe 00600000-00601000 r--p 00000000 08:10 747270 ./A.exe 00601000-00602000 rw-p 00001000 08:10 747270 ./A.exe 01e58000-01e79000 rw-p 00000000 00:00 0 [heap] … 7fb25b13d000-7fb25b141000 rw-p 00000000 00:00 0 7fb25b141000-7fb25b142000 r-xp 00000000 08:10 747268 ./libfoo.so 7fb25b142000-7fb25b341000 ---p 00001000 08:10 747268 ./libfoo.so 7fb25b341000-7fb25b342000 r--p 00000000 08:10 747268 ./libfoo.so 7fb25b342000-7fb25b343000 rw-p 00001000 08:10 747268 ./libfoo.so … 7ffed501b000-7ffed503c000 rw-p 00000000 00:00 0 [stack] 7ffed51bc000-7ffed51c0000 r--p 00000000 00:00 0 [vvar] 7ffed51c0000-7ffed51c1000 r-xp 00000000 00:00 0 [vdso] $ cat /proc/pidof B.exe/maps 00400000-00401000 r-xp 00000000 08:10 747269 ./B.exe 00600000-00601000 r--p 00000000 08:10 747269 ./B.exe 00601000-00602000 rw-p 00001000 08:10 747269 ./B.exe 01597000-015b8000 rw-p 00000000 00:00 0 [heap] … 7f2991e85000-7f2991e89000 rw-p 00000000 00:00 0 7f2991e89000-7f2991e8a000 r-xp 00000000 08:10 747268 ./libfoo.so 7f2991e8a000-7f2992089000 ---p 00001000 08:10 747268 ./libfoo.so 7f2992089000-7f299208a000 r--p 00000000 08:10 747268 ./libfoo.so 7f299208a000-7f299208b000 rw-p 00001000 08:10 747268 ./libfoo.so … 7f29922b6000-7f29922b7000 rw-p 00000000 00:00 0 7fff73f9e000-7fff73fbf000 rw-p 00000000 00:00 0 [stack] 7fff73fde000-7fff73fe2000 r--p 00000000 00:00 0 [vvar] 7fff73fe2000-7fff73fe3000 r-xp 00000000 00:00 0 [vdso]从上面的命令运行结果中我们可以观察到这样两个特点第一个特点是动态库的数据段和代码段是靠在一起的它并没有和可执行程序的数据段代码段分别合并这是与静态链接不同的地方。第二个特点是同一个动态库文件在两个进程中的虚拟地址并不相同A.exe 跟 B.exe 同时加载了 libfoo.so但所处的位置分别是 0x7fb25b141000 与 0x7f2991e89000并不相同。然后我们再看一下两个进程中 libfoo.so 代码段的物理内存占用情况先看 A 进程的$ 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 ......再看看 B 进程的$ 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 .......我们通过 smap 的结果来考察物理内存的实际占用情况。在第 1 节课的练习中我曾经让你自己动手研究 smap 各字段的含义今天我们来重点分析一下 Rss 与 Pss。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。命令的输出还包含其他字段如果你感兴趣的话可以通过man proc命令来查询 proc 的详细信息。通过这个小例子我们看到了动态链接技术确实是将共享部分的内存省了下来但是你也会发现库文件在不同进程的映射中虚拟内存地址可以不同。这就要求编译器在生成代码时能适应这个需求那么地址无关代码技术就诞生了。为什么会有地址无关代码首先我们思考一下动态库文件被加载到内存中并且被多个进程共享时它的内存是什么样子的。在之前我们已经看到了可执行文件或者动态库文件被加载进内存的时候文件中不同的 section 会被加载进内存中不同 segment比如.data 和.bss 段被加载进数据段 (data segment)而.code, .rodata 被加载进代码段 (code segment)。在多进程共享动态库的时候因为代码段是不可写的所以进程间共享不存在问题而数据段可写系统必须保证一个进程写了共享库的数据段另外一个进程看不到。这时的内存映射情况如下图所示上面这幅图与之前的页面映射的图几乎如出一辙。正是虚拟地址技术让我们在进程间共享动态库变得容易我们只需要在虚拟空间里设置一下到物理地址的映射即可完成共享。虽然 libc.so 在物理内存中只有一份但它可以被多个进程进行映射。而且进程 1 映射 libc.so 代码段的虚拟地址与进程 2 映射 libc.so 代码段的虚拟地址可以不相等。正如本节课开头所分析的这样做可以使得多个进程共享一份代码大大节约了内存。到目前为止动态库技术看上去都非常好。但不知道你有没有发现一个问题如果共享的动态库超过了两个并且这些动态库之间还有相互引用的时候情况就变得复杂了。我们还是用图来说明如上图所示如果两个进程共享了 libc.so 和 libd.so 两个动态库而且 libc 中会调用 libd 中定义的 foo 方法。进程 1 将 foo 方法映射到自己的虚拟地址 0x1000 处而调用 foo 方法的指令被映射到 0x2000 处那么 call 指令如果采用依赖 rip 寄存器的相对寻址的办法这个偏移量应该填 -0x1000。进程 2 将 foo 方法映射到自己虚拟地址 0x2000 处调用 foo 方法的指令被映射到 0x5000 处那么 call 指令的参数就应该填 -0x3000。这就产生了冲突。显然我们第 6 节课所讲的通过 rip 寄存器进行相对寻址的办法在这里行不通了相对寻址要求目标地址和本条指令的地址之间的相对值是固定的这种代码就是地址有关的代码。当目标地址和调用者的地址之间的相对值不固定时就需要地址无关代码技术了。地址无关代码的核心结构在计算机科学领域有一句名言“计算机领域的所有问题都可以使用新加一层抽象来解决”。这句话的应用在计算机领域随处可见。同样地要实现代码段的地址无关代码思路也是通过添加一个中间层使得对全局符号的访问由直接访问变成间接访问。我们可以引入一个固定地址让引用者与这个固定地址之间的相对偏移是固定的然后这个地址处再填入 foo 函数真正的地址。当然这个地方必然位于数据段中是每个进程私有的这样才能做到在不同的进程里可以访问不同的虚拟地址。这个新引入的固定地址就是全局偏移表 (Global Offset Table, GOT)。GOT 的工作原理如下图所示在上图中call 指令处被填入了 0x3000这是因为进程 1 的 GOT 与 call 指令之间的偏移是 0x5000-0x20000x3000同时进程 2 的 GOT 与 call 指令之间的偏移是 0x8000-0x50000x3000。所以对于这一段共享代码不管是进程 1 执行还是进程 2 执行它们都能跳到自己的 GOT 表里。然后进程 1 通过访问自己的 GOT 表查到 foo 函数的地址是 0x1000它就能真正地调用到 foo 函数了。进程 2 访问自己的 GOT 表查到 foo 函数的地址是 0x2000它也能顺利地调用 foo 函数。这样我们就通过引入了 GOT 这个间接层解决了 call 指令和 foo 函数定义之间的偏移不固定的问题。这种技术就是地址无关代码 (Position Independent Code, PIC)。接下来我们用一个实际例子让你加深对 PIC 技术的理解。与第 6 节课讲解 linker 相似我们继续用具体的例子来看一下 PIC 技术中对几种常见类型的地址访问是如何处理的。例子如下// foo.c static int static_var; int global_var; extern int extern_var; extern int extern_func(); static int static_func() { return 10; } int global_func() { return 20; } int demo() { static_var 1; global_var 2; extern_var 3; int ret_var static_var global_var extern_var; ret_var static_func(); ret_var global_func(); ret_var extern_func(); return ret_var; }例子中分别从指令、数据和它的作用域的角度区分了如下几种类型static_var表示静态变量的访问static_func表示静态函数的访问extern_var表示外部变量的访问extern_func表示外部函数的访问global_var表示全局变量的访问global_func表示全局函数的访问demo() 函数是用来对以上几种类型进行访问来查看代码的生成。我们把上边的例子编译成 so 文件然后反汇编看一下 demo 函数的汇编是怎样的。$ gcc foo.c -fPIC -shared -fno-plt -o libfoo.so $ objdump -S libfoo.so 0000000000000680 demo: 680: 55 push %rbp 681: 48 89 e5 mov %rsp,%rbp 684: 48 83 ec 10 sub $0x10,%rsp 688: c7 05 92 09 20 00 01 movl $0x1,0x200992(%rip) # 201024 static_var 68f: 00 00 00 692: 48 8b 05 27 09 20 00 mov 0x200927(%rip),%rax # 200fc0 global_var-0x68 699: c7 00 02 00 00 00 movl $0x2,(%rax) 69f: 48 8b 05 4a 09 20 00 mov 0x20094a(%rip),%rax # 200ff0 extern_var 6a6: c7 00 03 00 00 00 movl $0x3,(%rax) 6ac: 8b 15 72 09 20 00 mov 0x200972(%rip),%edx # 201024 static_var 6b2: 48 8b 05 07 09 20 00 mov 0x200907(%rip),%rax # 200fc0 global_var-0x68 6b9: 8b 00 mov (%rax),%eax 6bb: 01 c2 add %eax,%edx 6bd: 48 8b 05 2c 09 20 00 mov 0x20092c(%rip),%rax # 200ff0 extern_var 6c4: 8b 00 mov (%rax),%eax 6c6: 01 d0 add %edx,%eax 6c8: 89 45 fc mov %eax,-0x4(%rbp) 6cb: b8 00 00 00 00 mov $0x0,%eax 6d0: e8 95 ff ff ff callq 66a static_func 6d5: 01 45 fc add %eax,-0x4(%rbp) 6d8: b8 00 00 00 00 mov $0x0,%eax 6dd: ff 15 ed 08 20 00 callq *0x2008ed(%rip) # 200fd0 global_func0x20095b 6e3: 01 45 fc add %eax,-0x4(%rbp) 6e6: b8 00 00 00 00 mov $0x0,%eax 6eb: ff 15 ef 08 20 00 callq *0x2008ef(%rip) # 200fe0 extern_func 6f1: 01 45 fc add %eax,-0x4(%rbp) 6f4: 8b 45 fc mov -0x4(%rbp),%eax 6f7: c9 leaveq 6f8: c3 retq1. 静态变量访问方式这里先来看一下 static_var。从 demo 的汇编里来看在 0x688 的位置第 7 行我们可以看到这里对 static_var 变量的访问采用的是基于 %rip 的偏移。其中指令后边的注释标明了当前指令访问的虚拟地址 0x201024通过objdump -d libfoo.so查看 0x201024 位置存放的符号是 static_var在.bss 段中。因此可以看出在同一个共享文件里边对 static 变量的访问可以通过 %rip 偏移的方式来确定数据的位置。目前我们的讲解都是基于 64 位的系统但这里值得一提的是32 位系统下由于没有相对 PC 偏移的寻址方式编译器在生成 32 位 PC 偏移寻址时是如下的一段汇编000003c0 __x86.get_pc_thunk.bx: 3c0: 8b 1c 24 mov (%esp),%ebx 3c3: c3 ret ... 000004e5 demo: ... 4ec: e8 cf fe ff ff call 3c0 __x86.get_pc_thunk.bx 4f1: 81 c3 0f 1b 00 00 add $0x1b0f,%ebx 4f7: c7 83 14 00 00 00 01 movl $0x1,0x14(%ebx) ...这里可以看到32 位系统是通过一个 call stub 来获取的 pc 的值。因为 call 指令本身会做的一个操作是将 return address 压栈而在 __x86.get_pc_thunk.bx 这个 stub 里边则将当前栈顶的值 (%esp) 取出来放到 %ebx 寄存器中那么此时 %ebx 里存放的就是 ret 之后的 pc 的值了。这个设计利用了 call 指令的会将下一条指令地址压栈的思路非常巧妙的获取了 pc 的值还是很有意思的。2. 静态函数的访问方式静态函数和静态变量一样都是不能被外部访问的所以我们也可以推测它的寻址方式和静态变量一样那么这里我就不再详细讲解验证过程了请你自己动手验证。3. 外部变量的访问接着来看对 extern_var 的访问。demo 中对 extern_var 的访问是 0x69f 和 0x6a6 两条指令。0x69f 先将 extern_var 的地址 mov 到 rax 寄存器中然后 0x6a6 则将具体的数据 0x3 写到 extern_var 表示的内存地址中。可以得到这条指令中使用的实际地址地址是 0x6a6 0x20094a 0x200ff0继续通过 objdump 来查看对应位置的内容。$objdump -D libfoo.so Disassembly of section .got: 0000000000200fc0 .got: ...这里就是我们刚才讲过的 GOT 了。其中存放的是该模块需要访问的所有外部符号的地址。这样可以使得对外部符号的访问转换为对 GOT 表的访问。由于 GOT 表的相对偏移在同一个 so 中肯定是不变的所以对 GOT 的访问可以使用相对寻址完成。GOT 中指向的是调用目标的在各自进程中的虚拟地址我们是通过 GOT 表间接访问的方式将对外部符号地址的直接依赖消除了。每个进程都有自己的私有 GOT 段GOT 中记录了当前的 so 文件所引用的所有外部符号。这些外部符号都需要进行解析和重定位。这个工作由 loader 负责其为符号分配并记录地址然后将这些地址回写进 GOT 表。这个过程的原理和上节课所讲的两阶段重定位过程几乎一致区别仅仅是 linke 操作的是文件中的地址而 loader 操作的是内存地址。4. 外部函数访问例子中 0x6eb 位置是对 extern_func 的调用处同外部数据访问类似这里也是采用了 GOT 表的间接访问的方式GOT 表 0x200fe0 的位置存放的是 extern_func 的运行时地址也需要在启动时进行重定位。5. 全局变量和全局函数的访问从例子中可以看到对于全局变量和全局函数的访问的处理方式与外部变量和外部函数的访问方式是保持一致的都是采用 GOT 的方式因此在这里我就不再详细解释了你可以去上面的例子中看一下。总结为了节约内存让进程间可以共享代码人们把可以被共享的代码都抽出来放到一个文件中多个进程共享这个文件就可以了。这个可共享的文件就是动态库文件。动态库文件中的符号要在加载时才被解析所以这种技术就叫动态链接技术。动态库文件被加载进内存以后在物理内存只有一份多个进程都可以将它映射进自己的虚拟地址空间。各个进程在映射时可以将动态库的代码段映射到任意的位置。如果两个共享库之间有引用关系的话引用者和被引用者之间的相对位置就不能确定了这时就需要引入地址无关代码技术。对于内部函数或数据访问因为其相对偏移是固定的所以可以通过相对偏移寻址的方式来生成代码对于外部和全局函数或数据访问则通过 GOT 表的方式利用间接跳转将对绝对地址的访问转换为对 GOT 表的相对偏移寻址由此得到了地址无关的代码。地址无关的代码除了可以在 so 中使用同样可以在可执行文件中使用可以通过 -pie 选项使得 gcc 编译地址无关的可执行文件。地址文件的可执行文件可以被加载到内存的任意位置执行这会使得缓冲区溢出的难度增加你可以结合第 4 节课思考一下原因但代价是通过 GOT 访问地址会多一次访存性能会下降。通过今天这节课我们对动态链接和其中地址无关代码技术有了整体的认知但在这里面仍然可以看到引入动态链接带来的一些问题。下节课我们会进一步探讨动态链接的优化以及动态链接器与 loader 的实现。