try ai
科普
编辑
分享
反馈
  • 间接调用

间接调用

SciencePedia玻尔百科
核心要点
  • 间接调用带来了强大的运行时灵活性,如多态和插件,但其代价是性能可预测性和安全性的降低,这是一个根本性的权衡。
  • 其主要机制是函数指针和虚拟方法表 (vtables),它们允许程序在运行时确定函数的目标地址。
  • 在硬件层面,不可预测的间接调用会导致代价高昂的 CPU 流水线停顿,原因在于分支预测错误,从而阻碍性能。
  • 由于其目标由内存中的数据决定,间接调用是控制流劫持攻击的主要途径,需要采用控制流完整性 (CFI) 等缓解措施。
  • 编译器使用类层次结构分析 (CHA) 和快速类型分析 (RTA) 等高级静态分析技术来证明调用目标,并执行去虚拟化,将高开销的间接调用转换为高效的直接调用。

引言

在编程世界中,函数调用是将指令编织成一个连贯应用程序的线索。最直接的是直接调用,其目标在编译时已知且固定——一个从一点到另一点的简单、高效的跳转。然而,现代软件的真正力量在于其适应和扩展的能力,这种灵活性由一种更复杂的机制实现:间接调用。间接调用将决定跳转到何处的决策推迟到程序实际运行时,从而实现了多态、插件架构和动态库等优雅的功能。

然而,这种运行时的动态性并非没有代价。它引入了一个间接层,在灵活性、性能和安全性之间造成了根本性的张力。核心问题是,如果编译器和处理器不知道一个调用将去向何处,它们就无法完全优化其执行或保护其免受攻击。本文探讨了这一关键的权衡。

以下章节将引导您穿越这片复杂的领域。首先,“原理与机制”将揭示间接调用背后的机器构造,从实现它们的函数指针和虚表,到试图驯服它们的编译器分析,再到对 CPU 性能的硬件级影响。然后,“应用与跨学科关联”将审视它们的现实世界影响,探索对性能的追求、对抗 Spectre 等攻击的安全之战,以及它们在从操作系统到区块链等领域中出人意料的关联性。

原理与机制

代码的十字路口:直接调用与间接调用

想象一下你在编写一个计算机程序。程序的核心是一系列指令,但它不仅仅是一条直线。它是一个路径网络,函数调用其他函数,创建了一个复杂的交互网。最简单和最常见的交互类型是​​直接调用​​。直接调用就像给你通讯录里存了号码的朋友打电话。当你告诉手机“呼叫 Jane”时,系统确切地知道要拨打哪个号码。目标是固定的,在你编写程序时(或者用我们的比喻,在你保存联系人时)就已经确定。它快速、简单且完全可预测。当程序执行像 log() 这样的语句时,编译器知道 log 函数的精确内存地址,并且可以生成一条直接跳转到那里的指令。

但如果你事先不知道确切的目标呢?如果你希望你的程序更灵活,能根据情况调整其行为呢?这就把我们带到了​​间接调用​​这个迷人的世界。间接调用就像请求酒店礼宾员“帮我接通最好的意大利餐厅”。礼宾员作为中介,会根据他们的知识查找号码——也许是他们的推荐餐厅列表,这个列表甚至可能每天都在变化。在你提出请求时,你并不知道通话的最终目的地;它是在通话的瞬间动态确定的。

在编程中,这种动态性通常以两种主要形式出现:​​函数指针​​和​​虚方法​​(也称为​​动态分派​​)。函数指针是一个变量,它不像数字或字符串那样存储数据,而是存储一个函数的内存地址。通过函数指针的调用,如 p(),意味着“跳转到变量 p 中当前存储的任何地址”。虚方法调用,如 s->m(),是面向对象编程的基石。它意味着“调用适合于 s 所指向对象实际类型的方法 m,无论该类型是什么。”

这种在运行时决定调用目标的能力,正是实现多态、插件架构以及无数其他灵活软件设计的关键。但这种灵活性并非没有代价。它引入了一个间接层,这不仅对我们编写代码的方式,也对编译器如何理解代码以及处理器如何执行代码产生了深远的影响。要理解这一点,我们必须先一窥其内部,看看使其工作的美妙机制。

深入底层:动态分派的机制

当计算机遇到间接调用时,它是如何确定去向的呢?这个机制是一项精美的工程设计,是编译器和硬件之间达成的一种约定,称为​​应用程序二进制接口 (ABI)​​。

让我们从更简单的情况开始:​​函数指针​​。当你声明 int (*p)(int) 时,你是在告诉编译器预留一块内存(例如,在 64 位系统上是 8 字节)来存储一个函数的地址。当你的代码稍后执行像 r = (*p)(a) 这样的调用时,编译器会将其翻译成一系列机器指令,大致如下:

  1. 将参数 a 加载到为第一个整数参数指定的寄存器中(例如,在 x86-64 系统上的 EDI 寄存器)。
  2. 将存储在 p 中的 64 位内存地址加载到一个通用寄存器中(例如 RAX)。
  3. 执行一个间接调用指令,告诉 CPU 跳转到 RAX 中现在持有的地址。
  4. 在被调用的函数完成并返回后,从指定的返回值寄存器(例如 EAX)中检索结果。

​​虚方法​​的过程更为复杂,它位于面向对象编程的核心。它依赖于一个名为​​虚拟方法表​​(或 ​​vtable​​)的巧妙数据结构。你可以将 vtable 看作一个类的虚函数的目录或索引。对于每个至少有一个虚方法(如我们前面例子中的 Shape)的类,编译器会构建一个单一的、静态的 vtable。这个表是一个函数指针数组,类中的每个虚方法都对应一个条目。

至关重要的是,该类的每个对象都包含一个隐藏的指针,通常位于其最开始的位置(偏移量 0),称为​​虚表指针​​(或 vptr)。这个 vptr 指向该对象所属类的 vtable。

当你进行像 s->m() 这样的虚调用时,其中 s 是一个指向对象的指针,CPU 会执行由编译器精心编排的精确三步舞:

  1. ​​加载 vptr​​:程序首先查看 s 指向的对象内部,并从偏移量 0 处加载隐藏的 vptr。这让它获得了正确 vtable 的地址。
  2. ​​查找方法​​:编译器知道方法 m 总是对应于 vtable 中的一个特定槽位(比如说,槽位 1)。它会生成代码来从 vtable 的那个槽位加载函数指针。例如,如果一个函数指针是 8 字节,它会从 vtable_address + 1 * 8 的地址加载。
  3. ​​进行间接调用​​:最后,它调用刚才查找到的地址处的函数,并隐式地将对象自身的地址 (s) 作为隐藏的第一个参数(通常称为 this)传递。

这个序列——加载 vptr、加载函数指针、调用——是动态分派的基本成本。它比直接调用(只是一个单一的跳转)要多一些工作,但它是一个常数时间操作,却能实现令人难以置信的运行时灵活性。这个机制正是在一个 Shape* 上的调用能够根据对象的真实身份正确调用 Circle::m 或 Square::m 的原因。

对象的秘密生命:变化的身份

当我们考虑一个对象的生命周期:它的构造和析构时,vtable 机制展现出更深层、更优雅的精妙之处。想象一个 Derived 类继承自 Base 类。Derived 类重写了一个虚方法 f(),而这个重写依赖于一些仅在 Derived 自己的构造函数中初始化的数据。如果 Base 的构造函数(它首先运行)对 f() 进行了一次虚调用,会发生什么?如果它分派到 Derived::f(),那将是调用一个试图使用未初始化数据的方法——这简直是灾难的配方!。

语言和编译器必须防止这种情况。它们通过接受一个深刻的思想来做到这一点:一个对象的有效动态类型在它被构建和销毁的过程中是变化的。当 Base 构造函数运行时,该对象在所有意图和目的上都是一个 Base 对象。只有在 Base 构造函数完成并且 Derived 构造函数开始后,它才“成为”一个 Derived 对象。

编译器有两种标准方式来强制执行这一点。最常见的运行时策略是直接操作 vptr 本身。

  • 当一个 Derived 对象的构造开始时,内存被分配,并且 Base 构造函数被调用。Base 构造函数做的第一件事就是将对象的 vptr 设置为指向 ​​Base 类的 vtable​​。因此,在 Base 构造函数内部进行的任何虚调用都将正确地解析为 Base 的方法。
  • 一旦 Base 构造函数完成,控制权返回到 Derived 构造函数,后者立即将 vptr 更新为指向 ​​Derived 类的 vtable​​。现在,对象拥有了其最终身份,虚调用将分派到 Derived 的重写方法。
  • 析构过程则相反。Derived 析构函数首先运行,此时 vptr 仍然指向 Derived 的 vtable。然后,在调用 Base 析构函数之前,vptr 被“倒回”指向 Base 的 vtable,确保在 Base 析构期间的任何虚调用也是安全的。

或者,编译器可以静态地解决这个问题。当它在构造函数或析构函数中看到一个词法上写明的虚调用(例如,在 Base 构造函数中调用 f()),它知道对象此时的有效类型是 Base。因此,它可以将该调用重写为对 Base::f() 的直接、非虚调用,完全绕过 vtable 机制及其潜在的危险。这两种策略都优雅地维护了对象在其整个生命周期中的安全性和完整性。

编译器如侦探:驯服不可预测性

间接调用的强大功能给编译器带来了一个挑战:如果不知道调用的去向,它如何能推理程序的行为?对于优化和错误查找等任务,编译器需要构建一个​​调用图​​——一张描绘了哪些函数可以调用哪些其他函数的地图。这张地图必须是​​可靠的​​,意味着它必须是所有可能运行时行为的一个保守过近似。包含一些永远不会发生的潜在调用路径,比错过一个确实会发生的路径要好。

为了解开这个谜题,编译器就像一个侦探,使用静态分析技术来推断间接调用的可能目标。

  • 对于函数指针,主要工具是​​指针分析 (PTA)​​。在其较简单的形式中,这种分析是​​流不敏感的​​,意味着它忽略了操作的顺序。就好像编译器把所有的赋值语句都扔进一个袋子里,看看一个指针可能持有哪个地址。如果代码中有 p = h,并且在一个单独的分支中有 if (unknown()) { p = g },流不敏感分析会保守地得出结论,通过 p 的调用可能去到 h 或 g。

  • 对于虚方法调用,有更专门的分析。​​类层次结构分析 (CHA)​​ 是一种简单的方法,它查看对象指针的静态类型。如果它看到一个在 Shape* 上的调用,它会假设实际对象可能是整个继承自 Shape 的类层次结构中的任何类(如 Circle 或 Square)。一种更精确的技术是​​快速类型分析 (RTA)​​,它通过检查哪些类在可达程序中的任何地方被实际实例化(即,有 new 被调用)来精化 CHA。如果编译器看到 new Circle() 但从未看到 new Square(),RTA 可以证明 Shape* 不可能是 Square,从而从调用图中剪掉一条不可能的路径。

这项侦探工作的最终奖赏是​​去虚拟化​​。如果分析可以证明,对于一个特定的虚调用点,对象只有一种可能的具体类型,编译器就可以执行一个神奇的转换。它用一个简单、廉价的直接调用替换掉昂贵的、间接的虚调用(加载 vptr、加载函数指针、调用)到那个已知的唯一方法。这个优化弥合了动态多态的灵活世界和静态调用的高效世界之间的鸿沟。在像 Rust 这样的现代语言中,这种区别非常突出。泛型函数在编译时通过​​单态化​​来解析,生成带有直接调用的专门代码,从而“免费”提供性能。相比之下,trait 对象依赖于动态分派,并且需要这些强大的编译器分析才有望被去虚拟化。

有时,一个间接调用甚至可以被优化成仅仅一个跳转,这种技术称为​​尾调用优化 (TCO)​​。如果一个间接调用是函数做的最后一件事,编译器有时可以为被调用者重用当前函数的栈帧,有效地将调用变成一个 goto。调用目标是动态的这一事实本身并不会阻止这一点;它只要求没有剩余的清理工作(比如销毁局部对象)并且调用约定是兼容的。

力量的代价:芯片层面的性能

直接调用和间接调用之间的区别一直延伸到芯片层面。现代 CPU 是一个预测的奇迹,一个经过精细调校的引擎,旨在以连续、高速的流水线方式执行指令。你可以把它想象成在固定轨道上行驶的子弹头列车。一个分支指令(如调用)是轨道上的一个开关。如果 CPU 的​​分支预测器​​能在列车到达之前猜出开关会朝哪个方向转,它就能全速通过交汇点。如果猜错了——即​​预测错误​​——列车必须紧急刹车、倒车,然后走上正确的路径,浪费宝贵的时间。

直接调用是分支预测器的梦想。在第一次看到直接调用后,其固定的目标地址被存储在​​分支目标缓冲器 (BTB)​​ 中,随后对同一位置的调用几乎可以达到完美的预测准确率。然而,间接调用是一场噩梦。目标在每次执行时都可能改变。一个简单的预测器可能会使用“最后目标”方案:它只是假设这次的目标将和上次一样。

这种方法效果如何?答案,美妙地,来自信息论。一个调用点的可预测性可以用其​​香农熵​​来量化。一个低熵的调用点——绝大多数时间调用一个函数,偶尔才调用其他函数——是相当可预测的。一个高熵的调用点——以相等的概率调用许多不同的函数——是天生不可预测的。一个最后目标预测器的准确率由目标概率的平方和给出,即 ∑ipi2\sum_{i} p_i^2∑i​pi2​,其数学下界为 2−H(T)2^{-H(T)}2−H(T),其中 H(T)H(T)H(T) 是熵。一个高熵、不可预测的调用点会导致频繁的预测错误,每次都会耗费大量的时钟周期(例如 15 个周期或更多),可能严重影响性能。

虽然 CPU 还有其他技巧,比如专门的​​返回地址栈 (RAS)​​,它可以完美地预测 return 指令(除非函数的调用栈变得太深),但它无法逃脱间接调用本身固有的不确定性。这就是力量的最终代价:让我们在高级别上编写优雅、可扩展代码的动态灵活性,在芯片层面表现为熵和潜在的流水线停顿。理解间接调用就是理解一个贯穿整个计算技术栈的根本性权衡,从抽象的语言设计到具体的硬件执行。

应用与跨学科关联

间接调用就像一座宏伟建筑走廊里的一扇魔法门。与普通门不同,普通门有标签,总是通向同一个房间,而这扇魔法门没有标签。它的目的地写在穿过它的人手里的一张纸条上。这给了我们不可思议的力量。我们可以建造一条走廊,连接到任何现在或未来的房间,只需改变纸条上的地址。这就是多态、插件和动态库的核心——现代灵活软件的基础。

但这种魔法是有代价的,而且有其阴暗面。门口的人必须停下来读纸条,这会减慢他们的速度。如果为了节省时间而猜测目的地,但猜错了呢?他们必须折返,浪费更多时间。如果一个冒名顶替者把纸条换成通往地牢的纸条呢?我们的魔法门就成了一个安全噩梦。

在实践中间接调用的故事,就是一出驯服这扇魔法门的宏大戏剧。这是一段穿越计算机体系结构、编译器设计、操作系统乃至网络安全世界的旅程,我们试图驾驭它的力量,同时控制住它的两个狂野分身:性能窃贼和安全漏洞。

追求速度:驯服性能猛兽

处理器的流水线就像一条装配线;当下一步骤被提前知晓时,它工作得最好。间接调用是一个意外,是流水线中的一次中断。处理器必须停下来,读取目标地址,然后重新启动流程。现代处理器试图通过猜测目的地来变得聪明——这种技术被称为分支预测——但当它们猜错时,整个装配线都必须被清空和重启,这会产生高昂的代价。仅仅是在共享库中使用函数指针而不是静态链接的直接调用,就会引入这种不确定性,以及从内存中获取指针值的开销,而这个值可能正躺在缓慢的缓存级别中。

那么,我们如何提速呢?第一道防线是程序员。如果我们不需要“一门通吃”设计的全部运行时灵活性,我们可以使用语言特性在编译时创造类似的效果。在像 C++ 这样的语言中,像奇异递归模板模式 (CRTP) 这样的模式允许我们构建类似多态的结构,其中编译器在编译时知道每个对象的具体类型。然后,它可以将神奇的、间接的门替换为普通的、直接的门。运行时分派消失了,编译器甚至可以更进一步,内联目标函数,基本上是完全移除门,将房间的内容直接放入走廊。当然,权衡之处在于我们失去了在同一集合中混合不同类型对象的能力,并且由于编译器为每种类型生成专门的代码,我们最终可能会得到一个更大的程序。

如果我们必须使用虚调用怎么办?我们求助于我们的下一个英雄:优化编译器。如果编译器被授予对整个程序的上帝视角——这是由链接时优化 (LTO) 等现代技术提供的能力——它可以执行全局分析。它可能会发现,一个特定的虚调用,尽管有可能去任何地方,但在这个特定的程序中,实际上只调用一个函数。谜题解开了!编译器可以自信地将昂贵的间接调用替换为廉价的直接调用,这通常会带来显著的性能提升,因为它还解锁了像内联这样的进一步优化。当编程语言本身提供帮助时,这种能力会被放大。像 Java 或 Swift 等语言中的“密封类”等特性,是程序员向编译器做出的承诺:“这就是完整的子类列表。”有了这个封闭世界的保证,编译器可以分析所有可能的目标,并经常用一个高效的、硬编码的决策树来替换虚调用。

但是,在像 JavaScript 这样运行在即时 (JIT) 编译器中的真正动态世界里呢?在这里,世界总是开放的;新代码随时可能出现。JIT 编译器变成了一个侦探,采用“自适应优化”的策略。它观察程序运行并下注。如果一个调用点看起来是单态的(总是调用同一个函数),JIT 会为这种情况生成高度专门化、超快速的代码,并由一个“守卫”保护,该守卫检查假设是否仍然成立。如果守卫成功,执行就会飞速通过快速路径。如果失败——程序做了意想不到的事情——一个“去优化”事件被触发,执行回退到更慢、更通用的代码。这种推测和去优化的舞蹈是一种微妙的平衡。JIT 必须权衡在其推测路径上保存和恢复寄存器的成本,并且必须应对行为随时间变化的工作负载。这种策略的成功在很大程度上取决于程序的特性,例如它的类型反馈熵(对象类型的可预测性如何?)和它的调用图稳定性(“最爱”的目标多久改变一次?)。

守护者的两难:铸造安全的控制流

正是使间接调用强大的那个特性——其目标由内存中的数据决定——也使其成为攻击者的首要目标。如果攻击者能够破坏存储目标地址(函数指针或 vtable 条目)的内存位置,他们就可以劫持程序的控制流,迫使其执行恶意代码。这是软件中最常见和最危险的攻击途径之一。

我们的第一道防线是限制这扇魔法门。与其让它通向任何地方,我们给处理器一个小的有效目的地“白名单”。这就是​​控制流完整性 (CFI)​​ 的思想。一个用 CFI 来插桩程序的编译器会分析代码,并为每个间接调用确定一组可能的有效目标。例如,一个通过函数指针传递两个参数的调用,应该只被允许跳转到实际接受两个参数的函数。在运行时,跳转之前,一个检查会确保目标在批准的列表上。如果不在,程序将被终止,从而挫败攻击。

软件检查会增加开销。硬件本身能帮助保护跳转吗?现代架构正开始提供这种功能。一个强大的机制是​​指针认证码 (PAC)​​。可以把它想象成附加在指针上的一个加密签名。在指针存储到内存之前,处理器使用一个密钥对其进行签名。当指针被加载并准备用于间接调用时,处理器会验证签名。如果攻击者在内存中篡改了指针,签名将无效,检查将失败,攻击就会被当场阻止。这提供了强大的防御,但像所有安全措施一样,它也是有代价的:验证 PAC 的额外指令会增加关键路径的周期,而存储 PAC 本身也会增加内存开销。

然而,最阴险的威胁来自于处理器自身追求速度的尝试。为了避免停顿,现代 CPU 会在一个间接分支的预测路径上进行推测执行,在它知道预测是否正确之前。如果预测错误,它会丢弃结果。但推测的行为会在处理器的缓存中留下微妙的足迹,而一个聪明的攻击者可以观察到这些足迹——这就是“侧信道”。这是臭名昭著的 ​​Spectre​​ 攻击的基础。即使间接调用最终是安全的,处理器对其目标的推测也可能泄露秘密信息。缓解措施是残酷但有效的:插入一个“推测屏障”,这是一条告诉处理器停下来等待,直到间接分支的真实目的地被知晓的指令。这道屏障保护了侧信道,但代价是巨大的性能损失,实际上是回滚了一些使处理器变得快速的技术进步。

意料之外的殿堂回响:跨学科的联系

间接调用的故事远不止于单个程序的范畴。思考一下现代计算机的核心:​​操作系统内核​​。内核珍视灵活性,允许为新硬件动态加载新驱动程序。这是通过——你猜对了——间接调用的接口实现的。然而,内核也要求最高的性能和安全性。这造成了根本性的紧张关系。一个内核供应商可能会强制执行“封闭世界”策略,将内核及其所有驱动程序作为一个单一、密封的单元发布。这使得链接时优化器能够对驱动程序路径中的热点调用进行去虚拟化,从而提升性能。另一种选择是一个“开放世界”,允许第三方驱动程序,为了更大的生态系统灵活性而牺牲这种优化机会,并要求更严格的运行时检查。

现在让我们跳入一个更奇怪的世界:​​区块链​​。区块链是建立在共识之上的一台计算机。成千上万的节点必须执行相同的交易并达到完全相同的最终状态。在这里,确定性是法则。这对编译器优化意味着什么?假设我们想通过对一组已知的合约进行调用去虚拟化来加速一个智能合约虚拟机。这种优化改变了机器码。如果一个节点运行优化后的代码,而另一个节点运行原始代码,它们的执行可能会有细微的差别——例如,它们的“gas”消耗可能会改变。这会破坏共识。令人震惊的结论是,要使用这样的优化,优化后的程序本身必须得到网络的同意。一个低级别的性能调整变成了一个全网范围的共识行为,新二进制文件的哈希可能需要被写入区块链的状态中。对速度的追求与确定性的暴政发生了冲突。

最后,让我们反转视角。如果我们不是在构建程序,而是在尝试从它们的编译形式来理解它们呢?这就是​​逆向工程和反编译​​的世界。一个优化编译器可能会把一个清晰、高级的虚调用,如 object->process(),转换成一个混乱的、低级的 if-else 类型检查和直接调用的链条。反编译器的任务就是看到这种优化模式,并重构出原始的、优美的抽象。它必须认识到,这个复杂的控制流只是一个单一多态思想的巧妙实现。在这里,间接调用不是一个要消除的问题,而是一个需要恢复的程序员意图的概念。

结论

从一个简单的跳转指令出发,我们穿越了微处理器的流水线、编译器的逻辑、操作系统的设计、网络安全的防御以及区块链奇特的共识机制。间接调用是计算机科学中挑战与美的一个完美缩影。它既是优雅抽象的源泉,也是危险漏洞的温床;既是需要优化的瓶颈,也是需要恢复的思想。它体现了灵活性与性能、力量与安全之间持续不断的创造性张力。随着我们的机器和软件变得越来越复杂,这扇简单而神奇的门的故事还远未结束。