现代C: 标准库:非本地跳转与可变参数是怎样实现的?
引用我曾介绍过C 语言中的函数调用是在 call 与 ret 两个指令的共同协作下完成的。这个过程包括程序执行流的转移、栈帧的创建、函数代码的执行、资源的清理一直到函数调用完毕并返回至调用点的下一条指令上。总的来看函数在正常情况下的调用流程是稳定有序的。但实际上这种以函数为单位的“顺序”执行流并不能完全满足 C 语言在使用时的所有应用场景。因此C 标准从 C90 开始便为我们提供了名为 “setjmp.h” 的标准库头文件。通过使用该头文件提供的两个接口 setjmp 与 longjmp我们能够在函数调用过程中实现对执行流的跨函数作用域转变。而对于上述这种函数执行流程上的变化我们一般称它为“非本地跳转Non-local Jump”。除此之外在正常的 C 语法中函数在被实际调用时只能接收与其函数原型和函数定义中标注的类型及个数相同的实参。而为了进一步增强 C 函数在使用上的灵活性同样是在 C90 之后的标准中C 语言还为我们提供了名为 “stdarg.h” 的头文件。配合使用在该头文件中定义的宏我们便可以在 C 代码中定义“可变参数函数Variadic Function”。而可变参数函数与普通函数的最大区别就在于它们在被调用时可以接收任意多个实参而无需提前在函数原型或定义中声明这些参数的信息。那么今天我们就来聊一聊 C 语言中的非本地跳转与可变参数函数。在接下来的内容中我会分别介绍它们的基本用法与实现原理。非本地跳转非本地跳转的概念并不直观既然如此我们就换一个方式来理解它在继续深入之前我们先来看一个与它相对的简单概念本地跳转Local Jump。相信了解过这个概念后你会对非本地跳转与程序执行流变化的对应关系有更直观的理解。本地跳转在 C 语言中本地跳转一般是指由 goto 语句完成的程序执行流的转移过程。这里我们来看一个简单的例子可以看到在上图左侧的 C 代码中我们通过设置标签 “head” 的方式显式地指定了函数 main 内部一个可能的“执行跳入点”。随着程序的运行当用户的输入满足 if 条件判断语句的要求时我们便通过 goto 语句将程序的执行流程重新转移到了标签 head 的所在位置。从右侧红框内的汇编代码中也可以看到对应的执行流程转移过程是通过 jmp 指令来完成的。按照这样的方式程序得以在 main 函数内部根据外部的用户输入动态地调整其执行流程。而我们之所以称这种通过 goto 语句实现的程序执行流变化为本地跳转是因为在这种方式下的执行流程转移仅能够发生在当前程序运行所在的某个具体函数中。相对地程序无法做到从某个函数体的执行中途直接将其执行流转移到其他函数的内部。“跨函数”的调用仅能够通过常规的 call 与 ret 指令来实现。但是非本地跳转却可以打破这个限制。接下来我们具体看看与它有关的 setjmp 和 longjmp 函数。setjmp 与 longjmp 函数在 C 语言中非本地跳转的实现依赖于标准库头文件 setjmp.h 内的两个函数 setjmp 与 longjmp。关于它们的具体使用方式你可以点击这个链接参考更多信息这里我就不展开了。接下来我们直接使用这两个函数编写一段简单的代码并观察程序运行时非本地跳转的实际执行方式。代码如下所示上图中左侧为 C 代码右侧为对应的汇编代码。此时你可以先停下来观察整个程序的大致实现方式。程序的实际运行结果是连续打印出了从 A 到 J 共 10 个字符。这里建议你动手实践下并验证我们的结论。接下来回到左侧的 main 函数让我们来看一下程序的执行细节。首先我们定义了名为 c 的字符变量并将它的值初始化为字符 A。通过标注 volatile 关键字编译器不会对该变量的使用过程进行任何优化。然后我们在 if 条件语句中调用了函数 setjmp并将声明为 jmp_buf 类型的全局变量 jb 传递给了它。该函数的调用返回值会与字符 J 进行比较若满足小于关系则会调用另外的函数 inspect 来打印变量 c 的内容并同时递增 c 的值。接下来把目光移到 C 代码的第 5 行inspect 函数的定义部分。这里该函数接收一个字符变量并使用 putchar 函数将它的值打印了出来。可以看到函数在定义时被标注了 _Noreturn 关键字也就是说inspect 函数在调用结束后不会通过正常的 ret 指令退出。而之所以会发生这样的情况便是由于代码在第 7 行调用的 longjmp 函数。实际上随着 longjmp 函数执行完毕程序的执行流程将会直接跳转到 main 函数中 call setjmp 指令也就是调用 setjmp 函数的那条指令的下一条指令上。在上图右侧的汇编代码中我使用红色箭头标注出了程序在机器代码层面的具体执行顺序其中实线部分对应的机器代码将会在程序运行过程中执行多次。可以看到当程序执行流由 inspect 函数转移回 main 函数后借由 cmp 指令程序得以再次检查寄存器 rax 中的值是否小于字符 J 对应的 ASCII 码值 73。若条件成立则重复之前的程序执行逻辑否则程序直接退出。此时rax 寄存器中存放的值便对应于 longjmp 函数在被调用时传入的第二个参数值。不同于常规的函数调用过程非本地跳转为我们提供了一种可以暂存函数调用状态并在未来某个时刻再恢复的能力。借助这种能力我们便能够实现跨函数的、精确到某条具体语句的程序执行流程跳转。那这种能力是如何实现的呢运作原理通过上面的例子我们得知非本地跳转实际上是由 setjmp 与 longjmp 这两个函数共同协作完成的。我们来看看它们究竟都做了哪些事情。setjmp 函数在执行时会将程序此刻的函数调用环境信息存储在由其第一个参数指定的 jmp_buf 类型的对象中并同时将数值 0 作为函数调用结果返回。而当程序执行到 longjmp 函数时该函数便会从同一个 jmp_buf 对象中再次恢复之前保存的函数调用上下文。通过这种方式程序的执行流程得到了“重置”。那么与函数调用环境相关的信息有哪些呢我曾在 05 讲 中介绍过以 x86-64 为例SysV 调用约定中规定属于 Callee-saved 类型的寄存器信息需要在 call 指令调用时由被调用函数负责保存和恢复。这也就意味着这类寄存器中实际上存放着与当前调用函数caller有关的上下文状态信息。因此当被调用函数通过 ret 指令返回时这些寄存器中的“旧值”仍需要被调用函数继续使用。所以对于 setjmp 函数的实现是不是只要把所有 Callee-saved 寄存器中的值进行保存就可以了呢让我们来做个实验吧自定义实现在这个简单的实验中我们将构建自己的 setjmp 与 longjmp 函数并重新编译之前的 C 示例程序来让它使用这两个函数的自定义实现。这里我们将直接编写汇编代码并在最后以对象文件的形式将它们链接到程序中使用。需要注意的是下面我将要介绍的实现方式仅适用于 x86-64 平台。对于其他平台使用的汇编指令以及需要保存的寄存器可能有所区别但整体思路基本一致。按照之前的方案我们可以直接写出 setjmp 函数的汇编实现代码如下所示.global setjmp .intel_syntax noprefix setjmp: mov QWORD PTR [rdi], rbx mov QWORD PTR [rdi0x8], rbp mov QWORD PTR [rdi0x10], r12 mov QWORD PTR [rdi0x18], r13 mov QWORD PTR [rdi0x20], r14 mov QWORD PTR [rdi0x28], r15 lea rdx, [rsp0x8] mov QWORD PTR [rdi0x30], rdx mov rdx, QWORD PTR [rsp] mov QWORD PTR [rdi0x38], rdx xor eax, eax ret其中前两行以 “.” 开头的语句为汇编器指令它们用来指示汇编器应该如何处理接下来的汇编代码。第一行的 “.global” 指令指示汇编器可以将符号 setjmp 暴露给链接器使用第二行指令指示汇编器接下来的汇编代码将采用 Intel 语法格式。在 setjmp 的函数体实现中由于 SysV 调用规范的约束作为接收函数第一个实参的 rdi 寄存器其内部将保存有传入的 jmp_buf 对象的首地址。你可以直接将该对象看成是一个具有足够大小的字节数组而这里我们要做的就是把各个 Callee-saved 寄存器中的值按顺序放入这个数组中进行暂存。紧接着第 4 ~ 9 行的代码将寄存器 rbx、rbp、r12、r13、r14以及 r15 的值进行了暂存第 10 ~ 11 行的代码将 setjmp 函数调用之前的 rsp 寄存器的值进行了暂存第 12 ~ 13 行的代码将 setjmp 函数调用后的返回地址进行了暂存这个地址将由 longjmp 函数进行使用。最后第 14 行代码将寄存器 rax 的值置零以作为该函数的返回值。至此setjmp 函数的实现便完成了。按照相同的思路我们也可以直接得到 longjmp 函数实现的汇编代码如下所示.global longjmp .intel_syntax noprefix longjmp: xor eax, eax cmp esi, 0x1 adc eax, esi mov rbx, QWORD PTR [rdi] mov rbp, QWORD PTR [rdi0x8] mov r12, QWORD PTR [rdi0x10] mov r13, QWORD PTR [rdi0x18] mov r14, QWORD PTR [rdi0x20] mov r15, QWORD PTR [rdi0x28] mov rsp, QWORD PTR [rdi0x30] jmp QWORD PTR [rdi0x38]这里需要注意的是代码的第 5~6 行。longjmp 函数在调用时需要遵守的一个规则是如果传递给它的第二个实参为 0则使用数值 1 对其进行替换。这样做是为了能够通过 rax 寄存器中的不同“返回值”区分当前代码是在 setjmp 函数调用后首次被执行的还是由 longjmp 恢复函数调用环境后再次被执行的。这里我给你留个思考题这两行汇编代码是如何实现上述的替换逻辑的欢迎在评论区告诉我你的想法。至此setjmp 与 longjmp 这两个函数的自定义实现便准备完毕了。我们将这些汇编代码分别存放到名为 setjmp.s 与 longjmp.s 的文本文件中。然后通过下面这两行命令我们能够将它们编译成各自对应的 .o 对象文件。gcc -c setjmp.s -o setjmp.o gcc -c longjmp.s -o longjmp.o接下来为了在 C 代码中正确调用这两个函数我们还需要为它们提供相应的函数原型以及 jmp_buf 类型的详细定义。包含有上述这些内容以及原始示例程序的完整 C 代码如下所示#include stdio.h #include stdnoreturn.h // 定义 jmp_buf 类型 typedef long jmp_buf[8]; // 提供函数原型 int setjmp(jmp_buf); noreturn void longjmp(jmp_buf, int); // 原始 C 示例程序代码 jmp_buf jb; noreturn void inspect(char val) { putchar(val); longjmp(jb, val); } int main(void) { volatile char count A; if (setjmp(jb) J) inspect(count); return 0; }在这段代码的第 4 行我们为类型 jmp_buf 提供了相应的定义。由于该对象内部仅需要连续存放 8 个 64 位的寄存器值因此我们将它定义为具有 8 个元素的长整型数组。紧接着我们也分别为函数 setjmp 与 longjmp 提供了与标准库中实现完全一致的函数原型。最后将这段代码保存在文件 main.c 中然后通过下面的命令我们便可以编译并运行整个程序。gcc main.c setjmp.o longjmp.o -o main ./main请你动手实践下并观察程序的输出。在正常情况下你将会得到同示例程序完全一致的运行结果。当然为了便于你理解这里我简化了 setjmp 与 longjmp 函数的实现细节但它们的“核心思想”却可以通过这短短的几行汇编代码完全体现出来。通常来说在 C 语言中非本地跳转主要用来实现异常处理、协程等功能。标准中仅规定了 setjmp 函数可以正常使用的几种特定上下文情况因此在使用它时你需要十分小心以防出现未定义行为。可变参数函数下面我们来看另一个话题可变参数函数。不知道你有没有察觉这门课从开篇词开始就一直在各个示例程序中使用这类函数。我们在初学 C 语言时编写的第一个 “Hello, world” 程序中使用到的 printf 函数就是其中一种。而仔细观察你会发现printf 函数所能接收的参数个数实际上完全取决于我们在它第一个参数即格式控制字符串中指定的“格式控制符”的个数。基本使用可变参数函数能够接收不定数量的实参而为了定义这样一个函数我们需要配合使用由标准库头文件 stdarg.h 提供的类型与宏函数。让我们来看一个简单的例子代码如下所示#include stdio.h #include stdarg.h void print_sum(int count, ...) { int sum 0; va_list ap; va_start(ap, count); for (int i 0; i count; i) sum va_arg(ap, int); va_end(ap); printf(%d\n, sum); } int main(void) { print_sum(4, 1, 2, 3, 4); return 0; }这里我们定义了一个名为 print_sum 的可变参数函数该函数会计算传递给它的所有实参第一个实参除外之和。而它的第一个参数将被用来统计函数在调用时所传入的其余参数的个数。到这里你可以先暂缓脚步仔细看看在上面的代码中我们是如何使用类型 va_list以及宏函数 va_start、va_arg以及 va_end 的。如果你对它们还不太了解可以先点击这个链接来查看对它们用法的更多说明。运作原理接下来让我们把目光放到上面所说的几种类型与宏函数的底层实现上来探究它们在内部是如何对传入函数的多个实参进行管理的。对于 Clang 和 GCC 来说这些宏函数在展开后会调用由编译器在内部实现的 builtin 函数。因此如果想要了解它们的实现细节便需要深入到编译器代码的内部。但由于编译器实现较为庞杂这个方法并不适合我们快速了解相关内容。那有没有更方便的办法呢答案是有的。实际上在遵循 System V AMD64 ABI 的 x86-64 计算机上ABI 已经详细规定了编译器应该如何实现函数的可变参数列表。因此通过阅读其对应的文档我们便能够了解到上述这些宏函数和类型的一种实现方式。而对于其他平台虽然实现细节上可能稍有不同但整体思路不会有太大的差异。那么接下来我就带你看看标准中的可变参数函数是如何定义的。这是一个很好的问题。实际上System V AMD64 ABI 的全称为 “System V AMD64 Application Binary Interface”。它是一种描述了应用程序应该如何在机器代码中进行某种操作的标准规范而我们之前介绍的“函数调用约定”便是其重要内容之一。关于 ABI 的更多内容我将在这门课的“C 程序运行原理篇”中再为你详细介绍。SysV ABI 中详细规定了可变参数列表的实现要求但信息较多规则较为复杂。这里为了方便你理解我会按照上述代码中函数 print_sum 的执行顺序从整体的视角为你讲述它在机器指令层面对传入实参的处理方式。如果你想了解更多的细节性信息可以通过这个链接下载 ABI 文档并参考从第 54 页开始的内容。首先当 print_sum 函数被 call 指令调用前由于所传入的参数均为整型因此它们可以被直接存放在由 SysV ABI 函数调用约定规定的参数寄存器中。这里对应的 5 个数字值实参将被依次存放到寄存器 rdi、rsi、rdx、rcx以及 r8 中。接着函数被调用。寄存器 al 中将会存放有传入函数的浮点参数的个数该寄存器的值将会被编译器使用以进行相应优化。同时一块名为 “Register Save Area”后简称 RSA的栈内存区域将会被构建。而每一个通过寄存器传入函数的实参值都会按照 rdi、rsi、rdx、rcx、r8、r9、xmm0~xmm15 的寄存器先后顺序被拷贝并存放在这段内存中。函数继续执行在代码的第 5 行va_list 类型的变量 ap 被定义而其中存放有用于支持 va_arg 宏函数正常运作的必要信息。通常来说该类型可以被定义为如下所示的 C 数据结构。可以看到va_list 为一个指针指向了包含有 4 个字段的结构对象。typedef struct { unsigned int gp_offset; // 下一个整型数据相较于 RSA 的偏移 unsigned int fp_offset; // 下一个浮点数据相较于 RSA 的偏移 void *overflow_arg_area; // 指向使用栈进行传递的数据 void *reg_save_area; // 指向 RSA 的指针 } va_list[1];这里我将 va_list 所指向结构对象内部各个字段的具体功能以注释的方式标注了出来。你可以先浏览一遍有个大致的印象下面我就向你介绍它们的用途。在 print_sum 函数实现的第 3 行即代码第 6 行va_start 宏函数对 va_list 所指向的结构对象进行了初始化。在这个过程中字段 gp_offset 将会被设置为下一次将要从 RSA 中读取的整型实参其值距离 RSA 开始位置的偏移。类似地fp_offset 则用于浮点实参值。除此之外指针 overflow_arg_area 将会指向每一个使用栈进行传递的实参值最后的 reg_save_area 将指向 RSA 的起始地址。代码的第 8 行va_arg 宏函数将会根据传入的 va_list 指针以及想要提取的实参类型从 RSA 中取出相应的数据值。在这个过程中ap 结构体内字段 gp_offset、fp_offset以及 overflow_arg_area 的值会随着数据的不断提取而得到更新。在实现细节上va_arg 在提取实参的过程中还需要考虑对由多个寄存器存放的实参大于 64 位的处理以及栈上数据指针对齐等问题。最后当传入的实参被提取完毕后通过代码第 9 行的宏函数 va_endap 结构体得到了清理。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。今天我主要介绍了 C 语言中的非本地跳转与可变参数函数这两方面内容。通过使用由 C 标准库头文件 setjmp.h 与 stdarg.h 提供的一系列接口我们能够在程序中轻松地使用这两种特性。非本地跳转为我们提供了一种可以临时保存函数执行上下文并在未来某时刻再重新恢复的能力。通过这种方式我们可以在 C 语言中实现异常捕获、协程等特殊功能。非本地跳转的基本实现方式是在执行 setjmp 函数时将此刻所有 Callee-saved 寄存器的值进行暂存。而在未来某一时刻 longjmp 函数调用时再将这些寄存器的值进行复原。通过这种方式Caller 函数的原始执行状态便会得到恢复。可变参数函数则让我们使用函数的方式变得更加灵活。配合使用类型 va_list以及宏函数 va_start、va_arg、va_end我们可以在函数体内依次获取由外部调用者传入的若干个不定参数。而为了保证兼容性变长参数列表的实现细节需要遵循目标平台和操作系统对应的 ABI 规范。