我知道我可以使用va_arg
来编写自己的变差函数,但变差函数是如何在后台工作的,即在汇编指令级别上?
例如,printf
如何可能采用可变数量的参数?
*没有规则没有例外。没有C/C++语言,但是,这个问题对他们两个都有答案
*注:最初给出的答案是:如何在输出可变参数时,printf函数可以采用数字形式的参数?,但这似乎不适用于提问者
C和C++标准对其工作方式没有任何要求。一个顺从的编译器很可能会决定在引擎盖下发出链表、std::stack<boost::any>
甚至神奇的pony dust(根据@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
-令牌,并为每个令牌从堆栈中再加载一个值:
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/)。用户可能很容易错误地使用variadic函数,可能是因为他们没有阅读文档,或者忘记调整格式字符串或参数列表,或者是因为他们很邪恶,或者其他什么原因。另请参阅格式化字符串攻击。
C实施
在C和C++中,变量函数与va_list
接口一起使用。虽然对堆栈的推送是这些语言固有的(在K+RC中,你甚至可以在不声明参数的情况下转发声明函数,但仍然可以用任何数量和种类的参数调用它),但从这样一个未知的参数列表中读取是通过va_...
-宏和va_list
-类型进行接口的,这基本上抽象了底层堆栈帧访问。
变分函数是由标准定义的,几乎没有明确的限制。以下是一个例子,摘自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_list
- 从
va_list
对象,va_arg
可以迭代每个可变参数,并将其值强制为兼容类型 va_start
中可能发生了一些奇怪的事情,因此va_end
使事情再次正常
在最常见的基于堆栈的情况下,va_list
只是指向堆栈上参数的指针,而va_arg
会增加指针、强制转换指针并将其取消引用到值。然后va_start
通过一些简单的算术(和内部知识)初始化该指针,而va_end
什么也不做。没有什么奇怪的汇编语言,只有一些关于堆栈中的内容的内部知识。阅读标准标题中的宏,找出那是什么。
一些编译器(MSVC)将需要一个特定的调用序列,从而调用方将释放堆栈,而不是被调用方。
像printf
这样的函数就是这样工作的。固定参数是一个格式字符串,它允许计算参数的数量。
像vsprintf
这样的函数将va_list
对象作为普通参数类型传递。
如果您需要更多或更低级别的详细信息,请添加到问题中。