
在现代计算中,多个程序之间共享通用代码并非奢侈品,而是提升效率的必需品。传统方法,即静态链接,会导致可执行文件臃肿和内存浪费,因为每个程序都包含其自身所需的所有库的副本。而动态链接作为解决方案,允许程序在内存中共享库的单个副本。然而,这引入了一个重大挑战:当一个函数的地址在编译时未知,并在运行时为安全起见而随机化时,程序如何调用该函数?本文旨在解决这一根本问题。
本文将深入探讨延迟绑定这一优雅的解决方案,它是现代操作系统的基石。首先,在“原理与机制”一章中,您将学习全局偏移表 (GOT) 和过程链接表 (PLT) 的组合如何创建一个间接层来解决地址问题。然后,我们将揭示延迟绑定这一巧妙的优化,它将地址解析工作推迟到最后一刻,以最大限度地缩短程序启动时间。随后,“应用与跨学科联系”一章将探讨这种“即时”理念的深远影响,考察其在操作系统、编译器设计以及 C++ 和 JavaScript 等高级语言实现中的作用。
想象一下你正在盖房子。每次需要钉子时,你不是去五金店,而是在后院建一个小熔炉,从头开始制造一颗。需要窗户时,你就建一个玻璃厂。这听起来很荒谬,不是吗?然而在很长一段时间里,我们就是这样构建计算机程序的。每个程序都是一个庞大的、自给自足的宇宙。如果你的计算器程序需要一个将文本打印到屏幕的函数,该函数的代码会直接被烘焙到可执行文件中。如果你的文字处理器需要完全相同的函数,它也会得到自己的一份一模一样的副本。
这被称为静态链接,它极其浪费。你的硬盘上最终会散落着几十甚至上百份相同的通用代码副本——用于打印、数学计算、网络通信等。更糟糕的是,当你运行这些程序时,每个程序都会将其私有副本加载到内存中。十个程序都使用同一个库,意味着该库的代码会占用十份宝贵的物理 RAM。这个数字并非微不足道;从静态链接转向动态链接可以大幅减少磁盘占用和内存开销,有时甚至能减少一半或更多。
显而易见的解决方案是软件世界的“公共图书馆”:共享库。我们可以在磁盘上保留一份库的中央副本(如 libmath.so 或 libc.so),当任何程序需要它时,操作系统的加载器可以将这唯一的副本映射到内存中供大家共享。这就是动态链接的核心思想。
但这个绝妙的想法立刻遇到了一个深层次的问题:地址难题。当你编译程序时,它完全不知道那个共享库在运行时会位于其虚拟地址空间的哪个位置。程序 A 可能会在地址 加载 libmath.so,而程序 B 则可能在 加载它。更有趣的是,现代操作系统采用了一种名为地址空间布局随机化 (ASLR) 的安全特性。ASLR 会在每次程序运行时,故意打乱库(以及其他内存区域)的基地址。这就像一个老练的牌手不断洗牌,以防作弊者知道任何一张牌的位置。这使得攻击者更难利用与内存相关的漏洞。
所以,我们的挑战是:如果 foo() 函数的地址不仅在编译时未知,而且每次程序运行时都会被主动、随机地改变,你的程序如何调用共享库中的 foo() 函数?
一种幼稚的方法可能会说:“让加载器来处理吧!”当程序启动时,加载器知道它放置库的随机基地址。理论上,它可以扫描你程序的机器码,找到每一条调用外部函数的指令,并用新计算出的正确绝对地址来修补该指令。这被称为代码重定位。
温和地说,这个想法是一场灾难。
首先,它完全破坏了共享的好处。如果加载器修补了程序 A 的代码副本,那么该代码现在就为程序 A 定制化了。它无法与需要不同补丁的程序 B 共享。每个程序都需要自己在物理内存中私有的、被修改过的代码副本,我们又回到了试图摆脱的浪费状态。
其次,这是一个安全噩梦。为了让加载器修补代码,包含该代码的内存页必须是可写的。但现代系统的一个基本安全原则是 W^X (Write XOR Execute),即可写与可执行异或。一个内存页可以是可写的,也可以是可执行的,但绝不应同时两者皆是。允许在运行时写入代码会打开一个巨大的攻击面。
因此,我们必须将代码段视为神圣的:一旦加载,就只读且不可变。我们需要一个更好的方法。
解决方案是一个精妙的技巧,是现代系统编程的基石。其核心思想是:如果我们不能改变代码,就必须通过数据增加一个间接层,而数据是允许被改变的。
你的程序不再试图直接调用 foo(),而是会查阅一份由加载器准备的特殊指南——一个地址表。这便将不可变的代码与其依赖的可变地址分离开来。该方案有两个关键组件:全局偏移表 (GOT) 和过程链接表 (PLT)。
想象一下 GOT 是你程序数据段内的一本小小的电话号码簿。对于你的程序需要的每个外部函数或变量,这本簿子里都有一个条目。在编译时,这个条目是空的。当你的程序启动时,加载器扮演一个乐于助人的接线员角色。它查找所有函数的真实、随机化的运行时地址,并将它们填入你的 GOT 中。这是一个对数据段的写入操作,完全安全,不会违反 W^X 原则。你程序的代码保持原封不动。
但还有一个小问题。用于函数调用的机器码指令期望得到的是其他代码的地址,而不是一个电话簿条目的地址。所以我们增加了最后一个微小的跳板:过程链接表 (PLT)。PLT 是一系列微小的可执行代码存根 (stub) 的集合,每个外部函数对应一个。当你的编译器看到 call foo() 时,它实际上生成的是 call foo@plt。foo@plt 存根非常简单,它所做的只是跳转到存储在 GOT 中 foo 条目里的地址。
所以完整的流程是:
call 调用。(不可变代码)jump。(不可变代码)这种安排堪称杰作。代码可以完全是位置无关的(位置无关代码,即 PIC),通过巧妙的相对寻址找到自己的 GOT,并且可以被上千个进程共享。所有杂乱的、与地址相关的工作都被限制在每个进程的一个小小的私有数据表中。
PLT/GOT 机制已经非常出色,但它还可以被做得更好。考虑一个像网络浏览器这样的大型应用程序。它可能链接了包含数千个函数的库。但在一次典型的会话中,你可能只使用其中几百个。在启动时解析每一个可能的函数地址——这个过程称为即时绑定——会显著减慢程序的启动时间。
为什么要为那些可能永远不会被调用的函数预先做所有这些工作呢?这个问题引出了最终的优化:延迟绑定。
延迟绑定的机制是一出令人叹为观止的计算机科学戏剧。这出戏是这样上演的:
布局: 程序启动时,动态加载器不会解析任何函数地址。相反,对于 GOT 中的每个函数条目,它都写入一个特殊的辅助例程的地址:动态解析器。
第一幕: 你的程序运行,并首次调用 foo()。调用会转到 foo@plt 存根。存根跳转到 GOT 中的地址。但那个地址不是 foo() 的——而是解析器的!
解析器的独白: 控制权现在转移到了动态加载器的解析器。它检查请求的是哪个函数,并执行一次性任务:在共享库中搜索 foo() 的真实地址。
神奇转折: 这是关键部分。在跳转到 foo() 之前,解析器对程序的数据执行了一次自我修改。它覆盖了 foo 的 GOT 条目,用 foo() 的真实地址替换了它自己的地址。
终场: 解析器随后跳转到真实的 foo(),你的函数调用按预期完成。对你的程序来说,这看起来只是第一次调用多花了一点时间。
返场: 现在,当你的程序第二次调用 foo() 时,这出戏就短得多了。call 指令转到 foo@plt。存根跳转到 GOT 中的地址。但这一次,GOT 条目里存放的是 foo() 的真实地址。调用直接进行,只有一个额外跳转的微小开销。解析器再也不会因为这个函数而被惊动了。
这就是延迟绑定。它通过将符号解析的工作推迟到绝对必要时才进行,从而最大限度地降低了启动成本。我们可以通过跟踪程序执行来观察这一过程:对库函数的第一次调用会触发一系列活动和轻微的页错误,因为解析器的代码和数据首次被触及,但后续调用则悄无声息。性能上的权衡很明确:用更快的启动速度换取每个函数首次使用时的一个小的、一次性的性能损失。作为一个额外的、不那么明显的优点,通过仅在需要时才暴露绝对地址,延迟绑定甚至可以减少关于随机化内存布局的信息泄露,从而增强安全性。
PLT、GOT 和解析器之间这种错综复杂的协作不仅仅是一项优化,它还是实现惊人灵活性的基础。因为解析发生在运行时,所以它可以被拦截。
最著名的例子是 [LD_PRELOAD](/sciencepedia/feynman/keyword/ld_preload),这个机制允许你将自己的共享库注入到一个程序的进程中。如果你预加载的库提供了一个与另一个库中函数同名的函数,动态加载器的搜索会首先找到你的版本。它会很乐意地修补程序的 GOT,使其指向你的代码。这种技术被称为符号介入 (symbol interposition),对于调试、监控和扩展那些你没有源代码的程序的功能来说,功能极为强大。你甚至可以链接调用,让你的介入函数做一些工作,然后使用一个特殊的句柄 RTLD_NEXT 调用原始函数。
当然,懒惰并非总是美德。对于那些对可预测性能至关重要的应用程序来说,首次函数调用带来的微小、不可预测的停顿可能是不可接受的。对于安全加固的环境,你可能希望在启动后锁定 GOT 以防止任何进一步的修改。对于这些情况,系统提供了一个关闭懒惰的“开关”。通过设置像 LD_BIND_NOW=1 这样的环境变量或使用特定的链接器标志 (-z now),你可以指示加载器执行即时绑定:在启动时解析所有符号,然后使 GOT 只读。这让开发者能够在启动速度与运行时可预测性和安全性之间进行精细的权衡控制。
这整个系统,从位置无关代码到延迟绑定的优雅机制,是杰出设计的明证。它解决了在安全高效的环境中共享代码的根本挑战,并且其鲁棒性足以处理像库之间循环依赖这样的复杂边缘情况而不会出错。它是使现代计算成为可能的、静谧而美丽的工程交响曲之一。
我们花了一些时间来理解延迟绑定的巧妙机制——过程链接表、全局偏移表,以及与动态链接器的协作。这是一项美妙的工程杰作。但要真正领会其天才之处,我们必须观察它的实际应用。这种“即时”工作的理念究竟出现在哪里?答案是,无处不在。
这种拖延的原则,即将工作推迟到最后一刻的原则,并不仅仅是一个小众的优化。它是一个基本模式,在现代计算的几乎每一层中都有回响。它代表了一种权衡,一种在准备与敏捷、效率与灵活性之间达成的妥协。让我们在软件世界中走一遭,看看延迟绑定在一些意想不到的地方留下的印记。
我们遇到延迟绑定最常见的地方,或许就是启动应用程序或计算机的时候。在静态链接的旧时代,每个程序都是一个自成一体的庞然大物,携带了它所需的所有库的副本。这虽然简单,但极其浪费。如今,动态链接使得像标准 C 库这样的通用库能够被存储一次,并由数百个程序共享。这节省了大量的磁盘空间和内存。
但这种效率是有前期成本的。当你启动一个应用程序时,动态链接器必须被唤醒并执行一系列活动:找到所需的共享库,将它们加载到内存中,并解析程序需要的符号。即使有延迟绑定(它推迟了函数地址的查找),仍然有大量的初始工作需要完成。这会增加应用程序的启动时间。在一个像现代桌面操作系统这样的复杂系统中,这个初始链接过程可能是启动序列中一个明显的部分,因为最早的用户空间程序需要在系统其余部分启动之前链接到系统库。
在嵌入式系统的世界里,这种权衡变得更加戏剧化。想象一个智能恒温器或数码相机。这些设备的非易失性闪存容量有限,通常很小。将每个应用程序模块与其自己的库副本进行静态链接,很容易就会耗尽这宝贵的资源。在这里,动态链接不仅仅是为了方便;它可能成为使设备拥有丰富功能集的关键技术。通过只存储一次库,工程师可以节省大量空间。当然,代价是更长的启动时间,因为设备在开机时必须执行重定位。对于某些设备来说,这种延迟是可以接受的;而对于另一些设备,这是一个关键的设计约束。
现在,让我们把这个问题推向极致:一个硬实时系统,比如飞机的飞行控制器或汽车的安全系统。在这些系统中,正确性不仅仅是得到正确的答案,而是在每一次都在正确的时间得到它。错过截止时间不是一个小故障,而是一场灾难性的失败。在这里,延迟绑定的首次调用解析所引入的“微小延迟”可能是灾难性的。动态链接器所做的工作,特别是如果它涉及到获取锁,可能会创建一个不可抢占的代码段。这意味着一个低优先级的任务(如维护加载器)可能会阻塞一个高优先级的、时间关键的控制任务运行,导致其错过截止时间——这是一种被称为优先级反转的危险情况。由于这种不确定性,许多实时系统完全禁止动态链接,宁愿选择静态链接的可预测性,即使这意味着更大的代码体积 [@problem_-id:3676022]。
让我们换个角色,像编译器一样思考。编译器的任务是将人类可读的源代码翻译成最快、最高效的机器码。为了做好这一点,编译器希望尽可能多地了解整个程序——一种“封闭世界”假设。它喜欢证明诸如“这个函数总是返回数字 5”之类的事情,这样它就可以用常量 5 替换对该函数的调用,从而节省函数调用的开销。
动态链接打破了这个封闭世界。当一个可执行文件与一个共享库链接时,编译器被迫在一个“开放世界”中操作。它再也无法确定实际运行的代码是什么。共享库是一个黑盒子,其内容只在运行时才最终确定。事实上,在许多系统上,用户可以使用像 [LD_PRELOAD](/sciencepedia/feynman/keyword/ld_preload) 这样的环境变量来强制程序在运行时加载一个不同的兼容库!
这对优化产生了深远的影响。一个正在编译你的可执行文件的优化器可能会看到 libmath.so 的 get_pi() 函数返回 。它可能会很想用这个常量替换所有对 get_pi() 的调用。但这是一个非法的转换!在运行时,用户可以提供一个不同的 libmath.so,其中 get_pi() 返回一个更精确的值,或者干脆从文件中读取一个值。原来的优化将是不正确的。共享库的应用程序编程接口 (API) 成了一道神圣的边界,一道编译器无法逾越的墙。跨越这道边界,所有的假设都必须是保守的,像跨模块常量传播这样的优化通常是不安全的。
即使是看似简单的性能调整也会撞上这堵墙。对动态链接函数的标准调用涉及一次到 PLT 的跳转,然后 PLT 再使用 GOT 中的地址执行另一次跳转——一个两步过程。一些编译器提供了一个选项(如 -fno-plt),以生成直接从 GOT 加载地址到寄存器然后进行单次间接调用的代码,这可能会稍快一些。但即使是这个巧妙的技巧也无法启用最强大的优化:内联。因为函数的代码体在一个独立的、可替换的模块中,编译器根本无法在不违反动态链接基本契约的情况下将其代码粘贴到调用点。
延迟绑定的原则是如此基础,以至于它们不仅出现在系统层面,还深深地嵌入在编程语言本身的实现中。
考虑一个进行虚函数调用的 C++ 程序。这本身就是一种后期绑定:程序在运行时在对象的虚函数表 (vtable) 中查找要调用的正确方法。那么,如果那个虚方法定义在一个单独的共享库中会发生什么呢?系统将一层间接寻址叠加在另一层之上。虚函数调用首先从对象中读取 vtable 指针,然后从 vtable 中读取函数指针。但这个函数指针并不指向最终的方法,它指向一个 PLT 存根!然后 PLT 存根从 GOT 中读取真实的函数地址,最后进行跳转。这是一条间接寻址链——从对象到 vtable,从 vtable 到 PLT,从 PLT 到 GOT,最后从 GOT 到代码——每一层都以一点开销换取一种强大的灵活性。
当我们转向像 Python、Ruby 或 JavaScript 这样更动态的语言时,“延迟”变得更加明显。这些语言的运行时经常需要调用来自系统库的本地 C 函数。它们是如何做到的呢?它们实际上为自己重新发明了 PLT 和 GOT 机制。一个即时 (JIT) 编译器,当它第一次遇到对本地函数的调用时,会生成一小段称为“蹦床 (trampoline)”的代码。这个蹦床的任务是调用系统的 dlsym 函数来查找本地函数的地址,然后——关键地——修补自身,以便在所有后续调用中直接跳转到该地址。这种自修改代码必须小心翼翼地完成,以绕过像 W^X(防止内存同时可写和可执行)这样的现代安全特性,并在多核世界中保持线程安全。
事实上,JIT 编译器将延迟性又推进了一步。在动态语言内部,每个方法调用都是后期绑定的潜在候选者。为了使其快速,它们使用一种称为内联缓存 (IC) 的技术。在一个调用点 object.method(),JIT 编译器会做一个猜测:“下一个到达这里的对象可能会有与上一个相同的类型或‘形状’。”它生成代码来检查这个假设。如果检查通过(一个“单态 (monomorphic)”命中),它就直接跳转到缓存的目标函数。这速度极快。如果检查失败,它会回退到一个更慢、更通用的查找过程。系统甚至可以学会“多态 (polymorphically)”地处理几种不同的形状。这与延迟绑定的核心思想相同——做一个快速检查,只在“未命中”时才做缓慢、昂贵的工作——但它应用于单个调用点的粒度,而不是一个全局表。
最后,这种错综复杂的间接寻址之舞对我们程序员有着非常实际的影响。当你试图在一个被延迟绑定的函数上设置断点时,你的调试器可能要等到第一次调用解析它之后才能找到它。当你使用性能分析器时,你可能会困惑地看到时间花在了动态链接器 (ld.so) 上,而不是你的函数上。一个附加在函数入口点的探针与一个附加在其 PLT 条目上的探针的行为将大相径庭。理解延迟绑定揭开了这种行为的神秘面纱,让我们更清楚地了解我们的程序真正在做什么,尤其是在使用像 dlopen 这样的机制动态加载插件的复杂应用程序中。
从操作系统的启动到一行 JavaScript 代码,延迟绑定的原则证明了一个简单思想的力量。它是在性能与灵活性、编译时确定性与运行时可能性之间不断的协商。通过选择等待,我们的系统获得了适应、共享和以其他方式不可能实现的方式成长的能力。而这其中,蕴含着一种美妙的智慧。