
当一个软件调用另一个软件时,它们必须共享 CPU 最宝贵的资源:寄存器。这带来了一个根本性问题:如何协调它们的使用,以防止被调用函数破坏调用者的数据?没有明确的协议,计算将陷入混乱。本文旨在探讨解决这一挑战的优雅约定——调用者保存与被调用者保存寄存器,这是应用程序二进制接口(ABI)的基石。
首先,在“原理与机制”一章中,我们将剖析这一“社会契at约”,理解调用者保存(易失性)和被调用者保存(非易失性)寄存器之间的分工,以及使该系统如此高效的经济权衡。接着,在“应用与跨学科联系”一章中,我们将拓宽视野,观察这一核心原则如何影响从操作系统设计、编译器优化到语言运行时和网络安全的方方面面,揭示一条贯穿计算机科学不同领域的线索。
想象你正在一个繁忙的车间里,与人协作一个复杂的项目。你有一个私人工具箱,公共工作台上也有一套共享工具。当你叫来一位同事帮忙完成某项任务时,就需要一个协议——一种社会契약。你的同事需要使用一些工具。但如果你正在使用公共工作台上的某一把特定扳手怎么办?如果他们不问自取,拿走了你最喜欢的私人螺丝刀怎么办?混乱将会接踵而至,你的项目也会被毁掉。
这正是计算核心面临的困境。当一个函数(我们称之为调用者)调用另一个函数(被调用者)时,它们共享着一种有限而宝贵的资源:处理器的寄存器。寄存器是 CPU 中最快的存储位置,是我们类比中的工作台和工具箱。被调用者需要它们来执行计算,但调用者可能正用这些寄存器保存重要的中间结果。我们如何防止被调用者不假思索地覆盖调用者的关键数据呢?
解决方案并非技术奇迹,而是一种约定——一套被称为应用程序二进制接口(ABI)的既定规则。这个 ABI 是编程的社会契约,其核心是为寄存器管理制定的一套巧妙而简单的分工。
ABI 将通用寄存器划分为两个不同的类别,每一类都有不同的职责。
首先是调用者保存寄存器(caller-saved registers),也称为易失性寄存器(volatile registers)。可以把它们想象成共享工作台上的公共工具。契约很简单:任何函数(被调用者)都可以自由使用它们,无需征求许可。它可以使用这些寄存器,并将其置于不同的状态。它们是“易失性”的,因为它们的内容预计会在函数调用中被销毁。如果你(调用者)在某个调用者保存寄存器中有一个值,并希望在同事工作完成后继续使用它,那么在进行调用前,你有责任将其保存到安全的地方(比如栈上),并在调用后恢复它。当然,你只有在该值后续确实需要时才会这样做——编译器通过一个称为存活分析(liveness analysis)的过程来确定这一属性。如果一个值是“死的”(不会再被使用),保存它就毫无意义。
其次是被调用者保存寄存器(callee-saved registers),或称非易失性寄存器(non-volatile registers)。这些是私人工具。这里的契约恰恰相反:被调用者必须保护它们的值。如果一个函数想借用这些寄存器中的一个来完成自己的工作,它自己有责任先保存原始值(同样,通常在栈上),然后在返回前 meticulously 地恢复它。从调用者的角度看,这些寄存器中的值是“非易失性”的——它们能奇迹般地在函数调用后保持不变。
这种分工是过程式编程的基石。它提供了一个可预测的环境,防止了计算的混乱。
一个自然的问题是:为什么要有两种类型?为什么不简化一下,让所有寄存器要么是调用者保存,要么是被调用者保存?答案在于一种美妙的经济权衡,一种旨在最小化整个程序总工作量的优化。
让我们考虑两种极端情况。如果所有寄存器都是调用者保存的,那么一个不调用其他函数的函数——叶函数(leaf function)——将会极其高效。它可以将每个寄存器都当作临时草稿板使用,保存或恢复任何东西的开销都为零。由于许多程序中很大一部分函数都是简单的叶函数,这对于常见情况来说是一个巨大的胜利。然而,对于一个在循环中调用其他函数的非叶函数来说,这种约定将是一场灾难。如果它将一个关键的循环计数器存储在寄存器中,它将不得不在每次调用迭代时繁琐地保存和恢复该寄存器,从而产生巨大的成本。
那么,如果所有寄存器都是被调用者保存的呢?循环中的非叶函数会很高兴。它可以将其循环计数器放在一个寄存器中,并完全放心地调用其他函数,因为该值将被保留,所有成本由被调用者一次性支付(保存/恢复)。但现在叶函数遭殃了!即使是最简单的函数,仅仅为了将两个数字相加,如果想使用任何寄存器,也必须执行昂贵的保存和恢复操作。我们这样做会惩罚最简单和最常见的情况。
因此,混合约定是一种妥协,是为了优化整个函数生态系统而达成的平衡。它提供了一组“廉价”的易失性寄存器用于快速任务,以及一组“安全”的非易失性寄存器用于长期存在的状态。
这种平衡并非任意设定,而是一个精妙优化问题的解。我们甚至可以对其建模。想象一个简化的世界,一个被调用者使用任意给定寄存器的概率为 ,而调用者需要该寄存器中的值在调用后仍然存在的概率为 。一个简单的概率分析表明,纯粹的被调用者保存约定的期望成本与 成正比,而纯粹的调用者保存约定的期望成本与 成正比。最佳选择取决于哪个更有可能发生:是函数需要一个寄存器进行临时工作,还是调用者需要跨函数调用保留一个值。
我们可以建立更复杂的模型。假设一个处理器有 个寄存器,需要划分为 个调用者保存寄存器和 个被调用者保存寄存g器。我们可以通过经验测量发现,一个典型的调用者有 个希望保留的值,而一个典型的被调用者需要 个寄存器来完成工作。调用者的成本来自于其存活值多于可用的被调用者保存寄存器()。被调用者的成本来自于其需要的临时寄存器多于可用的调用者保存寄存器()。通过对这些成本建模,我们可以推导出总开销的函数,并找到使其最小化的最优值 。这揭示了 ABI 设计不仅仅是一种惯例;它是一门数据驱动的科学。
这种平衡甚至可能受到处理器硬件的影响。一些架构,如 ARM,拥有可以一次性保存或恢复多个寄存器的特殊指令(STM/LDM)。这使得被调用者保存策略的成本更低,因为保存一块寄存器的成本低于逐个保存它们。这一硬件特性改变了经济上的盈亏平衡点,可能有利于设置更多的被调用者保存寄存g器。
那么,这在实践中是如何体现的呢?如果你查看编译器生成的机器代码,你会发现在每个函数的开始和结尾,这个契约都在被强制执行。一个函数的序言(prologue)是它首次在栈上创建空间(其“栈帧”)并尽职地保存它计划使用的任何被调用者保存寄存器的地方。其尾声(epilogue)是它在返回前恢复那些寄存器并归还栈空间的地方。通过检查短短几行汇编代码,人们通常可以推断出正在使用的确切 ABI,观察哪些寄存器被保存以及栈是如何管理的。
这些规则是严格且绝对的。当在不同 ABI 下编译的程序需要通信时,例如一个 RISC-V 程序调用一个 x86-64 库,一个称为跳板(trampoline)的特殊代码段必须充当细致的翻译器。为了在调用中保留一个 RISC-V 的被调用者保存寄存器,跳板必须将其值存储在一个 x86-64 函数保证不会触及的地方:要么是一个 x86-64 的被调用者保存寄存器,要么是跳板自己的栈帧。一个世界的规则不会神奇地应用于另一个世界;它们必须被明确且正确地翻译。
也许这个严格契约最优雅的体现出现在一种名为尾调用优化(tail-call optimization, TCO)的优化中。考虑一个递归函数,其递归调用是它做的最后一件事。未经优化,每次调用都会创建一个新的栈帧,可能消耗大量内存。通过 TCO,整个调用链可以被折叠成一个单一的栈帧,方法是将递归的 call 转换成一个简单的 jump。为什么这成为可能?因为在尾调用点,当前函数的工作已经完成。它没有更多的“存活”值需要保存在调用者保存寄存器中。机器的状态与开始一个新调用所需的状态完全匹配,但旧的返回地址仍然有效。严格的调用者保存约定——即调用者不应期望易失性寄存器在调用后仍然存在——正是让编译器意识到不会丢失任何有价值的东西,从而能够执行这种极其强大的优化的原因。
从一个旨在防止函数相互干扰的简单社会契约,我们得到了一个涉及经济权衡、量化优化并支持优雅的高级编程范式的复杂系统。不起眼的调用者保存寄存器不仅仅是一个草稿板;它是现代软件拱门中的一块基石。
在我们之前的讨论中,我们探讨了调用者保存和被调用者保存寄存器约定的优雅原则。其核心是一个简单的契约,是两段代码——调用者和被调用者——之间的“君子协定”。调用者承诺不期望其临时值(在调用者保存寄存器中)在函数调用后仍然存在,作为回报,被调用者承诺 meticulously 地保存并恢复调用者可能存储在被调用者保存寄存器中的任何长期值。这种分工是效率的奇迹。
但当这个协定受到考验时会发生什么?当我们调用的伙伴不仅仅是另一个函数,而是强大的操作系统本身时会发生什么?或者当我们的程序不是被一个礼貌的调用所打断,而是被来自硬件设备的紧急、异步请求所中断时会发生什么?当一个恶意行为者试图利用这个契约达到邪恶目的时又会发生什么?
正如我们将看到的,这个简单约定的真正美妙之处并非在其孤立状态下显现,而在于它与整个计算生态系统的深刻且常常令人惊讶的互动中。它是一根单一的线,却被编织进操作系统、编译器、语言运行时甚至网络安全的面料中。让我们踏上旅程,追溯这根线索,发现它所揭示的看不见的统一性。
乍一看,用户程序及其操作系统(OS)存在于不同的世界,被一道神圣的特权级别屏障所分隔。然而,它们必须通信。当你的程序需要打开一个文件或通过网络发送数据时,它会执行一次系统调用(system call),这本质上是敲响操作系统的门请求帮助。这不是一个普通的函数调用;它是一个同步陷阱,一种将控制权传递给内核的特殊硬件指令。
但是,你的程序状态——它的变量,它小心保存在寄存器中的计算结果——如何能在这段进入完全不同上下文并返回的旅程中幸存下来?答案是 ABI 的调用约定跨越了这个特权边界。操作系统内核在接收到系统调用时,扮演的是被调用者的角色。它受制于相同的契约。虽然它可以自由使用调用者保存寄存器来处理请求,但它绝对有义务保护被调用者保存寄存器。如果内核在没有保存和恢复的情况下轻率地修改了一个被调用者保存寄存器,这就好比一个图书管理员归还一本撕掉几页的借阅书籍。返回用户空间后,程序可能会崩溃或产生无意义的结果,其状态已被本应为其服务的实体所破坏。这使得 ABI 成为稳定系统设计的基石,确保从用户代码到内核再返回的过程像任何其他函数调用一样无缝和可预测。
现在,让我们将这种礼貌的敲门与更突然的事情进行对比:硬件中断。一次中断——来自网卡宣布新数据包到达或磁盘控制器发出数据就绪信号——不会等待一个方便的时刻。它是异步的。它可以在任何时刻,在任意两条指令之间发生。被中断的代码不是“调用者”;它没有进行调用,也没有机会做准备。
在这里,君子协定被暂时中止。处理该事件的中断服务程序(ISR)不能假设被中断的代码保存了其易失性数据。为了保证完美恢复,ISR 必须承担保存的全部重担。它必须保存它打算使用的每一个寄存器,无论它是调用者保存还是被调用者保存,并在返回前恢复它们。面对这种突然的上下文切换,这种区分暂时变得无关紧要。这凸显了一个关键教训:调用者保存约定是针对同步调用这个可预测世界的一种强大优化,但计算的基本规则是,在不可预测的上下文切换中,状态必须始终被保留。
这种权衡对系统性能有着深远的影响。想象一下设计一个 ABI。如果你将参数传递寄存器指定为调用者保存(一种常见的选择),ISR 就必须在每次中断时保守地保存它们,以防万一它中断了一个正在进行的函数调用。这增加了延迟。但是,如果你将参数放在被调用者保存寄存器中,并设计你的 ISR 来避免使用它们,你就可以为中断创建一个“快速路径”,减少延迟,因为这些寄存器默认会被保留。这在实时和嵌入式系统中是一个微妙但关键的设计选择,在这些系统中,每一微秒都至关重要。
如果说 ABI 是契约,那么编译器就是负责在生成的每一行代码中维护它的工匠大师。对编译器来说,世界是一个复杂的函数调用图,它必须在导航这个图的同时确保没有数据被不当丢失。
考虑编译一个现代程序。为了安全性和灵活性,代码通常被编译成位置无关的(PIC),这意味着它可以被加载到内存的任何地方。一个后果是,调用一个外部函数,比如说来自一个共享库,不再是单个 call 指令。相反,调用首先会转到一个称为过程链接表(PLT)存根的小代码片段。这个存根在全局偏移表(GOT)中查找函数的真实地址,然后跳转到那里。这个 PLT 存根,尽管简单,却是一个被调用者!它可能会使用几个调用者保存寄存器来完成自己的任务。编译器在其智慧中必须知道这一点。在为调用生成代码时,它必须将 PLT 存根视为调用者保存寄存器的潜在破坏者,并相应地保存任何存活的数据。
这种保存的责任给编译器带来了一个持续的优化难题。想象一下,它有一个值在调用后需要使用,但目前它正位于一个调用者保存寄存器中。保护它的最廉价方式是什么?
一个复杂的编译器会在每个调用点权衡这些选项,选择成本最低的策略。一个空闲的被调用者保存寄存器的可用性可能是一个福音,可以节省本应花费在内存访问上的宝贵周期。
调用者-被调用者契约诞生于层次化函数调用的简单模型。但现代编程涉及更多奇特的控制流形式,在每一种形式中,我们都能看到该约定的原则被改编和重生。
以 Python 或 JavaScript 等语言的即时(JIT)编译世界为例。JIT 编译器在运行时将动态代码翻译成快速的机器代码。但有时,它必须“去优化”——退出优化代码,返回到较慢的解释器。为此,运行时必须能够完美地重建程序的状态。这是通过*栈图(stack maps)*实现的——由 JIT 生成的元数据,它像一张蓝图,在特定的“安全点(safepoints)”记录下每个存活变量的位置(哪个寄存器或栈槽)。在这里,调用者/被调用者保存的区别以一种新的形式重新出现。如果栈图格式对于跟踪被调用者保存寄存器有特殊开销(也许是为了更容易地进行栈回溯),一个聪明的 JIT 可以通过在调用安全点之前将值移出被调用者保存寄存器并移入调用者保存寄存器来减小元数据的大小。
或者考虑垃圾回收(GC)。一个精确的 GC 必须暂停程序并寻找所有的“根(roots)”——那些保存在寄存器或栈上、指向堆内存的指针。调用约定直接影响了这次搜寻。一个有很多被调用者保存寄存器的约定意味着,根据规则,被调用者会将这些寄存器保存到栈上。从 GC 的角度来看,这将根从分散的寄存器世界移动到了更有序的栈结构中。这可以简化查找寄存器根所需的元数据(“寄存器根图”),但代价是更复杂的栈扫描。约定的选择为 GC 设计者在元数据大小和扫描逻辑之间提供了一个根本性的权衡。
最后,想想协程(coroutines),现代 async/await 语法的基础。当一个协程 yields 或 awaits时,它会暂停执行并将控制权交给调度器,调度器可能会运行其他任务。就像一个异步中断一样,协程不知道在它恢复之前会发生什么。没有可以信任的被调用者。协程本身负责保存其全部存活状态——它在恢复时需要的每个寄存器中的每个值,无论是调用者保存还是被调用者保存。这呼应了从中断中学到的教训:简单的调用者/被调用者保存契约是为特定的、同步的交互而设计的。在此之外,必须回归到基本原则:保存你需要生存下来的一切 [@problemid:3626247]。
一个为合作而设计的契约,不幸的是,也可能成为被利用的目标。调用者保存约定,以其优雅的效率,也带来了微妙的安全隐患。
当一个调用者执行一个函数时,它会留下其调用者保存寄存器中的任何数据。ABI 说被调用者可以忽略这些数据并覆盖它。但如果被调用者是恶意的,或者只是有泄漏?它可能会读取这些“过时”的数据。如果这些数据是敏感的——密码片段、加密密钥——它就可能被窃取。这导致了一些安全强化策略,即指示编译器在调用前主动插入指令以清零调用者保存寄存器。这是一个权衡:付出小的、可预测的性能成本,以消除潜在的灾难性信息泄漏风险。
现在,让我们反转一下场景。如果攻击者是试图进行调用的一方呢?在一种名为返回导向编程(Return-Oriented Programming, ROP)的强大攻击技术中,攻击者通过串联称为“gadgets”的现有代码小片段来劫持程序的控制流,每个片段都以 ret 指令结尾。他们的目标是用程序自己的构建块来构建一个恶意的有效载荷。
在这里,被调用者保存约定,曾经是礼貌的规则,现在成了攻击者的障碍。假设攻击者需要将一个寄存器设置为特定值,并找到了一个可以做到这一点的 gadget。如果该寄存器是被调用者保存的,那么这个 gadget 可能是一个函数的一部分,该函数为了符合 ABI,包含了在返回前恢复该寄存器原始值的其他指令。或者,如果攻击者的 gadget 本身修改了一个被调用者保存寄存器,他们必须找到另一个 gadget 来稍后恢复其原始值,否则当一个合法的函数发现其宝贵的保存状态被破坏时,程序就会崩溃。这为构建一个稳定的 ROP 链增加了显著的复杂性。这个旨在帮助函数合作的约定,迫使攻击者做更多的工作来使其恶意代码与程序的其余部分“合作”,从而使攻击更难编写。
从程序与其操作系统之间的基础契约,到编译器的精细优化,再到现代语言运行时的复杂机制和网络安全的阴暗世界,调用者保存和被调用者保存寄存器约定的影响无处不在。它是一个简单、局部规则引发复杂、全局行为的完美例子。它是一个安静的证明,证明在计算世界中,没有什么是孤立存在的。每个组件、每个契约,都是一个庞大、互联且惊人优雅的整体的一部分。