从技术上讲,可变参数函数是如何工作的? printf 是如何工作的?

Posted

技术标签:

【中文标题】从技术上讲,可变参数函数是如何工作的? printf 是如何工作的?【英文标题】:Technically, how do variadic functions work? How does printf work? 【发布时间】:2014-04-16 09:00:28 【问题描述】:

我知道我可以使用va_arg 来编写我自己的可变参数函数,但是可变参数函数是如何在幕后工作的,即在汇编指令级别上?

例如,printf 怎么可能接受可变数量的参数?


* 没有规则,没有例外。没有语言 C/C++,但是,这两个问题都可以回答

* 注:答案原给How can printf function can take variable parameters in number while output them?,但似乎不适用于提问者

【问题讨论】:

@BЈовић:这些都是“猜测”;我会细化文字。 @BЈовић:You just copy&pasted the answer. So, this question is duplicate of other. 这是不合逻辑的。重复的答案不会重复提出的问题。 What is the format of the x86_64 va_list structure?的可能重复 @MatthieuM.:我不确定这是否足够“技术”。我会完善我的问题。 @phresnel:它似乎比您自己的答案更具技术性(或至少精确),尽管它专门用于一种架构。 【参考方案1】:

C 和 C++ 标准对其工作方式没有任何要求。一个符合要求的编译器很可能会决定在引擎盖下发出链表、std::stack<boost::any> 甚至是神奇的小马尘(根据@Xeo 的评论)。

但是,它通常按如下方式实现,即使在 CPU 寄存器中内联或传递参数等转换可能不会留下任何讨论的代码。

另请注意,此答案在下面的视觉效果中专门描述了向下增长的堆栈;此外,此答案只是为了演示该方案而进行的简化(请参阅https://en.wikipedia.org/wiki/Stack_frame)。

如何使用不固定数量的参数调用函数

这是可能的,因为底层机器架构对每个线程都有一个所谓的“堆栈”。堆栈用于将参数传递给函数。例如,当您有:

foobar("%d%d%d", 3,2,1);

然后编译成这样的汇编代码(示例性和示意性,实际代码可能看起来不同);请注意,参数是从右向左传递的:

push 1
push 2
push 3
push "%d%d%d"
call foobar

那些推入操作会填满堆栈:

              []   // empty stack
-------------------------------
push 1:       [1]  
-------------------------------
push 2:       [1]
              [2]
-------------------------------
push 3:       [1]
              [2]
              [3]  // there is now 1, 2, 3 in the stack
-------------------------------
push "%d%d%d":[1]
              [2]
              [3]
              ["%d%d%d"]
-------------------------------
call foobar   ...  // foobar uses the same stack!

栈底元素称为“栈顶”,通常缩写为“TOS”。

foobar 函数现在将访问堆栈,从 TOS 开始,即格式字符串,您记得它是最后推送的。想象stack 是你的堆栈指针,stack[0] 是 TOS 的值,stack[1] 是 TOS 之上的值,以此类推:

format_string <- stack[0]

... 然后解析格式字符串。在解析时,它会识别%d-tokens,并为每一个从堆栈中加载一个值:

format_string <- stack[0]
offset <- 1
while (parsing):
    token = tokenize_one_more(format_string)
    if (needs_integer (token)):
        value <- stack[offset]
        offset = offset + 1
    ...

这当然是一个非常不完整的伪代码,它演示了函数必须如何依赖传递的参数来找出它必须加载和从堆栈中删除多少。

安全

这种对用户提供的参数的依赖也是目前最大的安全问题之一(请参阅https://cwe.mitre.org/top25/)。用户可能很容易错误地使用可变参数函数,或者因为他们没有阅读文档,或者忘记调整格式字符串或参数列表,或者因为它们很邪恶,或者其他原因。另见Format String Attack。

C 实现

在 C 和 C++ 中,可变参数函数与 va_list 接口一起使用。虽然压入堆栈是这些语言所固有的(in K+R C you could even forward-declare a function without stating its arguments,但仍然使用任意数量和种类的参数调用它),从这样一个未知的参数列表中读取是通过 va_...-macros 和 va_list-type 接口的,它基本上抽象了低级堆栈帧访问。

【讨论】:

请注意,该标准对其工作方式没有实际要求。对于它的价值,它也可以使用神奇的小马尘来使其工作。 (另外,我没有投反对票。) stdcall 不能用作可变参数函数的调用约定。即使可变参数函数的编写者知道参数的数量,编译器也可能无法知道它。并且标准允许通过调用va_start乘法或使用va_copy来使用多个va_list,因此va_arg不是由pop实现的,而是通过直接读取堆栈来实现的(例如mov eax, [valist])。因此编译器无法确定在编译可变参数函数时应该弹出多少堆栈 - 只有“调用者”知道这一点。所以,应该使用cdecl 当然,如果堆栈向上增长,而不是向下增长,则一切都相反。即使正如你所描述的那样,它也不完全正确。访问它们时并没有真正弹出参数。通常,va_list 将定义一个指针类型,va_arg 将根据被提取参数的类型更新它。 (这就是为什么 va_argtype 参数必须对应于提升的类型,而不是你可能真正想要的类型。) @ikh stdcallcdecl 都是纯粹的 Microsoft 约定。大多数其他系统只有一个基本约定,并以相同的方式将所有参数传递给所有函数。少数不使用标准定义的机制(除了 Microsoft)来指定调用约定:extern "C"(或其他东西,而不是 C)。 -1:这仅(并且详细地)描述了堆栈如何工作以传递固定数量的参数。它设法忽略了几乎所有关于如何在大多数架构中实际实现具有可变数量参数的 Variadic 函数调用的要点:即,使用 帧指针参数计数器 除了一个堆栈指针。没有这些,被调用函数不知道调用框架的底部在哪里。【参考方案2】:

可变参数函数由标准定义,几乎没有明确的限制。这是一个来自 cplusplus.com 的示例。

/* va_start example */
#include <stdio.h>      /* printf */
#include <stdarg.h>     /* va_list, va_start, va_arg, va_end */

void PrintFloats (int n, ...)

  int i;
  double val;
  printf ("Printing floats:");
  va_list vl;
  va_start(vl,n);
  for (i=0;i<n;i++)
  
    val=va_arg(vl,double);
    printf (" [%.2f]",val);
  
  va_end(vl);
  printf ("\n");


int main ()

  PrintFloats (3,3.14159,2.71828,1.41421);
  return 0;

假设大致如下。

    必须有(至少一个)第一个固定的命名参数。 ... 实际上什么都不做,只是告诉编译器做正确的事。 固定参数通过未指定的机制提供有关有多少可变参数的信息。 va_start 宏可以从固定参数返回一个允许检索参数的对象。类型为va_listva_arg 可以从 va_list 对象迭代每个可变参数,并将其值强制转换为兼容类型。 va_start 中可能发生了一些奇怪的事情,所以va_end 让事情再次变得正确。

在最常见的基于堆栈的情况下,va_list 只是指向位于堆栈上的参数的指针,va_arg 递增指针,将其强制转换并取消引用它为一个值。然后va_start 通过一些简单的算术(和内部知识)初始化该指针,va_end 什么也不做。没有奇怪的汇编语言,只有一些关于堆栈位置的内部知识。阅读标准头文件中的宏以了解它是什么。

一些编译器 (MSVC) 需要特定的调用顺序,因此调用者将释放堆栈而不是被调用者。

printf 等函数的工作方式与此完全相同。固定参数是一个格式字符串,它允许计算参数的数量。

vsprintf 这样的函数将va_list 对象作为普通参数类型传递。

如果您需要更多或更低级别的详细信息,请添加到问题中。

【讨论】:

... 在通常期望调用函数在退出时清理推送参数的实现中可能很关键。 C 标准要求将额外的参数传递给诸如“printf”之类的东西没有任何效果,但是可以使用 callee-clean 约定的唯一方法是调用者知道它负责可变参数,或者它需要让被调用者知道它需要清理的被调用者的参数数量。

以上是关于从技术上讲,可变参数函数是如何工作的? printf 是如何工作的?的主要内容,如果未能解决你的问题,请参考以下文章

这个可变参数列表函数调用是如何工作的?

print使用可变参数宏对调试行进行多次打印

从可变长函数到legb

C 中 printf() 函数的工作(仅可变数量的参数)

如何在所有可变参数模板参数上调用函数?

Python可变长参数