try ai
科普
编辑
分享
反馈
  • 过程调用约定:代码背后看不见的机制

过程调用约定:代码背后看不见的机制

SciencePedia玻尔百科
核心要点
  • 过程调用约定(或 ABI)是调用函数(调用者)和被调用函数(被调用者)之间的契约,它定义了传递参数、返回值和管理资源的规则。
  • 函数调用通过使用栈来创建私有工作区(栈帧),并利用 CPU 寄存器来快速传递参数和返回值。
  • 寄存器被分为“调用者保存”和“被调用者保存”两类,以通过最小化跨函数调用保存和恢复其值的开销来优化性能。
  • 调用约定的设计对系统性能、安全漏洞(如栈溢出攻击)以及 FFI 和尾调用优化等语言特性的实现具有深远影响。

引言

在编程世界中,调用函数这一简单行为——一行像 result = f(x, y) 这样普通的代码——会触发一系列复杂而隐秘的操作。这个错综复杂的过程由一套严格的规则所支配,这是一项确保调用函数与被调用函数能够无缝协作的协议。该协议就是​​过程调用约定​​,或称​​应用程序二进制接口 (ABI)​​,它是使现代模块化软件成为可能的无形语法。虽然程序员很少直接与其交互,但理解这一约定揭示了软件执行的基本机制,以及某些编码选择为何会带来深远的性能和安全影响。本文将揭开这一基本机制的神秘面纱。第一部分​​“原理与机制”​​将解构函数调用的剖析,探讨栈、寄存器以及调用者与被调用者之间关键契约的作用。随后,​​“应用与跨学科联系”​​部分将展示这些底层规则如何在高性能计算、系统安全、编译器设计和语言互操作性等领域产生深远影响。

原理与机制

当我们写下一行 result = f(x, y) 这样的代码时,我们想当然地认为背后有一套复杂而静默的机制正在启动。函数调用不仅仅是从程序的一个部分跳转到另一个部分,它是一个精心编排的仪式,一次控制权和信息的交接。为了使这次交接顺利进行,进行调用的函数(​​调用者​​)和被调用的函数(​​被调用者​​)必须就一套精确的规则达成一致。这种一致,这种函数间的“社会契约”,在形式上被称为​​过程调用约定​​或​​应用程序二进制接口 (ABI)​​。正是这种无形的语法使得模块化、可复用的代码得以存在。

让我们来揭开这个仪式的面纱。基本问题很简单,但它们的答案却很深刻:调用者如何将参数 x 和 y 传递给被调用者 f?f 如何返回其 result?f 在工作时将其私有的笔记和工具——即它的局部变量——存放在哪里?而且最重要的是,当 f 完成后,它如何知道该怎样返回到调用者那里?这些问题的答案定义了 ABI,理解它们就像学习机器的秘密语言。

舞台:栈及其帧

想象一下计算机的内存是一个广阔的空间。这个空间的一个特殊部分被作为​​栈​​来管理。你可以把它想象成一叠盘子:你只能在最上面加一个新盘子,也只能移走最上面的盘子。在计算机中,栈的“顶部”由一个名为​​栈指针 (SPSPSP)​​ 的特殊寄存器来跟踪。当一个函数被调用时,它会在栈顶为自己预留一个新的工作空间。这个私有工作空间被称为​​活动记录​​,或者更常见的叫法是​​栈帧​​。

每次函数被调用时,一个新的帧被压入栈中;当它返回时,它的帧被弹出。这就形成了一个帧链,每个帧代表一个活跃的函数调用。栈帧是函数在其执行期间的整个世界。它被精心组织以包含几个关键信息:

  • ​​返回地址​​:这是最关键的一条信息。它是调用者代码中的一个内存地址,程序在被调用者完成其工作后必须返回到该地址处的指令。
  • ​​指向上一个帧的链接​​:为了在调用链中导航,一个帧通常会存储前一个帧基址的位置。这通常通过一个​​帧指针 (FPFPFP)​​ 来完成,它是一个稳定的寄存器,指向当前帧内的一个固定位置,起到锚点的作用。
  • ​​参数​​:如果一个函数的参数太多,无法全部放入寄存器(我们接下来会看到),多余的参数就会被放在这里,即栈上。
  • ​​局部变量​​:函数私有的草稿板,用于存储仅在其执行期间存在的变量。
  • ​​已保存的寄存器​​:一个临时存储某些寄存器值的空间,函数必须为它的调用者保留这些值。

这种基于栈的系统的美妙之处在于它能优雅地处理递归。考虑两个相互调用的函数 f 和 g。如果 main 调用 f(4),后者又调用 g(3),后者再调用 f(2),以此类推,栈会随着每次调用而变得更深。首先为 f(4) 创建一个新帧,然后一个 g(3) 的帧被堆叠在其上,再然后是 f(2) 的帧,等等。栈指针(在大多数现代系统中)稳定地向下移动,消耗内存。任何时刻栈的总深度都是函数调用深度的直接可视化。

这种结构也使得我们编码选择的性能后果变得异常清晰。想象一下将一个大数据块,比如一个 48 字节的结构体,传递给一个函数。如果我们​​按值传递​​,整个 48 字节的结构体被复制到被调用者栈帧的参数区域。如果我们​​按引用传递​​,我们只复制该结构体的地址——一个单独的指针,可能只有 8 字节。在深层递归调用链中,这个选择会产生显著影响。按值传递可能导致栈急剧增长,消耗大量内存并可能导致栈溢出,而按引用传递则能保持栈帧的苗条和高效。

台上的演员:寄存器

虽然栈提供了舞台,但主要的表演发生在​​寄存器​​中。寄存器是 CPU 最快、最宝贵的内存位置——一个紧邻处理器的小型工作台。因为它们速度极快,所以最常用的数据都保存在寄存器中。自然地,过程调用约定极其谨慎地规定了它们的使用。

最常见的策略是直接在寄存器中传递函数的前几个参数。例如,用于 Linux 和 macOS 的 AMD64 系统 System V ABI 规定,前六个整数或指针参数通过寄存器 rdi,rsi,rdx,rcx,r8,r9rdi, rsi, rdx, rcx, r8, r9rdi,rsi,rdx,rcx,r8,r9 传递。ARM 64 位 ABI (AAPCS64) 更进一步,为浮点值提供了一组独立的寄存器。它将前八个整数/指针参数分配给寄存器 x0x0x0 到 x7x7x7,前八个浮点参数分配给寄存器 v0v0v0 到 v7v7v7。如果一个函数的参数多于可用的寄存器,这些“溢出”的参数就会被放置在栈上,正如我们之前看到的。即使是像小型 struct 这样的复杂数据类型,如果能装下,也可以巧妙地打包进一两个寄存器,从而完全避免缓慢的内存访问。

这种以寄存器为中心的方法带来了一个新难题。寄存器是一种共享的全局资源。当调用者调用被调用者时,谁对这些寄存器中的值负责?如果调用者在一个寄存器中有一个重要的值,而那个被调用者需要用同一个寄存器进行自己的计算,就可能发生混乱。

解决方案是一种巧妙的分工,将寄存器分成两组:​​调用者保存​​和​​被调用者保存​​。

  • ​​调用者保存寄存器​​(或易失性寄存器)是被调用者可以随意使用的区域。被调用者可以未经许可地使用和修改它们。如果调用者在调用者保存寄存器中有一个值,并且在调用返回后还需要这个值,那么调用者有责任在进行调用前保存它(通常保存到自己的栈帧中),并在之后恢复它。

  • ​​被调用者保存寄存器​​(或非易失性寄存器)是调用者珍视的财产。调用者可以相信,在被调用者返回后,这些寄存器中的值将保持原样。如果被调用者需要使用这些寄存器中的一个,它必须首先小心地保存原始值,然后在返回给调用者之前恢复它。

这种区分对性能有直接影响。一个​​叶函数​​——即不调用其他任何函数的函数——通常可以完全在调用者保存寄存器中运行。它不需要保存和恢复任何东西,这使得它非常快。然而,一个​​非叶函数​​必须自己进行调用。为此,它可能需要保存一些自己的重要值。如果它用完了调用者保存寄存器,它将被迫使用被调用者保存寄存器,从而产生将它们保存到栈上再加载回来的开销。这个保存/恢复过程会消耗宝贵的 CPU 周期。

但是 ABI 设计者如何决定哪些寄存器属于哪个组呢?这并非任意选择;这是一个基于概率的优美优化问题。想象一下,一次保存-恢复周期的成本是 (cs+cr)(c_s + c_r)(cs​+cr​)。对于任意给定的寄存器,假设调用者在其中有活动值的概率是 pip_ipi​,而被调用者需要使用它的概率是 qiq_iqi​。

  • 如果我们将其设为调用者保存寄存器,成本以概率 pip_ipi​ 支付。
  • 如果我们将其设为被调用者保存寄存器,成本以概率 qiq_iqi​ 支付。 为了最小化平均成本,我们应该简单地选择概率较低的约定。因此,该寄存器的理想约定对应于 min⁡(pi,qi)\min(p_i, q_i)min(pi​,qi​)。通过为每个寄存器单独做出这个最优选择,所有寄存器的总开销得以最小化。ABI 文档中那些看似随意的寄存器列表,实际上是这种优雅的统计推理的结果,并针对典型的程序行为进行了调整。

契约的细则

一个设计良好的 ABI 的美妙之处在于其完备性。它为一系列微妙但重要的情况提供了优雅的解决方案。

如果一个函数需要返回多个值怎么办?单个返回寄存器是不够的。契约可以扩展:调用者可以为返回值分配内存,并传递一个指向该内存的​​隐藏指针​​作为不可见的第一个参数。被调用者在返回前用其结果填充这块内存。

函数如何回家?返回地址的放置位置是不同架构之间的关键哲学差异。x86-64 家族使用基于栈的方法:CALL 指令本身将返回地址推入栈中。这既简单又稳健。相比之下,像 ARM 和 MIPS 这样的 RISC 架构使用​​链接寄存器 (LRLRLR)​​。调用指令将返回地址放入这个特殊寄存器中。对于叶函数来说,这是一个极好的优化——它们可以无需接触栈就返回。但对于非叶函数,则有一个问题:在进行嵌套调用之前,函数必须将 LRLRLR 保存到其栈帧中,因为嵌套调用会覆盖它。这是一个经典的硬件-软件权衡,即在常见情况(叶函数)下追求速度与在复杂情况下增加一点额外工作之间的平衡。

ABI 还必须适应编程语言的特性。在 C 语言中,你可以获取函数参数的地址。但如果该参数是通过寄存器传递的,它就没有内存地址!为了解决这个问题,ABI 规定,如果一个参数的地址被获取,它必须被“安置”(homed)——即从其寄存器存储到栈帧上的一个指定位置。对于像 printf(format, ...) 这样的可变参数函数,也需要类似的机制。实现可变参数的代码需要遍历内存中一个连续的参数列表。为了实现这一点,被调用者系统地将所有通过寄存器传递的参数保存到其栈帧上的一个特殊“寄存器保存区域”,使它们变得可寻址。

也许最引人入胜的是 ABI 契约如何与操作系统互动。AMD64 System V ABI 包含一个名为​​红色区域 (red zone)​​ 的巧妙优化:这是当前栈指针下方一个 128 字节的区域,叶函数可以将其用作临时空间而无需移动栈指针。对于用户模式代码,操作系统承诺不会用像信号这样的异步事件来干扰这个区域。这是一块“免费”的内存。然而,这个承诺在内核模式下是无效的。硬件中断随时可能发生,而 CPU 对 ABI 的社会契约一无所知,会开始将其状态推到栈上,恰好从红色区域开始。内核函数存放在那里的任何数据都将立即被破坏。这是一个深刻的教训:ABI 是一个仅在其指定域内有效的契约,对其边界的无知可能导致灾难性的、难以调试的失败。

当契约被打破时

那么,如果调用者和被调用者对契约有不同的理解,会发生什么?由于编程错误,比如通过错误类型的指针调用函数,这种情况发生的频率惊人。结果是​​未定义行为​​,但这并非随机的魔法——它是一种可预测的、机械性的故障。

想象一个调用者认为它在调用一个函数 long f(long, long, long)。它尽职地将它的三个 long 参数放入整数寄存器 rdi,rsi,rdxrdi, rsi, rdxrdi,rsi,rdx。它期望在寄存器 raxraxrax 中取回 long 类型的结果。

但假设函数指针被意外地指向了一个函数 double h(double, int, long)。这个被调用者有一套完全不同的脚本。它期望它的第一个 (double) 参数在浮点寄存器 xmm0xmm0xmm0 中。它期望它的第二个 (int) 参数在 rdirdirdi 中,它的第三个 (long) 参数在 rsirsirsi 中。它将在 xmm0xmm0xmm0 中返回它的 double 结果。

结果是一场错误的闹剧:

  1. 被调用者 h 从 xmm0xmm0xmm0 读取它的第一个参数。调用者从未在那里放任何东西,所以 h 得到一个不确定的垃圾值。
  2. h 从 rdirdirdi 读取它的第二个参数,而调用者在那里放的是它的第一个参数。
  3. h 从 rsirsirsi 读取它的第三个参数,而调用者在那里放的是它的第二个参数。
  4. 调用者的第三个参数,存放在 rdxrdxrdx 中,被完全忽略了。
  5. h 基于一个垃圾值和两个错位的参数计算出一个结果,并将最终的垃圾 double 值放入 xmm0xmm0xmm0。
  6. 耐心等待的调用者在 raxraxrax 中寻找它的 long 结果,而 raxraxrax 中包含一个与计算完全无关的值。

程序不会立即崩溃。它只是默默地产生无意义的结果。这说明了最后也是最重要的一点。过程调用约定是我们所有高级软件赖以构建的刚性、无形的框架。它是一项标准化的胜利,使得复杂的系统可以由简单的、独立的部分构建而成。当这个契约得到遵守时,美妙的复杂性便应运而生。当它被打破时,我们便会想起,在我们优雅的抽象之下,是一台只精确执行其被告知指令的机器。

应用与跨学科联系

一台高性能超级计算机、保护您数据的安全协议,以及您所编写的编程语言本身,它们有什么共同点?它们都受到一套被称为​​过程调用约定​​的规则所编排的复杂而静默的舞蹈的支配。在探讨了函数如何相互调用——它们如何传递参数、返回值,以及如何管理机器寄存器的精细状态——的原理之后,我们现在可以领会这个契约所带来的深远且常常令人惊讶的后果。它远不止是一个技术细节;它是一个统一的原则,其影响力辐射到整个计算机科学领域。

性能的基石:硬件与高性能计算

在其最根本的层面上,调用约定关乎效率。每一纳秒都至关重要,而这个契约的规则旨在最小化不同代码片段之间通信的开销。当用户程序需要向操作系统内核请求服务,跨越用户空间和特权内核空间之间的“巨大鸿沟”时,这一点变得惊人地清晰。旧系统可能会使用一个通用的“陷阱”指令,这是一种成本高昂、一刀切的机制。然而,现代硬件通常提供一个专门的 syscall 指令。这个指令是与 ABI 协同设计的;它知道内核可能需要哪些寄存器,并能执行优化的、硬件辅助的状态保存和恢复。性能增益并非微不足道——一个专门的 syscall 指令可以比一个通用陷阱快将近两倍,仅仅通过使硬件适应调用约定的期望即可实现。

这种对速度的不懈追求深入到处理器本身的微架构中。在现代的乱序执行核心中,处理器是一个混乱但聪明的调度器,同时处理数十个微操作以保持其执行单元的繁忙。当一个函数需要其参数时,基于栈的约定迫使其发出 load 指令,这增加了混乱。这些加载操作会消耗处理器内部工作列表(保留站和重排序缓冲区)中的宝贵条目,并在其结果被广播给等待的指令时,在内部通信网络上产生更多流量。相比之下,基于寄存器的调用约定对处理器来说是一份礼物。参数已经就位,随时可用。通过消除那些额外的加载操作,我们减轻了整个乱序执行引擎的压力,可衡量地减少了内部通信,并为有用的计算释放了资源。

对于高性能计算(HPC)的巨头们来说,这种优化成为一种艺术形式。想象一下处理巨大的 512 位向量寄存器,这是科学模拟的主力。ABI 设计者面临一个有趣的困境:这些巨大的寄存器中,哪些应该被指定为被调用者保存?如果我们设置了太多的被调用者保存寄存器,调用者会很高兴——它可以在函数调用期间将临时值保存在那些寄存器中而无需担心。但负担转移到了每个被调用者身上,即使它不使用这些寄存器,也必须花费周期来保存和恢复这组庞大的寄存器。如果我们设置的被调用者保存寄存器太少,被调用者会很精简,但调用者将被迫不断地将其自己的数据溢出到栈上并重新加载。最优的 ABI 设计是一个精心的平衡行为,一个旨在最小化总数据移动并直接转化为更快科学发现的数学优化。

信任的基础:安全与健壮性

没有正确性,速度就毫无意义,而正确性是安全的孪生兄弟。调用约定是一个契约,当这个契约被违反——或被利用——时,后果可能是灾难性的。最著名的例子涉及返回地址,即函数用来找回其调用者路径的面包屑。几十年来,一种被称为“栈溢出攻击”的常见攻击方式就是用恶意代码覆盖这个返回地址。

我们如何防御这种攻击?通过加固调用约定本身。一种强大的技术是​​影子栈​​。编译器修改过程调用序列,将返回地址的第二个副本保存在一个独立的、受保护的内存区域中。在返回时,它会检查常规栈上的地址是否与影子栈上的地址匹配。如果它们不同,就表示有攻击发生并停止程序。这个优雅的解决方案,一个围绕 call 和 ret 指令的简单复制和比较,以可量化的性能成本提供了强大的保护。

更复杂的攻击,如​​面向返回的编程(ROP)​​,并不注入新代码,而是巧妙地将程序自身内存中现有的代码片段(“小工具”,gadgets)链接起来,每个片段都以 RET 指令结尾。攻击者在栈上植入一系列伪造的返回地址,将处理器自身的返回机制变成一个傀儡。然而,即使在这里,调用约定也扮演着防御角色。调用者保存和被调用者保存寄存器之间的区别至关重要。攻击者的小工具链通常需要为一个最终的恶意函数调用设置参数。设置一个调用者保存寄存器很容易。但设置一个被调用者保存寄存器则更难;ABI 保证其值在函数边界之间被保留。一个修改了被调用者保存寄存器然后返回的简单 ROP 链会违反这个契约,很可能在攻击目标达成之前就导致崩溃。为了成功,攻击者必须找到更复杂的小工具来恢复原始值,这增加了制作成功漏洞利用的难度。因此,一个为程序正确性设计的简单规则,为攻击者提供了内在的、可衡量的障碍。

通用翻译器:互操作性与语言特性

代码很少是独居的隐士;它生活在一个由库、模块甚至不同编程语言组成的繁华城市中。ABI 是通用翻译器,是允许这些不同世界进行交流的外交协议。当协议被误解时,结果是混乱的。考虑用不同浮点设置编译的代码。一个“软浮点”模块,在编译时没有假设有 FPU 硬件,会使用两个通用寄存器来传递一个 double 类型。而一个“硬浮点”模块期望在专用的 64 位浮点寄存器中接收同一个 double。如果一个软浮点调用者试图调用一个硬浮点被调用者,就像一个说话者把礼物放在桌子上,而接收者却在邮箱里寻找它。被调用者接收到垃圾数据,执行无意义的计算,并返回一个不正确的结果,所有这一切都是因为双方对调用约定契约有不同的理解。

这个原则是​​外部函数接口 (FFI)​​ 的基石,它允许,例如,一个 Rust 程序调用一个 C++ 库。这种魔法是通过一个共同的基础实现的:extern "C" 调用约定。这是双方承诺遵守一套简单、稳定的参数传递规则。但还需要另一部分:一个稳定的名称。C++ 使用“名称修饰” (name mangling) 将函数的命名空间、类和参数类型编码成一个唯一的链接器符号。这对于 C++ 来说非常棒,但对其他语言来说却是天书。FFI 桥梁的工作方式是让 C++ 端提供一个用 extern "C" 声明的简单包装函数。这告诉 C++ 编译器为该函数生成第二个、未修饰的名称——一个稳定的、面向公众的名称,Rust 代码可以与之链接,从而在两种语言国家之间建立一个强大的外交渠道。

有时,调用并非来自另一段软件,而是来自硬件本身。当嵌入式处理器上发生硬件中断时,CPU 会强制停止它正在做的事情,并跳转到一个中断服务程序 (ISR)。这是由硬件发起的突然的、不可协商的过程调用。为了用高级代码处理中断,汇编 ISR 包装器必须充当一个细致的翻译员。它必须确定硬件使用了哪个栈,获取必要的数据,并为 C 函数准备参数。至关重要的是,它必须遵守 ABI 的每一条规则,例如在调用 C 函数之前确保栈是 8 字节对齐的。一个单一的失误,一个未能正确对齐栈的失败,都会违反契约,并可能导致静默的数据损坏或系统崩溃。

抽象的引擎:编译器与运行时

也许最优雅的应用是那些调用约定成为强大编程抽象引擎的场景。例如,函数式编程语言将函数视为可以被传递和返回的一等公民。这通常通过一个​​闭包​​来实现,闭包是一个包含代码指针和指向函数捕获变量“环境”指针的数据结构。调用约定必须扩展以处理这种情况:你如何既传递普通参数又传递这个特殊的环境指针?

这个问题的答案解锁了函数式编程中最著名的特性之一:​​尾调用优化​​。当一个函数的最后一个动作是调用另一个函数时,就没有必要再回来了。新函数可以直接返回给原始调用者。一个聪明的编译器通过操纵调用约定来实现这一点。它不是执行一个 call 指令(这会推入一个新的返回地址),而是首先拆除自己的栈帧,为下一个函数设置好参数,然后执行一个简单的 jump。这种巧妙的手法将一个可能无限的递归调用链转变为一个简单、高效的循环,所有这些都是通过巧妙地绕过标准调用序列中的一个小步骤实现的。

最后,调用约定是运行时系统的沉默伙伴,支持着像精确​​垃圾回收 (GC)​​ 这样的特性。要使 GC 工作,它必须能够找到堆上指向对象的每一个活动引用;这些是回收的“根”。这些根中的许多都存在于调用栈上。但栈是一个混乱、不断变化的地方。GC 如何安全地导航它?它依赖于​​帧指针​​,这是一个由调用约定建立的稳定锚点,指向当前函数栈帧内的一个固定位置。即时(JIT)编译器知道栈帧相对于这个锚点的确切布局,为每个潜在的 GC 点生成一个​​栈图 (stack map)​​。这个图是垃圾回收器的寻宝指南,精确列出了从帧指针开始可以找到活动引用的偏移量。稳定的帧指针是灯塔,而栈图是让 GC 能够导航栈的险恶水域并以完美精度回收内存的航海图。

从 CPU 的裸机到语言抽象的最高层,过程调用约定是一条统一的线索。它是一项精心制定的协议,使我们复杂的软件系统能够由数十亿个微小的、独立的部分构建而成,所有这些部分都在一场静默、优雅且惊人有效的舞蹈中协同工作。