现代C:代码封装(上):函数调用 📅 2026/7/13 17:16:38 引言在前两讲中我介绍了 C 语言中的运算符、表达式、语句是如何被编译器实现的。不知你是否还记得在介绍运算符时我没有展开讲解有关函数调用运算符的内容。接下来我就用专门的两讲内容来带你深入看看 C 语言中有关函数调用的那些事儿。这一讲我们首先来看 C 语言中编译器实现函数调用时所遵循的一系列规则。这些规则实际影响着函数调用时在如何传参、如何使用寄存器和栈内存等问题上的处理细节。除此之外由于 C 语言中的函数调用过程与栈内存密切相关我还会介绍栈和栈帧的概念。栈是 C 程序在运行时用于存放临时数据的一块内存而每一个栈帧都对应着栈内存中的一段数据这些数据是在函数调用过程中所必须使用的。通过这一讲的学习你能了解到编译器对 C 函数调用的处理细节。而在下一讲中我们将以此为基础来深入探讨尾递归调用优化等更多函数调用的相关内容。快速回顾 C 语言中函数的使用方式函数的概念相信你已经十分熟悉了这里我们先来快速回顾一下。在编程语言中函数是一种用于封装可重用代码的语法结构。函数可以接收从外部调用环境传入的数据并在函数体内以复合语句的形式使用这些数据构建独立的功能逻辑单元。借助函数我们可以将一个程序的实现过程拆分为多个子步骤并以结构化的方式来构建程序。这种方式可以减少程序中的重复代码并通过抽象和替换来提高代码的整体可读性以及可追溯性。在 C 语言中函数的定义与使用方式跟其他语言大同小异我们先通过一个例子快速回顾一下。这里你可以先停下来尝试编译和运行下面这段代码并观察其中的函数调用逻辑。需要注意的是在编译时我们要为编译器指定 “-lm” 参数来让它链接程序运行所需要的数学库。#include stdio.h #include tgmath.h typedef struct { int x; int y; } Point; int foo(int x, int y, Point* p, int(handler)(int)) { return handler(x y p-x p-y); } int handler(int n) { return sqrt(n); } int main(void) { int x 2; int y 3; Point p { .x 10, .y 10 }; printf(%d, foo(x, y, p, handler)); // 5. return 0; }在 C 语言中函数有两种传递参数的方式即通过“值”传递和通过“指针”传递。其中对于值传递的方式编译器会在函数调用时将传入函数的参数值进行复制。因此在这种情况下调用时传入函数的参数与在函数内部使用的参数是两个不同的实体。而使用指针形式传入的参数因为指针所表示的地址在传入函数前后均不会发生变化所以如果在函数内部修改指针参数所指向的值则发生在该值上的变化在函数调用完成后也将一直存在。这里在代码中函数 foo 一共接收了四个参数其中整型参数 x 与 y 均以值的方式传递。而对于紧接着的结构体与函数类型的参数它们对应的变量 p 与 handler 则以指针的形式传递。需要注意的是对于函数指针来说可以在声明和调用时为其省略通常用于表明指针类型的 “*” 符号。这意味着函数 foo 的定义也可以写成如下形式int foo(int x, int y, Point* p, int(*handler)(int)) { return (*handler)(x y p-x p-y); }C 函数的调用约定我在 02 讲 中和你提到过C 标准中并未规定语言的各类语法结构应该以怎样的方式来实现。但实际上从编译器的角度来看每一个函数在被调用时应该以怎样的方式通过机器指令来实现其调用过程却存在着相应的事实标准。而通常我们把编译器实现函数调用时所遵循的一系列规则称为函数的“调用约定Calling Convention”。调用约定规定了函数调用时需要关注的一系列问题比如如何将实参传递给被调用函数、如何将返回值从被调用函数中返回、如何管理寄存器以及如何管理栈内存等等。调用约定并非 C 语言标准的一部分因此实际上每个编译器都可以使用自己独有的调用约定来实现 C 函数的调用过程。但相应地这也会导致另外一个问题当具有外部链接的函数在多个不同编译单元内被使用且这些不同编译单元对应的源文件通过不同的编译器进行编译时那么它们各自生成的对象文件可能无法再被整合在一起并生成最终的可执行文件。幸运的是对于 C 语言来说运行在 x86-64 平台上的编译器基本都会根据所在操作系统的不同选择使用几种常见的调用约定事实标准。比如对于 Windows 来说编译器会采用专有的 Microsoft x64 或 Vector 调用约定。而在 Unix 和类 Unix 系统上则会使用名为 System V AMD64 ABI后简称 “SysV”的调用约定。类似地对于 i386IA32、8086 等其他平台它们也都有着对应的调用约定事实标准。而较为统一的调用约定也在一定程度上保证了 C 程序在同一平台不同编译器下的最大可移植性。接下来让我们看看 SysV 调用约定中都规定了哪些重要的实现细节。为了更直观地观察这些内容让我们先来编写一段简单的 C 代码并在 x86-64 平台上使用默认优化等级通过 GCC 编译生成它所对应的汇编代码。具体如下图所示在后面提到这张图时我会统一用“图 A”代替在上图中左侧为 C 代码右侧为对应的汇编代码相同颜色的代码块表示源代码与汇编代码之间的对应关系。在 C 代码中我们定义了名为 bar 与 foo 的两个函数并在 foo 中调用了 bar。bar 函数不接收任何参数调用后直接返回整型值 10。foo 函数共接收 8 个参数调用后返回其内部整型变量 n 与函数 bar 调用返回值的和。在 main 函数中定义有两个整型局部变量 x 与 y而当函数 foo 被调用时直接使用这两个局部变量以及另外的 6 个字面量数字值作为它的参数。实际上在 x86-64 的机器指令中函数调用是通过 call 指令来完成。而每一个函数体在执行完毕后都需要再通过 ret 指令来退出函数的执行并转移代码执行流程到之前函数调用指令的下一条指令上。你可以通过下面这张图来直观地感受这个流程。其中箭头标注出了代码的整体执行顺序。接下来我们来具体看看 SysV 调用约定中都规定了函数调用时的哪些内容。参数传递SysV 调用约定的第一个规则是在调用函数时对于整型和指针类型的实参需要分别使用寄存器 rdi、rsi、rdx、rcx、r8、r9按函数定义时参数从左到右的顺序进行传值。而若一个函数接收的参数超过了 6 个则余下参数将通过栈内存进行传送。此时多出来的参数将按照从右往左RTL的顺序被逐个压入栈中。关于这一点你可以通过图 A 右侧第 30 到 40 行红框内的汇编代码得到验证。这里函数 foo 在调用前分别用寄存器 edi、esi 存放局部变量 x 与 y 的值并用寄存器 edx、ecx、r8d、r9d 存放字面量值 3、4、5、6如果你还不了解寄存器 rdi 与 edi 的关系可以在课前热身一讲中得到答案。而多出来的另外两个字面量值参数 7 和 8 则直接通过 push 指令被放在了栈内存中。你需要注意这里指令操作它们的先后顺序因为要保证这些参数以从右向左的顺序被放入栈中。另外由于 x、y 为局部变量因此最开始它们会被存储在栈内存中。除此之外对于浮点参数编译器将会使用另外的 xmm0 到 xmm7共 8 个寄存器进行存储。对于更宽的值也可能会使用 ymm 与 zmm 寄存器来替代 xmm 寄存器。而上面提到的 xmm、ymm、zmm 寄存器都是由 x86 指令集架构中名为 AVXAdvanced Vector Extensions的扩展指令集使用的。这些指令集一般专门用于浮点数计算以及 SIMD 相关的处理过程。返回值传递对于函数调用产生的返回值SysV 调用约定也有相应的规则当函数调用产生整数类型的返回值且小于等于 64 位时通过寄存器 rax 进行传递当大于 64 位小于等于 128 位时则使用寄存器 rax 与 rdx 分别存储返回值的低 64 位与高 64 位。你可以参考图 A 右侧第 4、21、47 行蓝框内的代码来验证这个规则。这三行代码分别处理了函数 bar、foo以及 main 的返回值。需要注意的是对于复合类型比如结构体的返回值编译器可能会直接使用栈内存进行“中转”。对于浮点数类型的返回值同参数传递类似编译器会默认使用 xmm0 与 xmm1 寄存器进行存储。而当返回值过大时则会选择性使用 ymm 与 zmm 来替代 xmm 寄存器。寄存器使用SysV 调用约定对寄存器的使用也作出了规定对于寄存器 rbx、rbp、rsp以及 r12 到 r15若被调用函数需要使用它们则需要该函数在使用之前将这些寄存器中的值进行暂存并在函数退出之前恢复它们的值callee-saved。而对于其他寄存器则根据调用方的需要自行保存和恢复它们的值caller-saved。堆栈清理每一个函数在调用结束前都需要由它自身完成堆栈的清理工作。比如在图 A 所示的代码中foo 函数在被调用时它在栈内存中分配了对应的空间用于存放局部变量 n 的值。而在该函数执行完毕准备退出前便需要由它自己将之前在栈上分配的数据清理干净。而这个任务是可以由 leave 指令来完成的。我会在接下来讲解“栈帧”时再深入介绍与该指令相关的内容。除此之外对于 foo 函数被调用前所传入实参的清理工作则是由调用函数也就是这里的 main 函数来完成的。可以看到当 foo 函数调用结束程序执行流程返回到之前 call 指令的下一条指令时程序通过 add 指令修改了 rsp 寄存器的值。通过这种方式main 函数对之前放入栈中传递给函数 foo 的实参进行了清理。其他约定除此之外SysV 调用约定还有下面这几点规定函数在被 call 指令调用前需要保证栈顶于 16 字节对齐也就是栈顶的所在地址值以字节为单位是 16 的倍数从栈顶向上保留 128 字节作为 “Red Zone”不同于用户函数的调用过程系统调用System Call函数需使用寄存器 rdi、rsi、rdx、r10、r8、r9 传递参数。我们来重点看看第二点Red Zone 是位于栈顶向上低地址方向的一段固定长度的内存段这块区域通常可以被函数调用栈中的“叶子”函数即不再调用其他函数的函数使用。这样在需要额外的栈内存时就能在一定条件下省去先调整栈内存大小的过程。而有关第三点中涉及到的与系统调用相关的内容我将在这门课的“C 程序运行原理篇”中再为你深入讲解。保存函数调用信息的栈帧函数的调用过程伴随着栈内存中数据的不断变化。从整体上来看每一个函数在调用时都会在栈内存中呈现出基本相同的数据布局结构。而通过这种方式划分出来的对应于每一次函数调用的栈内存数据块我们一般称它为“栈帧”。栈帧中存放有与每个函数调用相关的返回地址、实参、局部变量、返回值以及暂存的寄存器值等信息。在进程的 VAS 中栈内存是从高地址向低地址逐渐增长的即栈底位于高地址处栈顶位于低地址处。而当一个函数在执行过程中需要使用更多的栈内存空间时便需要首先通过某种方式来扩大进程的可用栈内存大小。通过操作寄存器 rsp我们便可完成这个操作。rsp 寄存器又被称为 Stack Pointer该寄存器中一直存放着当前栈内存顶部低位地址的地址。也就是说rsp 寄存器的值决定了进程所能够使用的栈内存大小。因此通过减小该寄存器的值我们便能够扩大进程的可用栈内存空间。你可以通过下图直观地体会到它们之间的关系现在让我们把目光移动到函数 bar 身上来详细看看它在通过 call 指令调用后都发生了什么。当 call 指令执行时函数执行完毕后的返回地址会被首先推入栈中。以 bar 函数为例当该函数被调用时图 A 中右侧代码第 20 行对应的机器指令地址便会被存放到栈内存中。接下来函数的第一行指令 push rbp 会将当前寄存器 rbp 的值暂存到栈中以便在函数执行完毕后恢复该寄存器的值。rbp 寄存器又被称为 Frame Pointer即“栈帧寄存器”。通常情况下它被用来存储函数调用前的“栈高度”即寄存器 rsp 的旧值以便用于在函数执行过程中进行栈帧中数据的寻址并在函数退出前把栈中的数据恢复到函数调用前的状态。紧接着第二句指令 mov rbp, rsp 便将存有此刻栈高度的寄存器 rsp 的值“备份”到寄存器 rbp 中。当函数体的内容第三条语句执行完毕后程序通过 pop 指令恢复寄存器 rbp 的值并通过 ret 指令将程序的执行转移到函数调用前存入栈中的那个返回地址上去。在函数 bar 的执行过程中由于我们没有在栈上分配任何数据因此在函数实际执行结束前也并不需要对栈进行任何清理工作。所以你会发现和 foo 函数与 main 函数相比bar 函数在 ret 指令之前少执行了一条 leave 指令。而事实上这条指令便会通过恢复寄存器 rsp 的值来“清理”栈上的数据并同时恢复寄存器 rbp 的值。进一步观察 main 函数的实现细节你会发现函数在执行时使用栈的痕迹。比如汇编代码中的第 29 行这里通过 sub 指令减小了寄存器 rsp 的值以将当前的可用栈空间扩大 16 个字节。接着通过第 30、31 行指令函数为局部变量 x 和 y 分配相应的栈内存并将初始值 1 和 2 分别存放到了栈上 rbp-4 与 rbp-8 的位置每一个占用 4 字节大小。随后在代码的第 34、35 行借助 push 指令额外的两个 4 字节参数值同样被存放到了栈内存中。此时main 函数对应的栈帧内容如下图所示到这里相信你已经对函数的调用过程以及栈帧的概念有了大致的了解。可以看到的是随着嵌套函数的不断调用每一个调用过程所产生的栈帧都会按照函数的调用顺序被依次存放在栈内存中。而当嵌套函数的层级足够深导致栈内存已达到可用的最大值进而无法再存放栈帧时便会发生我们常见的 “Stack Overflow”即“栈溢出”的问题。而在下一讲中我会带你一起看看如何借助“尾递归优化”技巧来解决这个问题。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我首先带你快速回顾了 C 语言中函数的具体用法然后介绍了编译器在实现 C 函数调用时需要关注的一系列规则即 C 函数中的调用约定。在类 Unix 系统上编译器通常会使用名为 System V AMD64 ABI 的调用约定来作为实现函数调用的事实标准。SysV 调用约定中规定了函数在调用时需要注意的参数传递、寄存器使用以及堆栈清理等方面的具体规则。每一个被调用函数都会在栈内存中存放与其对应的栈帧结构。栈帧中包含着函数在被调用时需要的所有关键信息其中包括函数返回地址、某些寄存器的旧值、函数调用过程中局部变量的值等等。