try ai
科普
编辑
分享
反馈
  • 过程调用

过程调用

SciencePedia玻尔百科
核心要点
  • 过程调用是一种结构化跳转,它使用调用栈来保存返回地址和局部状态,从而实现模块化和层次化编程。
  • 调用约定 (ABI) 定义了传递参数和管理寄存器的契约,不同的架构方法(如链接寄存器)提供了性能上的权衡。
  • 尾调用优化 (TCO) 和全程序优化等优化技术通过操纵调用图和栈的使用来改变程序性能。
  • 过程调用这一抽象概念从硬件(返回地址栈)延伸到软件(编译器、RPC、用户级线程),但其动态行为在根本上是不可判定的。

引言

在软件架构中,很少有哪个概念能像过程调用一样既基础又强大。正是这一机制,让我们能够将庞大复杂的问题分解成易于理解的小块,用简单可复用的模块构建起宏大的逻辑结构。没有它,现代结构化编程将无法想象,我们会迷失在混乱的非结构化跳转之中。本文将深入探讨这一计算基石背后优雅的运作机制。我们的旅程始于第一章“原理与机制”,在其中我们将剖析一次调用的核心组件——调用栈、活动记录和调用约定——并探索使其成为可能的硬件与软件之间错综复杂的协作。接着,第二章“应用与跨学科关联”将拓宽我们的视野,揭示这一基本概念如何在编译器设计、硬件架构、分布式系统中产生回响,甚至触及我们能对自己程序了解多少的理论极限。

原理与机制

想象你正在读一本引人入precepts的书,遇到一个脚注,指引你到附录去获取更深入的解释。你在当前行做一个书签,跳转到附录,读完后,再用这个书签回到你离开的确切位置。这个留下标记、探索分支、然后忠实返回的简单动作,正是过程调用的灵魂所在。

在计算世界里,一个简单的“跳转”或 GOTO 就像翻到书中的任意一页;你不知道自己从何而来。相比之下,过程调用是一个带有承诺的 GOTO——一个必定返回的承诺。这个简单而深刻的机制是所有现代结构化编程的基石。它允许我们将艰巨的任务分解为可管理的子程序,并确信每个子任务在完成后都会将控制权交还给其主控者。但是,机器是如何信守这个承诺的呢?答案在于硬件与软件之间一场优美而雅致的协作,而这场协作围绕着一个单一的核心概念展开:调用栈。

调用栈:工作空间的塔楼

当一个过程(我们称之为 Caller)调用另一个过程(Callee)时,Caller 必须保存​​返回地址​​——这个“书签”指向它本打算执行的下一条指令。但该把它放在哪里呢?答案是计算机内存中一个特殊的、以​​栈​​的形式组织的区域。

想象一下自助餐厅里的一叠盘子。你只能把新盘子放在最上面,也只能从最上面取走盘子。这种“后进先出”(LIFO)的规则恰好是过程调用所需要的。当 A 调用 B 时,它将 B 的返回地址压入栈中。如果 B 接着调用 C,它会将 C 的返回地址压在 B 的返回地址之上。当 C 完成时,它从栈顶弹出自己的返回地址并跳转过去,将控制权还给 B。当 B 完成时,它做同样的事情,弹出它的返回地址(现在位于栈顶)以返回 A。栈的 LIFO 特性完美地反映了调用的嵌套结构。

但一个过程需要的不仅仅是返回地址。它还需要一个用于其自身生命周期的私有工作空间:一个存放传入参数、存储局部变量以及保存任何临时值的地方。这一整套信息,对应于一个过程的单次活动调用,被称为​​活动记录​​(activation record)或​​栈帧​​(stack frame)。当一个过程被调用时,一个新的活动记录被推入调用栈。当它返回时,其记录被弹出。因此,调用栈不仅仅是一个返回地址列表,更是一个由这些活动记录组成的动态塔楼。

调用的精妙协作

创建和拆除这些栈帧是一场精密的协作。为了使系统正常工作,调用者和被调用者必须遵守一套精确的规则。这份契约被称为​​调用约定​​(calling convention)或​​应用程序二进制接口​​(Application Binary Interface, ABI)。它是过程调用通用的礼仪规范。

传递信息:参数

调用者如何向被调用者传递参数?ABI 定义了具体方法。通常,前几个参数会通过最快的方式传递:将它们直接放入 CPU 的通用寄存器中。如果参数太多,无法装入指定的寄存器,剩余的参数则会“溢出”到栈上,放置在被调用者的新活动记录内部。

这个决定对性能有实际影响。例如,在 ARM 架构中,传递浮点数可以通过通用寄存器(“软浮点”ABI)或专用的浮点寄存器(“硬浮点”ABI)来完成。如果一个函数在软浮点约定下被调用,且带有许多 double 精度的参数,它可能很快就会用尽可用的通用寄存器。关于 double(占用两个寄存器)必须如何对齐的规则会使这个打包难题进一步复杂化。在一次有七个 double 参数的调用中,也许只有前两个能放入寄存器,迫使其他五个通过缓慢的、基于内存的栈来传递。由于内存延迟,这种到内存的“溢出”可能会为每个参数带来显著的周期惩罚。ABI 的选择是简单性与性能之间一个典型的工程权衡。

返回的艺术:链接寄存器 vs. 栈

最关键的信息是返回地址。它是如何保存的?在这里,不同的架构分为两大主要哲学,每种都有其独特的优美逻辑。

  1. ​​直接保存在栈上:​​ 一些架构,如无处不在的 x86,拥有一个 call 指令,它会在执行过程中自动将返回地址压入栈中。这既简单又稳健。每次调用都产生一次内存写入,每次返回都产生一次内存读取。

  2. ​​链接寄存器:​​ 其他架构,特别是 RISC 傳統中的架构(如 ARM、MIPS、RISC-V),采用了一种不同的方法。call 指令将返回地址放入一个特殊的高速 CPU 寄存器,通常称为​​链接寄存器 (LRLRLR)​​。这非常快——不需要内存访问!然而,这也带来一个难题:如果被调用者需要进行另一次调用,该怎么办?新的调用会覆盖链接寄存器,从而永远丢失原始的返回地址。

解决方案是一个优雅的折衷方案。如果一个函数是​​叶函数​​(即不再进行其他调用),它可以将返回地址就留在链接寄存器中,并在返回时使用它,从而使返回地址的内存流量为零。这是一个巨大的优化。然而,如果函数是​​非叶函数​​(即需要调用另一个函数),它的首要任务必须是在进行下一次调用之前,将链接寄存器的值保存到自己的栈帧中。

这就产生了一个有趣的性能权衡。对于一个包含许多简单叶函数的程序,采用链接寄存器的架构可能明显更快。而对于一个具有深度嵌套调用的程序,其成本接近于基于栈的架构,因为几乎每个函数最终都会将链接寄存器保存到栈上。通过分析典型工作负载中叶函数与非叶函数的比例,架构师可以定量地比较这两种设计的预期内存流量。

礼貌协定:被调用者保存寄存器与调用者保存寄存器

那么,调用者可能正在使用的所有其他寄存器怎么办?如果被调用者开始使用相同的寄存器,它将覆盖调用者的数据。为了防止这种混乱,ABI 通过将寄存器分为两组来建立一个“礼貌协定”:

  • ​​调用者保存寄存器:​​ 这些是被调用者可以无限制自由使用的寄存器。如果调用者在这些寄存器中有一个值,并且在调用后仍需要它,那么调用者有责任在进行调用前将其保存(通常到自己的栈帧中),并在之后恢复它。
  • ​​被调用者保存寄存器:​​ 这些是被调用者必须保持其值的寄存器。如果被调用者想要使用其中一个寄存器,它必须首先保存其原始值,然后在返回给调用者之前恢复该值。

这种分工是一种 brilliantly 的优化,它最大限度地减少了不必要的工作。它构成了高级控制流(如实现用户级线程或“纤程”)的基础。一次纤程切换本质上是一次被劫持的过程调用,你只需保存最小的必要上下文——栈指针和所有被调用者保存的寄存器——以便稍后能够恢复纤程,就像它从一个函数调用中返回一样。

抽象的物理代价

栈上的所有这些活动——压入返回地址、保存寄存器、分配局部变量——不仅仅是一个抽象概念。它是一个消耗真实资源的物理过程。在经典的​​冯·诺依曼架构​​中,指令和数据共享同一内存,每次对栈的压入和弹出都是一次内存访问,需要争用内存总线。

考虑一个执行非常深度递归的程序。它以很高的速率 rrr 进行调用和返回。每次调用压入一个大小为 aaa 位的返回地址,每次返回则弹出它。如果内存总线宽度为 www 位,运行频率为每秒 fbf_bfb​ 个周期,那么每次压入或弹出将消耗 ⌈a/w⌉\lceil a/w \rceil⌈a/w⌉ 个总线周期。仅管理返回地址所消耗的总线周期总比例由这个简单而富有启发性的公式给出:U=2r⌈a/w⌉fbU = \frac{2 r \lceil a/w \rceil}{f_b}U=fb​2r⌈a/w⌉​。这个方程告诉我们一个深刻的故事:过程调用这个优雅的抽象对硬件施加了实实在在的“税收”。如果调用速率过高,仅这部分管理开销就可能使内存总线饱和,造成性能瓶颈。

递归与尾调用的魔力

有了栈的机制,我们就能理解编程中最强大的思想之一:​​递归​​。一个函数调用自身不再神秘。它仅仅意味着将一个新的、属于自己的活动记录推入栈中。每次调用都有自己私有的工作空间,与其他调用无关。

然而,这是有代价的。对于一个在 nnn 个元素的数组中进行递归线性搜索,最坏情况下栈将增长到 n+1n+1n+1 帧的深度。如果 nnn 非常大,你可能会用尽栈内存——这就是臭名昭著的​​栈溢出​​。

但请仔细观察这个递归步骤:return Search(A, n, x, i+1)。这个函数做的最后一件事就是进行一次递归调用,然后立即返回其结果。它不再进行任何进一步的计算。这种特殊情况被称为​​尾调用​​。一个聪明的编译器可以执行​​尾调用优化 (TCO)​​。它意识到既然当前的栈帧不再需要,就不必推入一个新的栈帧。它可以简单地复用当前帧来进行递归调用,从而有效地将递归转化为一个简单的循环。栈深度保持不变,为 O(1)\mathcal{O}(1)O(1)。

这种优化可以在更复杂的模式中观察到,比如相互递归。想象两个函数 f(n) 和 g(n) 相互调用。如果 f对 g 的调用不是尾调用(例如 return 1 + g(n-1)),但 g对 f 的调用是尾调用,那么栈深度的行为方式会非常有趣。每次调用 f 都会增加一个新的帧,但随后的对 g 的调用及其对 f 的尾调用会复用该帧。结果是,递归每进行两步,栈深度只增加一次,导致最大深度为 ⌈n/2⌉+1\lceil n/2 \rceil + 1⌈n/2⌉+1。观察栈随着这种节奏收缩和扩张,揭示了控制流的美妙动态。

在栈上构建世界

栈帧这个简单的机制如此强大和灵活,以至于它成为许多现代编程语言最先进特性的基础。

​​机器中的幽灵:对象与虚方法​​ 在面向对象编程中,像 myObject.doSomething() 这样的调用似乎很神奇,尤其是当 myObject 的确切类型在编译时未知时。这就是​​虚分派​​。然而,其实现是对标准过程调用的一个巧妙扩展。编译器秘密地转换了这次调用,将一个指向 myObject 的指针作为隐式的第一个参数传递,这个参数被称为 this。每个对象实例都包含一个隐藏的指针,即​​虚表指针​​,它指向一个每个类独有的“虚方法表”(VMT)——一个函数指针数组。为了进行调用,机器首先跟随 this 指針到對象,从对象头部读取虚表指针,在 VMT 中查找正确方法的地址,然后对该地址进行一次标准的过程调用。虚表指针是对象在堆或栈上的数据的一部分;它并不存在于方法自身的活动记录中 [@problemid:3678287]。

​​寻找你的根:词法作用域​​ 在允许嵌套函数的语言中,内部函数可以访问其父函数的变量。它如何找到它们?活动记录通过一个​​访问链接​​(或静态链接)得到增强,该链接指向词法上外层函数的栈帧。要访问上两层的变量,机器只需跟随两个这样的链接。这就产生了一个权衡:遍历一个链接链可能会很慢。另一种方法是使用 ​​display 数组​​,这是一个指向每个嵌套级别活动帧的指针数组,允许常数时间访问,但维护该数组会产生开销。在这些方法之间进行选择,需要分析预期的成本和收益,这是系统设计中一个反复出现的主题。

​​打破规则:非局部控制流​​ call/ret 规则提供了一种有序进入和退出过程的方式。但如果你需要一次性从一个深度嵌套的调用链中逃脱呢?这被称为​​非局部跳转​​。C 语言的函数 setjmp 和 longjmp 提供了一种原始而强大的方法来实现这一点。调用 setjmp(J) 就像为机器状态拍下一张快照,将当前的栈指针 (SPSPSP)、程序计数器 (PCPCPC) 和其他基本寄存器保存到一个缓冲区 J 中。随后对 longjmp(J) 的调用并不会优雅地展开栈;它是一种粗暴的重置。它只是从缓冲区中恢复保存的 SPSPSP 和 PCPCPC。在 setjmp 和 longjmp 之间压入栈的所有活动记录都会被瞬间抛弃,仿佛它们从未存在过。这种机制虽然危险,但有力地揭示了什么才是真正定义程序执行状态的东西:那些告诉它在哪里以及它的工作空间在哪里结束的指针。

从一个简单的返回承诺开始,我们构建了一个工作空间的塔楼,它支持递归、面向对象,甚至支持扭曲时间和控制规则的能力。过程调用不仅仅是一种便利;它是计算的一个基本原则,一个解决有组织的复杂性问题的优雅而统一的方案。

应用与跨学科关联

谈论过程调用的应用,有点像问动词在人类语言中的应用。它不是一个特性;它是我们构建复杂性、建立抽象和表达结构化思想的基本机制。一旦我们学会了为一个动作序列命名,我们就可以通过その名字来调用它,从而用简单的砖块建造起越来越宏伟的逻辑殿堂。然而,当我们看到这个“调用”的简单思想如何在计算机科学广阔多样的领域中产生回响时——从硬件设计的最深层到可计算性理论的抽象领域——其真正的美才得以展现。

编译器的世界:编织调用图

想象一个程序,不是作为线性的文本,而是作为一个巨大的网络。节点是你编写的过程,网络的丝线是它们之间潜在的调用。这就是​​调用图​​,你程序结构的静态地图。对于编译器来说,构建这张地图是理解你代码的第一步。

如果你写一个直接调用 f(),这条丝线是笔直而坚固的。但现代语言更加丰富。一个对象上的虚调用 x.m() 呢?在这里,x 在运行时可能是多种类型的对象之一,每种都有自己版本的 m 方法。这条丝线散开成一束可能性。一个函数指针 fp() 呢?这条丝线变成了一片潜在的连接,指向任何地址可能被存储在该指针中的函数。

编译器的首要任务是一种侦探工作:缩小这些可能性。通过使用类层次分析(CHA)和快速类型分析(RTA)等技术,编译器可以修剪对象潜在的运行时类型集合;通过指向分析,它可以约束一个指针可能持有的函数集合。目标是使调用图尽可能精确。当分析能够证明一个调用点只有一个可能的目标时——将一条磨损的丝线变回一条单一、坚固的线——优化的世界便豁然开朗。这个过程,被称为去虚拟化,是面向对象语言性能的基石。

一旦这张地图建成,我们就可以提出一些基本问题。例如,对函数 A 的调用是否可能通过任何后续调用链最终导致函数 B?这就是​​函数可达性​​问题。它不仅仅是学术性的;它对从链接(这个库函数真的被用了吗?)到安全(这个用户输入处理程序能否到达这个危险的系统调用?)等所有事情都至关重要。这个问题等价于在有向图中寻找一条路径,这是一个非常基础的问题,以至于它在计算复杂性层次结构中占有一席之地,位于 ​​NL​​ 类(非确定性对数空间)中。我们程序的结构本身就具有精确的计算特性。

优化器的艺术:洞悉调用

过程调用不仅仅是一次跳转;它是一个信息传递的管道。一个真正智能的编译器不只看到调用本身,它能洞悉调用。考虑一次调用 f(5, 10)。如果编译器知道正在传递的值,它可以在那个特定上下文中分析 f 的主体,可能会极大地简化它。这就是​​过程间优化​​的精髓。

为了做到这一点而不仅仅是到处粘贴函数代码(这个过程称为内联),编译器采用了复杂的中间表示,如静态单赋值(SSA)形式。一种朴素的方法可能会合并所有对 f 的调用的信息,观察到它有时被 5 调用,有时被未知变量 x 调用,从而断定参数值是未知的。但一种更优美的、上下文敏感的方法,可以为每个不同的调用上下文创建可以被认为是函数的“专业化心智模型”。它允许来自一个调用的常量 5 传播到 f 的主体中进行该调用的分析,而 x 的未知值则在另一个调用中传播,从而保持了精度并实现了强大的优化。

这种思想的顶峰是​​全程序优化​​。想象一个全局配置标志 debug,在一个发布版本中被设置为 false。这一个信息可以在整个调用图中产生涟漪效应。一个条件判断 if (debug) 变成 if (false),整个 "then" 分支作为死代码被消除。如果那个分支包含了对日志函数 log_error() 的唯一调用,那么 log_error() 本身现在可能从主程序中变得不可达。优化器看到这一点,就会移除整个函数。这可能会引发一个级联效应,即那些只被 log_error() 调用的函数现在也变得不可达,并被移除。通过这种方式,对程序调用结构的高层理解允许编译器外科手术般地切除大量代码,从而产生更小、更快的可执行文件。

架构师的困境:调用的物理原理

让我们从编译器的抽象世界下降到硅片的具体世界。一次过程调用在物理上是什么?其核心是对程序计数器 (PCPCPC) 和栈的操纵。call 指令将下一条指令的地址(返回地址)压入栈中,并跳转到目标地址。return 指令将该地址弹出并放回 PCPCPC 中。

现代处理器拥有深流水线,它们无法承受等待 return 指令执行完毕才知晓下一步去向的代价;流水线会因此停顿许多周期。取而代之的是,它们进行预测。由于调用和返回遵循后进先出(LIFO)模式,CPU 采用一个小型、快速的硬件栈,称为​​返回地址栈(RAS)​​。当取指阶段看到一个 call 指令时,它将预测的返回地址推入 RAS。当它看到一个 return 指令时,它从 RAS 弹出地址,并推测性地从该预测地址开始取指,远在实际的返回指令执行之前。

这在软件优化和硬件设计之间创造了一场优美的舞蹈。考虑​​尾递归​​,即一个函数的最后一个动作是调用自身。一个聪明的编译器可以通过将递归的 call 转化为一个简单的 jump 来优化它,从而复用现有的栈帧。但这对 RAS 有何影响?一个朴素的实现可能会将 call 视为普通调用,压入一个永不使用的返回地址,而最终的 return 会被错误预测。最优的解决方案是编译器和硬件串通:编译器发出一个特殊的 tailcall 指令,这只是一个 jump,但关键是它不触及 RAS。这将原始调用者的返回地址保留在 RAS 的顶部,为最终的 return 做好完美准备,确保了语义正确性和无瑕的预测性能。

当然,这场精巧的舞蹈可能会被打乱。当控制流不是严格的 LIFO 时,比如使用非局部跳转 (setjmp/longjmp) 或异步异常时,会发生什么?RAS 会与程序的真实调用栈失去同步,导致一连串的错误预测,直到它恢复过来。硬件的简单、优雅模型有其局限性,揭示了其假设与软件复杂现实之间的根本性张力。

系统设计者的宇宙:重新构想调用

过程调用概念的真正力量在于其弹性。我们可以将其跨越边界进行延伸,迫使我们直面其本质。

考虑程序与硬件本身之间的边界。在裸机嵌入式系统上,中断是一次由硬件发起的非自愿过程调用。处理器必须停止当前工作,保存其状态,并跳转到一个中断服务程序(ISR)。在这里,“活动记录”的抽象概念变得具体得可怕。微小系统栈上的每一个字节都必须根据严格的​​应用程序二进制接口(ABI)​​或调用约定进行 meticulous 的核算。编译器必须计算 ISR 内任何调用链的最坏情况栈深度,考虑到保存的寄存器、局部变量和对齐填充。一个字节的计算失误都可能导致栈溢出,使心脏起搏器崩溃或卫星失控翻滚。

现在,将调用延伸到网络上。一个从一个城市的客户端到另一个城市的服务器的​​远程过程调用(RPC)​​看起来很神奇,但它是抽象的胜利。系统必须创造出本地调用的幻象。它必须将参数序列化为字节流,通过网络发送,并在另一端反序列化。当涉及到源语言的语义如引用传递或别名时,这变得异常具有挑战性。如果客户端上的两个参数指向同一内存位置,当没有共享内存时,你如何保留这种语义?一个正确的 RPC 系统不能只是天真地复制值;它必须检测到别名,并在服务器上用一个单一的远程“句柄”来表示它,从而在分布式环境中忠实地再现原始程序的逻辑。

最后,让我们将这个概念向内转向,以创造并发性。为了实现成千上万个轻量级的​​用户级线程​​(协程或 goroutine),我们不能依赖操作系统为每个线程管理一个单独的内核栈。相反,我们必须在软件中构建我们自己的过程调用机制。我们将许多逻辑栈多路复用到一个单一的、更大的内存区域上。在这个世界里,硬件的 call 和 ret 指令变成了累赘,因为线程之间的上下文切换会破坏单一的机器栈。解决方案是在软件中完全转换过程调用。一次“调用”变成了一次显式地将软件管理的活动记录推入逻辑栈的操作。关键的是,返回地址不再位于特殊的硬件寄存器中,而只是存储在记录中的另一份数据。一次“返回”是一次显式的弹出操作,读取这个延续数据。这使得整个控制流不受上下文切换的影响,也是许多现代语言实现大规模并发的基础技术。

哲学家的极限:我们无法知晓之事

我们已经看到编译器和工具如何能够对调用图进行强大的分析,以优化和理解程序。这自然引出了一个诱人的问题:我们能构建出完美的分析工具吗?我们能否创建一个算法,对于任何程序 P 和任何输入 w,都能明确地告诉我们某个特定的子程序 S 是否会被调用?

答案,也许令人惊讶,是一个响亮的​​否定​​。

这不是我们独创性的失败,而是计算本身的一个基本限制,是停机问题的直接后果。我们可以通过从停机问题进行归约来证明,“子程序入口点分析”问题是不可判定的。我们可以构造一个程序,它首先模拟任意图灵机在任意输入上的运行,并且仅当该模拟停止时,它才会调用我们的目标子程序 S。一个能够解决入口点问题的工具因此也能够解决停机问题,而我们知道这是不可能的。

于是,我们的旅程在一个充满深刻谦卑的音符中结束。过程调用,这个用于组织我们思想的简单工具,是如此强大,以至于它允许我们构建出其最终行为超出我们完美预测能力的系统。虽然我们可以分析静态的调用图,但程序在运行时将通过这个网络所走的动态路径,在一般情况下,仍然是一个不可知的秘密,一个计算核心中美丽而持久的谜。