try ai
科普
编辑
分享
反馈
  • 被调用者保存寄存器

被调用者保存寄存器

SciencePedia玻尔百科
主要收获
  • 现代系统使用混合调用约定,将寄存器分为调用者保存(易失性)和被调用者保存(非易失性)两组,以平衡调用函数和被调用函数之间的性能权衡。
  • 编译器负责维护这一约定,通过存活分析确定何时保存调用者保存的寄存器,并依赖被调用者来保护被调用者保存的寄存器。
  • 寄存器保护原则不仅仅是编译器的细节;它对操作系统稳定性、性能优化、高级控制流的实现乃至计算机安全漏洞利用都至关重要。
  • ABI 中寄存器的具体划分是定量分析的结果,旨在为典型程序最小化寄存器保存/恢复操作的总成本。

引言

在每台计算机处理器的核心,都有一小组极其快速的存储位置,称为寄存器。它们是所有计算的工作台,但其稀缺性带来了一个根本性挑戰:当一个函数(“调用者”)将任务委托给另一个函数(“被调用者”)时,它们如何共享这个有限的工作空间而又不互相干扰?一个简单的失误就可能损坏数据并导致整个程序崩溃。本文将探讨解决此问题的优雅方案:一种被称为调用约定的“社会契약”。

本文探讨了将寄存器分为两类的原则及其深远影响:一类由调用者负责保存,另一类由被调用者必须保护。你将了解到这种务实的折衷方案如何成为高效、可靠软件的关键。第一章 ​​原理与机制​​ 将通过一个简单的类比来剖析“调用者保存”和“被调用者保存”规则之间的权衡,揭示所有现代系统所采用的混合方法背后的逻辑。随后的 ​​应用与跨学科联系​​ 章节将展示这个单一概念如何支撑起操作系统的稳定性、优化代码的速度、高级编程语言的机制,乃至黑客利用的漏洞。

原理与机制

共享工作间与社会契约

想象一下,你是一位繁忙作坊里的大师级工匠。你的工作台就是 CPU,而你放在上面以便随时使用的工具就是处理器的​​寄存器​​。这些寄存器十分宝贵;它们是存放数据的最快之处,但数量很少。你执行的每一项任务——程序中的一个函数——都需要使用这些工具来操作数据。

现在,假设你正在进行一个复杂的项目,需要将一小部分工作委托出去。你叫来一位同事——用编程术语来说,你的函数,即​​调用者​​,调用了另一个函数,即​​被调用者​​。这里存在一个根本问题:你的同事需要使用同一个工作台。如果你就这么走开,他们可能会移动你的工具,用于自己的目的,并将其置于不同的状态。当你回来继续工作时,你精心放置的工具已是一片混乱,你的项目也毁于一旦。混乱随之而来。

为防止这种情况发生,作坊里的工匠们必须商定一个协议,一套共享工作台的规则。在计算领域,这套规则被称为​​调用约定​​,它构成了​​应用程序二进制接口 (ABI)​​ 的关键部分。从本质上讲,这是一种管理调用者和被调用者之间交互的社会契약。

乍一看,似乎有两种简单、绝对的规则是可行的:

  1. ​​调用者保存规则:​​ 在请求同事帮助之前,你负责整理自己的工作区。你把你仍在使用的所有工具收到你的个人工具箱(一个称为​​栈​​的内存区域)中。同事来到一个干净的工作台前,可以不受约束地工作。当他们完成后,你取回你的工具,继续你的工作。

  2. ​​被调用者保存规则:​​ 你把你的工具原样留在工作台上。你的同事则有责任绕开它们工作。如果他们需要使用你的某个工具,他们必须先拍下它所在的位置,小心使用,清洁干净,并在离开前将其放回原处。如果他们不需要你的工具,他们就不碰它。他们的座右銘是:“离开时,让工作台恢复原样。”

不可避免的权衡

如果你仔细思考这两条规则,你会很快意识到它们都不是在所有情况下都完美。存在着一个不可避免的权衡,一种优美的张力,它正处于高效程序执行的核心。

​​调用者保存​​约定对被调用者来说非常有利。被调用的函数可以直接开始工作,将工作台上的寄存器用作“临时存储区”,无需任何设置或清理成本。这对于我们所说的​​叶函数​​——即那些执行任务而不调用任何其他辅助函数的简单、专业函数——来说效率极高。如果你只需要同事拧紧一个螺栓,强迫他们先清点整个工作台上的工具将是极大的浪费。对于一个有大量内部计算的叶函数来说,拥有大量“可随意使用”的临时寄存器是一个巨大的性能优势。

然而,这条规则给调用者带来了沉重的负担。想象一下你是一个“管理者”函数,正在协调一个复杂的任务,需要在循环中调用许多不同的专业函数。在纯粹的调用者保存规则下,你会在每次调用前后花费大量时间将自己的工具打包到工具箱再取出来。不断保存和恢复自身状态的开销将远远超过实际完成的工作。

另一方面,​​被调用者保存​​约定对调用者来说则是一份礼物。管理者函数可以将其重要的、生命周期长的变量——如循环计数器、指向关键数据结构的指针——保存在寄存器中,进行函数调用,并相信当被调用者返回时,这些值将得到完美的保留。调用者从而摆脱了在每次调用前后保存和恢复其上下文的繁瑣工作。

当然,缺点是负担转移到了被调用者身上。现在,即使是最简单的叶函数,如果它恰好需要使用这些“被保留”的寄存器之一,也必须执行保存和恢复的仪式。这个仪式包括在函数的开头(​​prologue​​,即函数序言)执行特殊指令,将寄存器的原始值保存到栈上,并在结尾(​​epilogue​​,即函数尾声)执行指令将其恢复。这为每个使用被调用者保存寄存器的函数增加了一个固定的开销,对于那些被非常频繁调用的函数来说,这可能是低效的。

优雅的折衷:现代 ABI

那么,解决方案是什么呢?我们是选择调用者的便利还是被调用者的速度?答案,在几乎所有现代计算系统中都能找到,是一个优美而务实的折衷:我们两者兼顾。

调用约定没有让所有寄存器都遵循同一条规则,而是将它们划分为两组:

  • ​​调用者保存寄存器​​(也称为​​易失性​​或​​临时寄存器​​):任何被调用者都可以无限制地自由使用这些寄存器。如果调用者需要在一次调用后保留其中某个寄存器的值,调用者必须自己保存它。
  • ​​被调用者保存寄存器​​(也称为​​非易失性​​或​​保留寄存器​​):这些是被调用者有义务保护的寄存器。如果被调用者使用了其中一个,它必须在自己的函数序言中保存原始值,并在函数尾声中恢复它。

这种混合方法提供了两全其美的解决方案。叶函数可以使用大量的调用者保存寄存器来完成工作,开销极小。而管理者函数,或称非叶函数,可以将其关键的长期状态存储在被调用者保存的寄存器中,并确信这些值在调用其他函数后依然存在。这些值被临时保存的物理位置是为活动函数专设的一个内存区域,称为其​​栈帧​​或​​激活记录​​。

这不仅仅是一个理论思想;它是现实世界软件的基石。寄存器的具体划分是架构 ABI 的关键部分。例如,用于 AMD64 处理器的 System V ABI 将 RBX,RBP,R12−R15RBX, RBP, R12-R15RBX,RBP,R12−R15 等寄存器指定为被调用者保存,而用于 64 位 ARM 的 AAPCS 则将 x19x19x19 到 x28x28x28 指定用于同样的角色。 原理是通用的,但实现是根据架构量身定制的,反映了平衡典型程序需求的精心设计。

编译器的负担:存活性与逻辑

有了这个社会契约,程序实际上是如何遵守规则的呢?这个责任落在了​​编译器​​身上,这位将人类可读代码翻译成机器指令的大师。编译器必须执行巧妙的分析,以确保契约永不被打破。

编译器使用的关键概念是​​存活性​​(liveness)。如果一个变量(保存在寄存器中)的值在程序的某个点之后可能还会被使用,那么它在该点被认为是​​存活的​​(live)。如果它的值再也不会被使用,那么它就是​​无用的​​(dead)。

当编译器遇到函数调用时,它会执行存活分析,以查看哪些寄存器持有存活的值。 编译器接下来的操作是一个基于 ABI 的简单逻辑判断:

  • 寄存器 RRR 中的值在这次调用返回后是否存活?
    • 如果否,则什么也不做。该值是无用的,所以即使被调用者覆盖它也无所谓。
    • 如果是,则必须保护它。现在,检查寄存器 RRR 的类型:
      • 如果 RRR 是一个​​被调用者保存​​的寄存器,编译器什么也不做!它依赖被调用者履行其约定,保护寄存器的值。
      • 如果 RRR 是一个​​调用者保存​​的寄存器,编译器就必须采取行动。它会生成指令,在调用前将 RRR 的值保存到栈上,并在调用后生成指令从栈上恢复它。

存活分析与调用约定之间的这种交互,完美地展示了编译器不同部分如何协同工作,以生成正确而高效的代码。

性能背后看不见的数学

这整套寄存器保存约定系统可能看起来像一套随意的规则,但在其表面之下,却蕴含着深刻的数学优雅。这些选择一点也不随意;它们是一个精心优化的问题的结果。

我们可以用惊人的简洁性来为这些约定的成本建模。想象一个有 rrr 个寄存器的系统,单次保存操作的成本是 csc_scs​ 个周期。如果我们把所有寄存器都当作被调用者保存,那么一次调用的期望成本取决于被调用者使用任意给定寄存器的概率 ppp。总的期望保存成本就是 csrpc_s r pcs​rp。如果我们把所有寄存器都当作调用者保存,成本则取决于调用者在一次调用中寄存器里存有存活值的概率 ℓ\ellℓ。总的期望成本是 csrℓc_s r \ellcs​rℓ。 这对简单的表达式,Ecallee=csrpE_{callee} = c_s r pEcallee​=cs​rp 与 Ecaller=csrℓE_{caller} = c_s r \ellEcaller​=cs​rℓ ,完美地捕捉了根本性的张力:一个成本由被调用者的行为驱动,另一个则由调用者的需求驱动。

更进一步,我们可以问:对于一个总共有 RRR 个寄存器的系统,要最小化一个典型程序的总执行时间,调用者保存寄存器的最优数量 CCC 和被调用者保存寄存器的最优数量 KKK 是多少?我们可以建立一个数学成本函数 T(C)T(C)T(C),它模拟了来自调用者端保存和被调用者端保存的综合开销。这个函数考虑了一个典型调用者需要保留的存活值的数量,以及一个典型被调用者工作时需要的临时寄存器的数量。通过最小化这个函数,计算机架构师可以确定理想的划分——即值 C⋆C^{\star}C⋆——从而达到最低的总体成本。

你在实际 ABI 中看到的被调用者保存寄存器的数量并非随机猜测。它是这类定量分析经过精细调整的结果,旨在创建一个对我们日常运行的程序来说平均效率最高的系统。一个始于简单的作坊规矩问题,最终演变为一条丰富的计算机科学原理,揭示了软件约定与机器性能之间一种优美而隐藏的和谐。

应用与跨学科联系

在了解了调用约定的原理之后,你可能会觉得这不过是一些晦涩的簿记工作,一套让编译器和 CPU 设计者烦恼但对计算的宏伟蓝图影响甚微的规则。事实远非如此。这个看似简单的协议——谁在何时保存什么——是支撑整个现代软件大厦的基础契约。它是一条逻辑线索,一旦你开始拉动它,就会解开并连接起一系列惊人的学科:操作系统的坚定可靠性、优化代码的惊人速度、高级编程语言令人费解的机制,甚至是计算机安全的黑暗艺术。

让我们踏上一段旅程,看看这一个理念,即调用者和被调用者之间的分工,如何在计算世界中回响,揭示出一种优美而意外的统一。

秩序的守护者:操作系统与法治

任何稳定计算环境的根基都是操作系统 (OS)。OS 内核是每个用户程序的终极“被调用者”。当一个程序需要服务时——比如打开一个文件,或者通过网络发送数据——它会执行一次系统调用。这不是一个普通的函数调用;它是一次特殊的、特权级的控制权转移,进入内核。然而,为了让用户程序在内核完成后能不受干扰地继续工作,这种交互必须表现得像一次完全文明的函数调用。

在这里,我们的契约就成了这片土地的法律。内核作为被调用者,必须一丝不苟地遵守应用程序二进制接口 (ABI)。它可以自由使用“调用者保存”的寄存器进行自己的临时计算,但它有严格的义务保护每一个“被调用者保存”的寄存器。如果它做不到这一点,就好比一个图书管理员借了读者的笔却还回来一支不同的;混乱将会接踵而至,因为用户程序稍后会试图使用一个值已神秘改变的寄存器,从而导致崩溃和不可预测的行为。一个稳定的操作系统,本质上就是跨越用户-内核边界严格保护被调用者保存状态的明证。

但对于那些不那么“文明”的事件呢?函数调用是一次计划好的访问。而一次中断,则是一场伏击。想象你的程序正在愉快地进行计算,突然一个网络数据包到达或一次磁盘读取完成。硬件会强制立即、无计划地跳转到操作系统中一段名为中断服务程序 (ISR) 的特殊代码。被中断的程序没有任何预警,没有机会从“调用者保存”的寄存器中保存其宝贵数据。它在思绪中途遭到了伏擊。

在这种情况下,旧规则被彻底颠覆。ISR 不能假定任何寄存器都是可以安全覆盖的。从被伏击代码的角度来看,每个寄存器都是神圣的。因此,ISR 必须以更高的谨慎度行事:它必须保存它打算使用的任何寄存器的原始值,无论 ABI 将其归类为调用者保存还是被调用者保存,并在返回控制权之前将其恢复。这确保了当被中断的程序恢复执行时,它完全不知道自己曾被打扰过。在这里,我们看到该原则从一个文明社会的规则转变为应急响应的规则,一切都是为了维持无缝执行的假象。

速度的构建师:编译器、优化与架构

虽然操作系统使用调用约定来确保正确性,但编译器则视之为一种性能不佳、“一刀切”的契约,常常可以加以改进。保存和恢复寄存器需要时间——这些时间花在了对实际计算没有贡献的内存操作上。聪明的编译器总是在寻找削减这种开销的方法。

标准 ABI 是保守的;它做了最坏的打算。调用者必须假设被调用者会涂写每一个调用者保存的寄存器。但如果编译器能窥探被调用者的内部,发现它只使用了六个可用调用者保存寄存器中的两个呢?有了这些特权信息——通常在​​链接时优化 (LTO)​​ 期间收集,此时整个程序都可见——编译器就可以打破一般规则。调用者现在可以安全地将其存活值保存在它知道这个特定被调用者不会触碰的四个调用者保存寄存器中,从而神奇地避免了昂贵的栈溢出操作。

我们还可以更进一步。对于性能关键的代码,例如在动态语言的​​即时 (JIT) 编译器​​中,我们甚至可以为一个特定的热点函数设计一个自定义的调用约定。通过分析调用者中寄存器存活的频率与被调用者使用它们的频率,我们可以做出一个定量的、概率性的决策:一个给定的寄存器应该是调用者保存还是被调用者保存,以最小化保存/恢复操作的总期望成本?这就像从一件成衣西装换成一件量身定制的西装,完美贴合代码的特定轮廓。

这种对减少内存流量的不懈追求也是计算机架构本身的主要动机之一。为什么现代处理器倾向于拥有越来越多的寄存器?答案可以通过考虑增加寄存器文件大小的效果来完美说明。当有更多寄存器可用时,会发生两件美妙的事情:首先,在复杂计算中,需要“溢出”到栈上的临时变量更少。其次,更多的函数参数可以通过寄存器传递,而不是通过栈传递。这两种效应都直接减少了内存访问次数,减轻了数据缓存的压力,从而带来了显著的性能提升。调用约定和物理寄存器的数量是同一枚硬币的两面:机器用于保存重要内容的预算。

最终,这些考虑因素都会回归到编译器的宏观策略中。一个看似简单的决定,比如是否内联一个函数(将其主体复制到调用者中以避免调用开销),变成了一个复杂的权衡。内联消除了 ABI 强制的寄存器保存操作,但它通常会增加同时存活的变量数量,可能导致更多的溢出。一个有效的内联启发式策略不可能是机器无关的;它必须被目标机器的模型所指导,包括寄存器数量及其特定 ABI 所施加的成本,才能做出明智的选择。

逃逸大师:扭曲控制流规则

标准的调用-返回机制就像沿着走廊走下去再原路返回。但一些编程构造更像是传送装置,允许你从一个房间跳到另一个房间,完全绕过走廊。这些非局部控制转移对我们整洁的契约提出了一个有趣的挑战。

考虑 C 语言中臭名昭著的 setjmp 和 longjmp 工具。setjmp 保存当前上下文(就像视频游戏中的“快速存档”),而 longjmp 则将执行从一个深层嵌套的函数调用中直接传送回那个点。这个跳转绕過了所有本应勤勉地恢复被调用者保存寄存器的正常函数尾声。为了防止状态损坏,setjmp 函数本身必须做到偏执。它不仅必须保存程序计数器和栈指针,还必须保存所有被调用者保存寄存器的值。当 longjmp 激活时,它会恢复这个完整的快照,确保世界看起来与最初调用 setjmp 时完全一样,从而强制性地维护了被调用者保存的契约。

这个问题的更现代、更结构化的版本出现在混合语言编程中。想象一个 C++ 函数调用一个 C 函数,后者又调用另一个抛出异常的 C++ 函数。该异常必须一路返回到最初的调用者,途中需要展开 C 函数的栈帧。就像 longjmp 一样,这个过程绕过了 C 函数的尾声。那么被调用者保存的寄存器是如何恢复的呢?答案在于编译器生成的​​展开元数据 (unwind metadata)​​,这是一个秘密地图,告诉 C++ 异常处理器 C 函数将其保存的寄存器存储在了哪里。没有这张地图,状态就会被破坏。一个更健壮但效率较低的解决方案是在语言边界建立一个“防火墙”,在所有异常能够穿越到一个不懂它们语言的世界之前捕获它们。

这一原则延伸到了像​​协程 (coroutines)​​ 这样的最新并发特性。当一个协程 yields (让出)时,它会暂停自己的执行并将控制权转移给一个调度器。这是另一种形式的非局部控制转移。与调度器之间不存在调用者-被调用者关系。协程自身负责在进入休眠前保存其全部存活状态——即任何它在恢复时将需要的、位于任何寄存器(无论是调用者保存还是被调用者保存)中的所有东西。

盔甲的裂缝:安全视角

我们已经看到了系统如何不懈地努力维护调用约定契约。调用者信任被调用者,操作系统信任自己的机制,编译器信任自己的模型。但在安全领域,每一丝信任都是一个潜在的漏洞。

经典的栈缓冲区溢出攻击涉及破坏栈上的返回地址,将控制权转移到恶意代码。但一种更为微妙的攻击则利用了被调用者保存寄存器约定的机制本身。想象一下,攻击者在函数 process 中发现了一个缓冲区溢出。他们并不覆盖返回地址,而是刚好写到足够远的位置,覆盖了 process 代表其调用者 dispatch 保存被调用者保存寄存器(比如 `RBXRBXRBX)到栈上的那个位置。

现在,process 函数的尾声开始执行。它尽职尽责、正确地“恢复”了被调用者保存的寄存器。它将攻击者的恶意值从栈中弹出到 $RBX 中。然后它执行一个完全正常的返回,控制权回到 dispatch。调用者 dispatch 相信被调用者履行了约定,于是继续使用 $RBX,以为它包含着调用前那个可信的值。但现在它持有的是攻击者的毒药。如果 dispatch 使用这个被投毒的寄存器进行间接调用,攻击者就获得了程序的完全控制权。攻击之所以成功,不是因为破坏了规则,而是利用了系统对规则的忠实遵守。

从操作系统的稳定性到 JIT 编译器的性能,从异常的实现到安全漏洞的利用,被调用者保存和调用者保存寄存器这个简单的约定是一条贯穿始终的主线。它证明了一个简单的、明确定义的契约,当应用于最低的抽象层次时,能够产生多么深刻而深远的影响,从而塑造整个数字世界的行为、性能和安全。