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

间接分支

SciencePedia玻尔百科
核心要点
  • 间接分支通过使用计算出的跳转地址,实现了对现代软件特性(如多态、函数指针和共享库)至关重要的动态控制流。
  • 为避免性能损失,CPU 使用分支预测来猜测间接分支的目标,但预测错误会强制进行一次代价高昂的流水线刷新。
  • 分支预测后的推测执行过程可能被利用,从而产生像 Spectre 这样的安全漏洞,通过侧信道泄露机密数据。
  • 减轻间接分支的风险需要在硬件和软件之间进行协同设计,以平衡安全性、性能和灵活性。

引言

在计算机程序这个指令通常一条接一条执行的线性世界里,间接分支代表了一个至关重要且复杂的十字路口。与目标固定的简单条件跳转不同,间接分支会跳到一个仅在执行瞬间才能确定的目标地址。这种能力并非晦涩的细节,而是支撑现代软件灵活性与强大功能的基础支柱,从面向对象编程到模块化操作系统无不如此。然而,这种动态性与现代处理器预测性的、流水线式的本质之间产生了根本性的张力,给性能和安全都带来了重大挑战。

本文探讨了间接分支的多方面角色。它揭示了这单一概念如何在我们计算机系统的核心同时扮演着强大推动者和潜在漏洞的双重角色。在接下来的章节中,您将深入理解这种二元性。“原理与机制”部分将深入探讨底层硬件机制,解释处理器如何处理这些跳转、它们为保持速度而使用的分支预测这一巧妙技术,以及这种优化本身如何被悲剧性地发现是通往 Spectre 等安全漏洞的门户。在此之后,“应用与跨学科联系”部分将拓宽视野,阐述这一核心 CPU 特性如何成为高级语言特性、系统软件以及性能优化与网络安全领域持续军备竞赛背后的引擎。

原理与机制

计算的十字路口

想象一下在一条又长又直的高速公路上行驶。大部分计算机程序的生命就是这样:以完全可预测的顺序一条接一条地执行指令。偶尔,你会遇到一个简单的岔路口——一个​​条件分支​​。你查看地图(一个类似 if (x > 5) 的条件),然后要么继续直行,要么从出口驶出。这是在两条路径之间的简单选择。

但如果你来到了一个有十几个出口的大型多车道环岛,而你需要走的出口写在手套箱里的一张纸条上呢?直到你已经进入环岛,摸索着找那张纸条时,你才会知道你的目的地。这就是​​间接分支​​的世界。它是程序中的一个点,其下一个目的地不是固定的,而是由一个动态计算出的值决定的——这个值可能每次经过时都会改变。这些并非罕见的弯路;它们是现代软件工作方式的基础。

我们为什么需要十字路口

从本质上讲,间接分支是一种控制流指令,其目标地址并未编码在指令本身之中。相反,处理器必须从寄存器或内存中的某个位置获取该地址。这种简单的机制带来了惊人的灵活性,是许多高级编程语言特性背后的主力。

想象一下打电话。直接呼叫就像把朋友的号码硬编码到你的手机里——按下“呼叫”键总是拨打那一个号码。间接呼叫则像使用你的联系人列表。“呼叫”按钮是同一个,但你联系到的人取决于你选择了哪个联系人。这正是 C 或 C++ 等语言中​​函数指针​​和​​回调​​的工作原理。

另一个常见的例子是 switch 语句。编译器可能会将其翻译成一个​​跳转表​​——内存中的一个地址数组,每个 case 对应一个地址。程序使用 switch 变量的值来计算该表的索引,检索一个地址,然后跳转到该地址。

也许间接分支最深刻和普遍的用途是在​​面向对象编程 (OOP)​​ 中。考虑一个图形程序,它有一个函数 shape.draw()。如果 shape 变量当前持有一个 Circle 对象,程序必须调用专门用于圆形的 draw 函数。如果它持有一个 Square,就必须调用用于方形的函数。这被称为​​多态​​,而 shape.draw() 这一行代码被编译成一个间接分支。该分支的目标完全取决于运行时的对象类型,这是一个在最后一刻才做出的决定。这种灵活性是强大的,但正如我们将看到的,它是有代价的。

甚至从函数返回这个简单的动作也是一个间接分支。​​返回指令​​ (ret) 不会跳转到一个固定的地址。它会跳转到函数被调用前保存的地址。这个“返回地址”通常存储在一个称为​​栈​​的特殊内存区域中。每次函数调用都会将一个返回地址推入栈中,每次返回都会弹出一个。这种优美、对称的后进先出 (LIFO) 之舞赋予了我们的程序结构化的、嵌套的调用与返回流程。

CPU 的水晶球

在这里我们遇到了一个问题,一个软件本质与硬件物理之间的冲突。现代处理器像精密的流水线一样构建,这种技术被称为​​流水线技术​​。它们不只是一次处理一条指令;它们会提前获取并开始处理一长串指令流。这在直路上工作得很好。但间接分支是一堵砖墙。如果 CPU 不知道接下来要去哪里,整个流水线就会戛然而生,等待。这些停顿,通常称为​​流水线气泡​​,是浪费的时间。每个气泡都是处理器无所事事的一个周期,而以​​每指令周期数 (CPI)​​ 衡量的整体性能会因此下降。

对于一个典型的五级流水线,遇到一个不可预测的分支,并且该分支直到第三级(“执行”级)才被解析,这意味着紧随其后获取的两条指令是错误的。它们必须被从流水线中刷新,从而引入 3−1=23-1=23−1=2 个气泡的浪费时间。

为了避免这种灾难性的走走停停,CPU 发展出了一项非凡的能力:它们会猜测。这被称为​​分支预测​​。处理器的前端包含一个“水晶球”——一组复杂的硬件预测器,它们对间接分支将去往何处做出有根据的猜测。如果预测正确,流水线就能全速顺畅运行。如果错了——即​​错误预测​​——所有在错误路径上推测执行的工作都会被丢弃,流水线被刷新,处理器从正确的目标重新开始。错误预测仍然会招致全部的惩罚,但希望在于,正确的预测足够普遍,使得这场赌博值得一试。

这场猜测游戏的难度在很大程度上取决于软件。思考我们 OOP 例子中的多态调用点。如果一个调用点有 kkk 个可能的目标,而程序在它们之间随机选择,那么一个简单地猜测目标将与上一次相同的预测器,其出错的概率为 Pm=k−1kP_m = \frac{k-1}{k}Pm​=kk−1​。当 k=2k=2k=2 时,它有一半的时间是错的。当 k=12k=12k=12 时,它有超过 90% 的时间是错的!随着可能目标数量的增长,预测器的准确性会崩溃,由错误预测引起的 CPI 惩罚会迅速压垮系统的性能。

预测器大杂烩

因为并非所有间接分支都是生而平等的,计算机架构师设计了一整套专门的预测器。

对于高度结构化和可预测的 ret 指令,处理器使用一个专用的​​返回地址栈 (RAS)​​。这是一个小而快的硬件栈,它镜像了软件的调用栈。当执行一条 call 指令时,CPU 会将返回地址推入 RAS。当遇到一条 ret 指令时,它 просто预测目标将是从 RAS 顶部弹出的地址。对于行为良好的程序来说,这种方法非常准确。然而,如果调用嵌套深度超过了 RAS 的有限容量,或者如果程序使用了打破调用/返回对称性的非标准控制流,RAS 可能会混淆并导致错误预测。

对于所有其他“狂野”的间接分支,主要的工作马力是​​分支目标缓冲器 (BTB)​​。BTB 是一个小型缓存,由分支指令本身的地址(其程序计数器,或 PC)索引。当地址 PC_A 的间接分支跳转到目标 Target_B 时,BTB 会创建一个条目:[PC_A -> Target_B]。下次 CPU 看到地址 PC_A 的分支时,它会查看 BTB 并预测它将再次跳转到 Target_B。通过考虑这些分离预测器中不同分支类型的命中率和惩罚,可以仔细地建模系统的整体性能。

当然,BTB 并非万无一失。由于它是一个由 PC 的低位索引的小型缓存,代码中不同位置的两个不同分支可能会映射到同一个 BTB 条目。这被称为​​混淆​​,它意味着一个分支可能会覆盖另一个分支的预测,从而导致错误预测。有时这只是一个性能问题,但正如我们即将看到的,它也可能是一个毁灭性的安全漏洞。为了处理特别具有挑战性的模式,例如 OOP 中高度多态的调用,设计者甚至创造了更专门的结构,如​​带标签的目标缓存 (TTCs)​​,它们利用额外的信息(例如对象的类型)来做出更明智的预测。

当水晶球被劫持

几十年来,分支预测一直被纯粹视为一种性能优化。过程很简单:预测,推测执行,如果错了,就丢弃结果然后继续。关键的假设是“丢弃结果”不会留下任何痕迹。这个假设后来被证明是灾难性的错误。

这个启示在于,虽然体系结构状态(寄存器和主内存的内容)在错误预测后会完美回滚,但微体系结构状态却不会。处理器内部缓存、缓冲器和预测器的状态,可能被那些“从未真正执行过”的指令永久性地改变。这打开了一个​​推测执行漏洞​​的潘多拉魔盒。

其中最著名的是 ​​Spectre​​。在一个被称为分支目标注入的变种中,攻击者可以操纵 BTB 来劫持受害者程序的推测执行。攻击过程如下:

  1. ​​训练​​:攻击者运行代码来“训练”一个 BTB 条目。通过重复执行一个与受害者代码中某个分支产生混淆的间接分支,攻击者可以毒化 BTB,使其预测受害者的分支将跳转到一个攻击者选择的地址——一段被称为“gadget”的代码片段。

  2. ​​劫持​​:受害者程序执行其分支。CPU 在查阅被毒化的 BTB 后,发生错误预测,并推测性地跳转到攻击者的 gadget。

  3. ​​泄露​​:该 gadget 是一段精心构造的指令序列。它执行一个依赖于秘密值的操作。例如,它可能会读取一个基于秘密字节的内存地址:data = memory[base_address + secret_byte]。这个动作虽然是短暂的,但它将该内存位置的数据拉入处理器的 L1 数据缓存中。

  4. ​​回滚​​:CPU 最终发现预测错误,废弃整个推测执行路径,并恢复正确的执行。在体系结构层面上,就好像什么都没发生过一样。

  5. ​​观察​​:但确实发生了某些事。秘密的痕迹现在留在了数据缓存的状态中。攻击者随后可以使用​​时序侧信道攻击​​。通过测量访问 gadget 可能接触到的每个可能内存位置所需的时间,攻击者可以找到一个比其他位置快得多的位置。那个快速的访问就是一次缓存命中,揭示了哪个位置被带入了缓存,从而揭示了 secret_byte 的值。

这是一种深刻而微妙的攻击。它没有打破任何经典的安全规则;它利用了存储程序计算机工作的基本性质——指令地址本身就是可以被预测的数据——并结合了推测执行这一性能优化。有时,其他架构特性,例如暴露 PC 值的指令,甚至有助于找到 gadget,这会削弱地址空间布局随机化 (ASLR) 等防御措施。

这个兔子洞还更深。攻击者甚至不需要劫持推测执行。在另一种类型的侧信道攻击中,BTB 本身成为了泄露源。攻击者可以预置一个 BTB 条目,让受害者运行,然后探测同一个条目。如果受害者的依赖于秘密的控制流导致它执行了一个与攻击者条目冲突的分支,攻击者在探测时就会经历一次错误预测。通过简单地监控自己的错误预测计数,他们就可以推断出受害者的秘密行为。

这些漏洞的发现迫使处理器设计发生了范式转变。解决方案在于在微体系结构层面加强隔离。通过给预测器条目打上​​地址空间标识符 (ASID)​​ 的标签,处理器可以确保一个进程无法看到或操纵另一个进程的预测器状态。预测与推测的优美、复杂的舞蹈仍在继续,但现在多了一份新的认识:在计算的世界里,即使是来自短暂、幽灵般执行路径的最微弱的低语,也可能泄露我们最深的秘密。

应用与跨学科联系

在理解了间接分支的机器级机制之后,我们可能会倾向于将其归类为一个纯粹的技术细节,是计算机这座宏伟大厦中的一小段管道。但这样做将只见树木,不见森林。间接分支不仅仅是一段管道;它是一个基础的架构元素,一个多才多艺的演员,在计算舞台上扮演着众多出人意料的关键角色。它的故事是计算机科学统一性的绝佳例证,展示了一个单一的底层概念如何绽放成一幅丰富的应用图景,连接了高级编程语言、复杂的软件系统,乃至高风险的网络安全世界。

翻译的艺术:从人类思想到机器行为

从本质上讲,计算机程序是将人类意图翻译成处理器可以执行的操作序列。间接分支是编译器在此翻译过程中最强大的工具之一,它能够优雅地实现常见的编程构造。

以 C++ 或 Java 等语言中常见的 switch 语句为例。它允许程序员根据一个变量的值选择多条路径之一。一个简单的翻译可能是一长串的 if-then-else 比较。但一个聪明的编译器可以做得更好。它可以在内存中构建一个“跳转表”——一个地址数组,其中每个地址指向特定 case 的代码。然后程序只需根据 switch 变量计算该表的索引,获取相应的地址,并执行一次间接跳转到正确的目标。这将一个可能很长的顺序搜索转变为一次高效的查找和跳转,完美地展示了间接性如何用少量内存换取大量速度。

同样的原理也驱动着面向对象编程 (OOP) 的基石之一:多态。当你有一个不同对象的集合——比如各种 Shape,如 Circle、Square 和 Triangle——并且你对每个对象调用像 draw() 这样的虚方法时,程序如何知道要执行哪个具体的 draw() 函数呢?答案再次是跳转表,通常称为虚表或“vtable”。每个对象都带有一个指向其类的 vtable 的隐藏指针,编译器将 draw() 调用翻译成通过该表中适当条目的间接调用。这个机制正是后期绑定的精髓,它允许代码灵活且可扩展,具体行为在运行时而非编译时确定。

间接跳转在表达控制流方面的效用甚至延伸到了函数式编程领域。一个著名的优化,尾调用优化,允许进行非常深或无限的递归而无需消耗栈空间。当一个函数的最后一个动作是调用另一个函数(或其自身)时,编译器可以将 call 转换为一个简单的 jump,从而重用当前的栈帧。如果这个调用是通过函数指针进行的,结果就是一个间接跳转——一种实现强大递归算法和状态机的简洁高效的方式。

构建现代系统:解释器、运行时和库

除了个别的语言特性,间接分支对于整个软件系统的结构也至关重要。它们是解释器背后的引擎,也是将现代模块化软件粘合在一起的胶水。

想象一下你正在为像 Python 这样的语言构建一个解释器,或者像 JVM 这样的虚拟机。你的解释器核心是一个分派循环,它读取下一条指令(一个“字节码”)并跳转到实现它的代码。一个常见的方法是使用一个大的 switch 语句。但一种更高级且通常更快的技术被称为直接线程化代码。在这种设计中,被解释的程序被编译成的不是操作码序列,而是处理程序例程的实际机器地址序列。解释器的主循环变得惊人地简单:从程序中获取下一个地址,间接跳转到它,而每个处理程序例程的结尾都是获取下一个地址并再次跳转。这模糊了数据和代码之间的界限,将可执行地址视为可操作的数据。这是对所有现代计算机核心的存储程序概念的深刻而直接的应用,它可以通过用一个简单、可预测的间接跳转替换复杂的解码逻辑来提供显著的性能优势。

这种间接性的力量也使得现代软件开发成为可能。当你的应用程序使用一个共享库(Windows 上的 .dll 或 Linux 上的 .so)时,你就在使用间接分支。如果每个库函数的精确内存地址都必须在编译程序时就确定,那将是不可思议的僵化。相反,编译器和链接器协同工作,使用了一层间接。对库函数的调用被编译为对“过程链接表”(PLT) 中一个小本地存根的调用。这个存根随后从“全局偏移表”(GOT) 中加载函数的真实地址并跳转到它。第一次调用函数时,动态链接器会解析真实地址并将其放入 GOT 中。所有后续调用都会在那里找到等待它们的地址。正是这种惰性的、间接的机制使得共享库可以独立更新并加载到内存的任何位置,提供了我们习以为常的模块化和效率。

性能的追求:一把双刃剑

尽管间接分支具有种种灵活性,但它们也带来了性能成本。处理器可以使用复杂的分支预测器来猜测一个简单条件分支的结果(跳转或不跳转)。但一个间接分支理论上可以跳转到数百万个可能的地址中的任何一个。这使得预测变得极为困难,而一次错误预测可能导致处理器流水线停顿多个周期,浪费宝贵的时间。

这种张力在软件和硬件之间创造了一个有趣的协同设计领域。编译器敏锐地意识到了这种成本。在面向对象的代码中,虽然一个虚调用理论上可以去任何地方,但在特定程序中,它通常只会去往一两个特定的函数实现。现代编译器可以使用性能剖析信息来发现这一点。然后它可以执行一种称为*去虚拟化*的优化,将昂贵的间接调用转变为一个快速的检查序列:if (object is type A) call A's method; else if (object is type B) call B's method; else do the slow indirect call。这种编译器通过减少难以预测的分支数量来帮助硬件的协作,可以产生显著的速度提升,并减少对硬件分支预测器的“污染”。

现代运行时,如 Java 或 JavaScript 的运行时,更进一步。即时 (JIT) 编译器与主程序并行运行,动态优化代码。它可以实时观察程序的行为。如果它注意到两个频繁使用的间接分支恰好映射到处理器分支目标缓冲器 (BTB) 的同一个条目中,导致它们不断相互驱逐并引发错误预测,JIT 可以做一些非凡的事情:它可以重新编译其中一个函数,并将其代码放置在不同的内存地址以解决冲突。这是动态的、硬件感知的代码布局——软件主动地重新组织自己,以变得更加“硬件友好”。

现代战场:推测时代的安全性

正是那个使得间接分支难以预测性能的特性——它们可以跳转到任何地方的能力——也使它们成为安全漏洞的主要目标。像 Spectre 这样的推测执行攻击的发现,将这个性能挑战转变为一个关键的安全漏洞,而间接分支正处于战场的中心。

这种攻击是微妙而巧妙的。攻击者无法强制处理器在体系结构上执行恶意代码。但他们可以“训练”分支预测器。通过重复地让一个间接分支跳转到某个特定地址,他们可以欺骗处理器猜测该分支会再次跳转到那里。如果攻击者随后构造一个该分支应跳转到别处的情景,处理器可能仍会推测性地执行攻击者选择的 gadget 处的指令,利用它来读取秘密数据(如密码或加密密钥)。尽管处理器最终会意识到自己的错误并丢弃结果,但推测性访问在系统的数据缓存中留下了痕迹。攻击者随后可以测量这些缓存时序来推断出秘密数据。

缓解这些攻击已成为整个行业的巨大努力,催生了新的硬件特性和巧妙的软件技巧。一种方法是强制执行控制流完整性 (CFI),它确保每个间接分支只能跳转到有效的、预先批准的目标。这通常是通过在分支执行前将其目标与一个白名单进行检查来完成的。虽然有效,但这种检查增加了开销,在安全性和性能之间造成了直接的权衡。此外,设计这个检查本身也充满危险。一个简单的 if (target is valid) 检查本身就可以被推测执行绕过!解决方案需要创建一个真正的数据依赖,例如使用条件移动指令来选择预期目标或一个安全的回退地址,以确保检查在跳转被分派之前完成。

一种更令人匪夷所思的缓解技术叫做 retpoline(“返回蹦床”)。retpoline 不试图阻止错误预测,而是利用它。一个易受攻击的间接分支被替换为一个调用微小子程序的序列。处理器的返回预测器(返回地址栈,或 RAS)记录了调用后指令的地址,期望子程序返回到那里。然而,该子程序操纵程序栈并执行一个 return 指令,该指令实际上跳转到了原始的预期目标。被自己的预测器愚弄的处理器,会推测性地执行一个无害的无限循环,而正确的体系结构执行则安全地进行。这是一招令人惊叹的软件柔术。这同样有代价。它引入了一个保证的错误预测,并可能污染 RAS,从而可能减慢程序中其他合法的函数返回。

一条统一的线索

从 switch 语句,到多态的魔力,再到共享库的胶水,JIT 编译器的性能,以及网络安全的前线——间接分支是那条统一的线索。它是计算机科学中核心权衡的完美缩影:灵活性与性能,性能与安全。它提醒我们,计算中最优雅、最强大的思想往往是最简单的,而理解机器的最深层次不仅仅是一项学术活动,更是构建驱动我们世界的快速、灵活和安全软件的必需品。