内存学习:深入理解动态链接(下)

内存学习:深入理解动态链接(下) 引言在上节课里我们学习了动态链接过程的基本原理。动态链接通过 GOT 表加一层间接跳转的方式解决了代码中 call 指令对绝对地址的依赖从而实现了 PIC 的能力。我们同时也讲到了 GOT 表中的地址是由加载器在加载时填充的。不过细心的你也发现了动态链接带来的代价是性能的牺牲。这里性能的牺牲主要来自于两个方面每次对全局符号的访问都要转换为对 GOT 表的访问然后进行间接寻址这必然要比直接的地址访问速度慢很多动态链接和静态链接的区别是将链接中重定位的过程推迟到程序加载时进行。因此在程序启动的时候动态链接器需要对整个进程中依赖的 so 进行加载和链接也就是对进程中所有 GOT 表中的符号进行解析重定位。这样就导致了程序在启动过程中速度的减慢。我们这节课来看看如何通过延迟绑定技术来解决性能下降的问题。延迟绑定不仅仅是用在动态链接中还被广泛地应用在 HotspotV8 等带有即时编译功能的虚拟机中。另外在游戏行业修复服务器的错误的同时保证用户不掉线是硬需求这种不停机进行代码修复的技术被称为热更新技术。学习完这节课后你不仅能理解动态链接的基本原理而且也能对热更新的基本原理有所感悟。其实不管是加载时重定位还是延迟绑定技术真正发挥作用的是动态链接器。所以这节课我也会给你简单介绍一下动态链接器的基本原理。首先我们从延迟绑定的最简单的形式也就是 Hotspot 虚拟机中的运行时重定位技术 patch code 讲起。patch code 技术我们知道在 Java 语言中类是按需加载的。也就是对于一个 class 文件只有当 hotspot 第一次使用它的时候它才会被加载进来。假如我们在即时编译 A 方法的时候要调用 B 方法但这时 B 方法还没有被加载进来该怎么办呢虚拟机会采用一种叫做 patch code 的技术在运行时再进行加载。简单地说就是在生成 call 指令时候它的目标地址填成一个虚拟机内部的用于解析符号的方法。在 CPU 执行这条 call 语句的时候就会调用符号解析函数。此时虚拟机就会加载 B 方法所在的类然后就能确定 B 方法的地址了这时再把 B 方法的地址写回到 call 指令里。这个过程如下图所示这个过程很像是在给原始的代码打补丁所以人们就把这种方式称为 patch code 技术。这就像是在原来的代码安装了一个机关当 CPU 执行到这个机关时就会触发一次符号的重定位然后这个机关就被替换掉了。下一次 CPU 再执行到这个 call 指令的时候就可以正常地调用到 B 方法了。上节课加载器在加载动态库时就把它的 GOT 中的所有符号都解析了这种方法却把解析符号的过程又往后推到了执行代码时解析。在 Hotspot 里的 patch code 技术会直接修改指令参数。不过运行时修改指令总是一件很危险的事情。所以动态库真正使用的运行时解析符号技术是延迟绑定技术它的关键步骤和 patch code 很相似但却比 patch code 的安全性更好一些我们一起来看一下。延迟绑定技术为了避免在加载时就把 GOT 表中的符号全部解析并重定位就需要采用计算机领域非常重要的一个思想Lazy。也就是说把要做的事情推迟到必须做的时刻。对于我们当前的问题来说将函数地址的重定位工作一直推迟到第一次访问的时候再进行这就是延迟绑定 (Lazy binding) 的技术。这样的话对于整个程序运行过程中没有访问到的全局函数可以完全避免对这类符号的重定位工作也就提高了程序的性能。patch code 显然也是一种延迟绑定的技术但是它要在运行时修改指令参数这会带来风险。所以动态库的延迟绑定选择了继续使用 GOT 表来进行间接调用然后 patch 的对象就不再是指令了而是 GOT 中的一项。理想情况下我们把 GOT 中的待解析符号的地方都填成动态符号解析的函数就可以了当 CPU 执行到这个函数的时候就会跳转进去解析符号然后把 GOT 表的这一项填成符号的真正的地址。如下图所示但是动态解析符号的函数 _dl_runtime_resolve 依赖两个参数一个是当前动态库的 ID另一个是要解析的符号在 GOT 表中的序号。动态库的 ID 存储在 GOT 的 0x8 偏移的位置而要解析的符号序号却不容易得到。为了解决传递参数的问题动态链接又引入了过程链接表Procedure Linkage Table PLT)将动态解析符号的过程做成了三级跳。如下图所示在图中我用序号①、②、③和它们旁边的箭头分别给你标注出了三级跳的路径。如果你仔细观察的话你还会发现这张图与上一张图的主要变化就是引入了.plt 段在代码段里main 函数对 B 函数的调用转成了对Bplt的调用Bplt函数只有三条指令。它的第一条指令 jmp *(GOT[3]) 是一个间接跳转跳转的目标是 GOT 表偏移为 0x18 的位置正常情况下这个位置应该放的是 B 函数的真实地址。但现在填入的是指向了 Bplt 0x6 的位置这是为了传递参数给 _dl_runtime_resolve 函数。Bplt0x6 的位置其实就是 Bplt 函数的第二条指令它的作用是将函数参数入栈然后执行第三条指令 jmp .plt 再准备第二个参数。我们再回到图中看看在序号①箭头的位置也就是第一级跳转它的目的是把参数 0 入栈。由于 GOT 表的 0x00x80x10 的位置都被占用了所以参数 0 代表的就是 0x18 位置这就是 B 函数的真实地址应该存放的地方。然后在序号②箭头的位置发生了第二级跳转这一次是为了把动态库的 ID 号压栈传参。最后在序号③箭头的位置继续进行第三级跳转这一次跳转才真正地调用到了 _dl_runtime_resolve。调用完这个方法以后B 函数的真实地址就会被填入 GOT 表中了。上述过程由于传参的需要而变成了多级跳转但如果抛开因为传参而产生的两级跳转你会发现它的基本结构与 patch code 技术如出一辙。这样的跳转虽然麻烦但有一个非常重要的优点就是运行期间不会修改代码段的指令所有的修改只涉及了 GOT 这个位于数据段的表里。我们在第 3 节课就已经介绍过.code 和.plt 会被加载到内存的代码段 (code segment)它的权限是可读可执行但不可写上节课也讲了.got 会被加载进数据段它的权限是可读可写。我们现在介绍的多级跳转的延迟绑定技术的整个重定位过程最终只会修改 GOT 的 0x18 这一个位置其他位置都不必发生变化。当执行完了重定位过程以后CPU 再一次运行到 main 里的 call 指令时就能通过一次跳转就调用到真正的 B 函数了这时的 GOT 已经与上节课所讲的加载时重定位后的 GOT 一模一样了。如图所示在这个图里重定位完以后只有红色字体的代码和数据是起作用的.plt 段里的其他代码就被“短路”掉了。这时GOT 表的结构就与上节课所讲的加载时重定位的情况完全一样了。只有用到的符号才会被重定位这就是延迟绑定技术。未被用到的符号在加载时被重定位这是一种浪费而延迟绑定技术避免了这种浪费。为了加深理解我结合一个具体例子向你展示延迟绑定是怎么实现的。延迟绑定技术的具体实现下面我们还是根据上节课的例子来看一下延迟绑定技术的具体实现。// 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; }我们将这个例子编译成 libfoo.so 编译命令是$ gcc foo.c -fPIC -shared -o libfoo.so这里跟上节课编译的区别是去掉了 -fno-plt 的编译选项这样可以打开 PLT 表的生成。上一节课里我们只需要关注 PIC 技术的实现因此需要通过 -fno-plt 的选项来关闭 PLT 表的生成。我们先通过反汇编先来看一下 demo 函数的汇编指令00000000000006a0 demo: ... 6fd: e8 7e fe ff ff callq 580 global_funcplt 702: 01 45 fc add %eax,-0x4(%rbp) 705: b8 00 00 00 00 mov $0x0,%eax 70a: e8 81 fe ff ff callq 590 extern_funcplt 70f: 01 45 fc add %eax,-0x4(%rbp) 712: 8b 45 fc mov -0x4(%rbp),%eax 715: c9 leaveq 716: c3 retq从汇编中你可以看到对函数 global_func 和 extern_func 的调用都变成了对 global_funcplt 和 extern_funcplt 的调用。继续查看这两个带 plt 后缀的函数其对应的 VMA 分别是 0x580 和 0x590所以接着看这两个位置的汇编代码。Disassembly of section .plt: 0000000000000570 .plt: 570: ff 35 92 0a 20 00 pushq 0x200a92(%rip) # 201008 _GLOBAL_OFFSET_TABLE_0x8 576: ff 25 94 0a 20 00 jmpq *0x200a94(%rip) # 201010 _GLOBAL_OFFSET_TABLE_0x10 57c: 0f 1f 40 00 nopl 0x0(%rax) 0000000000000580 global_funcplt: 580: ff 25 92 0a 20 00 jmpq *0x200a92(%rip) # 201018 global_func0x200983 586: 68 00 00 00 00 pushq $0x0 58b: e9 e0 ff ff ff jmpq 570 .plt 0000000000000590 extern_funcplt: 590: ff 25 8a 0a 20 00 jmpq *0x200a8a(%rip) # 201020 extern_func 596: 68 01 00 00 00 pushq $0x1 59b: e9 d0 ff ff ff jmpq 570 .plt这段汇编是对 libfoo.so 中.plt 段的反汇编。从这里我们可以看出来PLT 表的每一项其实都是一段相似的 stub 代码构成这个 stub 共三条指令这三条指令和我们上面的图中所画的是完全一样的。从反汇编的结果来看global_funcplt 的第一行是一个间接跳转跳转的目标地址存储在 0x201018 这个位置通过 objdump 我们可以找到这个位置位于.got.plt 段里。这个命令我们已经很熟悉了你可以自己动手试一下。从名字中可以看出.got.plt 段跟.got 段是一样的存放的是 GOT 表只不过.got.plt 里边的 GOT 表是为 PLT 表准备的。在这里 0x201018 的位置存放的值是 0x586。这就跳回到 global_funcplt 里继续执行了这是我们上面所分析的一级跳是为了传递参数给符号解析函数的。最终经过传参跳转控制流才终于进入到 dl_runtime_resolve 中解析符号并做重定位。最后我们再总结一下 GOT 表中的各个表项的含义。GOT.PLT[0]位置被加载器保留它里面存放的是.dynamic 段的地址这里我们不用关心。GOT.PLT[1]位置存放的是当前 so 的 ID这个 ID 是加载器在加载当前动态库文件的时候分配的。GOT.PLT[2]位置存放的是动态链接函数的入口地址一般是动态链接器中的 _dl_runtime_resovle 函数。这个函数的作用是找到需要查找的符号地址并最终回填到 GOT.PLT 表的对应位置。然后再回顾一下延迟绑定的整个过程。当 demo 函数想要调用 global_func 的时候程序调用先进入 global_funcplt 中在 global_funcplt 中会先执行 jmpq *GOT.PLT[3] 此时 GOT.PLT[3] 里存放的是 global_funcplt 项中的第二条指令因此控制流继续返回到 global_funcplt 中进行执行接下会把数值 0x0 进行压栈这个数值代表了 global_func 的 ID。然后 jmp 到 PLT[0] 的表项中进行执行在 PLT[0] 中继续将 GOT.PLT[1] 的值也就是库文件的 ID 进行压栈然后通过 GOT.PLT[2] 跳转到 _dl_runtime_resolve 函数中_dl_runtime_resolve 则根据存在栈上的函数 ID 和 so 的 ID 进行全局搜索找到对应的函数地址之后就可以将其重新填充到 GOT.PLT[3] 中这个时候延迟加载的整个过程就完成了当下一次调用 global_func 的时候CPU 就可以通过 global_funcplt 中第一条指令 jmpq *GOT.PLT[3] 直接跳转到 global_func 的真实地址中。到这里我们对动态链接中 PIC 技术和延迟加载技术进行了深入的分析。这个过程中我们几次提到动态链接器但一直没有展开说接下来我们就来揭开动态链接器的神秘面纱。Loader 的加载机制虽然我们已经搞清楚了链接的全部流程。不过还缺了最后一环就是可执行文件和共享库文件是如何被加载的在 Linux 下编译一个最简单的可执行程序通过 ldd a.out 命令你会发现有一个特殊的共享库文件ld-linux-x86-64.so。从名字上可以看出这个 ld-linux.so 跟链接器 ld 应该是存在某种联系的。动态链接会把不同模块之间符号重定位的操作推迟到程序运行的时候而 ld-linux.so 就负责这个工作。所以我们经常称 ld.so 为动态链接器又因为它还负责加载动态库文件所以我们有时也叫它 loader或者加载器。我们知道一个完全静态链接的可执行文件则不需要动态链接器的辅助所以内核加载完之后可以直接跳转到用户代码的入口中进行执行。内核加载的过程主要是打开文件初始化进程空间读磁盘加载文件数据等等这部分工作不是我们关心的重点所以就不再分析了。而对于一个需要动态链接的可执行文件 a.out当我们在 Linux 的 shell 终端里边敲了./a.out 的命令后内核会先准备好可执行文件需要的环境然后依次把 a.out 和 ld-linux.so 加载到内存中下一步就是跳转到 ld-linux.so 的入口函数中。进入 ld-linux.so 以后与上文所讲的内核的文件加载过程就有区别了。它已经不是内核态执行而是用户态执行了。ld-linux.so 的源码实际上是在 glibc 里边主要实现都是在 glibc 的 elf 文件夹下。ld-linux.so 做的事情主要有这么几件第一是启动动态链接器第二是根据可执行文件的动态链接信息寻找并加载可执行文件依赖的.so 文件第三步是跟静态链接器一样对所有的符号进行解析和重定位最后会根据 so 的情况来依次执行各个 so 的 init 函数。启动动态链接器在第一点中你可能会问加载跟启动动态链接器的事情不是已经在内核里边做过了么这里启动动态链接器是在做什么呢我们知道动态链接器的作用是用来对可执行文件中需要动态链接的这些全局符号进行重定位解析填写 GOT 表等这时候你会发现ld-linux.so 本身也是一个共享文件那它自己的动态链接的过程是谁来进行呢答案就是自己。ld-linux.so 在启动之后首先需要完成自己的符号解析和重定位的过程这个过程叫做动态链接器的自举 (Bootstrap)。ld-linux.so 中的整个自举过程的代码是需要非常小心翼翼的因为此时 ld-linux.so 本身的 GOT/PLT 信息都未完成所以在自举过程中的代码不能使用全局符号和外部符号稍有不慎就会导致整个程序崩溃。你可以到 elf/rtld.c 中看一下这块代码主要逻辑在 _dl_start 函数里。加载依赖共享文件完成自举后ld-linux.so 就可以放心的使用各种全局符号和外部符号了。接下来第二步是根据可执行文件的.dynamic 段信息依次加载程序依赖的共享库文件。程序的共享库依赖关系往往是一个图的关系所以这里在加载共享库的过程也相当于是图遍历的过程这里往往采用的是广度优先搜索的算法来遍历。在第 6 节课我们讲过静态链接在链接的过程中需要维护一个全局的符号表遍历.o 文件的时候不断收集文件中的符号并且合并到全局符号表中。同样的ld-linux.so 在加载共享文件的过程中也会维护一个全局符号表每次加载新的共享文件后将共享文件中的符号信息合并到全局符号表中。这个时候问题来了如果两个不同的 so如 libfoo1.so 与 libfoo2.so 都定义了一个 foo 函数那 ld-linux.so 加载这两个 so 的时候会发生什么在静态链接的过程中如果不同的.o 里边定义了相同的符号这时链接器会报出 redefine 的错误。而 ld-linux.so 的执行策略则是不同的ld-linux.so 在碰到相同的符号时只会将第一次碰到的符号添加到全局符号表中而后续碰到重名的符号就被自动忽略。这样导致的结果是不同 so 的同名函数在运行时能看到的只有加载顺序在前的函数定义。所以对于上面的问题而言如果 libfoo1.so 依赖在前那么最终运行时只能看到 libfoo1.so 的 foo 函数即使是 libfoo2.so 里的函数调用 foo调用的也是 libfoo1.so 里的 foo而不是自己 so 的 foo。由此我们在开发过程中一定需要注意不同 so 中符号重名的问题否则就会碰到意想不到的问题。符号重定位与解析在完成了共享文件的加载之后全局符号表的信息就收集完成了这时 ld-linux.so 就可以根据全局符号表和重定位表的信息依次对各个 so 和可执行文件进行重定位修正了。这个过程跟静态链接中重定位的过程类似你可以自己去分析一下。init 函数调用最后有的 so 文件还会有.init 段进行一些初始化函数的调用例如 so 中全局变量的对象构造函数或者用户自己生成在.init 段的初始化函数等。这些都会由 ld-linux.so 在最后的阶段进行一次调用。当这些完成之后ld-linux.so 就会结束自己的使命最终将程序的控制流转到可执行文件的入口函数中进行。整个 Loader 加载动态链接的可执行文件流程如下图所示总结我们通过学习弄明白了“将符号转成地址”这个工作是由谁、在何时、如何完成的。编译器在把源代码翻译成汇编指令的过程中由于不知道其他编译单元的符号的真实地址在引用这些符号的时候只能使用占位符通常是 0来代替。这些占位符由链接器填充。当链接器把所有的符号的位置都确定好以后再把真实地址回填到占位符里这个过程就是重定位。重定位的时机有三个分别是编译期重定位加载期和这节课介绍的运行时重定位。这节课我们先介绍了 patch code 技术它被采用了即时编译的语言虚拟机广泛地使用。它可以做到运行时解析符号。它的主要原理是把 call 指令的目标地址填成用于解析符号的函数地址当 CPU 执行到这个 call 指令时就会转去解析函数然后把 call 指令的目标地址替换成符号的真实地址。patch code 技术有一个缺点那就是在运行期要修改代码段的数据这为系统带来了风险。动态链接库则引入了.plt 和.got 段通过间接调用来解决这个问题。在运行时符号解析函数只需要修改 GOT 的内容就可以了代码段是不会发生任何变化的。当然因为要向符号解析函数传递参数所以动态库的.plt 设计成了三级跳转的结构看上去虽然很复杂但我们只需要牢牢记住.plt 最终的目标还是调用到符号解析函数然后重写 GOT 表的内容即可。我们这两节课的内容都是动态链接而真正负责动态链接的是 ld-linux.so它被称为动态链接器但因为它还负责加载文件工作所以也被人称为加载器或者 loader。它的工作流程主要有启动加载重定位和 init 四个步骤。链接与加载还有很多细节但我已经带你建立起了基本的知识框架。如果对链接和加载还有更浓厚的兴趣你可以参考《程序员的自我修养 》《链接器和加载器 》等书以便了解更多的相关结构和算法。