动态链接通过 GOT 表加一层间接跳转的方式,解决了代码中 call 指令对绝对地址的依赖,从而实现了 PIC 的能力,但这样也带来了性能上的牺牲。
- 每次对全局符号的访问都要转换为对 GOT 表的访问,然后进行间接寻址,这必然要比直接的地址访问速度慢很多;
- 动态链接和静态链接的区别是将链接中重定位的过程推迟到程序加载时进行。因此在程序启动的时候,动态链接器需要对整个进程中依赖的 .so 进行加载和链接,也就是对进程所有相关 GOT 表中的引用符号进行解析重定位,这样就导致了程序在启动过程中速度的减慢。
通过延迟绑定技术,可以解决动态链接性能下降的问题。延迟绑定不仅仅是用在动态链接中,还被广泛地应用在 Hotspot,V8 等带有即时编译功能的虚拟机中。另外,在游戏行业,修复服务器的错误的同时保证用户不掉线是硬需求,这种不停机进行代码修复的技术被称为热更新技术。
pach code 技术
先来看看延迟绑定的最简单的形式,也就是 Hotspot 虚拟机中的运行时重定位技术 patch code。
在 Java 语言中,类是按需加载的。也就是对于一个 class 文件,只有当 hotspot 第一次使用它的时候,它才会被加载进来。假设在即时编译 A 方法的时候要调用 B 方法,但这时 B 方法还没有被加载进来,该怎么办呢?虚拟机会采用一种叫做 patch code 的技术,在运行时再进行加载。
简单地说,就是在生成 call 指令时候,它的目标地址填成一个虚拟机内部的用于解析符号的方法。在 CPU 执行这条 call 语句的时候,就会调用符号解析函数。此时虚拟机就会加载 B 方法所在的类,然后就能确定 B 方法的地址了,这时再把 B 方法的地址写回到 call 指令里。这个过程如下图所示:
这个过程很像是在给原始的代码打补丁,所以就把这种方式称为 patch code 技术。这就像是在原来的代码安装了一个机关,当 CPU 执行到这个机关时,就会触发一次符号的重定位,然后这个机关就被替换掉了。下一次 CPU 再执行到这个 call 指令的时候,就可以正常地调用到 B 方法了。这种技术把解析符号的过程往后推到了执行代码时解析。
在 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 函数的调用转成了对 B@plt 的调用。
B@plt 函数只有三条指令。第一条指令 jmp (GOT[3]) 是一个间接跳转,跳转的目标是 GOT 表偏移为 0x18 的位置,正常情况下,这个位置应该放的是 B 函数的真实地址。但现在填入的是指向了 B@plt + 0x6 的位置,这是为了传递参数给 _dl_runtime_resolve 函数。B@plt+0x6 的位置其实就是 B@plt 函数的第二条指令,它的作用是将函数参数入栈,然后执行第三条指令 jmp .plt 再准备第二个参数以及执行符号解析函数。
在序号①箭头的位置,也就是第一级跳转,它的目的是把参数 0 入栈。由于 GOT 表的 0x0,0x8,0x10 的位置都被占用了,所以参数 0 代表的就是 0x18 位置,这就是 B 函数的真实地址应该存放的地方;然后在序号②箭头的位置,发生了第二级跳转,这一次是为了把动态库的 ID 号压栈传参;最后在序号③箭头的位置,继续进行第三级跳转,这一次跳转才真正地调用到了 _dl_runtime_resolve。调用完这个方法以后,B 函数的真实地址就会被填入 GOT 表中了。
这样的跳转虽然麻烦,但有一个非常重要的优点,就是*运行期间不会修改代码段的指令,所有的修改只涉及了 GOT 这个位于数据段的表里。.code 和 .plt 会被加载到内存的代码段 (code segment),它的权限是可读可执行,但不可写;.got 会被加载进数据段,它的权限是可读可写。对于 B 函数的调用,多级跳转的延迟绑定技术的整个重定位过程最终只会修改 GOT 的 0x18 这一个位置,其他位置都不必发生变化。
当执行完了重定位过程以后,CPU 再一次运行到 main 里的 call 指令时,就能通过一次跳转就调用到真正的 B 函数了,这时的 GOT 已经与加载时重定位后的 GOT 一模一样了。如下图所示:
在这个图里,重定位完以后,只有红色字体的代码和数据是起作用的,.plt 段里的其他代码就被“短路”掉了。只有用到的符号才会被重定位,这就是延迟绑定技术。未被用到的符号在加载时被重定位,这是一种浪费,而延迟绑定技术避免了这种浪费。
延迟绑定技术的具体实现
// foo.cstatic 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;}
$ gcc foo.c -fPIC -shared -o libfoo.so
去掉了 -fno-plt 的编译选项,这样可以打开 PLT 表的生成;对于不需要依赖其他动态库的动态链接库,就不需要位置无关代码(-fPIC)
通过反汇编来看一下 demo 函数的汇编指令:
00000000000006a0 <demo>:...6fd: e8 7e fe ff ff callq 580 <global_func@plt>702: 01 45 fc add %eax,-0x4(%rbp)705: b8 00 00 00 00 mov $0x0,%eax70a: e8 81 fe ff ff callq 590 <extern_func@plt>70f: 01 45 fc add %eax,-0x4(%rbp)712: 8b 45 fc mov -0x4(%rbp),%eax715: c9 leaveq716: c3 retq
如上述汇编指令所示,对函数 global_func 和 extern_func 的调用都变成了对 global_func@plt 和 extern_func@plt 的调用。继续查看这两个带 @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_func@plt>:580: ff 25 92 0a 20 00 jmpq *0x200a92(%rip) # 201018 <global_func+0x200983>586: 68 00 00 00 00 pushq $0x0 // 该符号在 got 表中的序号58b: e9 e0 ff ff ff jmpq 570 <.plt>0000000000000590 <extern_func@plt>:590: ff 25 8a 0a 20 00 jmpq *0x200a8a(%rip) # 201020 <extern_func>596: 68 01 00 00 00 pushq $0x1 // 该符号在 got 表中的序号59b: e9 d0 ff ff ff jmpq 570 <.plt>
这段汇编是对 libfoo.so 中 .plt 段的反汇编。从这里可以看出来,PLT 表的每一项其实都是一段相似的 stub 代码构成。从反汇编的结果来看,global_func@plt 的第一行是一个间接跳转,跳转的目标地址存储在 0x201018 这个位置,通过 objdump 可以找到这个位置位于.got.plt 段里。0x201018 位置存放的值是 0x586,通过该值就跳回到 global_func@plt 里继续执行了,这就是上面所分析的一级跳,是为了传递参数给符号解析函数的。最终经过传参,跳转,控制流才终于进入到 dl_runtime_resolve 中解析符号并做重定位。
这里的 .got.plt 段就是图中的 .got 段,存放的是 PLT 表的 GOT 表。
回顾一下延迟绑定的整个过程。
- 当 demo 函数想要调用 global_func 的时候,程序调用先进入 global_func@plt 中;
- 在 global_func@plt 中,会先执行 jmpq *GOT.PLT[3] ,此时 GOT.PLT[3] 里存放的是 global_func@plt 项中的第二条指令,因此控制流继续返回到 global_func@plt 中进行执行;
- 接下会把数值 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_func@plt 中第一条指令 jmpq *GOT.PLT[3] 直接跳转到 global_func 的真实地址中。
Loader 的加载机制
在 Linux 下,编译一个最简单的可执行程序,通过 ldd a.out 命令可以发现有一个特殊的共享库文件:ld-linux-x86-64.so。动态链接会把不同模块之间符号重定位的操作,推迟到程序运行的时候,而 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 函数。启动动态链接器
ld-linux.so 在启动之后,首先需要完成自己的符号解析和重定位的过程,这个过程叫做动态链接器的自举 (Bootstrap)。ld-linux.so 中的整个自举过程的代码是需要非常小心翼翼的,因为此时 ld-linux.so 本身的 GOT/PLT 信息都未完成,所以在自举过程中的代码不能使用全局符号和外部符号,稍有不慎就会导致整个程序崩溃。加载依赖共享文件
完成自举后,ld-linux.so 就可以放心的使用各种全局符号和外部符号了。接下来第二步是根据可执行文件的 .dynamic 段信息依次加载程序依赖的共享库文件。程序的共享库依赖关系往往是一个图的关系,所以这里在加载共享库的过程也相当于是图遍历的过程,这里往往采用的是广度优先搜索的算法来遍历。
对于静态链接,在链接的过程中需要维护一个全局的符号表,遍历 .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。符号重定位与解析
在完成了共享文件的加载之后,全局符号表的信息就收集完成了,这时 ld-linux.so 就可以根据全局符号表和重定位表的信息依次对各个 so 和可执行文件进行重定位修正了。这个过程跟静态链接中重定位的过程类似。init 函数调用
有的 so 文件还会有.init 段,进行一些初始化函数的调用,例如 so 中全局变量的对象构造函数,或者用户自己生成在.init 段的初始化函数等。这些都会由 ld-linux.so 在最后的阶段进行一次调用。当这些完成之后,ld-linux.so 就会结束自己的使命,最终将程序的控制流转到可执行文件的入口函数中进行。
整个 Loader 加载动态链接的可执行文件流程如下图所示:
总的来说,编译器在把源代码翻译成汇编指令的过程中,由于不知道其他编译单元的符号的真实地址,在引用这些符号的时候只能使用占位符(通常是 0)来代替。这些占位符由链接器填充。当链接器把所有的符号的位置都确定好以后,再把真实地址回填到占位符里,这个过程就是重定位。重定位的时机有三个,分别是编译期重定位,加载期和运行时重定位。
