如果我们查看一些现代调用约定,例如x86-64 SysV 样式或 AArch64 样式(文档 aapcs64.pdf,标题为“Arm® 64 位架构的程序调用标准”),我们会看到明确的注释,即可变参数的传递方式与其他参数相同。例如,open(path, mode, cflags)
x86-64 上的函数调用将在 RDI 中获取路径,在 RSI 中获取模式,并在 RDX 中获取(唯一的可变参数)cflags。
毫无疑问,将静态参数集传递到寄存器中有利于节省资源。但是,如果我们研究一个函数,该函数随后解释参数并调用va_start
它们,我们将看到它va_start
被转换为将所有可能的参数(通常比实际存在的参数多得多)放入堆栈;例如,printf
via的完整模拟vfprintf
以以下内容开头(我压缩了相似的行以避免列表太长):
my_printf:
endbr64
; nearly unconditional saving
subq $216, %rsp
movq %rsi, 40(%rsp)
<...>
movq %r9, 72(%rsp)
testb %al, %al
je .L2
movaps %xmm0, 80(%rsp)
<...>
movaps %xmm7, 192(%rsp)
; repacking into registers for enclosed vfprintf
.L2:
movq %fs:40, %rax
movq %rax, 24(%rsp)
xorl %eax, %eax
movl $8, (%rsp)
movl $48, 4(%rsp)
leaq 224(%rsp), %rax
movq %rax, 8(%rsp)
leaq 32(%rsp), %rax
movq %rax, 16(%rsp)
movq %rsp, %rcx
movq %rdi, %rdx
movl $1, %esi
; finally, call the function
movq stdout(%rip), %rdi
call __vfprintf_chk@PLT
... skipped epilogue
此处 VA 帧有 192 个字节。类似地,AArch64 版本推送 184 个字节(x1..x7 和 q0..q7)。
如果任何函数调用的可变参数尾部始终放在堆栈上,那么代码就会变得简单得多,运行时也会更便宜,因为不需要进行任何打包和复制。va_start
会减少到将可变参数列表起始位置(在堆栈中)移动到变量的一次移动。这就是它在 i386 上的实际工作方式(其中所有参数都在堆栈上传递)。Linux/i386 的相同简单包装器的汇编输出:
my_printf:
pushl %ebx
subl $8, %esp
call __x86.get_pc_thunk.bx
addl $_GLOBAL_OFFSET_TABLE_, %ebx
leal 20(%esp), %eax ; <--- This is va_start
pushl %eax ; VA pointer pushed for vfprintf
pushl 20(%esp)
pushl $1
movl stdout@GOT(%ebx), %eax
pushl (%eax)
call __vfprintf_chk@PLT
这里的问题是:为什么可变参数的实现(至少对于 x86-64 和 aarch64 而言)如此复杂且浪费资源?
(我可以想象,在某些情况下,在同一个函数的函数声明中,应该同样允许使用两种风格,既有固定参数的风格,也有可变参数列表的风格。但我不知道有这样的情况。所提到的open
不太可能是这种情况。)
似乎已解决:导致这种设计的因素是需要以声明的方式支持 C 遗留的“无原型”函数(详细信息请参阅@fuz 的回答),在不同的地方,有或没有原型,但使用与实际应用的签名一致。
请注意,并非所有调用约定都这样做。例如,macOS 上使用的 AArch64 调用约定在堆栈上传递可变参数。
也就是说,在寄存器中传递可变参数的一个关键动机是,这使得调用者和被调用者都不需要知道函数是否是可变的。例如,如果你要调用一个无原型的函数,声明如下:
您无法知道它是否是可变函数。但是,由于可变函数和非可变函数具有相同的调用约定,因此调用者可以简单地将 AL 设置为可变函数并调用它,如果不是,则被调用者将忽略 AL。
在 macOS 调用约定中这是不可能的,因为执行不使用原型一致声明可变函数的程序将会失败。