How are variable arguments implemented in gcc?
我正在使用cdecl调用约定,其中调用者在被调用者返回后清理变量。
我有兴趣知道宏va_end,va_start和va_arg是如何工作的?
调用者是否将参数数组的地址作为max的第二个参数传递?
-
这篇博客文章amd64和va_arg讨论了变量参数的va_arg函数集如何在机器体系结构和与特定处理器一起使用的调用约定ABI之间有所不同。 具有比旧x86架构更多寄存器的现代处理器允许在寄存器和堆栈中传递参数。
-
相关:stackoverflow.com/questions/5272703/
如果你看看C语言将参数存储在堆栈中的方式,宏的工作方式应该变得清晰: -
1 2 3 4 5 6
| Higher memory address Last parameter
Penultimate parameter
....
Second parameter
Lower memory address First parameter
StackPointer -> Return address |
(注意,根据硬件的不同,堆栈指针可能会向下一行,而较高和较低的指针可能会被交换)
即使没有...参数类型,参数也总是像this1一样存储。
va_start宏只是设置指向第一个函数参数的指针,例如:-
1 2 3 4 5
| void func (int a, ...)
{
// va_start
char *p = (char *) &a + sizeof a;
} |
这使得p指向第二个参数。 va_arg宏执行此操作: -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void func (int a, ...)
{
// va_start
char *p = (char *) &a + sizeof a;
// va_arg
int i1 = *((int *)p);
p += sizeof (int);
// va_arg
int i2 = *((int *)p);
p += sizeof (int);
// va_arg
long i2 = *((long *)p);
p += sizeof (long);
} |
va_end宏只将p值设置为NULL。
笔记:
优化编译器和一些RISC CPU将参数存储在寄存器中而不是使用堆栈。 ...参数的存在将关闭此功能并使编译器使用堆栈。
-
这实际上是特定于平台的,因为许多调用约定(包括常见的x64,PPC,ARM)将大部分参数传递给寄存器。许多平台没有将返回地址放在堆栈上,一个或两个平台的堆栈向上而不是向下扩展,而一些调用约定以相反的顺序在堆栈上放置参数。
-
@Skizz:很棒的答案!!
-
@DietrichEpp:我知道。但希望它能得到一些基础知识。我在答案中加入了一些注释,以反映堆栈的各种工作方式。尽管如此,要覆盖编译器实现此方法的大多数不同方法,还需要更长的答案。简单的方法是找到宏定义并看到它们扩展到并希望没有怪异的编译器魔法继续。
-
可变参数仅在32位上使用cdecl调用约定,该约定仅在堆栈上传递参数,并以相反的顺序传递,以允许调用者决定传递多少参数。只有调用者设置调用堆栈并在调用返回后将其清除,这是可变参数的关键。其他32位调用约定要么使用寄存器和堆栈的混合,要么混合调用者/被调用者关于设置/清理的责任,从而使可变参数无法使用。
-
在64位上,仍然可以使用可变参数,但va_arg()实现将非常复杂,需要编译器支持而不仅仅是用户模式代码。
-
@RemyLebau有没有办法让64位gcc(x86-64 Linux上的gcc 5或6)使用Skizz在上面描述的调用约定?我问,因为我想让我的学生在C中实现一个变量函数作为一个"手动"的运动问题(即没有va_ *宏),以便通过堆栈获得对参数传递的实际理解,但我不想要他们只为这一次练习安装32位gcc。
-
...参数的存在将关闭此功能并使编译器使用堆栈。实际上没有,在x86-64上,Windows和System V调用约定仍然在可变或不相同的寄存器中传递args。 Windows调用约定在堆栈args(如果有)之前需要在返回地址之上的"阴影空间",因此可变参数函数可以将4个寄存器转储到阴影空间并将其args索引为数组。 (调用者需要在整数和XMM寄存器中复制FP args以获得可变参数函数)。
-
因此,Windows以可正常功能为代价,针对可变功能进行了优化。但x86-64 System V要求可变函数更复杂,以确定其args的位置。 gccs实现转储arg将reg传递给堆栈(包括xmm if al!= 0,表明寄存器中有一些FP args),然后将其视为va_arg的不相交数组。正常的现代代码在紧密循环中不调用非内联可变参数函数,并且无序执行使得未使用的regs的死存储无论如何都不是非常昂贵。
-
@PeterCordes:不,操作系统对应用程序中的参数处理方式没有任何要求,操作系统定义的唯一规范是参数如何传递给操作系统本身,其编译器定义如何在应用程序中实现参数。在我的回答中处理参数的方式受到原始8088/6处理器的体系结构和当时的软件工程状态的严重影响,现在有了更快的处理器和SE的进步,它可能会以不同的方式完成,但那是什么被困住了。
-
GCC是否指定了如何处理参数?我只是问,因为GCC是一个非常通用的跨平台编译器,并且实现参数的方式可能更多地由目标体系结构定义而不是编译器,例如,88000处理器将具有与Z80非常不同的策略,但是GCC能够以给定相同的源代码为目标。
-
所有主要的C编译器都选择遵循标准调用约定/ ABI,因此您可以在同一目标平台上由不同编译器编译的库中调用库函数。在x86-64上,那些调用约定(x86-64 System V和x86-64 Windows约定)都使用寄存器args甚至是可变参数函数。有关调用printf的示例,请参阅stackoverflow.com/questions/6212665。所以不,编译器只能创建一些东西,不,...不会禁用寄存器arg传递。是的,当然不同的目标有不同的调用约定。
-
在32位x86 Windows上,其中一个标准调用约定(我认为__stdcall)让被调用者从堆栈中弹出args(例如,使用ret 8而不是ret)。但即使那是默认的,可变函数也不会使用它;他们使用来电者弹出(即__cdecl)。所以...会影响所选择的调用约定,但它不必禁用寄存器arg传递。 ABI设计可能就是这种情况,但不是规则。
当参数在堆栈上传递时,va_"函数"(它们大部分时间都是作为宏实现的)只是简单地操作私有堆栈指针。这个私有堆栈指针存储在传递给va_start的参数中,然后va_arg"弹出"来自"堆栈"的参数,因为它迭代参数。
假设您使用三个参数调用函数max,如下所示:
在max函数内部,堆栈基本上如下所示:
1 2 3 4 5 6
| +-----+
| c |
| b |
| a |
| ret |
SP -> +-----+ |
SP是真正的堆栈指针,它不是堆栈上的a,b和c,而是它们的值。 ret是返回地址,在功能完成时跳转到的位置。
va_start(ap, n)的作用是获取参数的地址(函数原型中的n)并从中计算下一个参数的位置,因此我们得到一个新的私有堆栈指针:
1 2 3 4 5 6
| +-----+
| c |
ap -> | b |
| a |
| ret |
SP -> +-----+ |
当您使用va_arg(ap, int)时,它返回私有堆栈指针指向的内容,然后通过将私有堆栈指针更改为现在指向下一个参数来"弹出"它。堆栈现在看起来像这样:
1 2 3 4 5 6
| +-----+
ap -> | c |
| b |
| a |
| ret |
SP -> +-----+ |
该描述当然是简化的,但显示了原理。
-
如果坐在调用者清理惯例中,肯定va_arg无法将其从堆栈中弹出。
-
@Joachim:你能举一些插图或者更详细地描述一下你的答案。我无法想象你在说什么。
-
@DeadMG当然它没有,这就是为什么我把pop放在引号内。 :)
-
@Bruce修改了我的答案,希望你现在可以更好地解决它。
-
这就是它在32位平台上的工作原理。在64位平台上,一些参数通过寄存器传递,其他参数传递到堆栈,因此64位实现更复杂。
-
@JoachimPileborg:非常感谢。我现在可以更好地想象它了。
一般来说,当我使用(,...)声明函数原型时,我如何设置target.def,编译器会设置一个标记有varargs标志的解析树,并引用指定参数的类型。对于严格的C一致性,当该参数是va_start的命名字段并且可能返回到va_arg()时,每个命名参数应该获得附加到设置va_list所需的任何附加信息,但是大多数编译器只为最后命名的参数生成此信息。当定义函数时,它的序言生成器注意到varargs标志已设置并添加了设置任何隐藏字段所需的代码,它添加到具有va_start宏可以引用的已知偏移的帧。
当它找到对该函数的引用时,它会为表示...的每个参数创建额外的解析和代码生成树,这可能会引入运行时类型信息的其他隐藏字段,例如数组边界,这些字段会附加到va_start的字段设置中和va_arg用于命名参数。这个组合树确定生成什么代码以将参数值复制到框架上,序言设置了va_start从任意或最后命名的参数开始创建va_list所需的内容,并且每次调用va_arg()都会生成引用的内联代码用于在编译时验证预期返回的任何参数特定隐藏字段是与正在编译的表达式用法兼容的赋值,并执行任何所需的参数提升/强制。命名字段值大小和隐藏字段大小的总和确定在调用之后编译的值,或者在callee清理模型的函数结尾中,以在返回时调整帧。
这些步骤中的每一步都有处理器和调用约定依赖关系,封装在config / proc / proc.c和proc.h文件中,它们覆盖了va_start()和va_arg()的简单默认定义,假设每个参数都有一个固定的大小分配在堆栈上第一个命名参数之上一定距离。对于某些平台或语言,作为单独的malloc()实现的参数帧比固定大小的堆栈更令人满意。另请注意,这些用法不是线程安全的;将va_list引用传递给另一个线程是不安全的,没有未指定的方法确保参数帧因函数返回或线程中止而无效。
1 2 3 4 5 6 7 8 9 10
| int max (int n , const char *msg ,... )
{
va_list args ;
char buffer [1024];
va_start(args , msg );
nb_char_written = vsnprintf (buffer , 1024, msg , args );
va_end(args );
printf("(%d):%s
",n ,buffer );
} |
-
谢谢您的回答。我更感兴趣的是知道如何为被调用者设置堆栈(如何推送)以及宏如何工作?