现代C:程序可以在运行时进行链接吗?
引言在上一讲中我介绍了有关 Linux 下静态链接的内容。而这一讲我们将继续程序的“链接”之旅来看看我之前提到的另外两种链接类型加载时链接与运行时链接。实际上加载时链接与运行时链接均可归为动态链接只是在这两种方式中程序进行链接的具体时刻有所不同。其中加载时链接发生在程序代码被真正执行之前而运行时链接则可发生在程序运行过程中的任意时刻。为什么要使用动态链接在上一讲中我已经简单介绍了静态链接与动态链接两者的区别。其实动态链接技术出现的最重要目的便是为了解决静态链接具有的一些明显缺点。试想假设一个应用程序依赖于多个第三方模块提供的函数实现而这些模块均以静态库包含有多个目标文件的方式提供。那么每次想要使用它们的最新版本时我们都需要显式地将程序与它们重新进行链接。对于大多数普通的应用使用者来说这个过程所花费的成本当然是无法接受的。另外使用完全静态链接也会导致那些本可以被多次重用的通用功能函数无法被统一“提取出来”这便会导致程序的二进制可执行文件体积变大。并且这些通用代码的副本会随着多个进程的运行被多次加载到内存中而这也极大地浪费了宝贵的内存资源。而动态链接技术的出现便可以解决上述这些问题。能够使用动态链接加载的库被称为“共享库Shared Library”。在 Linux 中这类库文件通常以 “.so” 后缀结尾。在深入介绍动态链接的基本原理之前我们先来看看如何在真实项目中使用它。使用共享库我们能够知道动态库本身也是 ELF 格式的一种具体文件类型它对应着 elf.h 中的宏常量 ET_DYN。接下来我仍以上一讲中的两段代码为例来带你看看如何在实际项目中使用动态库。这里我们将把 sum.c 文件编译成动态库并让 main.c 对应的应用程序使用。整个过程可以分为如下几步使用命令 gcc sum.c -shared -fPIC -o libsum.so 将文件 sum.c 编译成名为 libsum.so 的动态库文件。这一步中使用的参数 “-shared” 表明创建一个动态库参数 “-fPIC” 表明生成“位置无关代码”。关于这个选项的详细用途我会稍后为你介绍。使用命令 gcc -o main main.c -lsum -L. 编译应用程序。这里我们将 main.c 与第一步生成的 libsum.so 共享库放在一起编译。命令中参数 “-L.” 可用于为编译器指定更多的共享库查找目录这里我们为其添加了 libsum.so 的所在目录参数 “-l” 则用于指定需要参与编译的共享库通过指定名称 “sum”编译器会自动使用搜索到的合法的 libsum.so 文件。使用命令 export LD_LIBRARY_PATH.:$LD_LIBRARY_PATH 设置动态链接器在查找相关动态库时的位置。顾名思义动态链接器是一段在程序运行时用于帮助其查找所需共享库的代码。它在查找指定共享库文件时会按照一定顺序从多个不同位置进行查找。而这里通过 LD_LIBRARY_PATH 环境变量指定的位置便是其中一个。使用命令 ./main 运行程序。需要注意的是除了可以使用上述第三步介绍的“修改 LD_LIBRARY_PATH 变量”的方式来指定共享库的运行时查找目录外我们还可以使用 rpath 和 ldconfig 这两种方式。它们分别通过“将动态库所在路径嵌入到可执行文件”以及“将共享库安装到当前系统可访问的全局环境中”这两种方式使得对应的共享库可以顺利地被动态链接器查找。这里你可以直接点击对应的链接来了解更多信息。而为了让共享库真正地做到“可以被多个进程共享”我们便需要让它的代码成为“位置无关代码”。下面我们来看看这个概念。位置无关代码位置无关代码Position Independent CodePIC是一类特殊的机器代码这些代码在使用时可以被放置在每个进程 VAS 中的任意位置而无需链接器对它内部引用的地址进行重定位。大多数现代 C 编译器在编译源代码时均会默认产生这种 PIC 代码而无需用户显式指定。当然为了以防万一你也可以通过添加 “-fPIC” 等参数的方式来明确指出。通常来说我们可以将模块可以理解为独立的应用程序或共享库之间的数据引用分为四种方式模块内部的函数调用模块内部的数据访问模块之间的函数调用模块之间的数据访问。其中模块内部的函数调用在大多数情况下可以直接以 PC-relative 的寻址方式进行因此它并不依赖于目标函数在整个进程 VAS 内的绝对地址。而对于模块内部的数据访问由于编译器在生成模块代码时其 .data 与 .text 两个 Section 之间的相对位置是固定的数据的访问也可以使用稳定的相对地址进行。总的来看发生在模块内部的数据或函数资源引用都不会因为模块代码被加载到进程 VAS 的不同地址而受到影响。但对于不同模块之间来说事情就变得复杂了起来。来看一个简单的例子。假设有一个共享库 M它在内部的某个函数需要引用由应用程序定义的某个全局变量。而此时程序 A 与 B 都想使用 M 中的这个函数。但相关的共享库代码引用处以及程序代码被引用处两者在进程 VAS 中的具体加载位置都并不确定。因此在大多数情况下两个程序对 M 中该变量引用地址的重定位修改值也并不相同。而这便会导致它们无法真正地共享同一份物理内存中模块 M 的代码。PIC 的出现使得共享库代码可以做到真正地被多个进程复用它利用了一个很简单的思想即“将易变的部分抽离到进程独享的可修改内存中”。而为了做到这一点编译器需要为各个模块添加额外的 Section 结构这就是我接下来要讲的“全局偏移表”。全局偏移表全局偏移表Global Offset TableGOT是位于每个模块 Data Segment 起始位置处的一个特殊表结构其内部的每个表项中都存放有一个地址信息。而这些地址便分别对应于被当前模块引用的外部函数或变量在进程 VAS 中的实际地址。模块在被编译时其 Text Segment 与 GOT 之间的相对距离是能够计算出来的。因此编译器可以利用这一点来让代码直接引用 GOT 中的某个表项。同时编译器还会为这些表项生成相应的重定位记录。这样当程序被加载进内存时动态链接器就可以根据实际情况通过修正 GOT 表项中的值来做到间接修正代码中对应符号的实际引用地址。你可以通过下图来直观地感受这个流程但需要注意的是并非所有编译器都会通过 GOT 来间接引用模块使用到的所有全局变量。为了优化程序在某些特殊场景下的性能编译器可能还会采用 Copy Relocation 等方式来实现同样的效果。但对于外部函数的调用来说GOT 在整个过程中仍然扮演着十分重要的角色。过程链接表虽然我们可以让动态链接器在程序加载时将其代码中使用到的所有外部符号地址更新在相应的 GOT 表项中但当程序依赖的外部符号越来越多时重定位的成本也会越来越高。而这便会导致程序初次运行时的“启动延迟”逐渐变大甚至影响到程序正常功能的运作。为了解决这个问题编译器为模块另外添加了名为“过程链接表Procedure Linkage TablePLT”的 Section 结构。该表将协同 GOT一起进行针对函数符号地址的“延迟绑定”。PLT 是位于 Text Segment 中的一个表结构其内部同样由众多表项组成。每个表项中都有着一段特殊的机器代码用于完成相应任务。其中PLT[0]即 PLT 中的第一个表项其他写法依此类推较为特殊它内部存放的代码专门用于调用动态链接器。而其他表项中则依次存放着用于完成用户函数调用过程的相关代码。这些表项的地址将被程序中的 call 指令直接使用。除此之外在 ELF 文件中GOT 对应的整个 Section 实际上被划分为更细致的 .got 与 .got.plt 两个部分。其中前者主要用于保存相关全局变量的地址信息而后者则主要参与到函数符号的延迟绑定过程中。.got.plt 中的前三个表项具有特殊意义它们保存的具体内容描述如下第一个表项中保存的是 .dynamic 的地址。这个 Section 中保存了动态链接器需要使用的一些信息第二个表项中保存的是当前模块的描述符 ID第三个表项中保存的是函数 _dl_runtime_resolve 的地址。该函数由操作系统的运行时环境提供它将参与到 GOT 的运行时重定位过程中。接下来我们详细看看延迟绑定的具体执行过程。这里我将以上面“使用共享库”小节中共享库里 sum 函数的调用过程为例来进行介绍。你可以先看看下面的图片对整体流程有个大致的感知然后跟我具体来看每个步骤。sum 函数的初次调用过程可以分为四步程序通过 call 指令调用对应于 sum 函数的 PLT 表项中的代码该表项中的第一行代码位于 0x400560会通过 .got.plt 的第四个表项中的值进行间接跳转。该表项对应于函数 sum 的真实地址但在第一次访问时其值为对应 PLT 表项中第二条指令的地址即 0x400566push 指令位于 0x400566将 sum 函数的 ID 压入栈中。通过 jmp 指令位于 0x40056b程序跳转到 PLT[0]push 指令位于 0x400550将 GOT[1] 中存放的模块描述符 ID 压入栈中然后通过 jmp 指令位于 0x400556跳转到 GOT[2] 中存放的 _dl_runtime_resolve 函数的所在地址。该函数会使用当前存放于栈上的两个参数来完成 sum 函数在 GOT 中的重定位。最后它会将执行流程重新转移至 sum 函数内部。至此sum 函数的第一次执行便结束了。而在经过上述这一系列步骤后sum 函数在整个进程 VAS 中的真实地址便已经被更新到了对应的 GOT 表项中。因此当它被再次访问时程序仅通过以下这两个步骤便可完成调用程序通过 call 指令调用 sum 函数对应 PLT 表项中的第一行代码位于 0x400560该行 jmp 指令通过 sum 函数在 GOT 对应表项中已经修正的地址间接跳转到该函数的第一行代码。以上便是 sum 函数初次进行地址延迟绑定以及再次访问时的整体流程。到这里我已经为你介绍了动态链接的基本实现方式下面我们来看看基于此进行的加载时链接与运行时链接这两者的主要区别。加载时链接实际上加载时链接作为动态链接的一种具体类型便是基于我上面介绍的 GOT 与 PLT 两个表结构进行的。它的一个最主要特征是动态链接器进行的符号重定位过程发生在程序代码被真正执行之前。而为了做到这一点操作系统执行应用程序的具体步骤也发生了改变。操作系统内核在将应用程序装载到内存后会根据其具体 ELF 类型的不同来选择不同的处理方式。对于采用完全静态链接的可执行文件来说内核会将控制权直接转移给应用程序并执行其 Text Segment 中的入口代码。而对于使用了动态链接的可执行文件来说在执行程序代码前内核会首先根据名为 .interp 的 Section 中的内容将相应的动态链接器共享库ld.so映射至进程的 VAS 中并同时将控制权转移给它。动态链接器在执行过程中会通过其自身 .dynamic 中记录的信息来完成对自己的重定位工作。接着通过访问应用程序的 .dynamic动态链接器可以获得它依赖的所有外部共享库并在此基础之上完成对整个程序的动态链接过程。运行时链接顾名思义运行时链接即符号的重定位发生在程序的运行过程中。这种方式有时也被称为“动态载入”或“运行时加载”它的基本原理与正常的动态链接完全一致只是链接的发生过程被推迟到了程序运行时。通过这种方式程序可以自由选择想要加载的共享库模块并在不使用时及时卸载程序的模块化组织变得更加灵活。运行时链接主要通过由动态链接器提供的四个 API即 dlopen、dlsym、dlerror以及 dlclose 来实现。来看一个简单的例子#include stdio.h #include stdlib.h #include dlfcn.h typedef double (*cos_t)(double); int main(void) { cos_t cosine; char *error; void* handle dlopen(libm.so.6, RTLD_LAZY); if (!handle) { fprintf(stderr, %s\n, dlerror()); exit(EXIT_FAILURE); } dlerror(); cosine (cos_t) dlsym(handle, cos); error dlerror(); if (error ! NULL) { fprintf(stderr, %s\n, error); exit(EXIT_FAILURE); } printf(%f\n, (*cosine)(2.0)); dlclose(handle); return 0; }这段代码的逻辑十分简单。我们通过“运行时链接”的方式在程序的运行过程中从共享库文件 libm.so.6 内部加载了函数 cos。而在程序最后我们使用实参 2.0 调用了这个函数并打印出了执行结果。这里函数 dlopen 用于打开一个指定的共享库。通过它的第二个参数我们能够指定符号重定位的具体执行方式。这里的 RTLD_LAZY 表示延迟绑定即动态链接器仅会在特定函数被调用时才对其使用到的相关符号进行解析。函数 dlsym 则用于从一个打开的共享库实例中获取某个具体符号的地址。而在此之后我们便能够以函数指针的形式对它进行调用。最后当共享库使用完毕通过 dlclose 函数我们可以减少共享库实例的被引用次数。而当该次数变为 0且共享库对象中的符号没有被其他对象引用时该共享库对应内存便会从当前进程的 VAS 中被卸载卸载的具体时机则由操作系统决定。在上述整个流程中我们可以使用 dlerror 函数随时获取 dlopen API 函数在执行过程中产生的错误诊断信息。总结这一讲我主要为你介绍了动态链接的基本实现方式和基于此进行的加载时链接与运行时链接这两者的主要区别。动态链接利用 GOT将需要重定位的部分分离到所在进程的 Data Segment进而使得共享库文件可以被加载到进程 VAS 中的任意位置。在这种情况下多个进程便能够做到真正地共享同一段物理内存中的共享库代码。而为了降低程序初次执行时大量符号重定位带来的性能损耗编译器又利用名为 PLT 的表结构实现了对函数符号的延迟绑定。加载时链接是指在程序被真正执行前动态链接器会首先完成对符号的重定位过程。而运行时链接则把这个过程推迟到了程序运行过程中它的实现基于 dlopen、dlsym、dlerror以及 dlclose 等几个动态链接器函数。