try ai
科普
编辑
分享
反馈
  • 参数传递

参数传递

SciencePedia玻尔百科
核心要点
  • 应用程序二进制接口(ABI)是一项关键契约,它规定了函数如何传递参数,通常是采用一种混合方式:前几个参数使用快速的CPU寄存器,其余的则使用内存栈。
  • ABI 规则,例如调用者保存寄存器和被调用者保存寄存器的区别,或通过引用传递大型结构体,代表了一种直接影响软件性能的、经过精心调整的折衷。
  • 参数传递约定对于启用诸如尾递归和闭包等高级语言特性,以及通过在操作系统内调解信任边界来实施安全策略,都至关重要。

引言

乍一看,向函数传递参数是编程中最基本的操作之一——一次简单的数据交接。然而,这一简单的行为却受一套深刻而复杂的规则所约束,这套规则被称为应用程序二进制接口(ABI)。这份无形的契约是构建协作、高效和安全软件的基石,但其复杂性和后果却常常被忽视。本文旨在揭开这些关键约定的面纱,探讨它们如何运作以及为何如此重要。

我们将首先剖析参数传递的核心​​原理与机制​​,审视 CPU 寄存器和内存栈之间的基本权衡、寄存器使用的规范以及处理复杂数据的巧妙策略。随后,本文将把焦点扩展到​​应用与跨学科联系​​上,揭示这些底层规则如何对从软件速度、编译器优化到我们编程语言的特性乃至操作系统的安全架构等方方面面产生巨大影响。这段旅程将从探索最初允许函数间进行通信的“社会契约”开始。

原理与机制

想象一下,两位才华横溢的钟表匠在工坊里协同工作。要组装一块复杂的钟表,他们不能只是随意地把齿轮和弹簧递给对方。他们需要一个系统,一种关于如何传递零件、哪些工具是私人的、哪些可以借用的共识。如果一位钟表匠需要一颗微小的螺丝,她是直接开口要,还是另一位会把它放在指定的托盘上?如果她借用了一把特殊的扳手,她是否应该将其放回原位?

这恰恰是运行中的程序内部函数所面临的挑战。函数是一个自包含的代码单元,一个专家。任何一个非凡的程序要能工作,这些专家就必须进行沟通——它们必须相互调用,来回传递数据,并共享资源。这套支配这场错综复杂之舞的规则被称为​​应用程序二进制接口(ABI)​​,其核心便是​​参数传递​​机制。ABI 并非由处理器的硅晶片所决定的物理定律;它是一份精心制定的“社会契约”,使得由不同的人在不同时间编译的软件能够完美无瑕地协作。

沟通的两种通货:栈和寄存器

在最基础的层面上,函数有两种方式从其调用者那里接收信息:通过主存(经由​​栈​​)或通过 CPU 自带的高速存储(​​寄存器​​)。

栈是一个简单、健壮且通用的解决方案。它是一个像一叠盘子一样组织的内存区域——你可以将新项目推到顶部,或从顶部弹出项目。为了调用一个函数,调用者可以逐个将参数推入栈中。然后被调用者就可以从那个已知位置读取它们。这就像在共享的白板上留言。它总能奏效,但速度很慢。访问内存比访问已经在 CPU 内部的数据要慢上几个数量级。

一种效率高得多的方法是使用 CPU 的​​寄存器​​。寄存器是直接构建在处理器核心中的少量极快存储位置。在寄存器中传递参数就像一次直接对话——调用者将一个值放入寄存器,被调用者几乎可以瞬间使用它。这避免了到主存的缓慢往返,从而显著提高性能。正如一个思想实验所展示的,用基于寄存器的传递取代基于栈的传递,可以消除整个内存加载和存储操作流,减少处理器数据缓存内的流量,并释放关键的执行资源。

很自然地,现代 ABI 严重依赖寄存器。但有一个问题:寄存器是一种稀缺资源。一个典型的 64 位架构可能只有少数几个寄存器被指定用于传递参数。这就引出了任何调用约定的第一个主要原则。

传递的层级:当寄存器用尽时

大多数 ABI,比如 Linux 和 macOS 使用的通用 ​​x86_64 System V ABI​​,都采用了一种简单而优雅的混合方法。它们指定一个特定的寄存器序列来传递前几个参数。对于整数和指针参数,前六个在寄存器 RDI、RSI、RDX、RCX、R8 和 R9 中传递。如果一个函数有超过六个整数参数,第七个及后续的参数就会“溢出”到栈上。

但如果参数是不同类型的呢?ABI 在这里甚至更聪明。它建立了不同的​​寄存器类别​​。例如,x86_64 System V ABI 有一个独立的寄存器池(XMM0 到 XMM7)用于浮点(或 double)参数。参数从左到右处理,每个参数都被分配给其类别中下一个可用的寄存器。

考虑一个假设的函数 g(double, long, double, long, ...)。第一个参数(一个 double)进入 XMM0。第二个(一个 long)进入 RDI。第三个(double)进入 XMM1,第四个(long)进入 RSI,依此类推。整数和浮点参数独立地填充它们各自的寄存器池。第一个被传递到栈上的参数是那个首先耗尽其类别寄存器池的参数。在这种情况下,因为只有 6 个整数参数寄存器但有 8 个浮点寄存器,所以第 7 个整数参数(总体上是第 14 个参数)将是第一个被放到栈上的。

这个系统是一个绝佳的权衡,它为最常见的情况(参数少的函数)优先考虑了寄存器的速度,同时提供了栈的无限容量作为后备方案。

大型对象的问题:值传递 vs. 引用传递

传递一个简单的整数或指针是直接简单的——它能整洁地装入单个寄存器。但是,对于一个复杂的数据结构,一个包含多个字段的 struct,情况又如何呢?

在这里,ABI 必须做出另一个务实的决定,通常是基于大小。

  • ​​值传递:​​ 如果结构体足够小——比如说,它能装入一到两个寄存器——ABI 可能会选择将整个结构体的内容直接复制到参数寄存器中。这对于小型聚合体是高效的。“小”的定义取决于架构;一个 8 字节的结构体在 32 位 RISC-V CPU 上可能需要两个 32 位寄存器,但在 64 位 CPU 上只需要一个 64 位寄存器,这显示了 ABI 是如何根据硬件的原生字长量身定制的。

  • ​​引用传递:​​ 如果结构体很大,复制它的开销会很大,并且会消耗太多宝贵的参数寄存器。在这种情况下,ABI 强制要求​​通过引用​​传递结构体。调用者在自己的内存中为结构体分配空间,然后传递一个简单的参数:一个指向该结构体内存位置的​​指针​​,而不是传递整个结构体。被调用者随后使用这个指针来访问原始数据。这就像是交出一把房间的钥匙,而不是试图把里面所有的家具都搬出来。

同样的逻辑也适用于函数需要返回一个大型结构体的情况。它对于像 RAX 这样的单个返回值寄存器来说太大了。解决方案是对引用传递的巧妙反转,通常被称为​​通过隐藏指针返回结构体​​ (sret)。在调用之前,调用者在自己的栈上为返回值预留空间。然后它传递一个秘密的、隐式的第一个参数:一个指向这块空闲空间的指针。被调用者执行其工作,然后简单地将结果直接写入调用者提供的内存位置,而不是试图返回这个大型对象。

这揭示了与另一条 ABI 规则——​​栈对齐​​——之间一种微妙而绝佳的交互作用。为了确保性能,ABI 通常要求在任何 call 指令之前,栈指针必须对齐到 16 字节边界。如果一个调用者需要为一个 24 字节的返回结构体预留空间,它不能简单地从栈指针中减去 24,因为这很可能会破坏 16 字节对齐。它必须分配下一个不小于 24 的 16 的倍数,即 32 字节,这会留下 8 字节未使用的填充。这是一个完美的例子,说明了 ABI 内部不同、看似无关的规则是如何共同作用以维持一个有序高效的系统的。

寄存器规范:谁来清理?

我们已经确定寄存器是一种共享资源。这就提出了一个关键的规范问题:如果一个被调用者使用了某个寄存器,它是否有责任在返回前恢复该寄存器的原始值?答案将寄存器分为两个哲学阵营:​​调用者保存​​和​​被调用者保存​​。

  • ​​调用者保存的寄存器:​​ 这些是“临时”或“易变”寄存器。被调用者可以自由地将它们用于任何目的,而无需保存其内容。如果调用者在这些寄存器中有一个重要的值,并且在调用后还需要它,那么调用者有责任在调用前将其保存(通常是到栈上),并在调用后恢复它。传递参数的寄存器(RDI、RSI 等)几乎总是调用者保存的。这个约定对于​​叶函数​​——即不调用任何其他函数的简单函数——非常高效。叶函数可以免费获得一组临时寄存器来完成工作,而无需任何保存和恢复的开销。

  • ​​被调用者保存的寄存器:​​ 这些是“非易变”或“保留”寄存器。ABI 向调用者保证,这些寄存器中的值在函数调用之后将与调用之前相同。这给​​被调用者​​带来了负担。如果被调用者需要使用一个被调用者保存的寄存器,它必须首先保存其原始值,并在返回前一丝不苟地恢复它。这对​​非叶函数​​来说是一大福音,特别是那些在循环内部调用其他函数的函数。它们可以将重要的、长生命周期的变量(如循环计数器或指针)保存在被调用者保存的寄存器中,并确信这些值在调用过程中会得以保留。

一个设计良好的 ABI 在两者之间达到了一个审慎的平衡。拥有太多被调用者保存的寄存器会给每个简单的叶函数带来保存/恢复的开销。拥有太多调用者保存的寄存器会迫使复杂函数在每次调用前后不断地保存和恢复其状态。典型的划分——较多的调用者保存寄存器和较少的被调用者保存寄存器——是一种精心调整的折衷,它针对真实世界程序的常见统计特性进行了优化。

契约的层次:当约定发生冲突时

ABI 的精妙之处在其规则以不那么显而易见的方式相互作用时最为明显,这揭示了其设计背后的深思熟虑。

一个引人入胜的例子是 x86_64 System V ABI 中的​​红色区域 (red zone)​​。该规则规定,在当前栈指针下方的 128 字节区域被保留给叶函数用作临时空间,而无需通过移动栈指针来正式分配一个栈帧。这是一个为最简单的函数进行优化的“君子协定”。在用户模式下,操作系统遵守这个协定;如果发生硬件中断,操作系统会确保它不会践踏红色区域。然而,这个协定并不延伸到操作系统内核本身。如果 CPU 已经在内核模式下时发生中断,硬件可能会自动将状态信息直接推入该内存区域,从而破坏内核函数存储在那里的任何内容。因此,红色区域绝佳地说明了 ABI 是一个分层契约,不同的规则和保证适用于系统的不同层面。

也许对 ABI 的终极考验是​​可变参数函数​​,例如 C 语言的 printf,它可以接受可变数量的参数。考虑一个可变参数函数 F,它在寄存器 x0, x1, ... 中接收其参数,然后需要调用另一个函数 G。为了调用 G,F 必须使用完全相同的寄存器(x0, x1, ...)来将参数传递给 G。但是因为那些是调用者保存的寄存器,对 G 的调用将破坏传递给 F 的原始参数!ABI 提供了一个稳健的解决方案:在调用 G 之前,函数 F 必须将其所有潜在的传入参数寄存器保存到其自身栈上的一个连续块中。这种参数的“归位”确保了它们被保留下来并可以在以后访问,完美地展示了寄存器类别规则、调用者保存规范和栈管理是如何环环相扣,以支持函数间最复杂的会话模式。

应用与跨学科联系

向函数传递参数这门艺术,乍一看似乎是编程中最基本的操作之一。它是从程序的一个部分向另一部分传递信息的简单行为。然而,在这种表面的简单之下,隐藏着一个充满深远影响的世界,它由一套被称为应用程序二进制接口(ABI)的严格、精心定义的规则所支配。这份契约——精确规定了参数如何以及在何处放置(在哪些寄存器中、以何种顺序、在内存栈的哪个部分)——并不仅仅是一个技术细节。它是决定我们软件速度、促成我们编程语言特性、并强制执行我们操作系统安全的沉默、无形的机器。要理解参数传递的应用,就是要踏上一场穿越计算机科学核心的旅程,揭示其在性能工程、编译器设计和系统架构之间美妙的统一性。

速度的通货:性能工程

在计算中,终极通货是时间。每一个纳秒都至关重要,而 ABI 是主要的会计师之一。它所支配的最直接的经济交易是在使用处理器的寄存器和其主存之间的选择。寄存器是处理器的个人便签本——速度极快,但数量稀缺。内存虽然广阔,但访问它就像是步行去图书馆,而不是使用桌上的一张便条。ABI 规定,函数的前几个参数享有在寄存器中传递的特权。但是当参数太多时会发生什么呢?

想象一下,一个高性能科学计算库中的函数需要处理比可用寄存器更多的参数。例如,一个现代图形例程可能需要同时操作十几个 128-bit 向量,而 ABI——比如 System V AMD64 ABI——只为此保留了八个专用的 SIMD 寄存器。当第九个向量参数被添加时,就会到达一个性能“悬崖”。编译器别无选择,只能将多余的参数“溢出”到栈上,即主存的一个区域。现在,每个溢出的参数都会产生调用者的一次内存写入和被调用者的一次内存读取的成本,与寄存器相比,这增加了显著的周期开销。这不是一个抽象的成本;它是一个可测量的减速,是参数传递契约的直接后果。

这个僵化的规则激发了编译器设计中惊人的创造力。如果你不能改变 ABI,也许你可以改变参数本身。考虑一个接受单个复杂结构体的函数,该结构体包含许多小的数据字段。ABI 可能规定,作为聚合体,结构体必须通过引用传递——也就是说,通过在寄存器中传递一个单独的指针。然后,函数必须执行一系列内存加载来通过该指针访问每个字段。一种称为聚合体的标量替换(Scalar Replacement of Aggregates, SRA)的巧妙编译器优化,执行了一种概念上的炼金术。它在函数调用之前将结构体“溶解”为其组成的标量字段。突然之间,原本的一个指针参数变成了几个整数或浮点参数。如果这些新的标量参数,连同任何其他参数,仍然在寄存器预算之内,它们现在就可以直接在寄存器中传递。这个优化的唯一目的就是转换数据,以更好地适应参数传递契约的约束,绝佳地说明了高级优化是如何由底层 ABI 的现实所驱动的。

当我们将视野从单台计算机扩展到大型超级计算机时,风险会变得更高。在共享内存系统中,将一个巨大的数组传递给函数是微不足道的;你只需传递一个指针,一个 8 字节的值,成本仅为几纳秒。但在分布式内存系统中,函数在另一台物理机器上运行,“传递参数”就变成了远程过程调用(RPC)。整个数组必须被复制、序列化成字节流,并通过网络发送。这里的成本由网络延迟(α\alphaα)和带宽(β\betaβ)主导,可能比前者昂贵数百万倍。系统的物理架构从根本上重新定义了参数传递的意义和成本。

语言的逻辑:编译器与编程范式

参数传递契约不仅是一名会计师;它也是一名语法学家,定义了使复杂的语言特性成为可能的规则。计算机科学中最优雅的概念之一是尾递归,即一个函数的最后一个动作是调用自身。通过正确的优化——尾调用消除(TCE)——无限递归可以在有限的、恒定的内存量中执行。但这种“魔力”完全取决于 ABI。

想象一个函数 F,它已经收到了它的参数,一些在寄存器中,三个在栈上。作为它的最后一个动作,它需要尾调用另一个需要六个参数在栈上的函数 G。为了使尾调用起作用,F 必须设置好 G 的参数,然后直接跳转到 G 的代码,这样当 G 完成时,它会返回到 F 的原始调用者。但问题就在这里:G 需要的栈空间比 F 所拥有的更多。ABI 的栈纪律是严格的;F 在其调用者的栈帧中只是一个“客人”,被禁止写入超出其自身分配的参数空间。此外,ABI 规定原始调用者负责清理它传递给 F 的那三个栈参数。如果 F 以某种方式为 G 分配了更多空间,那么当 G 最终返回时,栈就会变得不平衡。这个尾调用因此不合格。这个为可预测行为而设计的僵硬契约,排除了一项强大的优化。

那么,一门语言如何才能保证尾递归呢?它必须采用一个从头开始就为此设计的 ABI。这样的 ABI 可能会禁止函数分配可变大小的栈帧,而是为任何给定的调用提供一个固定大小的“临时空间”。在这种世界里,尾调用会重用现有的临时空间。栈指针永不移动,递归可以无限进行。另一种方法是采用完全依赖寄存器传递参数并且完全禁止为变量进行栈分配的 ABI。我们在高级语言中渴望的特性并非抽象的愿望;它们是建立在一个精心协商的底层契约基础之上的。

这种相互作用对于其他语言特性同样至关重要,比如闭包——即“捕获”其周围环境中的变量的函数。当你将一个闭包作为参数传递时,你不仅传递了一段代码,还传递了它的记忆。这个“环境”必须作为一个隐藏参数来传递。但是,如果你需要让你的语言与 C 语言互操作,而 C 语言对闭包一无所知,你该怎么做呢?C ABI 没有为这个隐藏的环境指针提供规定。一个绝妙而常见的解决方案是,征用处理器的一个通用寄存器——一个被 C ABI 指定为“调用者保存”的寄存器——来作为环境指针的私有通道。在新语言内部的调用使用这个寄存器;而对 C 的调用则不使用。一个薄的“蹦床”包装器可以使一个闭包看起来像一个普通的 C 函数指针。参数传递约定成为了一座桥梁,允许两种不同的语言范式共存和通信。

ABI 还必须与硬件共同进化。Arm 可伸缩向量扩展(SVE)引入了在编译时大小未知的向量,从而实现了“向量长度无关”编程。你如何传递一个连大小都不知道的参数?你不能把它放在栈上,因为你不知道要预留多少空间。唯一的解决方案是采用一种 ABI,在新的、可伸缩的寄存器中传递这些可伸缩类型。调用约定本身成为了解锁硬件潜力的关键。

信任的架构:操作系统与安全

除了速度和语义之外,参数传递机制还是一个系统安全和整体架构的基石。它是跨越信任边界的正式仪式。

在操作系统中,一个用户进程可能拥有一个文件描述符——比如整数 3。如果该进程只是将数字 3 写入另一个进程,接收方只会得到一个整数,仅此而已。它只是数据。但是,如果发送方使用一种特殊的、由内核中介的进程间通信(IPC)机制,例如 sendmsg 并附带一个类型为 SCM_RIGHTS 的控制消息,那么神奇的事情就发生了。内核不会将其解释为传递一个数字,而是解释为转移一项能力的请求。内核会授予接收进程自己的文件描述符,该描述符指向同一个底层的打开文件。参数传递机制已成为安全转移访问权限的载体。这可以通过 SCM_CREDENTIALS 这样的消息得到进一步增强,在这种消息中,是内核而非用户,将发送方的已认证凭证(如其进程ID和用户ID)附加到消息上。接收方可以信任这些凭证,因为参数传递机制本身是由系统中最受信任的实体——内核——来保证的。

当从用户应用程序的“普通世界”跨越到处理器上的“安全世界”(一个可信执行环境)时,这个仪式变得更加正式。在 Arm 处理器上,这是通过安全监视器调用(Secure Monitor Call, SMC)指令完成的,该指令有其自己独特的调用约定(SMCCC)。为 SMC 调用编写包装器的程序员必须像一位外交大师一样,同时驾驭三套规则:C 语言 ABI(AAPCS)、安全调用 ABI(SMCCC)以及编译器的内联汇编语义。包装器必须小心地将参数放置在安全世界期望的寄存器中,执行 SMC 指令,然后从安全世界放置结果的寄存器中收集结果。它还必须向编译器精确声明在这次进入另一个世界的旅程中,哪些寄存器可能会被“破坏”或改变。在这里,参数传递是两个不同信任域之间一次安全而精妙的握手。

这个想法可以被放大,用以设计整个操作系统。在传统的单体内核中,系统调用涉及用户进程向内核传递指针。内核随后解引用这些指针以访问用户数据。这创造了一种危险的亲密关系,为诸如“检查时-使用时”(Time-of-Check-to-Time-of-Use, TOCTOU)竞争之类的安全漏洞打开了大门,恶意进程可以在内核检查数据之后、使用数据之前改变数据。

相比之下,微内核架构将服务视为通过消息进行通信的独立进程。当客户端需要某项服务时,它不传递指针。相反,它将其所有参数序列化——创建数据的完整副本——到一个消息中。这个消息是一个自包含的、明确的契约,通常带有版本号和明确的长度。这种设计具有深远的优势。通过操作数据的副本,服务器完全免受 TOCTOU 攻击。通过强制执行一个明确的、带版本的消息格式,系统变得更加健壮且易于演进。“函数调用”的“参数列表”已被提升为独立程序之间的正式、序列化协议。安全参数传递的原则,当应用于系统范围的规模时,会导向一个根本上更安全和模块化的架构。

从寄存器与栈的最小选择,到整个操作系统的宏伟架构,传递参数这个简单的行为被揭示为一个深刻而统一的原则。它是一份将硬件与软件绑定在一起的契约,塑造了什么是快的、什么是可能的、以及什么是安全的。它证明了计算机科学之美,即一个单一、简单的想法可以向外涟漪,带来如此巨大而复杂的后果。